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(); + } +}