diff --git a/FinlyticCore/Database/DatabaseBootstrapper.cs b/FinlyticCore/Database/DatabaseBootstrapper.cs
new file mode 100644
index 0000000..52b8bd2
--- /dev/null
+++ b/FinlyticCore/Database/DatabaseBootstrapper.cs
@@ -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;
+
+///
+/// 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.
+///
+public static class DatabaseBootstrapper
+{
+ ///
+ /// 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.
+ ///
+ 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.");
+ }
+ }
+
+ ///
+ /// Combines catalog database auto-creation and EF Core Migration execution in a single call.
+ ///
+ public static async Task MigrateWithBootstrapAsync(
+ this TContext context,
+ string connectionString,
+ ILogger? logger = null,
+ CancellationToken cancellationToken = default) where TContext : DbContext
+ {
+ await EnsureDatabaseCreatedAsync(connectionString, logger, cancellationToken);
+ await context.Database.MigrateAsync(cancellationToken);
+ }
+}
diff --git a/FinlyticCore/Dtos/Bot/BotDtos.cs b/FinlyticCore/Dtos/Bot/BotDtos.cs
new file mode 100644
index 0000000..ab4923f
--- /dev/null
+++ b/FinlyticCore/Dtos/Bot/BotDtos.cs
@@ -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))]
+public enum BotExecutionVenue
+{
+ AlpacaPaperTrading, // Offizielle Alpaca API (US-Equities / ETFs)
+ SyntheticPaperBroker // Interner Engine-Broker (EU / Knock-Outs)
+}
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+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
+);
+
+///
+/// Result of an emergency "panic close" of every open paper-trading position (see
+/// ). 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
+/// as if the whole operation succeeded (Rules.md §4: no fabricated full success on a partial result).
+///
+public record PanicCloseResultDto(
+ int ClosedCount,
+ int SkippedCount,
+ List ClosedOrders
+);
diff --git a/FinlyticCore/Dtos/Fundamentals/AssetFundamentalsDto.cs b/FinlyticCore/Dtos/Fundamentals/AssetFundamentalsDto.cs
index f7c11bc..f8f6a12 100644
--- a/FinlyticCore/Dtos/Fundamentals/AssetFundamentalsDto.cs
+++ b/FinlyticCore/Dtos/Fundamentals/AssetFundamentalsDto.cs
@@ -39,4 +39,28 @@ public record AssetFundamentalsDto
///
[JsonPropertyName("lastUpdatedAt")]
public DateTime LastUpdatedAt { get; init; } = DateTime.UtcNow;
+
+ ///
+ /// Berechnete Tage bis zum nächsten Quartalszahlen-Termin (Earnings Lockout Check).
+ ///
+ [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();
+
+ ///
+ /// 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).
+ ///
+ [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();
}
\ No newline at end of file
diff --git a/FinlyticCore/Dtos/MqttRequestDtos.cs b/FinlyticCore/Dtos/MqttRequestDtos.cs
index 3f5c0e5..fbd4532 100644
--- a/FinlyticCore/Dtos/MqttRequestDtos.cs
+++ b/FinlyticCore/Dtos/MqttRequestDtos.cs
@@ -60,6 +60,20 @@ public record ArticleRequest(
[property: JsonPropertyName("id")] string? Id = null
);
+///
+/// Request payload for fetching sentiment by ISIN.
+///
+public record GetSentimentByIsinRequest(
+ [property: JsonPropertyName("isin")] string Isin
+);
+
+///
+/// Request payload for fetching sentiment by Sector.
+///
+public record GetSectorSentimentRequest(
+ [property: JsonPropertyName("sector")] string Sector
+);
+
///
/// Request payload for triggering a manual sentiment analysis for an article or ISIN.
///
@@ -84,36 +98,6 @@ public record GetEventsByMonthRequest(
[property: JsonPropertyName("month")] int Month
);
-///
-/// Request payload for triggering a manual AI analysis.
-///
-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
-);
-
///
/// Response payload returned by microservice health pings over MQTT.
///
@@ -137,22 +121,176 @@ public record FetchLogoResponse(
bool Success
);
-///
-/// 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.
-///
-public record ServiceConfigUpdatePayload(
- [property: JsonPropertyName("serviceName")]
- string ServiceName,
- [property: JsonPropertyName("timestamp")]
- DateTime Timestamp,
- [property: JsonPropertyName("settings")]
- Dictionary Settings
-);
-
///
/// Payload published to MQTT when a live market tick is received.
///
public record TickMessageDto(
[property: JsonPropertyName("price")] decimal Price
+);
+
+///
+/// Request payload for fetching trade proposals from FinlyticEngine.
+///
+public record GetTradeProposalsRequest(
+ [property: JsonPropertyName("onlyActive")] bool OnlyActive = true,
+ [property: JsonPropertyName("limit")] int Limit = 50
+);
+
+///
+/// Request payload for fetching active trades from FinlyticEngine. 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).
+///
+public record GetActiveTradesRequest(
+ [property: JsonPropertyName("userId")] Guid UserId,
+ [property: JsonPropertyName("mode")] FinlyticCore.Dtos.Trading.ExecutionMode? Mode = null
+);
+
+///
+/// Request payload for triggering an on-demand evaluation in FinlyticEngine. identifies
+/// the human caller for the resulting EngineEvaluationSnapshotEntity.TriggeredByUserId audit trail
+/// (this RPC channel is only ever reached from the manual Web UI flows - the autonomous
+/// OpportunityPollerBackgroundService calls ITradeLifecycleService.EvaluateAssetAsync directly
+/// in-process and never goes through this channel at all). Exactly like
+/// 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
+/// here only exists so / can keep
+/// their own defaults (C# requires optional parameters to trail).
+///
+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
+);
+
+///
+/// Request payload for adding an executed fill to an active trade. is mandatory so
+/// FinlyticEngine can verify the caller owns before mutating it; a value supplied by an
+/// untrusted client must always be overwritten server-side (FinlyticBackend) with the identity from the JWT.
+///
+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
+);
+
+///
+/// Request payload for manually or algorithmically adjusting a trade's stop loss. is
+/// mandatory so FinlyticEngine can verify the caller owns before mutating it; a value
+/// supplied by an untrusted client must always be overwritten server-side (FinlyticBackend) with the identity
+/// from the JWT.
+///
+public record UpdateTradeStopLossRequest(
+ [property: JsonPropertyName("userId")] Guid UserId,
+ [property: JsonPropertyName("tradeId")] Guid TradeId,
+ [property: JsonPropertyName("newStopLoss")] decimal NewStopLoss,
+ [property: JsonPropertyName("reason")] string Reason
+);
+
+///
+/// Request payload for closing an active trade. is mandatory so FinlyticEngine can verify
+/// the caller owns before closing it; a value supplied by an untrusted client must always
+/// be overwritten server-side (FinlyticBackend) with the identity from the JWT.
+///
+public record CloseEngineTradeRequest(
+ [property: JsonPropertyName("userId")] Guid UserId,
+ [property: JsonPropertyName("tradeId")] Guid TradeId,
+ [property: JsonPropertyName("closePrice")] decimal ClosePrice,
+ [property: JsonPropertyName("reason")] string Reason
+);
+
+///
+/// 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 , and other users may still accept the same proposal. Proposals
+/// disappear on their own once ExpiresAtUtc passes; there is deliberately no "reject" round trip,
+/// because declining a proposal has no server-side effect.
+/// must always be overwritten server-side (FinlyticBackend) with the identity from
+/// the JWT and never trusted from the client.
+///
+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
+);
+
+///
+/// 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). 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 ProposalId field: EngineTradeEntity.ProposalId stays a
+/// non-nullable everywhere else in the codebase (grouping trades that share one accepted
+/// proposal), so FinlyticEngine substitutes for a manually created trade instead of
+/// widening that column to nullable for the sake of this single caller.
+///
+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
+);
+
+///
+/// Machine-readable classification of a server-side RPC fault, carried by 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 SubscribeRpcAsync handlers across the
+/// fleet today (see ); it is not meant to be a full HTTP-status
+/// mirror. Each value has a corresponding standard .NET exception type that
+/// reconstructs client-side, so existing
+/// catch (InvalidOperationException) / catch (ArgumentException) blocks written against the
+/// service-layer methods' local exception types keep working unchanged across the MQTT boundary.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum RpcFaultCode
+{
+ ///
+ /// 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.
+ ///
+ Internal = 0,
+
+ /// The request conflicts with current server-side state (e.g. a proposal already accepted by this same user).
+ Conflict = 1,
+
+ /// The request payload failed validation (e.g. a blank ISIN or a non-positive price/quantity).
+ InvalidArgument = 2,
+
+ ///
+ /// 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 ): a
+ /// caller must never learn that a trade ID exists under another user's account.
+ ///
+ NotFound = 3,
+
+ /// The caller's identity could not be established, or is not permitted to perform this operation.
+ Unauthorized = 4
+}
+
+///
+/// Typed error envelope published by
+/// 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
+/// and a short, safe, fully-formed ; 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).
+///
+public record RpcErrorResponse(
+ [property: JsonPropertyName("code")] RpcFaultCode Code,
+ [property: JsonPropertyName("message")] string Message
);
\ No newline at end of file
diff --git a/FinlyticCore/Dtos/Sentiment/FinBertResultDto.cs b/FinlyticCore/Dtos/Sentiment/FinBertResultDto.cs
index 84a8fcb..56b2f6c 100644
--- a/FinlyticCore/Dtos/Sentiment/FinBertResultDto.cs
+++ b/FinlyticCore/Dtos/Sentiment/FinBertResultDto.cs
@@ -40,7 +40,7 @@ public record FinBertResultDto
///
/// Gets or sets the compound score (-1.0 to +1.0).
///
- [JsonPropertyName("compoundScore")]
+ [JsonPropertyName("compound_score")]
public double CompoundScore { get; init; }
///
@@ -49,6 +49,12 @@ public record FinBertResultDto
[JsonPropertyName("confidence")]
public double Confidence { get; init; }
+ ///
+ /// Gets or sets the estimated market impact ("HIGH", "MEDIUM", "LOW").
+ ///
+ [JsonPropertyName("impact")]
+ public string? Impact { get; init; }
+
///
/// Gets or sets the probability breakdown.
///
@@ -56,8 +62,14 @@ public record FinBertResultDto
public FinBertProbabilities Probabilities { get; init; } = new();
///
- /// Gets or sets the short summary snippet highlighting the impact of the article.
+ /// Gets or sets the short key highlight extracted by FinBERT / n8n.
+ ///
+ [JsonPropertyName("key_highlight")]
+ public string? KeyHighlight { get; init; }
+
+ ///
+ /// Legacy alias for KeyHighlight / summary snippet.
///
[JsonPropertyName("summarySnippet")]
- public string? SummarySnippet { get; init; }
+ public string? SummarySnippet => KeyHighlight;
}
diff --git a/FinlyticCore/Dtos/Sentiment/IsinSentimentSummaryDto.cs b/FinlyticCore/Dtos/Sentiment/IsinSentimentSummaryDto.cs
index 6c7f890..b453d6e 100644
--- a/FinlyticCore/Dtos/Sentiment/IsinSentimentSummaryDto.cs
+++ b/FinlyticCore/Dtos/Sentiment/IsinSentimentSummaryDto.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Sentiment;
@@ -97,6 +98,36 @@ public record IsinCurrentSummary
[JsonPropertyName("totalArticlesAnalyzed")]
public int TotalArticlesAnalyzed { get; init; }
+ ///
+ /// Gets or sets the number of positive articles.
+ ///
+ [JsonPropertyName("positiveArticles")]
+ public int PositiveArticles { get; init; }
+
+ ///
+ /// Gets or sets the number of negative articles.
+ ///
+ [JsonPropertyName("negativeArticles")]
+ public int NegativeArticles { get; init; }
+
+ ///
+ /// Gets or sets the number of neutral articles.
+ ///
+ [JsonPropertyName("neutralArticles")]
+ public int NeutralArticles { get; init; }
+
+ ///
+ /// Gets or sets the sentiment trend ("IMPROVING", "DETERIORATING", "STABLE").
+ ///
+ [JsonPropertyName("trend")]
+ public string? Trend { get; init; }
+
+ ///
+ /// Gets or sets the key highlight summary.
+ ///
+ [JsonPropertyName("keyHighlight")]
+ public string? KeyHighlight { get; init; }
+
///
/// Gets or sets the overall synthesized sentiment text overview.
///
@@ -105,7 +136,7 @@ public record IsinCurrentSummary
}
///
-/// 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.
///
public record IsinSentimentSummaryDto
{
diff --git a/FinlyticCore/Dtos/Sentiment/SectorSentimentSummaryDto.cs b/FinlyticCore/Dtos/Sentiment/SectorSentimentSummaryDto.cs
index 3d4ea96..3659de7 100644
--- a/FinlyticCore/Dtos/Sentiment/SectorSentimentSummaryDto.cs
+++ b/FinlyticCore/Dtos/Sentiment/SectorSentimentSummaryDto.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Sentiment;
@@ -55,6 +56,18 @@ public record SectorCurrentSummary
[JsonPropertyName("sentimentLabel")]
public string SentimentLabel { get; init; } = "NEUTRAL";
+ ///
+ /// Gets or sets the total number of articles analyzed for this sector.
+ ///
+ [JsonPropertyName("totalArticlesAnalyzed")]
+ public int TotalArticlesAnalyzed { get; init; }
+
+ ///
+ /// Gets or sets the total number of distinct companies in this sector.
+ ///
+ [JsonPropertyName("totalCompanies")]
+ public int TotalCompanies { get; init; }
+
///
/// Gets or sets the list of active asset ISINs influencing the sector.
///
@@ -69,7 +82,7 @@ public record SectorCurrentSummary
}
///
-/// 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.
///
public record SectorSentimentSummaryDto
{
diff --git a/FinlyticCore/Dtos/Simulation/SimulationDtos.cs b/FinlyticCore/Dtos/Simulation/SimulationDtos.cs
new file mode 100644
index 0000000..0044ecd
--- /dev/null
+++ b/FinlyticCore/Dtos/Simulation/SimulationDtos.cs
@@ -0,0 +1,145 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+
+namespace FinlyticCore.Dtos.Simulation;
+
+///
+/// Per-run overrides for 's tunable indicator parameters, keyed by
+/// "{StrategyKey}.{ParameterName}" (e.g. "MeanReversion.RsiOversold") - see
+/// TechnicalContext.ParameterOverrides. /empty means "use that strategy's own
+/// hardcoded defaults". Deliberately scoped to backtesting only - live scanning never applies these.
+///
+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? 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 Trades,
+ List 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"
+);
+
+///
+/// Filters for MqttTopics.Channels.SimGetBacktestHistory. is optional -
+/// 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.
+///
+public record GetBacktestHistoryRequest(
+ string Isin,
+ string? StrategyKey = null,
+ int Limit = 20
+);
+
+///
+/// One row of the backtest history list - a lightweight summary (no Trades/EquityCurve) mapped
+/// 1:1 from a persisted SimulationRunEntity, so listing many runs for an asset stays cheap. Fetch the
+/// full for one specific run via SimGetBacktestRunDetail when the user
+/// drills into it.
+///
+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
+);
+
+/// Looks up one specific past backtest run's full report by its RunId (MqttTopics.Channels.SimGetBacktestRunDetail).
+public record GetBacktestRunDetailRequest(Guid RunId);
+
+/// Looks up a saved parameter profile for one (Isin, StrategyKey) pair (MqttTopics.Channels.SimGetStrategyParameters).
+public record GetStrategyParametersRequest(string Isin, string StrategyKey);
+
+/// Upserts a saved parameter profile for one (Isin, StrategyKey) pair (MqttTopics.Channels.SimSaveStrategyParameters).
+public record SaveStrategyParametersRequest(string Isin, string StrategyKey, Dictionary Parameters);
+
+///
+/// A saved set of tunable indicator parameter overrides for one (Isin, StrategyKey) pair, keyed by
+/// "{StrategyKey}.{ParameterName}" (matching TechnicalContext.ParameterOverrides 1:1) - see
+/// SimulationStrategyParameterEntity.
+///
+public record StrategyParameterProfileDto(
+ string Isin,
+ string StrategyKey,
+ Dictionary Parameters,
+ DateTime UpdatedAtUtc
+);
diff --git a/FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs b/FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs
new file mode 100644
index 0000000..773e521
--- /dev/null
+++ b/FinlyticCore/Dtos/TechnicalAnalysis/ExitPlanDto.cs
@@ -0,0 +1,55 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace FinlyticCore.Dtos.TechnicalAnalysis;
+
+///
+/// Individual take-profit tier in a staged scale-out exit plan.
+///
+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
+);
+
+///
+/// Break-even trigger rule for locking in free-rolls.
+///
+public record BreakEvenRule(
+ [property: JsonPropertyName("enabled")] bool Enabled,
+ [property: JsonPropertyName("triggerPrice")] decimal TriggerPrice,
+ [property: JsonPropertyName("offsetToCoverFees")] decimal OffsetToCoverFees
+);
+
+///
+/// Trailing stop management rule for trend following.
+///
+public record TrailingStopRule(
+ [property: JsonPropertyName("type")] TrailingStopType Type,
+ [property: JsonPropertyName("multiplier")] decimal Multiplier,
+ [property: JsonPropertyName("activationPrice")] decimal ActivationPrice,
+ [property: JsonPropertyName("indicatorKey")] string IndicatorKey
+);
+
+///
+/// Indicator or structural reversal condition that triggers an early trade exit.
+///
+public record ReversalCondition(
+ [property: JsonPropertyName("ruleDescription")] string RuleDescription,
+ [property: JsonPropertyName("indicatorTrigger")] string IndicatorTrigger
+);
+
+///
+/// Composable, complete exit plan decoupling entry strategy logic from execution management.
+///
+public record ExitPlan(
+ [property: JsonPropertyName("strategyType")] ExitStrategyType StrategyType,
+ [property: JsonPropertyName("initialStopLoss")] decimal InitialStopLoss,
+ [property: JsonPropertyName("takeProfitStages")] List 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
+);
diff --git a/FinlyticCore/Dtos/TechnicalAnalysis/PatternResultDto.cs b/FinlyticCore/Dtos/TechnicalAnalysis/PatternResultDto.cs
new file mode 100644
index 0000000..f40ca41
--- /dev/null
+++ b/FinlyticCore/Dtos/TechnicalAnalysis/PatternResultDto.cs
@@ -0,0 +1,25 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace FinlyticCore.Dtos.TechnicalAnalysis;
+
+///
+/// Output result of an isolated pattern detection evaluation.
+///
+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? ExtraData = null
+);
diff --git a/FinlyticCore/Dtos/TechnicalAnalysis/StrategyResultDto.cs b/FinlyticCore/Dtos/TechnicalAnalysis/StrategyResultDto.cs
new file mode 100644
index 0000000..e0c84c4
--- /dev/null
+++ b/FinlyticCore/Dtos/TechnicalAnalysis/StrategyResultDto.cs
@@ -0,0 +1,49 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace FinlyticCore.Dtos.TechnicalAnalysis;
+
+///
+/// Fully evaluated technical trading setup output from an ITechnicalStrategy.
+///
+///
+/// Which FinlyticTechnicals universe-selection mechanism this ISIN was being monitored under at analysis time
+/// (favorite/discovery/sentiment-spike), or 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
+/// EngineEvaluationSnapshotEntity 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.
+///
+/// When the ISIN above entered that scan universe, alongside .
+///
+/// The overall market/asset technical 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 (AiReasoningGateService) so the model has the same regime context a human trader
+/// would use to judge whether a breakout is likely to follow through.
+///
+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 TriggeringPatterns,
+ [property: JsonPropertyName("indicatorSnapshot")] Dictionary 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
+);
diff --git a/FinlyticCore/Dtos/TechnicalAnalysis/TechnicalContext.cs b/FinlyticCore/Dtos/TechnicalAnalysis/TechnicalContext.cs
new file mode 100644
index 0000000..b8611d5
--- /dev/null
+++ b/FinlyticCore/Dtos/TechnicalAnalysis/TechnicalContext.cs
@@ -0,0 +1,80 @@
+using System;
+using System.Collections.Generic;
+
+namespace FinlyticCore.Dtos.TechnicalAnalysis;
+
+///
+/// Execution context supplied to pattern detectors and strategy evaluators containing multi-timeframe candles and indicators.
+///
+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;
+
+ ///
+ /// Multi-timeframe historical candles (e.g. "1m", "5m", "15m", "1h", "1d").
+ ///
+ public Dictionary> MultiTimeframeCandles { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Pre-calculated mathematical indicator values for the primary timeframe.
+ ///
+ public Dictionary Indicators { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Per-run overrides for a strategy's tunable indicator parameters (e.g. "MeanReversion.RsiOversold"),
+ /// keyed by "{StrategyKey}.{ParameterName}" so a single context could in principle carry overrides
+ /// for more than one strategy without name collisions. Always empty for live scanning
+ /// (TechnicalScoringEngine 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
+ /// FinlyticSimulation.Engine.HistoricalReplayRunner from BacktestRequestDto.StrategyParameters,
+ /// so per-asset/per-strategy tuning is opt-in and scoped to backtesting. See .
+ ///
+ public Dictionary ParameterOverrides { get; init; } = new(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Resolves a tunable strategy parameter: the override in under
+ /// "{strategyKey}.{parameterName}" if present, otherwise (the
+ /// strategy's own hardcoded default, unchanged from before parametrization existed).
+ ///
+ public decimal GetParameter(string strategyKey, string parameterName, decimal defaultValue)
+ {
+ return ParameterOverrides.TryGetValue($"{strategyKey}.{parameterName}", out var v) ? v : defaultValue;
+ }
+
+ ///
+ /// Gets the candles for a specific timeframe (defaults to empty list if not found).
+ ///
+ public IReadOnlyList GetCandles(string timeframe)
+ {
+ if (MultiTimeframeCandles.TryGetValue(timeframe, out var list))
+ {
+ return list;
+ }
+ return [];
+ }
+
+ ///
+ /// Gets the primary timeframe candle sequence.
+ ///
+ public IReadOnlyList PrimaryCandles => GetCandles(Timeframe);
+
+ ///
+ /// Gets a specific indicator value or null if not computed.
+ ///
+ public decimal? GetIndicator(string key)
+ {
+ if (Indicators.TryGetValue(key, out var val))
+ {
+ return val;
+ }
+ return null;
+ }
+}
diff --git a/FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs b/FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs
new file mode 100644
index 0000000..313bebd
--- /dev/null
+++ b/FinlyticCore/Dtos/TechnicalAnalysis/TechnicalEnums.cs
@@ -0,0 +1,124 @@
+using System.Text.Json.Serialization;
+
+namespace FinlyticCore.Dtos.TechnicalAnalysis;
+
+///
+/// Major category of a chart pattern.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum PatternCategory
+{
+ Candlestick,
+ Chart,
+ SmartMoney
+}
+
+///
+/// Directional bias indicated by a pattern or technical setup.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum PatternBias
+{
+ Bullish,
+ Bearish,
+ Neutral
+}
+
+///
+/// Specific pattern type recognized by pattern detection engines.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+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
+}
+
+///
+/// Strategy exit model defining how positions are closed or trailed.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ExitStrategyType
+{
+ StagedScaleOutWithBreakEven,
+ PureTrailingStop,
+ DynamicBandTouch,
+ FixedSingleTarget,
+ IndicatorReversal
+}
+
+///
+/// Type of trailing stop mechanic.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum TrailingStopType
+{
+ AtrMultiplier,
+ SuperTrendLine,
+ SwingPoints
+}
+
+///
+/// Direction of a technical trading setup signal.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum SignalDirection
+{
+ Buy,
+ Sell,
+ Neutral
+}
+
+///
+/// Overall market or asset technical regime.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum MarketRegime
+{
+ BullishTrending,
+ BearishTrending,
+ HighVolatilityChoppy,
+ LowVolatilityRangebound
+}
+
+///
+/// Which recurring FinlyticTechnicals selection mechanism added an ISIN to the continuously-scanned universe
+/// (TechnicalUniverseManager in FinlyticTechnicals). Defined here rather than in FinlyticTechnicals
+/// because it is carried on across the MQTT boundary into
+/// FinlyticEngine's evaluation snapshot, so more than one service needs it (Rules.md §3).
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum UniverseSource
+{
+ /// Promoted temporarily because FinlyticSentiment reported a strong/shifting sentiment reading.
+ SentimentSpike = 1,
+
+ /// Favorited by at least one user, aggregated across all users via FinlyticBackend.
+ UserFavorite = 2,
+
+ /// Part of FinlyticAssets' curated discovery/watchlist asset set.
+ Discovery = 3
+}
diff --git a/FinlyticCore/Dtos/TechnicalAnalysis/WatchlistEntryDto.cs b/FinlyticCore/Dtos/TechnicalAnalysis/WatchlistEntryDto.cs
new file mode 100644
index 0000000..b2ffc9a
--- /dev/null
+++ b/FinlyticCore/Dtos/TechnicalAnalysis/WatchlistEntryDto.cs
@@ -0,0 +1,26 @@
+using System;
+
+namespace FinlyticCore.Dtos.TechnicalAnalysis;
+
+///
+/// A single entry of FinlyticTechnicals' currently monitored scan universe ("watchlist") - the DB-backed set
+/// of assets TechnicalScannerBackgroundService 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.
+///
+public record WatchlistEntryDto(
+ string Isin,
+ string? Symbol,
+ string Source,
+ int Priority,
+ DateTime AddedAtUtc,
+ DateTime? ExpiresAtUtc
+);
+
+///
+/// Requests the last technical-analysis setups computed for ,
+/// 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.
+///
+public record GetRecentSetupHistoryRequest(string Isin, int Limit = 8);
diff --git a/FinlyticCore/Dtos/TradeRepublic/TradeRepublicTickerResponse.cs b/FinlyticCore/Dtos/TradeRepublic/TradeRepublicTickerResponse.cs
index fb683de..f069523 100644
--- a/FinlyticCore/Dtos/TradeRepublic/TradeRepublicTickerResponse.cs
+++ b/FinlyticCore/Dtos/TradeRepublic/TradeRepublicTickerResponse.cs
@@ -1,17 +1,18 @@
using System;
-using System.Globalization;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.TradeRepublic;
public record TradeRepublicPriceTick(
- [property: JsonPropertyName("time")] long Time,
- [property: JsonPropertyName("price")] string Price,
- [property: JsonPropertyName("size")] decimal Size
+ [property: JsonPropertyName("time")] long? Time = null,
+ [property: JsonPropertyName("price"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] decimal Price = 0m,
+ [property: JsonPropertyName("size")] decimal? Size = null
)
{
- public decimal PriceValue => decimal.TryParse(Price, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0m;
- public DateTime DateTimeUtc => DateTimeOffset.FromUnixTimeMilliseconds(Time).UtcDateTime;
+ public decimal PriceValue => Price;
+ public DateTime DateTimeUtc => Time.HasValue && Time.Value > 0
+ ? DateTimeOffset.FromUnixTimeMilliseconds(Time.Value).UtcDateTime
+ : DateTime.UtcNow;
}
public record TradeRepublicTickerResponse(
diff --git a/FinlyticCore/Dtos/Trading/EngineTradeDtos.cs b/FinlyticCore/Dtos/Trading/EngineTradeDtos.cs
new file mode 100644
index 0000000..df15250
--- /dev/null
+++ b/FinlyticCore/Dtos/Trading/EngineTradeDtos.cs
@@ -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
+);
+
+///
+/// Kennzeichnet die Herkunft einer -Entscheidung, damit
+/// Konsumenten (Frontend, Logs) eine echte KI-Analyse von einer regelbasierten Ersatzentscheidung
+/// unterscheiden können. Der Enum-Wert ist absichtlich der Default (0), damit ein
+/// vom N8N-Webhook geliefertes JSON, das dieses Feld (noch) nicht setzt, korrekt als KI-Ergebnis
+/// interpretiert wird.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum ValidationSource
+{
+ Ai,
+ RuleBased
+}
+
+///
+/// Ergebnis des AI-Reasoning-Gates. ist nur gesetzt, wenn
+/// den Wert hat, da eine Konfidenz ohne tatsächliche KI-Bewertung
+/// erfunden wäre (Rules.md §4).
+///
+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 KeyCatalysts,
+ [property: JsonPropertyName("identifiedRisks")] List 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
+);
+
+///
+/// Full result of ITradeLifecycleService.EvaluateAssetAsync, carrying both possible outcomes of the
+/// evaluation pipeline (technicals, sentiment, fundamentals, simulation-reliability, AI reasoning gate):
+/// an accepted opportunity ( is set) or a rejection, in which case
+/// is 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).
+///
+/// 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 0 and carries a
+/// "[Regelbasiert]"-prefixed explanation — the same prefix uses for
+/// its fallback — so a caller/UI can recognize this is not a real
+/// AI verdict, just as it already must for a rule-based .
+///
+///
+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 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 Fills,
+ [property: JsonPropertyName("openedAtUtc")] DateTime OpenedAtUtc,
+ [property: JsonPropertyName("closedAtUtc")] DateTime? ClosedAtUtc
+);
diff --git a/FinlyticCore/Dtos/Trading/EvaluationHistoryDtos.cs b/FinlyticCore/Dtos/Trading/EvaluationHistoryDtos.cs
new file mode 100644
index 0000000..45c2745
--- /dev/null
+++ b/FinlyticCore/Dtos/Trading/EvaluationHistoryDtos.cs
@@ -0,0 +1,121 @@
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+
+namespace FinlyticCore.Dtos.Trading;
+
+///
+/// Filter/pagination request for the admin-only evaluation-history RPC channel
+/// (MqttTopics.Channels.EngineGetEvaluationHistory), served by FinlyticEngine and exposed to the Web UI
+/// via FinlyticBackend/Controllers/AdminEvaluationHistoryController. All filters are optional and are
+/// combined with logical AND; means "do not filter on this field".
+///
+/// Inclusive lower bound on EngineEvaluationSnapshotEntity.EvaluatedAtUtc.
+/// Inclusive upper bound on EngineEvaluationSnapshotEntity.EvaluatedAtUtc.
+/// Restricts results to a single .
+/// Restricts results to a single .
+///
+/// Case-sensitive substring search against both Isin and Symbol (matches either). Trimmed
+/// server-side; blank/whitespace-only values are treated as "no search".
+///
+/// 1-based page number. Values below 1 are treated as 1 server-side.
+///
+/// Requested page size. Server-side clamped to at least 1 and at most 200 (see
+/// EvaluationHistoryService.MaxPageSize) so a caller cannot force FinlyticEngine to materialize/transmit
+/// an unbounded result set in a single response.
+///
+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
+);
+
+///
+/// One row of the evaluation history: the full, already-persisted record of a single
+/// TradeLifecycleService.EvaluateAssetAsync run, mapped 1:1 from EngineEvaluationSnapshotEntity.
+/// Every score field is the real, already-computed value - including the honest "0/default" values recorded
+/// for the case, never a fabricated placeholder (Rules.md §4).
+///
+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
+);
+
+///
+/// Number of evaluation-history rows matching a given filter set that carry a specific .
+/// A typed list of these (rather than a Dictionary<OutcomeReason,int>) is used on
+/// purely so this DTO round-trips through
+/// System.Text.Json (including the AOT source-generated FinlyticJsonSerializerContext) without needing a
+/// custom enum-keyed dictionary converter.
+///
+public record OutcomeReasonCountDto(
+ [property: JsonPropertyName("outcomeReason")] OutcomeReason OutcomeReason,
+ [property: JsonPropertyName("count")] int Count
+);
+
+///
+/// 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 is scoped to
+/// exactly the same filters as the paginated list it accompanies (see
+/// ) - only pagination (Page/PageSize) does not apply,
+/// since these are totals over the whole filtered set, not just the current page.
+///
+/// Total number of snapshot rows matching the request's filters (unpaginated).
+/// Breakdown of by .
+///
+/// Average CompositeOpportunityScore across the filtered set; 0 when is 0.
+///
+///
+/// Number of filtered rows whose is
+/// - i.e. the same value as the entry
+/// in , exposed directly so the UI does not need to search that list.
+///
+///
+/// Timestamp of the most recently created EngineTradeProposalEntity across the ENTIRE proposals table -
+/// deliberately NOT scoped to this request's FromUtc/ToUtc 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. only if no proposal has ever been created.
+///
+public record EvaluationHistorySummaryDto(
+ [property: JsonPropertyName("totalEvaluations")] int TotalEvaluations,
+ [property: JsonPropertyName("countsByOutcome")] List CountsByOutcome,
+ [property: JsonPropertyName("averageCompositeScore")] decimal AverageCompositeScore,
+ [property: JsonPropertyName("proposalsCreated")] int ProposalsCreated,
+ [property: JsonPropertyName("lastProposalCreatedAtUtc")] System.DateTime? LastProposalCreatedAtUtc
+);
+
+///
+/// Full response for the evaluation-history RPC channel: a page of matching rows, the total match count (for
+/// pagination), and a pre-aggregated 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.
+///
+public record GetEvaluationHistoryResponse(
+ [property: JsonPropertyName("totalCount")] int TotalCount,
+ [property: JsonPropertyName("entries")] List Entries,
+ [property: JsonPropertyName("summary")] EvaluationHistorySummaryDto Summary
+);
diff --git a/FinlyticCore/Dtos/Trading/TradeEnums.cs b/FinlyticCore/Dtos/Trading/TradeEnums.cs
new file mode 100644
index 0000000..6602c9d
--- /dev/null
+++ b/FinlyticCore/Dtos/Trading/TradeEnums.cs
@@ -0,0 +1,110 @@
+using System.Text.Json.Serialization;
+
+namespace FinlyticCore.Dtos.Trading;
+
+[JsonConverter(typeof(JsonStringEnumConverter))]
+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))]
+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))]
+public enum InstrumentCategoryType
+{
+ Stock,
+ Etf,
+ TurboLong,
+ TurboShort,
+ FactorCertificate
+}
+
+///
+/// Identifies whether an EngineEvaluationSnapshotEntity row was produced by the autonomous
+/// OpportunityPollerBackgroundService scan loop or by an on-demand, human-initiated call (Web UI
+/// "Analyze now" / EngineController.EvaluateAsset / AnalyzeController.TriggerManualAnalysis).
+/// is deliberately value 0 (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 ).
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum TriggerSource
+{
+ Unknown = 0,
+ Automatic = 1,
+ Manual = 2
+}
+
+///
+/// Classifies why a single asset evaluation in TradeLifecycleService.EvaluateAssetAsync did or did not
+/// result in a trade proposal. is deliberately value 0 (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
+/// TradeLifecycleService for the exact priority order applied when more than one gate failed at once.
+///
+[JsonConverter(typeof(JsonStringEnumConverter))]
+public enum OutcomeReason
+{
+ Unknown = 0,
+
+ /// The AI reasoning gate approved the opportunity and a EngineTradeProposalEntity was created.
+ Approved = 1,
+
+ ///
+ /// ScoringResult.CompositeScore stayed below Engine.MinCompositeScore and the evaluation was
+ /// not forced, so the AI reasoning gate was never even consulted (a synthetic rule-based rejection was
+ /// recorded instead).
+ ///
+ BelowScoreThreshold = 2,
+
+ /// The asset is within the earnings blackout window (Engine.EarningsLockoutDays).
+ EarningsLockout = 3,
+
+ /// FinlyticSimulation's backtest-reliability matrix vetoed this strategy/asset combination.
+ SimulationVeto = 4,
+
+ ///
+ /// 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.
+ ///
+ AiRejected = 5,
+
+ ///
+ /// 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 0/default rather than fabricated.
+ ///
+ NoTechnicalSetups = 6,
+
+ ///
+ /// Not a real rejection: the evaluation genuinely cleared every gate and the AI reasoning gate approved the
+ /// opportunity (PassedAiValidation on this same row is ), but an active,
+ /// non-expired EngineTradeProposalEntity for the same UnderlyingIsin already exists, so no
+ /// second, near-identical proposal row was created and no finlytic/engine/proposals/created 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.
+ ///
+ DuplicateActiveProposal = 7,
+
+ /// The asset is within the ex-dividend blackout window (Engine.DividendGateDays).
+ DividendGate = 8
+}
+
diff --git a/FinlyticCore/FinlyticCore.csproj b/FinlyticCore/FinlyticCore.csproj
index 36ed671..a4c5449 100644
--- a/FinlyticCore/FinlyticCore.csproj
+++ b/FinlyticCore/FinlyticCore.csproj
@@ -9,6 +9,7 @@
+
diff --git a/FinlyticCore/Models/Analyzer/AssetRecommendationDto.cs b/FinlyticCore/Models/Analyzer/AssetRecommendationDto.cs
deleted file mode 100644
index 5c771d7..0000000
--- a/FinlyticCore/Models/Analyzer/AssetRecommendationDto.cs
+++ /dev/null
@@ -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 Support { get; set; } = new();
-
- [JsonPropertyName("resistance")]
- public List Resistance { get; set; } = new();
-}
diff --git a/FinlyticCore/Models/Analyzer/ManualAnalysisResponseDto.cs b/FinlyticCore/Models/Analyzer/ManualAnalysisResponseDto.cs
deleted file mode 100644
index 7d57d0f..0000000
--- a/FinlyticCore/Models/Analyzer/ManualAnalysisResponseDto.cs
+++ /dev/null
@@ -1,31 +0,0 @@
-using System.Text.Json.Serialization;
-using FinlyticCore.Models.Trades;
-
-namespace FinlyticCore.Models.Analyzer;
-
-///
-/// Response payload for manual AI analysis trigger RPC.
-///
-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;
-}
diff --git a/FinlyticCore/Models/Analyzer/N8nAnalysisRequestDto.cs b/FinlyticCore/Models/Analyzer/N8nAnalysisRequestDto.cs
deleted file mode 100644
index 6f9e4e3..0000000
--- a/FinlyticCore/Models/Analyzer/N8nAnalysisRequestDto.cs
+++ /dev/null
@@ -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 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();
-}
diff --git a/FinlyticCore/Models/Analyzer/N8nAnalysisResponseDto.cs b/FinlyticCore/Models/Analyzer/N8nAnalysisResponseDto.cs
deleted file mode 100644
index 9dcb266..0000000
--- a/FinlyticCore/Models/Analyzer/N8nAnalysisResponseDto.cs
+++ /dev/null
@@ -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? 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;
-}
diff --git a/FinlyticCore/Models/Auth/ITradeClient.cs b/FinlyticCore/Models/Auth/ITradeClient.cs
deleted file mode 100644
index b1a3527..0000000
--- a/FinlyticCore/Models/Auth/ITradeClient.cs
+++ /dev/null
@@ -1,16 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using FinlyticCore.Models.Trades;
-
-namespace FinlyticCore.Models.Auth;
-
-///
-/// Strongly typed SignalR client interface for real-time WebSocket/SSE streaming.
-///
-public interface ITradeClient
-{
- Task OnTradeProposed(TradeProposalDto proposal);
- Task OnTradeUpdated(TradeHourlyUpdateDto update);
- Task OnTradeClosed(string tradeId, decimal exitPrice, string reason);
- Task OnNewsReceived(object newsItem);
-}
diff --git a/FinlyticCore/Models/Auth/RegisterRequestDto.cs b/FinlyticCore/Models/Auth/RegisterRequestDto.cs
deleted file mode 100644
index ce1049a..0000000
--- a/FinlyticCore/Models/Auth/RegisterRequestDto.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-namespace FinlyticCore.Models.Auth;
-
-///
-/// DTO representing a request for self-registration by a new user.
-///
-public class RegisterRequestDto
-{
- ///
- /// User email address.
- ///
- public string Email { get; set; } = string.Empty;
-
- ///
- /// User plain-text password.
- ///
- public string Password { get; set; } = string.Empty;
-
- ///
- /// User full name.
- ///
- public string FullName { get; set; } = string.Empty;
-}
diff --git a/FinlyticCore/Models/MqttConfiguration.cs b/FinlyticCore/Models/MqttConfiguration.cs
index 272209e..75cf38e 100644
--- a/FinlyticCore/Models/MqttConfiguration.cs
+++ b/FinlyticCore/Models/MqttConfiguration.cs
@@ -1,3 +1,6 @@
+using System;
+using Microsoft.Extensions.Configuration;
+
namespace FinlyticCore.Models;
///
@@ -29,4 +32,51 @@ public class MqttConfiguration
/// Gets or sets the password for authentication (optional).
///
public string? Password { get; set; }
+
+ ///
+ /// Builds an from application configuration, understanding both the
+ /// colon-separated key style (MQTT:Host, used by appsettings.json) and the double-underscore
+ /// style (MQTT__Host, 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.
+ ///
+ /// The application configuration to read MQTT settings from.
+ ///
+ /// The service-specific client ID prefix to fall back to when no MQTT:ClientId/MQTT__ClientId
+ /// 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.
+ ///
+ ///
+ /// A populated . and are left
+ /// unless both are actually configured, so connections to brokers without
+ /// authentication enabled remain anonymous and continue to work unchanged.
+ ///
+ /// Thrown when is .
+ 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
+ };
+ }
}
\ No newline at end of file
diff --git a/FinlyticCore/Models/Settings/LogLevelEnum.cs b/FinlyticCore/Models/Settings/LogLevelEnum.cs
deleted file mode 100644
index 4b566a0..0000000
--- a/FinlyticCore/Models/Settings/LogLevelEnum.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-namespace FinlyticCore.Models.Settings;
-
-public enum LogLevelEnum
-{
- None,
- Debug,
- Info,
- Error
-}
\ No newline at end of file
diff --git a/FinlyticCore/Models/Trades/CloseTradeRequest.cs b/FinlyticCore/Models/Trades/CloseTradeRequest.cs
deleted file mode 100644
index 3399e59..0000000
--- a/FinlyticCore/Models/Trades/CloseTradeRequest.cs
+++ /dev/null
@@ -1,15 +0,0 @@
-using System;
-
-namespace FinlyticCore.Models.Trades;
-
-///
-/// Request payload for manually closing an active trade via REST API.
-///
-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"
-}
-
diff --git a/FinlyticCore/Models/Trades/TradeFeedbackRecord.cs b/FinlyticCore/Models/Trades/TradeFeedbackRecord.cs
deleted file mode 100644
index 580d25f..0000000
--- a/FinlyticCore/Models/Trades/TradeFeedbackRecord.cs
+++ /dev/null
@@ -1,35 +0,0 @@
-using System;
-using FinlyticCore.Models.Analyzer;
-
-namespace FinlyticCore.Models.Trades;
-
-///
-/// Structured closed trade record exported to JSON/Parquet for AI win-rate calibration feedback loops.
-///
-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; }
-}
diff --git a/FinlyticCore/Models/Trades/TradeProposalDto.cs b/FinlyticCore/Models/Trades/TradeProposalDto.cs
deleted file mode 100644
index 98d66a3..0000000
--- a/FinlyticCore/Models/Trades/TradeProposalDto.cs
+++ /dev/null
@@ -1,170 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Text.Json.Serialization;
-using FinlyticCore.Models.Analyzer;
-
-namespace FinlyticCore.Models.Trades;
-
-///
-/// Trade proposal generated by FinlyticAnalyzer and dispatched via MQTT QoS 2.
-///
-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 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? 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? HourlyUpdates { get; set; }
-
- [JsonPropertyName("createdAt")]
- public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
-}
diff --git a/FinlyticCore/Models/Trades/TradeStatus.cs b/FinlyticCore/Models/Trades/TradeStatus.cs
deleted file mode 100644
index c429b35..0000000
--- a/FinlyticCore/Models/Trades/TradeStatus.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-namespace FinlyticCore.Models.Trades;
-
-///
-/// Status of a proposed/active trade lifecycle.
-///
-public enum TradeStatus
-{
- Proposed = 0,
- Active = 1,
- Closed = 2,
- Expired = 3,
- Rejected = 4,
- Invalidated = 5
-}
diff --git a/FinlyticCore/Project.md b/FinlyticCore/Project.md
deleted file mode 100644
index 26d96d9..0000000
--- a/FinlyticCore/Project.md
+++ /dev/null
@@ -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.
diff --git a/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs b/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs
index 3ce4b91..b859c62 100644
--- a/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs
+++ b/FinlyticCore/Services/FinlyticLogger/FinlyticLogger.cs
@@ -1,4 +1,6 @@
using System;
+using System.Globalization;
+using System.Text.RegularExpressions;
using System.Threading.Tasks;
using FinlyticCore.Dtos.Logging;
using FinlyticCore.Models.Settings;
@@ -77,31 +79,58 @@ public class FinlyticLogger : IFinlyticLogger
_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);
+
+ ///
+ /// Substitutes every named placeholder in with the corresponding entry of
+ /// , in order of appearance - the same positional mapping
+ /// ILogger.LogInformation(message, args) itself performs internally for structured-logging message
+ /// templates. string.Format(message, args) (the previous implementation) expects numeric
+ /// placeholders ("{0}") instead, throws a 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.
+ ///
+ 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 channelKey, LogLevel level, string message, Exception? exception, params object[] args)
{
- try
- {
- string formattedMsg = args != null && args.Length > 0 ? string.Format(message, args) : message;
- FinlyticLogBroadcaster.Broadcast(new LogMessageDto(
- Timestamp: DateTime.UtcNow,
- ServiceName: ServiceName,
- Channel: channelKey.Name,
- 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()
- ));
- }
+ FinlyticLogBroadcaster.Broadcast(new LogMessageDto(
+ Timestamp: DateTime.UtcNow,
+ ServiceName: ServiceName,
+ Channel: channelKey.Name,
+ Level: level.ToString(),
+ Message: FormatLogMessage(message, args),
+ Exception: exception?.ToString()
+ ));
}
#region Debug
diff --git a/FinlyticCore/Services/Settings/SettingsService.cs b/FinlyticCore/Services/Settings/SettingsService.cs
index 01cf375..3b470b6 100644
--- a/FinlyticCore/Services/Settings/SettingsService.cs
+++ b/FinlyticCore/Services/Settings/SettingsService.cs
@@ -108,11 +108,10 @@ public class SettingsService : ISettingsService
IEnumerable? customKeyHolders = null,
CancellationToken cancellationToken = default)
{
- var holderTypes = new List { typeof(CoreSettingKeys) };
- if (customKeyHolders != null)
- {
- holderTypes.AddRange(customKeyHolders);
- }
+ var isCustomScoped = customKeyHolders != null && customKeyHolders.Any();
+ var holderTypes = isCustomScoped
+ ? customKeyHolders!.ToList()
+ : new List { typeof(CoreSettingKeys) };
var resultList = new List();
var seenKeys = new HashSet(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
- try
+ // 2. Prüfen, ob in der DB weitere gespeicherte Settings existieren (nur wenn nicht strikt auf custom KeyHolders begrenzt)
+ if (!isCustomScoped)
{
- await using var scope = _scopeFactory.CreateAsyncScope();
- var dbContext = scope.ServiceProvider.GetService();
- if (dbContext != null)
+ try
{
- var dbSettings = await dbContext.DynamicSettings.AsNoTracking().ToListAsync(cancellationToken);
- foreach (var dbSetting in dbSettings)
+ await using var scope = _scopeFactory.CreateAsyncScope();
+ var dbContext = scope.ServiceProvider.GetService();
+ 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);
- var (inferredVal, inferredType) = InferJsonValueAndType(dbSetting.ValueJson);
- resultList.Add(new DynamicSettingDto(
- Key: dbSetting.Key,
- Value: inferredVal,
- Type: inferredType,
- Description: FormatDescriptionFromKey(dbSetting.Key),
- UpdatedAt: dbSetting.LastUpdatedUtc
- ));
+ if (!seenKeys.Contains(dbSetting.Key))
+ {
+ seenKeys.Add(dbSetting.Key);
+ var (inferredVal, inferredType) = InferJsonValueAndType(dbSetting.ValueJson);
+ resultList.Add(new DynamicSettingDto(
+ Key: dbSetting.Key,
+ Value: inferredVal,
+ Type: inferredType,
+ Description: FormatDescriptionFromKey(dbSetting.Key),
+ UpdatedAt: dbSetting.LastUpdatedUtc
+ ));
+ }
}
}
}
- }
- catch (Exception ex)
- {
- _logger?.LogWarning(ex, "[SettingsService] Error reading database settings during GetAllRegisteredSettingsAsync.");
+ catch (Exception ex)
+ {
+ _logger?.LogWarning(ex, "[SettingsService] Error reading database settings during GetAllRegisteredSettingsAsync.");
+ }
}
return resultList.OrderBy(s => s.Key).ToList();
diff --git a/FinlyticCore/Services/Yahoo/YahooFinanceScraper.cs b/FinlyticCore/Services/Yahoo/YahooFinanceScraper.cs
index fa5c527..c4d7bc6 100644
--- a/FinlyticCore/Services/Yahoo/YahooFinanceScraper.cs
+++ b/FinlyticCore/Services/Yahoo/YahooFinanceScraper.cs
@@ -8,7 +8,7 @@ using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Yahoo;
using FinlyticCore.Models.Settings;
using FinlyticCore.Services;
-using FinlyticCore.Utils;
+using FinlyticCore.Util;
using Microsoft.Extensions.Configuration;
namespace FinlyticCore.Services.Yahoo;
diff --git a/FinlyticCore/Utils/CryptoSubtitleResolver.cs b/FinlyticCore/Util/CryptoSubtitleResolver.cs
similarity index 99%
rename from FinlyticCore/Utils/CryptoSubtitleResolver.cs
rename to FinlyticCore/Util/CryptoSubtitleResolver.cs
index dc03d55..eea0b1a 100644
--- a/FinlyticCore/Utils/CryptoSubtitleResolver.cs
+++ b/FinlyticCore/Util/CryptoSubtitleResolver.cs
@@ -2,7 +2,7 @@ using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Npgsql;
-namespace FinlyticCore.Utils;
+namespace FinlyticCore.Util;
///
/// Resolves the crypto subtitle/ticker (e.g. "BTC", "ETH", "SOL") for Trade Republic internal ISINs starting with 'X'.
diff --git a/FinlyticCore/Util/FinlyticJsonSerializerContext.cs b/FinlyticCore/Util/FinlyticJsonSerializerContext.cs
index 6274d63..0bdecee 100644
--- a/FinlyticCore/Util/FinlyticJsonSerializerContext.cs
+++ b/FinlyticCore/Util/FinlyticJsonSerializerContext.cs
@@ -6,9 +6,8 @@ using FinlyticCore.Dtos.Sentiment;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Yahoo;
using FinlyticCore.Models.Trades;
-using FinlyticCore.Models.Analyzer;
using System.Collections.Generic;
-using FinlyticAssets.Models;
+using FinlyticCore.Models.Assets;
namespace FinlyticCore.Util;
@@ -16,18 +15,11 @@ namespace FinlyticCore.Util;
WriteIndented = false,
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
-[JsonSerializable(typeof(TradeProposalDto))]
-[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Logging.LogMessageDto))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(TradeAcceptanceDto))]
[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(CloseTradeRequest))]
-[JsonSerializable(typeof(ManualAnalysisResponseDto))]
-[JsonSerializable(typeof(N8nAnalysisResponseDto))]
[JsonSerializable(typeof(TradeHourlyUpdateDto))]
-[JsonSerializable(typeof(TradeFeedbackRecord))]
-[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(NewsArticleDto))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(DiscoveredArticle))]
@@ -52,9 +44,12 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(IsinSentimentSummaryDto))]
[JsonSerializable(typeof(IsinAnalysisEntry))]
[JsonSerializable(typeof(SectorSentimentSummaryDto))]
+[JsonSerializable(typeof(GetSentimentByIsinRequest))]
+[JsonSerializable(typeof(GetSectorSentimentRequest))]
[JsonSerializable(typeof(CandleDto))]
[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(IReadOnlyList))]
[JsonSerializable(typeof(ChartPatternDto))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(IndicatorValuesDto))]
@@ -62,6 +57,9 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(MarketRegimeDto))]
[JsonSerializable(typeof(StrategySignalDto))]
[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(StrategyResultDto))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(UniverseSource))]
[JsonSerializable(typeof(TechnicalAnalysisDto))]
[JsonSerializable(typeof(LivePriceDto))]
[JsonSerializable(typeof(string))]
@@ -79,13 +77,75 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(AnalyzeSentimentRequest))]
[JsonSerializable(typeof(EmptyRequest))]
[JsonSerializable(typeof(GetEventsByMonthRequest))]
-[JsonSerializable(typeof(ManualAnalysisRpcRequest))]
+[JsonSerializable(typeof(GetTradeProposalsRequest))]
+[JsonSerializable(typeof(GetActiveTradesRequest))]
+[JsonSerializable(typeof(EvaluateAssetRequest))]
+[JsonSerializable(typeof(AddTradeFillRequest))]
+[JsonSerializable(typeof(UpdateTradeStopLossRequest))]
+[JsonSerializable(typeof(CloseEngineTradeRequest))]
+[JsonSerializable(typeof(AcceptTradeProposalRequest))]
+[JsonSerializable(typeof(CreateManualTradeRequest))]
+[JsonSerializable(typeof(RpcFaultCode))]
+[JsonSerializable(typeof(RpcErrorResponse))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.ExecutionMode))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.TradeStatus))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.InstrumentCategoryType))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.TradeProposalDto))]
+[JsonSerializable(typeof(List))]
+[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))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.ActiveTradeDto))]
+[JsonSerializable(typeof(List))]
+[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))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Trading.OutcomeReasonCountDto))]
+[JsonSerializable(typeof(List))]
+[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))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.EquityPointDto))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.BacktestReportDto))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.StrategyAssetReliabilityDto))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.GetReliabilityRequest))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.GetBacktestHistoryRequest))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Simulation.BacktestHistoryEntryDto))]
+[JsonSerializable(typeof(List))]
+[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))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.AccountSummaryDto))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.ExecuteProposalRequest))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotPortfolioSnapshotDto))]
+[JsonSerializable(typeof(List))]
+
[JsonSerializable(typeof(ServiceHealthResponse))]
+
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(FetchLogoResponse))]
[JsonSerializable(typeof(Dictionary))]
-[JsonSerializable(typeof(ServiceConfigUpdatePayload))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.AssetDto))]
[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.StockDto))]
@@ -93,12 +153,14 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.CryptoDto))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.BondDto))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.DerivativeDto))]
+[JsonSerializable(typeof(List))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.SyntheticDto))]
[JsonSerializable(typeof(FinlyticCore.Dtos.Assets.TagDto))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetValidAssetRequest))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.SearchAssetsRequest))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDiscoveryAssetsRequest))]
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDerivativesRequest))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicPriceTick))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerResponse))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerRequest))]
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicConnectRequest))]
@@ -149,8 +211,19 @@ namespace FinlyticCore.Util;
[JsonSerializable(typeof(YahooQuoteResultWrapperDto))]
[JsonSerializable(typeof(YahooQuoteItemDto))]
[JsonSerializable(typeof(List))]
-[JsonSerializable(typeof(N8nAnalysisRequestDto))]
[JsonSerializable(typeof(TickMessageDto))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Settings.DynamicSettingDto))]
+[JsonSerializable(typeof(List))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotStatusDto))]
+[JsonSerializable(typeof(FinlyticCore.Dtos.Bot.BotTradeOrderDto))]
+[JsonSerializable(typeof(List))]
+[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))]
+[JsonSerializable(typeof(Dictionary))]
+[JsonSerializable(typeof(List))]
public partial class FinlyticJsonSerializerContext : JsonSerializerContext
{
}
diff --git a/FinlyticCore/Util/ManagedMqttClient.cs b/FinlyticCore/Util/ManagedMqttClient.cs
index a2feec5..2bd748e 100644
--- a/FinlyticCore/Util/ManagedMqttClient.cs
+++ b/FinlyticCore/Util/ManagedMqttClient.cs
@@ -1,10 +1,12 @@
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
+using FinlyticCore.Dtos;
using FinlyticCore.Models;
using FinlyticCore.Models.Settings;
using FinlyticCore.Services;
@@ -15,11 +17,19 @@ namespace FinlyticCore.Util;
///
/// 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 .
///
public abstract class ManagedMqttClient : IDisposable
{
+ protected static readonly JsonSerializerOptions DefaultJsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
+ };
+
private readonly ILogger _logger;
private readonly ISettingsService? _settingsService;
private readonly IFinlyticLogger? _finlyticLogger;
@@ -29,6 +39,27 @@ public abstract class ManagedMqttClient : IDisposable
// Tracks pending RPC requests waiting for a specific correlation ID reply
private readonly ConcurrentDictionary> _pendingRequests = new();
+ ///
+ /// Literal suffix appended to a normal RPC response topic to build its "fault" sibling topic, e.g.
+ /// services/response/{channel}/{correlationId}/error. 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 TResponse — which matters because a generic RPC client has no way to heuristically tell a
+ /// legitimate TResponse 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.
+ ///
+ private const string ErrorTopicSuffix = "/error";
+
+ // Tracks registered topic handlers for direct routing
+ private readonly ConcurrentDictionary>> _topicHandlers = new(StringComparer.OrdinalIgnoreCase);
+
+ // Tracks all active topic filters for automatic re-subscription on reconnect
+ private readonly ConcurrentDictionary _subscribedTopics = new(StringComparer.OrdinalIgnoreCase);
+
///
/// Gets a value indicating whether the client is currently connected to the MQTT broker.
///
@@ -89,7 +120,6 @@ public abstract class ManagedMqttClient : IDisposable
///
/// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop.
///
- /// The network and credential configuration options for the broker.
public async Task ConnectAsync(MqttConfiguration config)
{
if (IsConnected)
@@ -116,6 +146,7 @@ public abstract class ManagedMqttClient : IDisposable
await _mqttClient.ConnectAsync(options, _cts.Token);
await LogMqttInfoAsync("Successfully connected to MQTT broker.");
+ await ResubscribeAllAsync();
await OnConnectedAsync();
}
catch (Exception ex)
@@ -124,14 +155,22 @@ public abstract class ManagedMqttClient : IDisposable
}
}
+ private bool _disposed;
+
///
/// Gracefully disconnects from the broker and stops all ongoing background loops.
///
public async Task DisconnectAsync()
{
+ if (_disposed) return;
+
if (_cts != null)
{
- await _cts.CancelAsync();
+ try
+ {
+ await _cts.CancelAsync();
+ }
+ catch (ObjectDisposedException) { }
}
if (_mqttClient.IsConnected)
@@ -152,18 +191,269 @@ public abstract class ManagedMqttClient : IDisposable
}
///
- /// Subscribes to a specific MQTT topic filter.
+ /// Subscribes to a specific MQTT topic filter without attaching a direct handler.
///
- /// The topic pattern or wildcard to subscribe to.
- /// If set to true, the broker will not forward messages published by this client back to itself.
- protected async Task SubscribeAsync(string topic, bool noLocal = false)
+ public async Task SubscribeAsync(string topic, bool noLocal = false)
{
+ _subscribedTopics[topic] = noLocal;
+
if (!IsConnected)
{
- _logger.LogWarning("Subscription to topic '{Topic}' delayed: Client is currently offline.", topic);
+ _logger.LogWarning("Subscription to topic '{Topic}' queued: Client is currently offline.", topic);
return;
}
+ await ExecuteSubscriptionAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter and maps an asynchronous raw string handler (topic, payload).
+ ///
+ public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, handler);
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter and maps a synchronous raw string handler (topic, payload).
+ ///
+ public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, (t, p) => { handler(t, p); return Task.CompletedTask; });
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter and maps an asynchronous handler receiving the raw payload string.
+ ///
+ public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, (_, p) => handler(p));
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into ,
+ /// extracts the correlation ID, and invokes the asynchronous handler with (payload, topic, correlationId).
+ ///
+ public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, async (t, p) =>
+ {
+ var data = DeserializePayload(p);
+ var correlationId = ExtractCorrelationId(t);
+ await handler(data, t, correlationId);
+ });
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into ,
+ /// extracts the correlation ID, and invokes the synchronous handler with (payload, topic, correlationId).
+ ///
+ public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, (t, p) =>
+ {
+ var data = DeserializePayload(p);
+ var correlationId = ExtractCorrelationId(t);
+ handler(data, t, correlationId);
+ return Task.CompletedTask;
+ });
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// 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 to "services/response/{channel}/{correlationId}".
+ /// If the request payload cannot be deserialized into , or if
+ /// throws, no response is silently dropped: a typed
+ /// fault is published instead (see ), so a caller using
+ /// observes a specific fault instead of only ever
+ /// hitting its request timeout.
+ ///
+ public async Task SubscribeRpcAsync(string requestTopic, Func> 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(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);
+ }
+
+ ///
+ /// Maps an exception thrown by an RPC handler onto the small, coarse set so the
+ /// caller-side can reconstruct an equivalent standard
+ /// .NET exception type across the MQTT boundary (see for the mapping rationale).
+ ///
+ /// The exception thrown by the RPC handler.
+ /// The fault classification to report to the caller.
+ private static RpcFaultCode ClassifyFault(Exception ex) => ex switch
+ {
+ ArgumentException => RpcFaultCode.InvalidArgument,
+ KeyNotFoundException => RpcFaultCode.NotFound,
+ UnauthorizedAccessException => RpcFaultCode.Unauthorized,
+ InvalidOperationException => RpcFaultCode.Conflict,
+ _ => RpcFaultCode.Internal
+ };
+
+ ///
+ /// 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 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 regardless of which branch is taken.
+ ///
+ /// The exception thrown by the RPC handler.
+ /// A short, safe message describing the fault to an external caller.
+ 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."
+ };
+
+ ///
+ /// Logs an RPC handler fault locally (with full exception detail) and publishes a corresponding
+ /// to the fault sibling of (see
+ /// ), so the caller of
+ /// 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.
+ ///
+ /// The normal ("success") response topic for the failed request.
+ /// The machine-readable fault classification to report.
+ /// The safe, non-sensitive message to report.
+ /// The original exception, logged locally in full but never placed on the wire.
+ 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);
+ }
+ }
+
+ ///
+ /// Registers a server-side RPC handler without correlation ID parameter in the delegate.
+ ///
+ public async Task SubscribeRpcAsync(string requestTopic, Func> handler, bool noLocal = false)
+ {
+ await SubscribeRpcAsync(requestTopic, (req, _) => handler(req), noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into ,
+ /// and invokes the asynchronous handler with (payload, topic).
+ ///
+ public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, async (t, p) =>
+ {
+ var data = DeserializePayload(p);
+ await handler(data, t);
+ });
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into ,
+ /// and invokes the asynchronous handler with the payload.
+ ///
+ public async Task SubscribeAsync(string topic, Func handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, async (_, p) =>
+ {
+ var data = DeserializePayload(p);
+ await handler(data);
+ });
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into ,
+ /// and invokes the synchronous handler with (payload, topic).
+ ///
+ public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, (t, p) =>
+ {
+ var data = DeserializePayload(p);
+ handler(data, t);
+ return Task.CompletedTask;
+ });
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ ///
+ /// Subscribes to a specific MQTT topic filter, automatically deserializes the JSON payload into ,
+ /// and invokes the synchronous handler with the payload.
+ ///
+ public async Task SubscribeAsync(string topic, Action handler, bool noLocal = false)
+ {
+ RegisterTopicHandler(topic, (_, p) =>
+ {
+ var data = DeserializePayload(p);
+ handler(data);
+ return Task.CompletedTask;
+ });
+ await SubscribeAsync(topic, noLocal);
+ }
+
+ private void RegisterTopicHandler(string topic, Func handler)
+ {
+ _topicHandlers.AddOrUpdate(
+ topic,
+ _ => new List> { handler },
+ (_, list) =>
+ {
+ lock (list)
+ {
+ list.Add(handler);
+ }
+ return list;
+ });
+ }
+
+ private async Task ExecuteSubscriptionAsync(string topic, bool noLocal)
+ {
var filterBuilder = new MqttTopicFilterBuilder().WithTopic(topic);
if (noLocal)
{
@@ -178,6 +468,21 @@ public abstract class ManagedMqttClient : IDisposable
await LogMqttDebugAsync("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal);
}
+ private async Task ResubscribeAllAsync()
+ {
+ foreach (var kvp in _subscribedTopics)
+ {
+ try
+ {
+ await ExecuteSubscriptionAsync(kvp.Key, kvp.Value);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to re-subscribe to topic '{Topic}' after reconnect.", kvp.Key);
+ }
+ }
+ }
+
///
/// Publishes a raw string message payload to the specified topic.
///
@@ -198,25 +503,43 @@ public abstract class ManagedMqttClient : IDisposable
///
/// 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.
///
public Task PublishAsync(string topic, T data, bool retain = false)
{
- byte[] jsonBytes;
- var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T))
- ?? (data != null ? FinlyticJsonSerializerContext.Default.GetTypeInfo(data.GetType()) : null);
+ if (!IsConnected)
+ throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
- if (typeInfo != null)
+ byte[] jsonBytes;
+ if (data is string str)
{
- jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, typeInfo);
+ jsonBytes = Encoding.UTF8.GetBytes(str);
+ }
+ else if (data is byte[] b)
+ {
+ jsonBytes = b;
}
else
{
- jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data);
- }
+ try
+ {
+ jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, DefaultJsonOptions);
+ }
+ catch
+ {
+ var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T))
+ ?? (data != null ? FinlyticJsonSerializerContext.Default.GetTypeInfo(data.GetType()) : null);
- if (!IsConnected)
- throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
+ if (typeInfo != null)
+ {
+ jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data, typeInfo);
+ }
+ else
+ {
+ jsonBytes = JsonSerializer.SerializeToUtf8Bytes(data);
+ }
+ }
+ }
var message = new MqttApplicationMessageBuilder()
.WithTopic(topic)
@@ -230,6 +553,7 @@ public abstract class ManagedMqttClient : IDisposable
///
/// Sends a parameterless request to an RPC channel and asynchronously blocks until a matching response arrives.
+ /// See for the exact timeout/fault-propagation contract.
///
public Task SendRpcRequestAsync(
string channel,
@@ -239,10 +563,37 @@ public abstract class ManagedMqttClient : IDisposable
return SendRpcRequestAsync(channel, string.Empty, timeout);
}
+ ///
+ /// Sends a generic request payload to an RPC channel and asynchronously waits for a matching response.
+ /// See for the exact timeout/fault-propagation contract.
+ ///
+ public Task RequestAsync(
+ 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(cleanChannel, requestData, timeout);
+ }
+
///
/// Sends a generic request payload to an RPC channel and asynchronously blocks until a matching response arrives.
/// Uses the topic conventions: services/request/{channel}/{correlationId} and services/response/{channel}/{correlationId}.
+ /// If the serving handler faulted, the server publishes an on the sibling
+ /// error topic () instead of the normal response; this method then throws a
+ /// reconstructed exception (an , ,
+ /// , , or, for anything that does
+ /// not map onto one of those, an ) instead of returning. This lets a caller
+ /// distinguish a specific server-side fault from an unreachable/silent server, which still surfaces as a
+ /// -driven null return exactly as before this fault channel existed.
///
+ /// The remote handler reported .
+ /// The remote handler reported , or the client is offline.
+ /// The remote handler reported .
+ /// The remote handler reported .
+ /// The remote handler reported , or its fault payload could not be parsed.
public async Task SendRpcRequestAsync(
string channel,
TRequest requestData,
@@ -253,21 +604,18 @@ public abstract class ManagedMqttClient : IDisposable
if (!IsConnected)
throw new InvalidOperationException("Cannot execute RPC request: MQTT client is offline.");
- // 1. Generate a unique Correlation ID for this specific transaction
string correlationId = Guid.NewGuid().ToString("N");
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
_pendingRequests.TryAdd(correlationId, tcs);
- string requestTopic = $"services/request/{channel}/{correlationId}";
+ string requestTopic = MqttTopics.RequestTopic(channel, correlationId);
- // 2. Serialize and dispatch via the existing JSON helper
await PublishAsync(requestTopic, requestData);
await LogMqttInfoAsync("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId);
try
{
- // 3. Block asynchronously until the response loop resolves the token
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(25);
var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout);
@@ -276,13 +624,7 @@ public abstract class ManagedMqttClient : IDisposable
return rawJsonResult as TResponse;
}
- var respTypeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(TResponse));
- if (respTypeInfo != null)
- {
- return JsonSerializer.Deserialize(rawJsonResult, respTypeInfo) as TResponse;
- }
-
- return JsonSerializer.Deserialize(rawJsonResult);
+ return DeserializePayload(rawJsonResult);
}
catch (TimeoutException)
{
@@ -291,7 +633,6 @@ public abstract class ManagedMqttClient : IDisposable
}
finally
{
- // Always clean up the dictionary to prevent memory leaks
_pendingRequests.TryRemove(correlationId, out _);
}
}
@@ -307,22 +648,62 @@ public abstract class ManagedMqttClient : IDisposable
await LogMqttDebugAsync("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0);
// Intercept message if it belongs to the RPC response convention
- if (topic.StartsWith("services/response/"))
+ if (topic.StartsWith(MqttTopics.ResponsePrefix))
{
- var lastSlashIndex = topic.LastIndexOf('/');
+ // A fault sibling topic ends in ErrorTopicSuffix (see SubscribeRpcAsync/PublishRpcFaultAsync);
+ // strip it before extracting the correlation ID so both topic shapes resolve the same pending
+ // request. An old client build (pre-dating this suffix) would instead extract "error" itself
+ // as a bogus correlation ID, find no matching pending request below, and fall through to time
+ // out exactly as it did before this fault channel existed - see ErrorTopicSuffix remarks.
+ bool isFault = topic.EndsWith(ErrorTopicSuffix, StringComparison.Ordinal);
+ var correlationTopic = isFault ? topic[..^ErrorTopicSuffix.Length] : topic;
+
+ var lastSlashIndex = correlationTopic.LastIndexOf('/');
if (lastSlashIndex != -1)
{
- string correlationId = topic[(lastSlashIndex + 1)..];
+ string correlationId = correlationTopic[(lastSlashIndex + 1)..];
if (_pendingRequests.TryRemove(correlationId, out var tcs))
{
- tcs.SetResult(payload ?? string.Empty);
+ if (isFault)
+ {
+ tcs.SetException(BuildFaultException(payload ?? string.Empty));
+ }
+ else
+ {
+ tcs.SetResult(payload ?? string.Empty);
+ }
return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles
}
}
}
- // Regular Pub/Sub message propagation
+ // Match registered topic handlers
+ foreach (var kvp in _topicHandlers)
+ {
+ if (TopicMatches(kvp.Key, topic))
+ {
+ List> handlersCopy;
+ lock (kvp.Value)
+ {
+ handlersCopy = new List>(kvp.Value);
+ }
+
+ for (int i = 0; i < handlersCopy.Count; i++)
+ {
+ try
+ {
+ await handlersCopy[i](topic, payload ?? string.Empty);
+ }
+ catch (Exception ex)
+ {
+ OnError(ex);
+ }
+ }
+ }
+ }
+
+ // Regular Pub/Sub message propagation (for overridden OnMessageReceivedAsync)
await OnMessageReceivedAsync(topic, payload ?? string.Empty);
}
catch (Exception ex)
@@ -357,6 +738,7 @@ public abstract class ManagedMqttClient : IDisposable
if (_mqttClient.IsConnected)
{
await LogMqttInfoAsync("MQTT client reconnected successfully after {Attempt} attempt(s).", attempt);
+ await ResubscribeAllAsync();
await OnConnectedAsync();
return;
}
@@ -369,6 +751,114 @@ public abstract class ManagedMqttClient : IDisposable
}
}
+ ///
+ /// Deserializes a JSON string payload into using standard System.Text.Json with fallback.
+ ///
+ public static T? DeserializePayload(string payload)
+ {
+ if (string.IsNullOrWhiteSpace(payload)) return default;
+ if (typeof(T) == typeof(string)) return (T)(object)payload;
+
+ try
+ {
+ return JsonSerializer.Deserialize(payload, DefaultJsonOptions);
+ }
+ catch
+ {
+ var typeInfo = FinlyticJsonSerializerContext.Default.GetTypeInfo(typeof(T));
+ if (typeInfo != null)
+ {
+ return (T?)JsonSerializer.Deserialize(payload, typeInfo);
+ }
+ throw;
+ }
+ }
+
+ ///
+ /// Reconstructs the exception a caller should observe for a fault reported on an RPC error topic (see
+ /// / ). Faults whose
+ /// maps onto a familiar .NET exception type are thrown as that type
+ /// (see ), so pre-existing catch blocks written against the underlying
+ /// service-layer exception types (e.g. in FinlyticBackend 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 .
+ ///
+ /// The raw JSON payload received on the fault topic.
+ /// The exception to throw to the RPC caller.
+ private Exception BuildFaultException(string payload)
+ {
+ RpcErrorResponse? fault;
+ try
+ {
+ fault = DeserializePayload(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)
+ };
+ }
+
+ ///
+ /// Checks whether an MQTT topic matches a topic filter with wildcards ('+' and '#').
+ ///
+ 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;
+ }
+
+ ///
+ /// Extracts the Correlation ID from the end of an RPC request or response topic (e.g. services/request/abc/123 -> 123).
+ ///
+ 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;
+ }
+
///
/// Fired automatically whenever a connection or reconnection is successfully established.
///
@@ -377,7 +867,7 @@ public abstract class ManagedMqttClient : IDisposable
///
/// Fired whenever a new message lands on a registered subscription channel.
///
- protected abstract Task OnMessageReceivedAsync(string topic, string payload);
+ protected virtual Task OnMessageReceivedAsync(string topic, string payload) => Task.CompletedTask;
///
/// Virtual fallback method to catch and handle processing level exceptions inside the incoming pipeline.
@@ -389,9 +879,43 @@ public abstract class ManagedMqttClient : IDisposable
public void Dispose()
{
- DisconnectAsync().GetAwaiter().GetResult();
+ if (_disposed) return;
+ _disposed = true;
+
+ try { DisconnectAsync().GetAwaiter().GetResult(); } catch { }
_cts?.Dispose();
_mqttClient.Dispose();
GC.SuppressFinalize(this);
}
+}
+
+///
+/// Thrown client-side by when a remote
+/// RPC handler reported a fault () whose has no
+/// equivalent standard .NET exception type — i.e. , or a fault payload
+/// that could not be parsed at all. Faults that DO map onto an existing exception type
+/// ( to ,
+/// to ,
+/// to ,
+/// to ) are deliberately
+/// thrown as that familiar type instead of this one: several existing callers (e.g.
+/// FinlyticBackend/Controllers/UserTradesController.cs) already have catch (InvalidOperationException)
+/// / catch (ArgumentException) 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.
+///
+public sealed class RpcFaultException : Exception
+{
+ /// Gets the machine-readable fault classification reported by the remote RPC handler.
+ public RpcFaultCode Code { get; }
+
+ ///
+ /// Initializes a new instance carrying the remote fault's classification and its safe, non-sensitive message.
+ ///
+ /// The machine-readable fault classification reported by the remote RPC handler.
+ /// The safe, non-sensitive message reported by the remote handler.
+ public RpcFaultException(RpcFaultCode code, string message) : base(message)
+ {
+ Code = code;
+ }
}
\ No newline at end of file
diff --git a/FinlyticCore/Util/MqttTopics.cs b/FinlyticCore/Util/MqttTopics.cs
new file mode 100644
index 0000000..270dd70
--- /dev/null
+++ b/FinlyticCore/Util/MqttTopics.cs
@@ -0,0 +1,438 @@
+namespace FinlyticCore.Util;
+
+///
+/// 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.
+///
+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";
+
+ ///
+ /// 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.
+ ///
+ public const string RequestPrefix = RequestRoot + "/";
+
+ ///
+ /// 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.
+ ///
+ public const string ResponsePrefix = ResponseRoot + "/";
+
+ ///
+ /// 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 SendRpcRequestAsync calls can resolve.
+ ///
+ public const string ResponseWildcard = ResponseRoot + "/#";
+
+ ///
+ /// Builds the concrete RPC request topic for a channel and correlation ID: services/request/{channel}/{correlationId}.
+ ///
+ public static string RequestTopic(string channel, string correlationId) => $"{RequestRoot}/{channel}/{correlationId}";
+
+ ///
+ /// Builds the concrete RPC response topic for a channel and correlation ID: services/response/{channel}/{correlationId}.
+ ///
+ public static string ResponseTopic(string channel, string correlationId) => $"{ResponseRoot}/{channel}/{correlationId}";
+
+ ///
+ /// Builds the subscription wildcard filter that matches every request on a given RPC channel: services/request/{channel}/#.
+ ///
+ public static string RequestFilter(string channel) => $"{RequestRoot}/{channel}/#";
+
+ ///
+ /// Named RPC channel identifiers (the {channel} segment of the request/response envelope above),
+ /// grouped by the service that owns/serves each channel.
+ ///
+ public static class Channels
+ {
+ ///
+ /// 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.
+ ///
+ public const string HealthPing = "health_Ping";
+
+ // ---- FinlyticAssets ----
+
+ /// Served by FinlyticAssets: resolves valid assets for an ISIN.
+ public const string AssetsGet = "assets_Get";
+
+ /// Served by FinlyticAssets: returns the curated discovery/watchlist asset set.
+ public const string AssetsGetDiscovery = "assets_GetDiscovery";
+
+ /// Served by FinlyticAssets: resolves derivative instruments for an underlying ISIN.
+ public const string AssetsGetDerivatives = "assets_GetDerivatives";
+
+ /// Served by FinlyticAssets: returns a live Trade Republic price tick for an ISIN.
+ public const string TrGetLivePrice = "tr_GetLivePrice";
+
+ /// Served by FinlyticAssets: returns all dynamic settings for the service.
+ public const string AssetsSettingsGetAll = "assets_settings_GetAll";
+
+ /// Served by FinlyticAssets: applies dynamic setting updates for the service.
+ public const string AssetsSettingsUpdate = "assets_settings_Update";
+
+ // ---- FinlyticNews ----
+
+ /// Served by FinlyticNews: returns filtered/paginated news articles.
+ public const string NewsGet = "news_Get";
+
+ /// Served by FinlyticNews: returns a single article by ID.
+ public const string NewsGetById = "news_GetById";
+
+ /// Served by FinlyticNews: returns articles awaiting downstream sentiment analysis.
+ public const string NewsGetPending = "news_GetPending";
+
+ /// Served by FinlyticNews: updates the processing status of an article.
+ public const string NewsUpdateStatus = "news_UpdateStatus";
+
+ /// Served by FinlyticNews: returns all dynamic settings for the service.
+ public const string NewsSettingsGetAll = "news_settings_GetAll";
+
+ /// Served by FinlyticNews: applies dynamic setting updates for the service.
+ public const string NewsSettingsUpdate = "news_settings_Update";
+
+ // ---- FinlyticSentiment ----
+
+ /// Served by FinlyticSentiment: returns the pre-aggregated sentiment summary for an ISIN.
+ public const string SentimentGetIsin = "sentiment_GetIsin";
+
+ /// Served by FinlyticSentiment: returns the pre-aggregated sentiment summary for a sector.
+ public const string SentimentGetSector = "sentiment_GetSector";
+
+ /// Served by FinlyticSentiment: returns the persisted FinBERT analysis entry for a single article.
+ public const string SentimentGetArticle = "sentiment_GetArticle";
+
+ /// Served by FinlyticSentiment: returns paginated per-company sentiment summaries.
+ public const string SentimentGetAll = "sentiment_GetAll";
+
+ /// Served by FinlyticSentiment: runs FinBERT analysis for an inline article payload or article ID.
+ public const string SentimentAnalyze = "sentiment_Analyze";
+
+ /// Served by FinlyticSentiment: returns all dynamic settings for the service.
+ public const string SentimentSettingsGetAll = "sentiment_settings_GetAll";
+
+ /// Served by FinlyticSentiment: applies dynamic setting updates for the service.
+ public const string SentimentSettingsUpdate = "sentiment_settings_Update";
+
+ // ---- FinlyticFundamentals ----
+
+ /// Served by FinlyticFundamentals: returns fundamentals data for an ISIN/ticker.
+ public const string FundamentalsGet = "fundamentals_Get";
+
+ /// Served by FinlyticFundamentals: returns all known calendar events.
+ public const string EventsGetAll = "events_GetAll";
+
+ /// Served by FinlyticFundamentals: returns calendar events for a given year/month.
+ public const string EventsGetByMonth = "events_GetByMonth";
+
+ /// Served by FinlyticFundamentals: returns all dynamic settings for the service.
+ public const string FundamentalsSettingsGetAll = "fundamentals_settings_GetAll";
+
+ /// Served by FinlyticFundamentals: applies dynamic setting updates for the service.
+ public const string FundamentalsSettingsUpdate = "fundamentals_settings_Update";
+
+ // ---- FinlyticTechnicals ----
+
+ /// Served by FinlyticTechnicals: returns the technical analysis DTO for an ISIN.
+ public const string TaGetAnalysis = "ta_GetAnalysis";
+
+ /// Served by FinlyticTechnicals: returns active strategy setups for a single ISIN.
+ public const string TaGetSetupsForIsin = "ta_GetSetupsForIsin";
+
+ /// Served by FinlyticTechnicals: returns active strategy setups across the universe.
+ public const string TaGetSetups = "ta_GetSetups";
+
+ /// Served by FinlyticTechnicals: returns aggregated candles for an ISIN/timeframe.
+ public const string TaGetCandles = "ta_GetCandles";
+
+ /// Served by FinlyticTechnicals: returns the current monitored scan universe ("watchlist").
+ public const string TaGetWatchlist = "ta_GetWatchlist";
+
+ /// Served by FinlyticTechnicals: returns an ISIN's recent setup/score history (see ).
+ public const string TaGetRecentSetupHistory = "ta_GetRecentSetupHistory";
+
+ /// Served by FinlyticTechnicals: returns all dynamic settings for the service.
+ public const string TaSettingsGetAll = "ta_settings_GetAll";
+
+ /// Served by FinlyticTechnicals: applies dynamic setting updates for the service.
+ public const string TaSettingsUpdate = "ta_settings_Update";
+
+ // ---- FinlyticEngine ----
+
+ /// Served by FinlyticEngine: returns trade proposals.
+ public const string EngineGetProposals = "engine_GetProposals";
+
+ /// Served by FinlyticEngine: returns active trades.
+ public const string EngineGetTrades = "engine_GetTrades";
+
+ /// Served by FinlyticEngine: evaluates a single ISIN and returns a trade proposal if warranted.
+ public const string EngineEvaluateIsin = "engine_EvaluateIsin";
+
+ /// Served by FinlyticEngine: records a fill against an active trade.
+ public const string EngineAddFill = "engine_AddFill";
+
+ /// Served by FinlyticEngine: updates the stop-loss of an active trade.
+ public const string EngineUpdateStopLoss = "engine_UpdateStopLoss";
+
+ /// Served by FinlyticEngine: closes an active trade.
+ public const string EngineCloseTrade = "engine_CloseTrade";
+
+ ///
+ /// Served by FinlyticEngine: accepts a proposal on behalf of one user and creates a trade owned by that
+ /// user. Takes an . 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.
+ ///
+ public const string EngineAcceptProposal = "engine_AcceptProposal";
+
+ ///
+ /// Served by FinlyticEngine: opens a trade owned by one user with no backing proposal (manual entry from
+ /// the Web UI). Takes a . Unlike
+ /// , the resulting trade's ProposalId is .
+ ///
+ public const string EngineCreateManualTrade = "engine_CreateManualTrade";
+
+ ///
+ /// Served by FinlyticEngine: returns a paginated, filtered history of every persisted evaluation
+ /// snapshot (EngineEvaluationSnapshotEntity) for the admin-only "why no proposals" Web UI tab.
+ /// Takes a and returns a
+ /// .
+ ///
+ public const string EngineGetEvaluationHistory = "engine_GetEvaluationHistory";
+
+ /// Served by FinlyticEngine: returns all dynamic settings for the service.
+ public const string EngineSettingsGetAll = "engine_settings_GetAll";
+
+ /// Served by FinlyticEngine: applies dynamic setting updates for the service.
+ public const string EngineSettingsUpdate = "engine_settings_Update";
+
+ // ---- FinlyticSimulation ----
+
+ /// Served by FinlyticSimulation: runs a quantitative backtest.
+ public const string SimRunBacktest = "sim_RunBacktest";
+
+ /// Served by FinlyticSimulation: returns the reliability score for a strategy/asset/timeframe.
+ public const string SimGetReliability = "sim_GetReliability";
+
+ /// Served by FinlyticSimulation: returns the full strategy reliability matrix for an asset.
+ public const string SimGetMatrixForAsset = "sim_GetMatrixForAsset";
+
+ ///
+ /// Served by FinlyticSimulation: returns a paginated, filterable summary history of past backtest runs
+ /// for an ISIN - every run is already persisted (SimulationRunEntity) but was previously only
+ /// reachable indirectly (it fed the reliability matrix), never queryable as a history in its own right.
+ ///
+ public const string SimGetBacktestHistory = "sim_GetBacktestHistory";
+
+ /// Served by FinlyticSimulation: returns the full, already-persisted report (trades + equity curve) for one past backtest run by its RunId.
+ public const string SimGetBacktestRunDetail = "sim_GetBacktestRunDetail";
+
+ /// Served by FinlyticSimulation: returns a saved per-asset/per-strategy indicator parameter profile, or null if none was saved.
+ public const string SimGetStrategyParameters = "sim_GetStrategyParameters";
+
+ /// Served by FinlyticSimulation: saves/updates a per-asset/per-strategy indicator parameter profile.
+ public const string SimSaveStrategyParameters = "sim_SaveStrategyParameters";
+
+ /// Served by FinlyticSimulation: returns all dynamic settings for the service.
+ public const string SimSettingsGetAll = "sim_settings_GetAll";
+
+ /// Served by FinlyticSimulation: applies dynamic setting updates for the service.
+ public const string SimSettingsUpdate = "sim_settings_Update";
+
+ // ---- FinlyticBot ----
+
+ /// Served by FinlyticBot: returns the current paper-trading bot status.
+ public const string BotGetStatus = "bot_GetStatus";
+
+ /// Served by FinlyticBot: returns currently open paper-trading positions.
+ public const string BotGetPositions = "bot_GetPositions";
+
+ /// Served by FinlyticBot: returns the paper-trading account summary.
+ public const string BotGetSummary = "bot_GetSummary";
+
+ /// Served by FinlyticBot: executes a trade proposal as a paper trade.
+ public const string BotExecuteProposal = "bot_ExecuteProposal";
+
+ ///
+ /// 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).
+ ///
+ public const string BotPanicClose = "bot_PanicClose";
+
+ /// Served by FinlyticBot: returns all dynamic settings for the service.
+ public const string BotSettingsGetAll = "bot_settings_GetAll";
+
+ /// Served by FinlyticBot: applies dynamic setting updates for the service.
+ public const string BotSettingsUpdate = "bot_settings_Update";
+
+ // ---- FinlyticBackend ----
+
+ ///
+ /// 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.
+ ///
+ public const string BackendGetAggregatedFavorites = "backend_GetAggregatedFavorites";
+ }
+
+ // ---------------------------------------------------------------------------------------------------
+ // Event / stream topics: plain fire-and-forget pub/sub outside the RPC envelope.
+ // ---------------------------------------------------------------------------------------------------
+
+ ///
+ /// Published by FinlyticNews once an article finishes ingestion and asset matching. Consumed by
+ /// FinlyticSentiment (to trigger analysis) and the FinlyticBackend bridge.
+ ///
+ public const string NewsCompleted = "services/news/completed";
+
+ ///
+ /// Gets the literal prefix ("finlytic/news/") shared by every FinlyticNews event topic.
+ /// and every per-ISIN topic are derived from this constant so a StartsWith check (as used by
+ /// the FinlyticBackend bridge) can never drift from the wildcard subscription filter.
+ ///
+ public const string NewsPrefix = "finlytic/news/";
+
+ private const string NewsStreamTemplate = NewsPrefix + "stream/{0}";
+
+ ///
+ /// Builds the per-ISIN topic that FinlyticNews publishes newly matched articles to: finlytic/news/stream/{isin}.
+ /// The ISIN is normalized (trimmed, lower-cased) to match the convention already used by every publisher/subscriber pair.
+ ///
+ public static string NewsStream(string isin) => string.Format(NewsStreamTemplate, NormalizeIsin(isin));
+
+ ///
+ /// Wildcard filter matching every FinlyticNews stream topic, used by the FinlyticBackend bridge.
+ ///
+ public const string NewsStreamWildcard = NewsPrefix + "#";
+
+ ///
+ /// Gets the literal prefix ("finlytic/sentiment/") shared by every FinlyticSentiment event topic. Used to
+ /// detect whether an incoming message on the subscription is a sentiment event.
+ /// and every per-ISIN topic are derived from this
+ /// constant so they cannot drift apart.
+ ///
+ public const string SentimentPrefix = "finlytic/sentiment/";
+
+ private const string SentimentStreamTemplate = SentimentPrefix + "stream/{0}";
+
+ ///
+ /// Builds the per-ISIN topic that FinlyticSentiment publishes updated sentiment summaries to: finlytic/sentiment/stream/{isin}.
+ ///
+ public static string SentimentStream(string isin) => string.Format(SentimentStreamTemplate, NormalizeIsin(isin));
+
+ ///
+ /// Wildcard filter matching every FinlyticSentiment topic (currently only the per-ISIN stream). Used by
+ /// FinlyticTechnicals to detect sentiment spikes and by the FinlyticBackend bridge.
+ ///
+ public const string SentimentWildcard = SentimentPrefix + "#";
+
+ ///
+ /// Gets the literal prefix ("finlytic/engine/") shared by every FinlyticEngine event topic (proposals and
+ /// trade status changes). is derived from this constant, and
+ /// / are namespaced sub-prefixes of it, so
+ /// none of the three can drift apart from one another.
+ ///
+ public const string EnginePrefix = "finlytic/engine/";
+
+ ///
+ /// Wildcard filter matching every FinlyticEngine event topic (proposals and trade status changes). Used by
+ /// the FinlyticBackend bridge.
+ ///
+ public const string EngineWildcard = EnginePrefix + "#";
+
+ ///
+ /// 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 subscription.
+ ///
+ public const string EngineProposalsPrefix = EnginePrefix + "proposals/";
+
+ ///
+ /// Published by FinlyticEngine whenever a new trade proposal is created. Consumed by FinlyticBot (to
+ /// evaluate auto-execution) and the FinlyticBackend bridge.
+ ///
+ public const string EngineProposalsCreated = EngineProposalsPrefix + "created";
+
+ ///
+ /// 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 subscription.
+ ///
+ public const string EngineTradesPrefix = EnginePrefix + "trades/";
+
+ ///
+ /// Published by FinlyticEngine whenever an active trade's lifecycle status changes (fills, stop-loss
+ /// updates, closes). Consumed by the FinlyticBackend bridge.
+ ///
+ public const string EngineTradesStatusChanged = EngineTradesPrefix + "status_changed";
+
+ ///
+ /// Gets the literal prefix ("finlytic/bot/") shared by every FinlyticBot event topic.
+ /// and are derived from this constant so they cannot drift apart.
+ ///
+ public const string BotPrefix = "finlytic/bot/";
+
+ ///
+ /// 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
+ /// subscription.
+ ///
+ public const string BotTradesPrefix = BotPrefix + "trades/";
+
+ ///
+ /// Published by FinlyticBot whenever a paper-trading position's lifecycle status changes. Consumed by the
+ /// FinlyticBackend bridge.
+ ///
+ public const string BotTradesStream = BotTradesPrefix + "stream";
+
+ ///
+ /// Wildcard filter matching every FinlyticBot event topic. Used by the FinlyticBackend bridge.
+ ///
+ public const string BotWildcard = BotPrefix + "#";
+
+ ///
+ /// Gets the literal prefix ("finlytic/logs/") shared by every structured-log broadcast topic.
+ /// and every per-service topic are derived from this constant.
+ ///
+ public const string LogsPrefix = "finlytic/logs/";
+
+ private const string LogsTemplate = LogsPrefix + "{0}";
+
+ ///
+ /// Builds the structured-log broadcast topic for a given service name (e.g. finlytic/logs/FinlyticAssets),
+ /// published by every service's hook and consumed by the FinlyticBackend bridge.
+ ///
+ public static string Logs(string serviceName) => string.Format(LogsTemplate, serviceName);
+
+ ///
+ /// Wildcard filter matching structured-log broadcasts from every service. Used by the FinlyticBackend bridge.
+ ///
+ public const string LogsWildcard = LogsPrefix + "#";
+
+ ///
+ /// 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 .Trim().ToLowerInvariant() at every call site.
+ ///
+ /// Thrown when is null, empty, or whitespace.
+ 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();
+ }
+}