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