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

This commit is contained in:
2026-08-24 21:35:24 +02:00
parent 6ab84fe1de
commit 44b161d509
39 changed files with 2545 additions and 709 deletions
+110
View File
@@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Dtos.Trading;
namespace FinlyticCore.Dtos.Bot;
[JsonConverter(typeof(JsonStringEnumConverter<BotExecutionVenue>))]
public enum BotExecutionVenue
{
AlpacaPaperTrading, // Offizielle Alpaca API (US-Equities / ETFs)
SyntheticPaperBroker // Interner Engine-Broker (EU / Knock-Outs)
}
[JsonConverter(typeof(JsonStringEnumConverter<BotPositionStatus>))]
public enum BotPositionStatus
{
Pending,
Active,
BreakEvenTriggered,
Tp1Hit,
Tp2Hit,
Closed,
StoppedOut,
KnockedOut,
Canceled
}
public record BotTradeOrderDto(
Guid OrderId,
Guid ProposalId,
string Isin,
string Symbol,
BotExecutionVenue Venue,
string? AlpacaOrderId,
string? ClientOrderId,
SignalDirection Direction,
decimal RequestedQuantity,
decimal FilledQuantity,
decimal EntryPrice,
decimal AverageBuyIn,
decimal InitialStopLoss,
decimal CurrentStopLoss,
decimal TakeProfit1,
decimal TakeProfit2,
decimal CurrentPrice,
decimal UnrealizedPnlEur,
decimal RealizedPnlEur,
BotPositionStatus Status,
ExitPlan ExitPlan,
DateTime CreatedAtUtc,
DateTime? FilledAtUtc,
DateTime? ClosedAtUtc
);
public record AccountSummaryDto(
decimal Equity,
decimal Cash,
decimal BuyingPower,
string Currency,
string Status
);
public record BotStatusDto(
bool IsRunning,
bool AutoExecutionEnabled,
int ActivePositionsCount,
int MaxPositions,
decimal RiskPerTradePercent,
int MinCompositeScore,
string VenuesActive
);
public record ExecuteProposalRequest(
Guid ProposalId,
BotExecutionVenue? PreferredVenue = null,
decimal? CustomQuantity = null
);
public record BotPortfolioSnapshotDto(
Guid Id,
DateTime SnapshotDateUtc,
decimal TotalEquityEur,
decimal CashEur,
int OpenPositionsCount,
decimal DailyRealizedPnlEur,
decimal TotalUnrealizedPnlEur,
decimal? WinRatePercent
);
public record UpdateBotSettingsRequest(
bool? AutoExecutionEnabled,
int? MaxPositions,
decimal? RiskPerTradePercent,
int? MinCompositeScore
);
/// <summary>
/// Result of an emergency "panic close" of every open paper-trading position (see
/// <see cref="FinlyticCore.Util.MqttTopics.Channels.BotPanicClose"/>). <see cref="SkippedCount"/> is
/// non-zero whenever an Alpaca position could not be liquidated (Alpaca not configured or the broker call
/// failed) — callers MUST surface that count to the user instead of only reporting <see cref="ClosedCount"/>
/// as if the whole operation succeeded (Rules.md §4: no fabricated full success on a partial result).
/// </summary>
public record PanicCloseResultDto(
int ClosedCount,
int SkippedCount,
List<BotTradeOrderDto> ClosedOrders
);
@@ -39,4 +39,28 @@ public record AssetFundamentalsDto
/// </summary>
[JsonPropertyName("lastUpdatedAt")]
public DateTime LastUpdatedAt { get; init; } = DateTime.UtcNow;
/// <summary>
/// Berechnete Tage bis zum nächsten Quartalszahlen-Termin (Earnings Lockout Check).
/// </summary>
[JsonPropertyName("daysToNextEarnings")]
public int? DaysToNextEarnings => Events?
.Where(e => (e.Type.Equals("Earnings", StringComparison.OrdinalIgnoreCase) || e.EventType.Equals("Earnings", StringComparison.OrdinalIgnoreCase)) && e.Date >= DateTime.UtcNow.Date)
.OrderBy(e => e.Date)
.Select(e => (int?)(e.Date.Date - DateTime.UtcNow.Date).TotalDays)
.FirstOrDefault();
/// <summary>
/// Berechnete Tage bis zum nächsten Ex-Dividenden-Tag (Dividend Gate Check). Nur Events mit dem
/// kanonischen Type "Dividend" zählen - dieser wird ausschließlich aus Trade Republics strukturierten
/// Dividend-Feldern (ExpectedDividend/Dividends, echtes ExDate) befüllt, nicht aus dem generischen
/// Events/PastEvents-Feed, dessen freie Type/Title-Strings nicht zuverlässig auf "Dividende" gemappt werden
/// können (Rules.md §4: kein Raten anhand unsicherer Freitext-Strings).
/// </summary>
[JsonPropertyName("daysToNextExDividend")]
public int? DaysToNextExDividend => Events?
.Where(e => e.Type.Equals("Dividend", StringComparison.OrdinalIgnoreCase) && e.Date >= DateTime.UtcNow.Date)
.OrderBy(e => e.Date)
.Select(e => (int?)(e.Date.Date - DateTime.UtcNow.Date).TotalDays)
.FirstOrDefault();
}
+181 -43
View File
@@ -60,6 +60,20 @@ public record ArticleRequest(
[property: JsonPropertyName("id")] string? Id = null
);
/// <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>
/// Request payload for triggering a manual sentiment analysis for an article or ISIN.
/// </summary>
@@ -84,36 +98,6 @@ public record GetEventsByMonthRequest(
[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>
/// Response payload returned by microservice health pings over MQTT.
/// </summary>
@@ -137,22 +121,176 @@ public record FetchLogoResponse(
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>
/// Payload published to MQTT when a live market tick is received.
/// </summary>
public record TickMessageDto(
[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>
/// Gets or sets the compound score (-1.0 to +1.0).
/// </summary>
[JsonPropertyName("compoundScore")]
[JsonPropertyName("compound_score")]
public double CompoundScore { get; init; }
/// <summary>
@@ -49,6 +49,12 @@ public record FinBertResultDto
[JsonPropertyName("confidence")]
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>
/// Gets or sets the probability breakdown.
/// </summary>
@@ -56,8 +62,14 @@ public record FinBertResultDto
public FinBertProbabilities Probabilities { get; init; } = new();
/// <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>
[JsonPropertyName("summarySnippet")]
public string? SummarySnippet { get; init; }
public string? SummarySnippet => KeyHighlight;
}
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Sentiment;
@@ -97,6 +98,36 @@ public record IsinCurrentSummary
[JsonPropertyName("totalArticlesAnalyzed")]
public int TotalArticlesAnalyzed { get; init; }
/// <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>
/// Gets or sets the overall synthesized sentiment text overview.
/// </summary>
@@ -105,7 +136,7 @@ public record IsinCurrentSummary
}
/// <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>
public record IsinSentimentSummaryDto
{
@@ -1,3 +1,4 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Sentiment;
@@ -55,6 +56,18 @@ public record SectorCurrentSummary
[JsonPropertyName("sentimentLabel")]
public string SentimentLabel { get; init; } = "NEUTRAL";
/// <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>
/// Gets or sets the list of active asset ISINs influencing the sector.
/// </summary>
@@ -69,7 +82,7 @@ public record SectorCurrentSummary
}
/// <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>
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.Globalization;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.TradeRepublic;
public record TradeRepublicPriceTick(
[property: JsonPropertyName("time")] long Time,
[property: JsonPropertyName("price")] string Price,
[property: JsonPropertyName("size")] decimal Size
[property: JsonPropertyName("time")] long? Time = null,
[property: JsonPropertyName("price"), JsonNumberHandling(JsonNumberHandling.AllowReadingFromString | JsonNumberHandling.WriteAsString)] decimal Price = 0m,
[property: JsonPropertyName("size")] decimal? Size = null
)
{
public decimal PriceValue => decimal.TryParse(Price, NumberStyles.Any, CultureInfo.InvariantCulture, out var v) ? v : 0m;
public DateTime DateTimeUtc => DateTimeOffset.FromUnixTimeMilliseconds(Time).UtcDateTime;
public decimal PriceValue => Price;
public DateTime DateTimeUtc => Time.HasValue && Time.Value > 0
? DateTimeOffset.FromUnixTimeMilliseconds(Time.Value).UtcDateTime
: DateTime.UtcNow;
}
public record TradeRepublicTickerResponse(
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticCore.Dtos.Trading;
public record DerivativeSelectionDto(
[property: JsonPropertyName("derivativeIsin")] string DerivativeIsin,
// Trade Republic liefert für Derivate keine WKN, nur die ISIN (siehe TradeRepublicDerivativeItemDto).
// Daher ist dieses Feld nullable: eine ISIN darf hier NICHT als Ersatz-WKN eingetragen werden (Rules.md §4).
[property: JsonPropertyName("derivativeWkn")] string? DerivativeWkn,
[property: JsonPropertyName("issuer")] string Issuer,
[property: JsonPropertyName("optionType")] string OptionType, // "LONG" oder "SHORT"
[property: JsonPropertyName("strike")] decimal Strike,
[property: JsonPropertyName("barrier")] decimal Barrier,
[property: JsonPropertyName("leverage")] decimal Leverage,
[property: JsonPropertyName("safetyBufferPercent")] decimal SafetyBufferPercent,
[property: JsonPropertyName("spreadPercentage")] decimal SpreadPercentage,
[property: JsonPropertyName("size")] decimal Size
);
/// <summary>
/// Kennzeichnet die Herkunft einer <see cref="AiValidationResultDto"/>-Entscheidung, damit
/// Konsumenten (Frontend, Logs) eine echte KI-Analyse von einer regelbasierten Ersatzentscheidung
/// unterscheiden können. Der Enum-Wert <see cref="Ai"/> ist absichtlich der Default (0), damit ein
/// vom N8N-Webhook geliefertes JSON, das dieses Feld (noch) nicht setzt, korrekt als KI-Ergebnis
/// interpretiert wird.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<ValidationSource>))]
public enum ValidationSource
{
Ai,
RuleBased
}
/// <summary>
/// Ergebnis des AI-Reasoning-Gates. <see cref="Confidence"/> ist nur gesetzt, wenn <see cref="Source"/>
/// den Wert <see cref="ValidationSource.Ai"/> hat, da eine Konfidenz ohne tatsächliche KI-Bewertung
/// erfunden wäre (Rules.md §4).
/// </summary>
public record AiValidationResultDto(
[property: JsonPropertyName("isApproved")] bool IsApproved,
[property: JsonPropertyName("confidence")] decimal? Confidence,
[property: JsonPropertyName("validationSource")] ValidationSource Source,
[property: JsonPropertyName("thesisSummary")] string ThesisSummary,
[property: JsonPropertyName("invalidationReason")] string InvalidationReason,
[property: JsonPropertyName("keyCatalysts")] List<string> KeyCatalysts,
[property: JsonPropertyName("identifiedRisks")] List<string> IdentifiedRisks
);
public record TradeProposalDto(
[property: JsonPropertyName("proposalId")] Guid ProposalId,
[property: JsonPropertyName("underlyingIsin")] string UnderlyingIsin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("strategyKey")] string StrategyKey,
[property: JsonPropertyName("direction")] SignalDirection Direction,
[property: JsonPropertyName("qualityScore")] decimal QualityScore,
[property: JsonPropertyName("compositeScore")] decimal CompositeScore,
[property: JsonPropertyName("currentPrice")] decimal CurrentPrice,
[property: JsonPropertyName("entryPrice")] decimal EntryPrice,
[property: JsonPropertyName("invalidationPrice")] decimal InvalidationPrice,
[property: JsonPropertyName("exitPlan")] ExitPlan ExitPlan,
[property: JsonPropertyName("selectedDerivative")] DerivativeSelectionDto? SelectedDerivative,
[property: JsonPropertyName("aiValidation")] AiValidationResultDto AiValidation,
[property: JsonPropertyName("createdAtUtc")] DateTime CreatedAtUtc,
[property: JsonPropertyName("expiresAtUtc")] DateTime ExpiresAtUtc
);
/// <summary>
/// Full result of <c>ITradeLifecycleService.EvaluateAssetAsync</c>, carrying both possible outcomes of the
/// evaluation pipeline (technicals, sentiment, fundamentals, simulation-reliability, AI reasoning gate):
/// an accepted opportunity (<see cref="Proposal"/> is set) or a rejection, in which case <see cref="Proposal"/>
/// is <see langword="null"/> but every score/reasoning field below is still populated with the real,
/// already-computed values instead of leaving the caller with silence (Rules.md §4).
/// <para>
/// When the pipeline could not even produce a score (no technical setups available for the ISIN, or the
/// ISIN was blank), the score fields are <c>0</c> and <see cref="AiThesisSummary"/> carries a
/// "<c>[Regelbasiert]</c>"-prefixed explanation — the same prefix <see cref="AiValidationResultDto"/> uses for
/// its <see cref="ValidationSource.RuleBased"/> fallback — so a caller/UI can recognize this is not a real
/// AI verdict, just as it already must for a rule-based <see cref="AiValidationResultDto"/>.
/// </para>
/// </summary>
public record AssetEvaluationResultDto(
[property: JsonPropertyName("proposal")] TradeProposalDto? Proposal,
[property: JsonPropertyName("compositeScore")] decimal CompositeScore,
[property: JsonPropertyName("technicalScore")] decimal TechnicalScore,
[property: JsonPropertyName("sentimentScore")] decimal SentimentScore,
[property: JsonPropertyName("fundamentalScore")] decimal FundamentalScore,
[property: JsonPropertyName("passedEarningsLockout")] bool PassedEarningsLockout,
[property: JsonPropertyName("daysToNextEarnings")] int? DaysToNextEarnings,
[property: JsonPropertyName("passedDividendGate")] bool PassedDividendGate,
[property: JsonPropertyName("daysToNextExDividend")] int? DaysToNextExDividend,
[property: JsonPropertyName("aiApproved")] bool AiApproved,
[property: JsonPropertyName("aiThesisSummary")] string AiThesisSummary,
[property: JsonPropertyName("aiIdentifiedRisks")] List<string> AiIdentifiedRisks
);
public record TradeFillDto(
[property: JsonPropertyName("fillId")] Guid FillId,
[property: JsonPropertyName("executedAtUtc")] DateTime ExecutedAtUtc,
[property: JsonPropertyName("price")] decimal Price,
[property: JsonPropertyName("quantity")] decimal Quantity,
[property: JsonPropertyName("fee")] decimal Fee,
[property: JsonPropertyName("note")] string? Note
);
public record ActiveTradeDto(
[property: JsonPropertyName("tradeId")] Guid TradeId,
[property: JsonPropertyName("proposalId")] Guid ProposalId,
[property: JsonPropertyName("underlyingIsin")] string UnderlyingIsin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("derivativeIsin")] string? DerivativeIsin,
[property: JsonPropertyName("derivativeWkn")] string? DerivativeWkn,
[property: JsonPropertyName("executionMode")] ExecutionMode ExecutionMode,
[property: JsonPropertyName("instrumentType")] InstrumentCategoryType InstrumentType,
[property: JsonPropertyName("direction")] SignalDirection Direction,
[property: JsonPropertyName("status")] TradeStatus Status,
[property: JsonPropertyName("averageBuyIn")] decimal AverageBuyIn,
[property: JsonPropertyName("totalQuantity")] decimal TotalQuantity,
[property: JsonPropertyName("initialStopLoss")] decimal InitialStopLoss,
[property: JsonPropertyName("currentStopLoss")] decimal CurrentStopLoss,
[property: JsonPropertyName("currentPrice")] decimal CurrentPrice,
[property: JsonPropertyName("unrealizedPnlEur")] decimal UnrealizedPnlEur,
[property: JsonPropertyName("unrealizedPnlPercent")] decimal UnrealizedPnlPercent,
[property: JsonPropertyName("realizedPnlEur")] decimal RealizedPnlEur,
[property: JsonPropertyName("exitPlan")] ExitPlan ExitPlan,
[property: JsonPropertyName("fills")] List<TradeFillDto> Fills,
[property: JsonPropertyName("openedAtUtc")] DateTime OpenedAtUtc,
[property: JsonPropertyName("closedAtUtc")] DateTime? ClosedAtUtc
);
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using FinlyticCore.Dtos.TechnicalAnalysis;
namespace FinlyticCore.Dtos.Trading;
/// <summary>
/// Filter/pagination request for the admin-only evaluation-history RPC channel
/// (<c>MqttTopics.Channels.EngineGetEvaluationHistory</c>), served by FinlyticEngine and exposed to the Web UI
/// via <c>FinlyticBackend/Controllers/AdminEvaluationHistoryController</c>. All filters are optional and are
/// combined with logical AND; <see langword="null"/> means "do not filter on this field".
/// </summary>
/// <param name="FromUtc">Inclusive lower bound on <c>EngineEvaluationSnapshotEntity.EvaluatedAtUtc</c>.</param>
/// <param name="ToUtc">Inclusive upper bound on <c>EngineEvaluationSnapshotEntity.EvaluatedAtUtc</c>.</param>
/// <param name="OutcomeFilter">Restricts results to a single <see cref="OutcomeReason"/>.</param>
/// <param name="TriggerSourceFilter">Restricts results to a single <see cref="TriggerSource"/>.</param>
/// <param name="IsinOrSymbolSearch">
/// Case-sensitive substring search against both <c>Isin</c> and <c>Symbol</c> (matches either). Trimmed
/// server-side; blank/whitespace-only values are treated as "no search".
/// </param>
/// <param name="Page">1-based page number. Values below 1 are treated as 1 server-side.</param>
/// <param name="PageSize">
/// Requested page size. Server-side clamped to at least 1 and at most 200 (see
/// <c>EvaluationHistoryService.MaxPageSize</c>) so a caller cannot force FinlyticEngine to materialize/transmit
/// an unbounded result set in a single response.
/// </param>
public record GetEvaluationHistoryRequest(
[property: JsonPropertyName("fromUtc")] System.DateTime? FromUtc = null,
[property: JsonPropertyName("toUtc")] System.DateTime? ToUtc = null,
[property: JsonPropertyName("outcomeFilter")] OutcomeReason? OutcomeFilter = null,
[property: JsonPropertyName("triggerSourceFilter")] TriggerSource? TriggerSourceFilter = null,
[property: JsonPropertyName("isinOrSymbolSearch")] string? IsinOrSymbolSearch = null,
[property: JsonPropertyName("page")] int Page = 1,
[property: JsonPropertyName("pageSize")] int PageSize = 50
);
/// <summary>
/// One row of the evaluation history: the full, already-persisted record of a single
/// <c>TradeLifecycleService.EvaluateAssetAsync</c> run, mapped 1:1 from <c>EngineEvaluationSnapshotEntity</c>.
/// Every score field is the real, already-computed value - including the honest "0/default" values recorded
/// for the <see cref="OutcomeReason.NoTechnicalSetups"/> case, never a fabricated placeholder (Rules.md §4).
/// </summary>
public record EvaluationHistoryEntryDto(
[property: JsonPropertyName("id")] System.Guid Id,
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("technicalScore")] decimal TechnicalScore,
[property: JsonPropertyName("sentimentScore")] decimal SentimentScore,
[property: JsonPropertyName("fundamentalScore")] decimal FundamentalScore,
[property: JsonPropertyName("compositeOpportunityScore")] decimal CompositeOpportunityScore,
[property: JsonPropertyName("reliabilityBonus")] decimal ReliabilityBonus,
[property: JsonPropertyName("passedEarningsLockout")] bool PassedEarningsLockout,
[property: JsonPropertyName("daysToNextEarnings")] int? DaysToNextEarnings,
[property: JsonPropertyName("passedDividendGate")] bool PassedDividendGate,
[property: JsonPropertyName("daysToNextExDividend")] int? DaysToNextExDividend,
[property: JsonPropertyName("universeSource")] UniverseSource? UniverseSource,
[property: JsonPropertyName("universeEnteredAtUtc")] System.DateTime? UniverseEnteredAtUtc,
[property: JsonPropertyName("passedSimulationVeto")] bool PassedSimulationVeto,
[property: JsonPropertyName("passedAiValidation")] bool PassedAiValidation,
[property: JsonPropertyName("aiThesisSummary")] string AiThesisSummary,
[property: JsonPropertyName("outcomeReason")] OutcomeReason OutcomeReason,
[property: JsonPropertyName("triggerSource")] TriggerSource TriggerSource,
[property: JsonPropertyName("triggeredByUserId")] System.Guid? TriggeredByUserId,
[property: JsonPropertyName("proposalId")] System.Guid? ProposalId,
[property: JsonPropertyName("evaluatedAtUtc")] System.DateTime EvaluatedAtUtc
);
/// <summary>
/// Number of evaluation-history rows matching a given filter set that carry a specific <see cref="OutcomeReason"/>.
/// A typed list of these (rather than a <c>Dictionary&lt;OutcomeReason,int&gt;</c>) is used on
/// <see cref="EvaluationHistorySummaryDto.CountsByOutcome"/> purely so this DTO round-trips through
/// System.Text.Json (including the AOT source-generated <c>FinlyticJsonSerializerContext</c>) without needing a
/// custom enum-keyed dictionary converter.
/// </summary>
public record OutcomeReasonCountDto(
[property: JsonPropertyName("outcomeReason")] OutcomeReason OutcomeReason,
[property: JsonPropertyName("count")] int Count
);
/// <summary>
/// Pre-aggregated headline numbers for the admin evaluation-history tab (e.g. "1.847 Analysen letzte 24h ·
/// 0 Vorschläge seit 14h · Ø-Score 66,7"), computed server-side so the Web UI never has to aggregate the full,
/// unpaginated result set itself. Every field except <see cref="LastProposalCreatedAtUtc"/> is scoped to
/// exactly the same filters as the paginated <see cref="EvaluationHistoryEntryDto"/> list it accompanies (see
/// <see cref="GetEvaluationHistoryResponse"/>) - only pagination (<c>Page</c>/<c>PageSize</c>) does not apply,
/// since these are totals over the whole filtered set, not just the current page.
/// </summary>
/// <param name="TotalEvaluations">Total number of snapshot rows matching the request's filters (unpaginated).</param>
/// <param name="CountsByOutcome">Breakdown of <see cref="TotalEvaluations"/> by <see cref="OutcomeReason"/>.</param>
/// <param name="AverageCompositeScore">
/// Average <c>CompositeOpportunityScore</c> across the filtered set; <c>0</c> when <see cref="TotalEvaluations"/> is 0.
/// </param>
/// <param name="ProposalsCreated">
/// Number of filtered rows whose <see cref="EvaluationHistoryEntryDto.OutcomeReason"/> is
/// <see cref="OutcomeReason.Approved"/> - i.e. the same value as the <see cref="OutcomeReason.Approved"/> entry
/// in <see cref="CountsByOutcome"/>, exposed directly so the UI does not need to search that list.
/// </param>
/// <param name="LastProposalCreatedAtUtc">
/// Timestamp of the most recently created <c>EngineTradeProposalEntity</c> across the ENTIRE proposals table -
/// deliberately NOT scoped to this request's <c>FromUtc</c>/<c>ToUtc</c> filters, because "how long since the
/// last real proposal" is a single wall-clock fact the admin wants regardless of which historical window they
/// are currently browsing. <see langword="null"/> only if no proposal has ever been created.
/// </param>
public record EvaluationHistorySummaryDto(
[property: JsonPropertyName("totalEvaluations")] int TotalEvaluations,
[property: JsonPropertyName("countsByOutcome")] List<OutcomeReasonCountDto> CountsByOutcome,
[property: JsonPropertyName("averageCompositeScore")] decimal AverageCompositeScore,
[property: JsonPropertyName("proposalsCreated")] int ProposalsCreated,
[property: JsonPropertyName("lastProposalCreatedAtUtc")] System.DateTime? LastProposalCreatedAtUtc
);
/// <summary>
/// Full response for the evaluation-history RPC channel: a page of matching rows, the total match count (for
/// pagination), and a pre-aggregated <see cref="Summary"/> so the Web UI never needs a second round trip (and a
/// second, potentially-inconsistent set of filters) just to render a header line above the table.
/// </summary>
public record GetEvaluationHistoryResponse(
[property: JsonPropertyName("totalCount")] int TotalCount,
[property: JsonPropertyName("entries")] List<EvaluationHistoryEntryDto> Entries,
[property: JsonPropertyName("summary")] EvaluationHistorySummaryDto Summary
);
+110
View File
@@ -0,0 +1,110 @@
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Trading;
[JsonConverter(typeof(JsonStringEnumConverter<ExecutionMode>))]
public enum ExecutionMode
{
SignalProposal, // Reines Signal zur manuellen Ansicht
ManualTradeRepublic, // Händisch bei Trade Republic ausgeführt
PaperTradingBot // Vollautomatisch im Paper-Trading-Modus
}
[JsonConverter(typeof(JsonStringEnumConverter<TradeStatus>))]
public enum TradeStatus
{
Proposed, // KI-geprüfter Vorschlag, wartet auf Ausführung
Active, // Mindestens 1 Fill ausgeführt, Trade läuft
BreakEvenTriggered, // Kurs hat TP1 erreicht, SL liegt auf Mischkurs
Tp1Hit, // Teilverkauf 1 ausgeführt
Tp2Hit, // Teilverkauf 2 ausgeführt
Closed, // Vollständig mit Gewinn glattgestellt
StoppedOut, // Durch Stop-Loss beendet
Invalidated, // Kurs hat Invalidation erreicht, bevor Einstieg erfolgte
Expired // Gültigkeitsfenster abgelaufen
}
[JsonConverter(typeof(JsonStringEnumConverter<InstrumentCategoryType>))]
public enum InstrumentCategoryType
{
Stock,
Etf,
TurboLong,
TurboShort,
FactorCertificate
}
/// <summary>
/// Identifies whether an <c>EngineEvaluationSnapshotEntity</c> row was produced by the autonomous
/// <c>OpportunityPollerBackgroundService</c> scan loop or by an on-demand, human-initiated call (Web UI
/// "Analyze now" / <c>EngineController.EvaluateAsset</c> / <c>AnalyzeController.TriggerManualAnalysis</c>).
/// <see cref="Unknown"/> is deliberately value <c>0</c> (the default) so that snapshot rows written before
/// this field existed - and any future row where the caller genuinely failed to specify a source - are never
/// silently mis-reported as one of the two real sources (Rules.md §4: no fabricated data, an honest
/// "we don't know" beats a fabricated default of <see cref="Automatic"/>).
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<TriggerSource>))]
public enum TriggerSource
{
Unknown = 0,
Automatic = 1,
Manual = 2
}
/// <summary>
/// Classifies why a single asset evaluation in <c>TradeLifecycleService.EvaluateAssetAsync</c> did or did not
/// result in a trade proposal. <see cref="Unknown"/> is deliberately value <c>0</c> (the default) so snapshot
/// rows persisted before this field existed read honestly as "reason unknown" rather than fabricating a
/// specific-looking cause (Rules.md §4). See the "DetermineOutcomeReason" doc comment in
/// <c>TradeLifecycleService</c> for the exact priority order applied when more than one gate failed at once.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter<OutcomeReason>))]
public enum OutcomeReason
{
Unknown = 0,
/// <summary>The AI reasoning gate approved the opportunity and a <c>EngineTradeProposalEntity</c> was created.</summary>
Approved = 1,
/// <summary>
/// <c>ScoringResult.CompositeScore</c> stayed below <c>Engine.MinCompositeScore</c> and the evaluation was
/// not forced, so the AI reasoning gate was never even consulted (a synthetic rule-based rejection was
/// recorded instead).
/// </summary>
BelowScoreThreshold = 2,
/// <summary>The asset is within the earnings blackout window (<c>Engine.EarningsLockoutDays</c>).</summary>
EarningsLockout = 3,
/// <summary>FinlyticSimulation's backtest-reliability matrix vetoed this strategy/asset combination.</summary>
SimulationVeto = 4,
/// <summary>
/// The composite score cleared the minimum threshold (or the evaluation was forced) and neither the
/// earnings-lockout nor the simulation-veto gate blocked it, but the AI reasoning gate itself - whether a
/// real AI call or one of its own rule-based fallbacks (gate disabled, webhook unreachable) - still declined.
/// </summary>
AiRejected = 5,
/// <summary>
/// No technical setup could be produced for the ISIN at all (FinlyticTechnicals returned nothing), or the
/// ISIN itself was blank - in both cases the pipeline never reached scoring, so every score field on the
/// snapshot is <c>0</c>/default rather than fabricated.
/// </summary>
NoTechnicalSetups = 6,
/// <summary>
/// Not a real rejection: the evaluation genuinely cleared every gate and the AI reasoning gate approved the
/// opportunity (<c>PassedAiValidation</c> on this same row is <see langword="true"/>), but an active,
/// non-expired <c>EngineTradeProposalEntity</c> for the same <c>UnderlyingIsin</c> already exists, so no
/// second, near-identical proposal row was created and no <c>finlytic/engine/proposals/created</c> MQTT
/// event was re-broadcast. Exists specifically to stop the autonomous scanner from spamming a fresh
/// proposal (and a fresh push event to every connected client) every single poll cycle for as long as one
/// asset stays above the approval threshold - the underlying bug this value was introduced to fix.
/// </summary>
DuplicateActiveProposal = 7,
/// <summary>The asset is within the ex-dividend blackout window (<c>Engine.DividendGateDays</c>).</summary>
DividendGate = 8
}