diff --git a/FinlyticBackend/Controllers/AdminEvaluationHistoryController.cs b/FinlyticBackend/Controllers/AdminEvaluationHistoryController.cs
new file mode 100644
index 0000000..ef580c0
--- /dev/null
+++ b/FinlyticBackend/Controllers/AdminEvaluationHistoryController.cs
@@ -0,0 +1,171 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FinlyticBackend.Util;
+using FinlyticCore.Dtos.TechnicalAnalysis;
+using FinlyticCore.Dtos.Trading;
+using FinlyticCore.Util;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Controllers;
+
+///
+/// Admin-only Web UI endpoint over FinlyticEngine's persisted evaluation history
+/// (engine_evaluation_snapshots), answering "why hasn't a new trade proposal appeared" by exposing every
+/// evaluation the engine ever ran - approved or not, automatic or manual - with the real, already-computed
+/// scores/gates/outcome for each. Mirrors the /
+/// pattern: [Authorize(Roles = "Admin")] at the controller level, a thin pass-through to FinlyticEngine
+/// over MQTT RPC, and (never an anonymous object) on failure.
+///
+[ApiController]
+[Route("api/v1/admin/evaluations")]
+[Authorize(Roles = "Admin")]
+[EnableCors("AllowAll")]
+public class AdminEvaluationHistoryController : ControllerBase
+{
+ private readonly WebMqttClient _mqttClient;
+ private readonly ILogger _logger;
+
+ public AdminEvaluationHistoryController(WebMqttClient mqttClient, ILogger logger)
+ {
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Returns a filtered, paginated page of evaluation-history rows plus a summary of the same (unpaginated)
+ /// filtered set - see /
+ /// for the exact filter and aggregation semantics. All query parameters are optional; omitting a filter
+ /// means "do not restrict on this field".
+ ///
+ /// Inclusive lower bound on EvaluatedAtUtc.
+ /// Inclusive upper bound on EvaluatedAtUtc.
+ /// Restricts results to a single .
+ /// Restricts results to a single .
+ /// Case-sensitive substring search against both ISIN and Symbol.
+ /// 1-based page number (defaults to 1; values below 1 are treated as 1 server-side).
+ ///
+ /// Requested page size (defaults to 50; server-side clamped to at most 200 by FinlyticEngine to prevent an
+ /// unbounded response).
+ ///
+ [HttpGet]
+ public async Task GetEvaluationHistory(
+ [FromQuery] DateTime? fromUtc = null,
+ [FromQuery] DateTime? toUtc = null,
+ [FromQuery] OutcomeReason? outcome = null,
+ [FromQuery] TriggerSource? triggerSource = null,
+ [FromQuery] string? search = null,
+ [FromQuery] int page = 1,
+ [FromQuery] int pageSize = 50)
+ {
+ if (!_mqttClient.IsConnected)
+ {
+ return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ try
+ {
+ var request = new GetEvaluationHistoryRequest(
+ FromUtc: fromUtc,
+ ToUtc: toUtc,
+ OutcomeFilter: outcome,
+ TriggerSourceFilter: triggerSource,
+ IsinOrSymbolSearch: search,
+ Page: page,
+ PageSize: pageSize
+ );
+
+ var result = await _mqttClient.SendRpcRequestAsync(
+ MqttTopics.Channels.EngineGetEvaluationHistory, request, TimeSpan.FromSeconds(10));
+
+ if (result == null)
+ {
+ // Null means the RPC call itself got no response (transport failure) - not a legitimate "zero
+ // results" outcome, which is always a populated response with an empty Entries list.
+ return Problem(title: "FinlyticEngine did not respond to the evaluation history request.", statusCode: StatusCodes.Status502BadGateway);
+ }
+
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[AdminEvaluationHistory] Failed to fetch evaluation history via MQTT RPC.");
+ return Problem(title: "Internal server error fetching evaluation history.", statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Returns FinlyticTechnicals' currently monitored scan universe ("watchlist") - the assets the background
+ /// scanner is actively evaluating every cycle, independent of whether any of them have cleared the
+ /// engine's opportunity-poller score threshold yet.
+ ///
+ [HttpGet("watchlist")]
+ public async Task GetWatchlist()
+ {
+ if (!_mqttClient.IsConnected)
+ {
+ return Problem(title: "FinlyticTechnicals is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ try
+ {
+ var result = await _mqttClient.SendRpcRequestAsync, object>(
+ MqttTopics.Channels.TaGetWatchlist, new object(), TimeSpan.FromSeconds(10));
+
+ if (result == null)
+ {
+ return Problem(title: "FinlyticTechnicals did not respond to the watchlist request.", statusCode: StatusCodes.Status502BadGateway);
+ }
+
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[AdminEvaluationHistory] Failed to fetch watchlist via MQTT RPC.");
+ return Problem(title: "Internal server error fetching the watchlist.", statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Returns the last technical-analysis setups computed for
+ /// (most recent first), so the admin UI can show whether its quality score is trending up or down across
+ /// recent scan cycles - including setups too weak to ever have reached the engine.
+ ///
+ [HttpGet("watchlist/{isin}/history")]
+ public async Task GetWatchlistEntryHistory([FromRoute] string isin, [FromQuery] int limit = 8)
+ {
+ if (string.IsNullOrWhiteSpace(isin))
+ {
+ return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
+ }
+
+ if (!_mqttClient.IsConnected)
+ {
+ return Problem(title: "FinlyticTechnicals is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ try
+ {
+ var result = await _mqttClient.SendRpcRequestAsync, GetRecentSetupHistoryRequest>(
+ MqttTopics.Channels.TaGetRecentSetupHistory,
+ new GetRecentSetupHistoryRequest(isin.Trim().ToUpperInvariant(), limit),
+ TimeSpan.FromSeconds(10));
+
+ if (result == null)
+ {
+ return Problem(title: "FinlyticTechnicals did not respond to the setup history request.", statusCode: StatusCodes.Status502BadGateway);
+ }
+
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[AdminEvaluationHistory] Failed to fetch setup history for ISIN '{Isin}' via MQTT RPC.", isin);
+ return Problem(title: "Internal server error fetching the setup history.", statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+}
diff --git a/FinlyticBackend/Controllers/AdminSettingsController.cs b/FinlyticBackend/Controllers/AdminSettingsController.cs
index f379e43..cdab9d8 100644
--- a/FinlyticBackend/Controllers/AdminSettingsController.cs
+++ b/FinlyticBackend/Controllers/AdminSettingsController.cs
@@ -4,12 +4,15 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
+using FinlyticBackend.Settings;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Settings;
+using FinlyticCore.Services;
using FinlyticCore.Util;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -37,6 +40,15 @@ public record ServiceConfigItemResponseDto(
[property: JsonPropertyName("updatedAt")] DateTime UpdatedAt
);
+///
+/// Result DTO for a service settings update request, reporting whether the RPC broadcast to the
+/// targeted microservice actually succeeded (AOT-compliant; replaces an anonymous response object).
+///
+public record ServiceSettingsUpdateResultDto(
+ [property: JsonPropertyName("message")] string Message,
+ [property: JsonPropertyName("mqttDispatched")] bool MqttDispatched
+);
+
///
/// DTO representing the operational health status of a microservice (AOT-compliant).
///
@@ -63,24 +75,55 @@ public class AdminSettingsController : ControllerBase
{
["FinlyticFundamentals"] = "fundamentals",
["FinlyticNews"] = "news",
+ ["FinlyticTechnicals"] = "ta",
["FinlyticTechnicalAnalysis"] = "ta",
["FinlyticSentiment"] = "sentiment",
- ["FinlyticAnalyzer"] = "analyzer",
- ["FinlyticTrades"] = "trades",
["FinlyticAssets"] = "assets",
- ["FinlyticBot"] = "bot"
+ ["FinlyticEngine"] = "engine",
+ ["FinlyticAnalyzer"] = "engine",
+ ["FinlyticTrades"] = "engine",
+ ["FinlyticBot"] = "bot",
+ // FinlyticSimulation was previously missing from this map entirely, so its settings
+ // (SimulationSettingKeys: slippage/fee defaults, matrix-recompute schedule, etc.) never showed up
+ // in the admin UI's settings screen even though the sim_settings_GetAll/Update RPC channels exist.
+ ["FinlyticSimulation"] = "sim"
};
+ ///
+ /// FinlyticBackend is the RPC *caller* for every entry in above, not a
+ /// callee - it has no MQTT-served "backend_settings_GetAll" channel to ask, because it would just be
+ /// asking itself over the network for no reason. Its own settings () are
+ /// instead read directly, in-process, via - see the special-casing in
+ /// //.
+ ///
+ private const string BackendServiceName = "FinlyticBackend";
+
private static readonly ConcurrentDictionary> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase);
+ private readonly ISettingsService _settingsService;
+
public AdminSettingsController(
WebMqttClient mqttClient,
+ ISettingsService settingsService,
ILogger logger)
{
_mqttClient = mqttClient;
+ _settingsService = settingsService;
_logger = logger;
}
+ private async Task> GetBackendOwnSettingsAsync()
+ {
+ var settings = await _settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(BackendSettingKeys) });
+ return settings.Select(d => new ServiceConfigItemResponseDto(
+ Key: d.Key,
+ Value: d.Value?.ToString() ?? "",
+ DataType: d.Type,
+ Description: d.Description,
+ UpdatedAt: d.UpdatedAt ?? DateTime.UtcNow
+ )).ToList();
+ }
+
///
/// Retrieves recent buffered in-memory logs for a specific service.
///
@@ -144,6 +187,10 @@ public class AdminSettingsController : ControllerBase
)).ToList()
);
+ // FinlyticBackend's own settings never go through the MQTT RPC loop above (see BackendServiceName's
+ // doc comment) - read directly, in-process.
+ grouped[BackendServiceName] = await GetBackendOwnSettingsAsync();
+
return Ok(grouped);
}
@@ -155,13 +202,13 @@ public class AdminSettingsController : ControllerBase
{
var servicesToCheck = new (string Name, string Channel, string Type, string Db)[]
{
- ("FinlyticAssets", "health_Ping/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
- ("FinlyticNews", "health_Ping/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
- ("FinlyticTechnicalAnalysis", "health_Ping/FinlyticTechnicalAnalysis", "Technical Indicators (EMA/RSI)", "PostgreSQL ta"),
- ("FinlyticSentiment", "health_Ping/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
- ("FinlyticAnalyzer", "health_Ping/FinlyticAnalyzer", "Multi-Layer Signal Engine", "PostgreSQL analyzer"),
- ("FinlyticTrades", "health_Ping/FinlyticTrades", "Trade Lifecycle Manager", "PostgreSQL trades"),
- ("FinlyticFundamentals", "health_Ping/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
+ ("FinlyticAssets", $"{MqttTopics.Channels.HealthPing}/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
+ ("FinlyticNews", $"{MqttTopics.Channels.HealthPing}/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
+ ("FinlyticTechnicals", $"{MqttTopics.Channels.HealthPing}/FinlyticTechnicals", "Technical Indicators & SMC Patterns", "PostgreSQL ta"),
+ ("FinlyticSentiment", $"{MqttTopics.Channels.HealthPing}/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
+ ("FinlyticFundamentals", $"{MqttTopics.Channels.HealthPing}/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
+ ("FinlyticEngine", $"{MqttTopics.Channels.HealthPing}/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
+ ("FinlyticBot", $"{MqttTopics.Channels.HealthPing}/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
};
var results = new List
@@ -231,6 +278,11 @@ public class AdminSettingsController : ControllerBase
[HttpGet("{serviceName}")]
public async Task GetServiceSettings(string serviceName)
{
+ if (string.Equals(serviceName, BackendServiceName, StringComparison.OrdinalIgnoreCase))
+ {
+ return Ok(await GetBackendOwnSettingsAsync());
+ }
+
if (ServiceRpcPrefixes.TryGetValue(serviceName, out var prefix) && _mqttClient.IsConnected)
{
try
@@ -281,7 +333,17 @@ public class AdminSettingsController : ControllerBase
{
if (updatedValues == null || !updatedValues.Any())
{
- return BadRequest(new { message = "No settings provided for update." });
+ return Problem(title: "No settings provided for update.", statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ if (string.Equals(serviceName, BackendServiceName, StringComparison.OrdinalIgnoreCase))
+ {
+ // No MQTT round trip needed - FinlyticBackend updates its own dynamic settings directly, in-process.
+ await _settingsService.UpdateSettingsAsync(updatedValues);
+ return Ok(new ServiceSettingsUpdateResultDto(
+ Message: $"Settings successfully updated for {BackendServiceName}.",
+ MqttDispatched: false
+ ));
}
_logger.LogInformation("[AdminSettings] Transmitting {Count} config settings to microservice '{ServiceName}' via MQTT", updatedValues.Count, serviceName);
@@ -307,16 +369,11 @@ public class AdminSettingsController : ControllerBase
)).ToList();
}
- string topic = $"services/config/updated/{serviceName}";
- var payload = new ServiceConfigUpdatePayload(
- ServiceName: serviceName,
- Timestamp: DateTime.UtcNow,
- Settings: updatedValues.ToDictionary(kv => kv.Key, kv => kv.Value?.ToString() ?? "")
- );
-
- await _mqttClient.PublishAsync(topic, payload);
- mqttPublished = true;
- _logger.LogInformation("[AdminSettings] Broadcasted config update event to MQTT topic '{Topic}' for microservice persistence.", topic);
+ // Settings propagation happens entirely over the {prefix}_settings_Update RPC call above.
+ // There used to be an additional fire-and-forget MQTT publish to a per-service config-updated
+ // topic here, but no service in the fleet ever subscribed to it (dead legacy code predating the
+ // RPC mechanism) - removed rather than kept as a no-op broadcast.
+ mqttPublished = updated != null && updated.Count > 0;
}
}
catch (Exception ex)
@@ -324,10 +381,9 @@ public class AdminSettingsController : ControllerBase
_logger.LogError(ex, "[AdminSettings] Failed to publish MQTT config update event for service '{ServiceName}'", serviceName);
}
- return Ok(new
- {
- message = $"Settings successfully transmitted to microservice {serviceName}.",
- mqttDispatched = mqttPublished
- });
+ return Ok(new ServiceSettingsUpdateResultDto(
+ Message: $"Settings successfully transmitted to microservice {serviceName}.",
+ MqttDispatched: mqttPublished
+ ));
}
}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/AnalyzeController.cs b/FinlyticBackend/Controllers/AnalyzeController.cs
index 25f9316..c24302b 100644
--- a/FinlyticBackend/Controllers/AnalyzeController.cs
+++ b/FinlyticBackend/Controllers/AnalyzeController.cs
@@ -1,17 +1,14 @@
using System;
using System.Collections.Generic;
-using System.Linq;
+using System.Security.Claims;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
-using FinlyticCore.Dtos.Fundamentals;
-using FinlyticCore.Dtos.Sentiment;
-using FinlyticCore.Dtos.TechnicalAnalysis;
-using FinlyticCore.Models.Analyzer;
-using FinlyticCore.Models.Trades;
+using FinlyticCore.Dtos.Trading;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -40,152 +37,123 @@ public record AnalyzeRequest(
[EnableCors("AllowAll")]
public class AnalyzeController : ControllerBase
{
- private readonly WebMqttClient _mqttClient;
+ private readonly BackendMqttBridge _mqttClient;
private readonly ILogger _logger;
- public AnalyzeController(WebMqttClient mqttClient, ILogger logger)
+ public AnalyzeController(BackendMqttBridge mqttClient, ILogger logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
///
- /// Triggers a manual analysis for an asset by gathering context in parallel and dispatching to FinlyticAnalyzer.
+ /// Resolves the authenticated caller's identity from the JWT NameIdentifier claim, exactly like
+ /// EngineController.GetUserIdFromClaims. Every manual evaluation triggered through this controller
+ /// is tagged with this identity as EngineEvaluationSnapshotEntity.TriggeredByUserId, so a request
+ /// whose identity cannot be established must be rejected rather than silently recorded as anonymous.
+ ///
+ ///
+ /// The NameIdentifier claim (or its sub/nameid fallbacks) is missing or is not a
+ /// parseable .
+ ///
+ private Guid GetUserIdFromClaims()
+ {
+ var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? User.FindFirstValue("sub")
+ ?? User.FindFirstValue("nameid");
+
+ if (Guid.TryParse(claimUserId, out var userId))
+ {
+ return userId;
+ }
+
+ _logger.LogWarning("[AnalyzeController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
+ throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
+ }
+
+ ///
+ /// Triggers an on-demand analysis for an asset via FinlyticEngine.
+ /// Always returns 200 OK with a full body — the analysis
+ /// pipeline now reports the real, already-computed scores and AI reasoning even when it did not produce a
+ /// proposal (Proposal == null), so there is no longer a "silent" 204 No Content outcome for a
+ /// completed-but-rejected evaluation (Rules.md §4). 204 is gone entirely: the only remaining failure
+ /// modes (missing ISIN/Symbol, unreachable engine, no RPC response, unexpected error) are standardized
+ /// Problem Details (Rules.md §11) instead of anonymous status objects.
///
[HttpPost("manual")]
public async Task TriggerManualAnalysis([FromBody] AnalyzeRequest request)
{
+ if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
+ {
+ return Problem(title: "ISIN or Symbol is required.", statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ if (!_mqttClient.IsConnected)
+ {
+ return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
try
{
- if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
- {
- return BadRequest(new { error = "ISIN or Symbol is required" });
- }
-
- if (!_mqttClient.IsConnected)
- {
- return StatusCode(503, new { error = "Analysis service is currently unavailable." });
- }
-
string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant();
- string targetSymbol = (request.Symbol ?? request.Isin ?? string.Empty).Trim().ToUpperInvariant();
- var isinReq = new IsinRequest(targetIsin);
- // 1. Parallelisiertes Context-Gathering (TA, Fundamentals, Sentiment) für minimale Latenz
- var taTask = FetchTaDataAsync(isinReq);
- var fundTask = FetchFundamentalsDataAsync(isinReq);
- var sentTask = FetchSentimentDataAsync(isinReq);
+ // UserId always comes from the JWT, never from the request body - matches every other engine
+ // request DTO with a UserId field (see EvaluateAssetRequest's doc comment).
+ var userId = GetUserIdFromClaims();
- await Task.WhenAll(taTask, fundTask, sentTask);
-
- var taData = await taTask;
- var fundData = await fundTask;
- var sentData = await sentTask;
-
- decimal lastCandlePrice = taData?.Candles != null && taData.Candles.Count > 0 ? (decimal)taData.Candles.Last().Close : 0m;
- decimal resolvedPrice = request.CurrentPrice ?? (lastCandlePrice > 0 ? lastCandlePrice : 100.0m);
-
- var rpcRequest = new ManualAnalysisRpcRequest(
+ var evalRequest = new EvaluateAssetRequest(
Isin: targetIsin,
- Symbol: targetSymbol,
- Sector: !string.IsNullOrWhiteSpace(request.Sector) ? request.Sector : "Technology",
- Headline: !string.IsNullOrWhiteSpace(request.Headline) ? request.Headline : "Manual Analysis Triggered by User",
- CurrentPrice: resolvedPrice,
- RiskScore: request.RiskScore ?? 50,
- MinTimeframeValue: request.MinTimeframeValue ?? 4,
- MaxTimeframeValue: request.MaxTimeframeValue ?? 6,
- TimeframeUnit: !string.IsNullOrWhiteSpace(request.TimeframeUnit) ? request.TimeframeUnit : "Tage",
- InstrumentType: !string.IsNullOrWhiteSpace(request.InstrumentType) ? request.InstrumentType : "Stock",
- UserNotes: request.UserNotes ?? string.Empty,
- TaData: taData,
- FundamentalsData: fundData,
- SentimentData: sentData
+ UserId: userId,
+ Ticker: null,
+ ForceAiEvaluation: true
);
- // 2. Ausführen des RPC Triggers am FinlyticAnalyzer (mit typisierter Response)
- try
- {
- var response = await _mqttClient.SendRpcRequestAsync(
- "analyzer_TriggerManual", rpcRequest, TimeSpan.FromSeconds(10));
+ var evaluation = await _mqttClient.SendRpcRequestAsync(
+ "engine_EvaluateIsin", evalRequest, TimeSpan.FromSeconds(15));
- if (response != null)
- {
- return Ok(response);
- }
- }
- catch (Exception ex)
+ if (evaluation == null)
{
- _logger.LogWarning(ex, "[AnalyzeController] RPC analyzer_TriggerManual timed out or failed for ISIN '{Isin}'.", targetIsin);
+ // Null here means the RPC call itself timed out / got no response - a transport failure, not
+ // a legitimate "evaluated, no proposal" business outcome (that is now always a populated DTO).
+ return Problem(title: "FinlyticEngine did not respond to the evaluation request.", statusCode: StatusCodes.Status502BadGateway);
}
- return Ok(new
- {
- status = "AnalysisTriggered",
- isin = rpcRequest.Isin,
- message = "Manuelle KI-Analyse wurde gestartet, verarbeitet Ergebnisse im Hintergrund."
- });
+ return Ok(evaluation);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to trigger manual analysis via MQTT RPC.");
- return StatusCode(500, new { error = "Internal server error triggering analysis." });
+ _logger.LogError(ex, "Failed to trigger manual analysis via FinlyticEngine.");
+ return Problem(title: "Internal server error triggering analysis.", statusCode: StatusCodes.Status500InternalServerError);
}
}
///
- /// Fetches the currently active trade proposals from the FinlyticTrades service.
+ /// Fetches the currently active trade proposals from FinlyticEngine.
///
[HttpGet("proposals")]
public async Task GetActiveProposals()
{
+ if (!_mqttClient.IsConnected)
+ {
+ return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
try
{
- if (!_mqttClient.IsConnected)
- {
- return StatusCode(503, new { error = "Analysis service is currently unavailable." });
- }
+ var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
+ var proposals = await _mqttClient.SendRpcRequestAsync, GetTradeProposalsRequest>(
+ "engine_GetProposals", request, TimeSpan.FromSeconds(5));
- // AOT-sicherer RPC-Aufruf an FinlyticTrades für vorgeschlagene Trades
- var request = new GetTradesRequest(Isin: null, Status: "Proposed", UserId: null);
- var proposals = await _mqttClient.SendRpcRequestAsync, GetTradesRequest>(
- "trades_Get", request, TimeSpan.FromSeconds(4));
-
return Ok(proposals ?? new List());
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to fetch active proposals via MQTT RPC.");
- return StatusCode(500, new { error = "Internal server error fetching proposals." });
+ _logger.LogError(ex, "Failed to fetch active proposals from FinlyticEngine.");
+ return Problem(title: "Internal server error fetching proposals.", statusCode: StatusCodes.Status500InternalServerError);
}
}
-
- private async Task FetchTaDataAsync(IsinRequest request)
- {
- try
- {
- return await _mqttClient.SendRpcRequestAsync(
- "ta_GetAnalysis", request, TimeSpan.FromSeconds(2));
- }
- catch { return null; }
- }
-
- private async Task FetchFundamentalsDataAsync(IsinRequest request)
- {
- try
- {
- return await _mqttClient.SendRpcRequestAsync(
- "fundamentals_Get", request, TimeSpan.FromSeconds(2));
- }
- catch { return null; }
- }
-
- private async Task FetchSentimentDataAsync(IsinRequest request)
- {
- try
- {
- return await _mqttClient.SendRpcRequestAsync(
- "sentiment_GetIsin", request, TimeSpan.FromSeconds(2));
- }
- catch { return null; }
- }
}
\ No newline at end of file
diff --git a/FinlyticBackend/Controllers/AssetsController.cs b/FinlyticBackend/Controllers/AssetsController.cs
index 237a99c..55f44c8 100644
--- a/FinlyticBackend/Controllers/AssetsController.cs
+++ b/FinlyticBackend/Controllers/AssetsController.cs
@@ -7,7 +7,6 @@ using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
-using FinlyticAssets.Models;
using FinlyticAssets.Util;
using FinlyticBackend.Database;
using FinlyticBackend.Util;
@@ -268,6 +267,42 @@ public class AssetsController : ControllerBase
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
}
+ ///
+ /// Liest den aktuellen Live-Kurs eines Assets oder Derivats via TR MQTT RPC.
+ ///
+ [HttpGet("{isin}/live")]
+ public async Task GetLivePrice([FromRoute] string isin)
+ {
+ string normalizedSymbol = isin.Trim().ToUpperInvariant();
+ if (string.IsNullOrWhiteSpace(normalizedSymbol))
+ {
+ return BadRequest(new { message = "Eine gültige ISIN ist erforderlich." });
+ }
+
+ try
+ {
+ if (_mqttClient.IsConnected)
+ {
+ var rpcResult = await _mqttClient.SendRpcRequestAsync(
+ "tr_GetLivePrice",
+ new IsinRequest(normalizedSymbol),
+ TimeSpan.FromSeconds(5)
+ );
+
+ if (rpcResult != null && rpcResult.CurrentPrice > 0m)
+ {
+ return Ok(rpcResult);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "RPC tr_GetLivePrice für Symbol '{Symbol}' fehlgeschlagen.", normalizedSymbol);
+ }
+
+ return NotFound(new { message = $"Kein Live-Kurs für Asset '{normalizedSymbol}' verfügbar." });
+ }
+
///
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
///
@@ -304,7 +339,7 @@ public class AssetsController : ControllerBase
ForceRefresh: forceRefresh
);
- var rpcResult = await _mqttClient.SendRpcRequestAsync, GetDerivativesRequest>(
+ var rpcResult = await _mqttClient.SendRpcRequestAsync, GetDerivativesRequest>(
"assets_GetDerivatives",
req,
TimeSpan.FromSeconds(30)
@@ -312,7 +347,7 @@ public class AssetsController : ControllerBase
if (rpcResult != null)
{
- var derivatives = rpcResult.OfType().ToList();
+ var derivatives = rpcResult;
if (minLeverage.HasValue)
{
@@ -365,6 +400,10 @@ public class AssetsController : ControllerBase
///
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
+ /// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7), not an
+ /// oversight: logos are static, non-user-specific assets (looked up only by ISIN), and generic image
+ /// loaders/`<img>` tags do not attach an Authorization header by default. Keeping this endpoint
+ /// anonymous lets every image loader render it without special-casing headers.
///
[HttpGet("/api/v1/logo/{isin}")]
[AllowAnonymous]
diff --git a/FinlyticBackend/Controllers/AuthController.cs b/FinlyticBackend/Controllers/AuthController.cs
index 43d3d8d..8fd7bf3 100644
--- a/FinlyticBackend/Controllers/AuthController.cs
+++ b/FinlyticBackend/Controllers/AuthController.cs
@@ -21,17 +21,15 @@ public record UserProfileResponseDto(
[property: JsonPropertyName("role")] string Role
);
+///
+/// Request payload for changing a user's initial (admin-issued) password. The target user is derived
+/// from the caller's own JWT identity claim (see ),
+/// not from this payload, so it deliberately carries no user identifier.
+///
public record ChangeInitialPasswordDto(
- [property: JsonPropertyName("userId")] Guid UserId,
[property: JsonPropertyName("newPassword")] string NewPassword
);
-public class ForgotPasswordRequestDto
-{
- [JsonPropertyName("email")]
- public string Email { get; set; } = string.Empty;
-}
-
[ApiController]
[Route("api/v1")]
public class AuthController : ControllerBase
@@ -45,7 +43,13 @@ public class AuthController : ControllerBase
///
/// Authenticates a user and returns a JWT token.
+ /// Deliberately the single anonymous authentication entry point of the Gateway (Rules.md §7): a client
+ /// has no JWT to present before it has logged in, so there is no way to require authentication here.
+ /// Every other route in the Gateway requires authentication except three other sanctioned exceptions:
+ /// the Docker healthcheck (GET /health), the static asset-logo endpoint (GET /api/v1/logo/{isin},
+ /// which image loaders cannot attach a bearer token to), and the Flutter Web SPA fallback file.
///
+ [AllowAnonymous]
[HttpPost("auth/login")]
public async Task Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
{
@@ -64,17 +68,29 @@ public class AuthController : ControllerBase
}
///
- /// Changes the initial password required by an admin reset or creation.
+ /// Changes the initial password required after an admin-driven account creation or password reset.
+ /// Requires authentication (Rules.md §7): a caller always already holds a valid JWT at this point,
+ /// because issues one immediately, carrying RequiresPasswordChange in the
+ /// response body, before the client ever calls this endpoint. The target user is derived from the
+ /// caller's own token claim rather than accepted as a request parameter, so a caller can never
+ /// change another account's initial password by supplying an arbitrary user identifier.
///
+ [Authorize]
[HttpPost("auth/change-initial-password")]
public async Task ChangeInitialPassword([FromBody] ChangeInitialPasswordDto request, CancellationToken cancellationToken)
{
- if (request.UserId == Guid.Empty || string.IsNullOrWhiteSpace(request.NewPassword))
+ if (string.IsNullOrWhiteSpace(request?.NewPassword))
{
- return BadRequest(new { error = "UserId and NewPassword are required." });
+ return BadRequest(new { error = "NewPassword is required." });
}
- bool changed = await _userService.ChangeInitialPasswordAsync(request.UserId, request.NewPassword, cancellationToken);
+ var userIdClaim = User.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? User.FindFirst("sub")?.Value;
+ if (!Guid.TryParse(userIdClaim, out var userId))
+ {
+ return Unauthorized(new { error = "Invalid User Token claim." });
+ }
+
+ bool changed = await _userService.ChangeInitialPasswordAsync(userId, request.NewPassword, cancellationToken);
if (!changed)
{
return BadRequest(new { error = "Password change failed. User not found or password change not required." });
diff --git a/FinlyticBackend/Controllers/BotController.cs b/FinlyticBackend/Controllers/BotController.cs
new file mode 100644
index 0000000..554ebf7
--- /dev/null
+++ b/FinlyticBackend/Controllers/BotController.cs
@@ -0,0 +1,242 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FinlyticCore.Dtos.Bot;
+using FinlyticCore.Dtos.Settings;
+using FinlyticCore.Util;
+using FinlyticBackend.Util;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/v1/bot")]
+public class BotController : ControllerBase
+{
+ private readonly BackendMqttBridge _mqttBridge;
+ private readonly ILogger _logger;
+
+ public BotController(BackendMqttBridge mqttBridge, ILogger logger)
+ {
+ _mqttBridge = mqttBridge;
+ _logger = logger;
+ }
+
+ [HttpGet("status")]
+ public async Task GetStatus()
+ {
+ try
+ {
+ var status = await _mqttBridge.SendRpcRequestAsync(
+ "bot_GetStatus",
+ new object(),
+ TimeSpan.FromSeconds(5)
+ );
+
+ return Ok(status ?? new BotStatusDto(false, false, 0, 5, 1.0m, 75, "Unknown"));
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting bot status");
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ [HttpGet("positions/active")]
+ public async Task GetActivePositions()
+ {
+ try
+ {
+ var positions = await _mqttBridge.SendRpcRequestAsync, object>(
+ "bot_GetPositions",
+ new object(),
+ TimeSpan.FromSeconds(5)
+ );
+
+ return Ok(positions ?? new List());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting active bot positions");
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ [HttpGet("portfolio/summary")]
+ public async Task GetPortfolioSummary()
+ {
+ try
+ {
+ var summary = await _mqttBridge.SendRpcRequestAsync(
+ "bot_GetSummary",
+ new object(),
+ TimeSpan.FromSeconds(5)
+ );
+
+ if (summary == null)
+ {
+ // No fabricated account numbers (Rules.md §4): if FinlyticBot didn't answer the RPC in time,
+ // report that honestly instead of inventing a fake portfolio.
+ return Problem(title: "FinlyticBot is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ return Ok(summary);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error getting bot portfolio summary");
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ [HttpPost("orders/execute")]
+ public async Task ExecuteProposal([FromBody] ExecuteProposalRequest request)
+ {
+ if (request == null || request.ProposalId == Guid.Empty)
+ {
+ return BadRequest(new { Message = "Valid ProposalId is required." });
+ }
+
+ try
+ {
+ var order = await _mqttBridge.SendRpcRequestAsync(
+ "bot_ExecuteProposal",
+ request,
+ TimeSpan.FromSeconds(10)
+ );
+
+ if (order == null)
+ {
+ return StatusCode(422, new { Message = "Proposal could not be executed (Risk Gate rejection or not found)." });
+ }
+
+ return Ok(order);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error executing bot proposal {Id}", request.ProposalId);
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ ///
+ /// Emergency-closes every open FinlyticBot paper-trading position. Served by FinlyticBot's
+ /// bot_PanicClose RPC handler (FinlyticBot.Util.BotMqttClient.HandlePanicCloseRpcAsync),
+ /// which closes synthetic-ledger positions unconditionally but only closes Alpaca positions the broker
+ /// has actually confirmed liquidating — anything it could not confirm is left open and reported via
+ /// rather than silently counted as closed (Rules.md §4).
+ ///
+ [HttpPost("orders/panic-close")]
+ public async Task PanicCloseAllPositions()
+ {
+ try
+ {
+ var result = await _mqttBridge.SendRpcRequestAsync(
+ MqttTopics.Channels.BotPanicClose,
+ new object(),
+ TimeSpan.FromSeconds(15)
+ );
+
+ if (result == null)
+ {
+ // FinlyticBot did not answer in time: do NOT report a fabricated "0 closed" success for an
+ // emergency action — a false-positive panic-close result would be the worst possible outcome
+ // (Rules.md §4). The caller must know this attempt did not go through at all.
+ return Problem(
+ title: "FinlyticBot is currently unreachable. Panic close could NOT be confirmed - positions may still be open.",
+ statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ return Ok(result);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error triggering panic close");
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ ///
+ /// Updates FinlyticBot's dynamic settings. Routed through the same generic
+ /// Dictionary<string, object?> -> {prefix}_settings_Update ->
+ /// List<DynamicSettingDto> contract every other microservice uses (see
+ /// AdminSettingsController.UpdateServiceSettings) rather than a bespoke, nonexistent
+ /// "bot_UpdateSettings" channel with a mismatched contract. FinlyticBot is a
+ /// single shared instance with no per-tenant settings, so the generic mechanism applies directly.
+ ///
+ [HttpPost("settings/update")]
+ public async Task UpdateBotSettings([FromBody] UpdateBotSettingsRequest request)
+ {
+ if (request == null)
+ {
+ return BadRequest(new { Message = "Settings payload is required." });
+ }
+
+ // Raw setting-key strings are used here (rather than a shared constants type) because
+ // FinlyticBackend has no project reference to FinlyticBot (by design - the backend is a thin MQTT
+ // aggregation bridge, not a consumer of worker-service internals). The canonical source of truth for
+ // these keys is FinlyticBot/Settings/BotSettingKeys.cs; this mirrors the identical precedent already
+ // used in the opposite direction in FinlyticBot.Util.BotMqttClient.HandleGetStatusRpcAsync, which reads
+ // FinlyticEngine's "Engine.MinCompositeScore" the same way for the same reason.
+ var updates = new Dictionary();
+ if (request.AutoExecutionEnabled.HasValue)
+ {
+ updates["Bot.EnableAutoExecution"] = request.AutoExecutionEnabled.Value;
+ }
+ if (request.MaxPositions.HasValue)
+ {
+ updates["Bot.MaxConcurrentPositions"] = request.MaxPositions.Value;
+ }
+ if (request.RiskPerTradePercent.HasValue)
+ {
+ updates["Bot.RiskPerTradePercent"] = request.RiskPerTradePercent.Value;
+ }
+
+ // request.MinCompositeScore is deliberately NOT forwarded: that value is owned by FinlyticEngine
+ // (EngineSettingKeys.MinCompositeScore == "Engine.MinCompositeScore"), not by FinlyticBot.
+ // FinlyticBot's generic settings_Update handler only persists keys registered under BotSettingKeys
+ // (see BotMqttClient.HandleSettingsGetAllRpcAsync/HandleSettingsUpdateRpcAsync), so writing an
+ // Engine-owned key through the Bot's settings channel would either be silently dropped or written to
+ // a key FinlyticBot itself never reads back — either way it would misrepresent to the caller that the
+ // score was updated. The request DTO field is left in place (not this fix's concern to remove) for a
+ // future change that routes it to FinlyticEngine's own settings channel instead.
+ if (request.MinCompositeScore.HasValue)
+ {
+ _logger.LogWarning(
+ "Ignoring MinCompositeScore={Score} in bot settings update request: this value is owned by FinlyticEngine, not FinlyticBot, and cannot be set through this endpoint.",
+ request.MinCompositeScore.Value);
+ }
+
+ if (updates.Count == 0)
+ {
+ return BadRequest(new { Message = "At least one settable field (AutoExecutionEnabled, MaxPositions, RiskPerTradePercent) must be provided." });
+ }
+
+ try
+ {
+ var updatedSettings = await _mqttBridge.SendRpcRequestAsync, Dictionary>(
+ MqttTopics.Channels.BotSettingsUpdate,
+ updates,
+ TimeSpan.FromSeconds(5)
+ );
+
+ if (updatedSettings == null)
+ {
+ // No fabricated success (Rules.md §4): if FinlyticBot didn't answer the RPC in time, report
+ // that honestly instead of inventing a BotStatusDto that implies the settings were applied.
+ return Problem(title: "FinlyticBot is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ return Ok(updatedSettings);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error updating bot settings");
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+}
diff --git a/FinlyticBackend/Controllers/CalendarController.cs b/FinlyticBackend/Controllers/CalendarController.cs
index 9361b5e..e0ff096 100644
--- a/FinlyticBackend/Controllers/CalendarController.cs
+++ b/FinlyticBackend/Controllers/CalendarController.cs
@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
-using FinlyticAssets.Models;
+using FinlyticCore.Models.Assets;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Fundamentals;
@@ -90,7 +90,7 @@ public class CalendarController : ControllerBase
EventType: eventType,
EventDate: eventDate,
Date: eventDate.ToString("o"),
- Isin: e.Isin,
+ Isin: eventIsin,
Ticker: ticker,
Description: $"{eventType} - {companyName}",
Details: $"Termin für {companyName} am {eventDate:dd.MM.yyyy}",
diff --git a/FinlyticBackend/Controllers/DashboardController.cs b/FinlyticBackend/Controllers/DashboardController.cs
deleted file mode 100644
index f30a6f0..0000000
--- a/FinlyticBackend/Controllers/DashboardController.cs
+++ /dev/null
@@ -1,29 +0,0 @@
-using Microsoft.AspNetCore.Authorization;
-using Microsoft.AspNetCore.Mvc;
-using System.Threading.Tasks;
-
-namespace FinlyticBackend.Controllers;
-
-[ApiController]
-[Route("api/v1/[controller]")]
-[Authorize(Roles = "Admin")]
-public class DashboardController : ControllerBase
-{
- ///
- /// Retrieves global statistics aggregated from all microservices.
- /// Currently returns mocked data for UI development.
- ///
- [HttpGet("stats")]
- public IActionResult GetGlobalStats()
- {
- return Ok(new
- {
- totalAssetsLoaded = 12450,
- totalNewsArticles = 8340,
- activeTrades = 12,
- systemUptimeHours = 342,
- sentimentAnalysesCompleted = 45000,
- technicalAnalysesCompleted = 120000
- });
- }
-}
diff --git a/FinlyticBackend/Controllers/EngineController.cs b/FinlyticBackend/Controllers/EngineController.cs
new file mode 100644
index 0000000..c18af04
--- /dev/null
+++ b/FinlyticBackend/Controllers/EngineController.cs
@@ -0,0 +1,281 @@
+using System;
+using System.Collections.Generic;
+using System.Security.Claims;
+using System.Threading.Tasks;
+using FinlyticBackend.Util;
+using FinlyticCore.Dtos;
+using FinlyticCore.Dtos.Trading;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Cors;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/v1/engine")]
+[EnableCors("AllowAll")]
+public class EngineController : ControllerBase
+{
+ private readonly WebMqttClient _mqttClient;
+ private readonly ILogger _logger;
+
+ public EngineController(WebMqttClient mqttClient, ILogger logger)
+ {
+ _mqttClient = mqttClient;
+ _logger = logger;
+ }
+
+ ///
+ /// Resolves the authenticated caller's identity from the JWT NameIdentifier claim, parsed as a
+ /// exactly like the global user-validation middleware in
+ /// FinlyticBackend/Program.cs. There is no pseudo-identity fallback: every trade in FinlyticEngine
+ /// is tenant-scoped by EngineTradeEntity.UserId, so a request whose identity cannot be established
+ /// must be rejected with 401.
+ ///
+ ///
+ /// The NameIdentifier claim (or its sub/nameid fallbacks) is missing or is not a
+ /// parseable .
+ ///
+ private Guid GetUserIdFromClaims()
+ {
+ var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? User.FindFirstValue("sub")
+ ?? User.FindFirstValue("nameid");
+
+ if (Guid.TryParse(claimUserId, out var userId))
+ {
+ return userId;
+ }
+
+ _logger.LogWarning("[EngineController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
+ throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
+ }
+
+ ///
+ /// Retrieves active or all AI-validated trade proposals generated by FinlyticEngine. Proposals are
+ /// system-wide opportunities owned by no user, so this action is not scoped to the caller's identity.
+ ///
+ [HttpGet("proposals")]
+ public async Task GetProposals([FromQuery] bool onlyActive = true, [FromQuery] int limit = 50)
+ {
+ if (!_mqttClient.IsConnected)
+ {
+ return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
+ }
+
+ try
+ {
+ var request = new GetTradeProposalsRequest(onlyActive, limit);
+ var proposals = await _mqttClient.SendRpcRequestAsync, GetTradeProposalsRequest>(
+ "engine_GetProposals", request, TimeSpan.FromSeconds(5));
+
+ return Ok(proposals ?? new List());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[EngineController] Failed to retrieve trade proposals via MQTT RPC.");
+ return StatusCode(500, new { error = "Internal server error fetching trade proposals." });
+ }
+ }
+
+ ///
+ /// Retrieves active trades (positions) owned by the authenticated caller and being tracked or managed by
+ /// FinlyticEngine.
+ ///
+ [HttpGet("trades")]
+ public async Task GetActiveTrades([FromQuery] ExecutionMode? mode = null)
+ {
+ if (!_mqttClient.IsConnected)
+ {
+ return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
+ }
+
+ try
+ {
+ var userId = GetUserIdFromClaims();
+ var request = new GetActiveTradesRequest(UserId: userId, Mode: mode);
+ var trades = await _mqttClient.SendRpcRequestAsync, GetActiveTradesRequest>(
+ "engine_GetTrades", request, TimeSpan.FromSeconds(5));
+
+ return Ok(trades ?? new List());
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Unauthorized(new { error = "A valid user identity is required." });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[EngineController] Failed to retrieve active trades via MQTT RPC.");
+ return StatusCode(500, new { error = "Internal server error fetching active trades." });
+ }
+ }
+
+ ///
+ /// Triggers an immediate full-pipeline evaluation (FTA + Sentiment + Fundamentals + AI Gate + Knock-Out Resolver) for an asset.
+ /// Always returns 200 OK with a full body — including the real
+ /// scores and AI reasoning when the evaluation did not clear the bar for a proposal (Proposal == null),
+ /// instead of the previous anonymous placeholder object (Rules.md §3/§4).
+ ///
+ [HttpPost("evaluate")]
+ public async Task EvaluateAsset([FromBody] EvaluateAssetRequest request)
+ {
+ if (string.IsNullOrWhiteSpace(request?.Isin))
+ {
+ return BadRequest(new { error = "Mandatory ISIN parameter is missing." });
+ }
+
+ if (!_mqttClient.IsConnected)
+ {
+ return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
+ }
+
+ try
+ {
+ var userId = GetUserIdFromClaims();
+
+ // Any UserId supplied by the client in the body is discarded and replaced with the JWT identity,
+ // exactly like every other engine request DTO with a UserId field (see EvaluateAssetRequest's doc
+ // comment) - it is never trusted from the client.
+ var req = request with { UserId = userId };
+
+ var evaluation = await _mqttClient.SendRpcRequestAsync(
+ "engine_EvaluateIsin", req, TimeSpan.FromSeconds(15));
+
+ if (evaluation == null)
+ {
+ // Null means the RPC call itself got no response (transport failure), not a legitimate
+ // "evaluated, no proposal" outcome - that case is now always a populated DTO.
+ return StatusCode(502, new { error = "FinlyticEngine did not respond to the evaluation request." });
+ }
+
+ return Ok(evaluation);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Unauthorized(new { error = "A valid user identity is required." });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[EngineController] Failed to evaluate asset {Isin} via MQTT RPC.", request.Isin);
+ return StatusCode(500, new { error = "Internal server error evaluating asset." });
+ }
+ }
+
+ ///
+ /// Adds an executed fill (partial buy or scale-in) to an existing active trade owned by the authenticated
+ /// caller, triggering dynamic buy-in recalculation.
+ ///
+ [HttpPost("trades/{tradeId:guid}/fills")]
+ public async Task AddTradeFill(Guid tradeId, [FromBody] AddTradeFillRequest request)
+ {
+ if (!_mqttClient.IsConnected)
+ {
+ return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
+ }
+
+ try
+ {
+ var userId = GetUserIdFromClaims();
+
+ // Any UserId supplied by the client in the body is discarded and replaced with the JWT identity,
+ // so a caller can never add a fill to another user's trade.
+ var req = request with { UserId = userId, TradeId = tradeId };
+ var updatedTrade = await _mqttClient.SendRpcRequestAsync(
+ "engine_AddFill", req, TimeSpan.FromSeconds(5));
+
+ if (updatedTrade != null)
+ {
+ return Ok(updatedTrade);
+ }
+
+ return NotFound(new { error = $"Trade {tradeId} not found." });
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Unauthorized(new { error = "A valid user identity is required." });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[EngineController] Failed to add fill to trade {TradeId}.", tradeId);
+ return StatusCode(500, new { error = "Internal server error adding trade fill." });
+ }
+ }
+
+ ///
+ /// Manually or algorithmically adjusts the Stop-Loss of an active trade owned by the authenticated caller.
+ ///
+ [HttpPut("trades/{tradeId:guid}/stoploss")]
+ public async Task UpdateStopLoss(Guid tradeId, [FromBody] UpdateTradeStopLossRequest request)
+ {
+ if (!_mqttClient.IsConnected)
+ {
+ return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
+ }
+
+ try
+ {
+ var userId = GetUserIdFromClaims();
+
+ // Any UserId supplied by the client in the body is discarded and replaced with the JWT identity.
+ var req = request with { UserId = userId, TradeId = tradeId };
+ var updatedTrade = await _mqttClient.SendRpcRequestAsync(
+ "engine_UpdateStopLoss", req, TimeSpan.FromSeconds(5));
+
+ if (updatedTrade != null)
+ {
+ return Ok(updatedTrade);
+ }
+
+ return NotFound(new { error = $"Trade {tradeId} not found." });
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Unauthorized(new { error = "A valid user identity is required." });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[EngineController] Failed to update stop loss for trade {TradeId}.", tradeId);
+ return StatusCode(500, new { error = "Internal server error updating stop loss." });
+ }
+ }
+
+ ///
+ /// Closes an active trade owned by the authenticated caller at a specific market or exit price.
+ ///
+ [HttpPost("trades/{tradeId:guid}/close")]
+ public async Task CloseTrade(Guid tradeId, [FromBody] CloseEngineTradeRequest request)
+ {
+ if (!_mqttClient.IsConnected)
+ {
+ return StatusCode(503, new { error = "FinlyticEngine service is currently unreachable." });
+ }
+
+ try
+ {
+ var userId = GetUserIdFromClaims();
+
+ // Any UserId supplied by the client in the body is discarded and replaced with the JWT identity.
+ var req = request with { UserId = userId, TradeId = tradeId };
+ var closedTrade = await _mqttClient.SendRpcRequestAsync(
+ "engine_CloseTrade", req, TimeSpan.FromSeconds(5));
+
+ if (closedTrade != null)
+ {
+ return Ok(closedTrade);
+ }
+
+ return NotFound(new { error = $"Trade {tradeId} not found." });
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Unauthorized(new { error = "A valid user identity is required." });
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "[EngineController] Failed to close trade {TradeId}.", tradeId);
+ return StatusCode(500, new { error = "Internal server error closing trade." });
+ }
+ }
+}
diff --git a/FinlyticBackend/Controllers/NewsController.cs b/FinlyticBackend/Controllers/NewsController.cs
index 9062748..2450c23 100644
--- a/FinlyticBackend/Controllers/NewsController.cs
+++ b/FinlyticBackend/Controllers/NewsController.cs
@@ -64,7 +64,7 @@ public class NewsController : ControllerBase
HasSentiment: hasSentiment
);
- _logger.LogInformation("[payload] " + payload.Date.ToString());
+ _logger.LogInformation("[NewsController] Fetching news. Date filter: {Date}", payload.Date?.ToString("o") ?? "None");
var articles = await _mqttClient.SendRpcRequestAsync, DailyNewsRequest>(
"news_Get",
diff --git a/FinlyticBackend/Controllers/SimulationController.cs b/FinlyticBackend/Controllers/SimulationController.cs
new file mode 100644
index 0000000..36040c9
--- /dev/null
+++ b/FinlyticBackend/Controllers/SimulationController.cs
@@ -0,0 +1,201 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using FinlyticCore.Dtos;
+using FinlyticCore.Dtos.Simulation;
+using FinlyticBackend.Util;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Controllers;
+
+[ApiController]
+[Authorize]
+[Route("api/v1/simulation")]
+public class SimulationController : ControllerBase
+{
+ private readonly BackendMqttBridge _mqttBridge;
+ private readonly ILogger _logger;
+
+ public SimulationController(BackendMqttBridge mqttBridge, ILogger logger)
+ {
+ _mqttBridge = mqttBridge;
+ _logger = logger;
+ }
+
+ [HttpPost("run")]
+ public async Task RunBacktest([FromBody] BacktestRequestDto request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.Isin) || string.IsNullOrWhiteSpace(request.StrategyKey))
+ {
+ return BadRequest(new { Message = "ISIN and StrategyKey are required for backtesting." });
+ }
+
+ try
+ {
+ _logger.LogInformation("Starting backtest via REST for {Isin} ({StrategyKey}) on {Timeframe}", request.Isin, request.StrategyKey, request.Timeframe);
+
+ var report = await _mqttBridge.SendRpcRequestAsync(
+ "sim_RunBacktest",
+ request,
+ TimeSpan.FromSeconds(25)
+ );
+
+ if (report == null)
+ {
+ return StatusCode(504, new { Message = "Simulation service timed out or did not return a report." });
+ }
+
+ return Ok(report);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error running backtest for {Isin}", request.Isin);
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ [HttpGet("matrix/{isin}")]
+ public async Task GetStrategyMatrix(string isin)
+ {
+ if (string.IsNullOrWhiteSpace(isin))
+ {
+ return BadRequest(new { Message = "ISIN is required." });
+ }
+
+ try
+ {
+ var matrix = await _mqttBridge.SendRpcRequestAsync, IsinRequest>(
+ "sim_GetMatrixForAsset",
+ new IsinRequest(isin, null, false),
+ TimeSpan.FromSeconds(5)
+ );
+
+ return Ok(matrix ?? new List());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching strategy matrix for {Isin}", isin);
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ ///
+ /// Lightweight history of past backtest runs for an ISIN, optionally narrowed to one strategy - every run
+ /// is already persisted server-side (sim_RunBacktest) but was previously only reachable indirectly
+ /// via the reliability matrix, never queryable as a history in its own right.
+ ///
+ [HttpGet("history/{isin}")]
+ public async Task GetBacktestHistory(string isin, [FromQuery] string? strategyKey = null, [FromQuery] int limit = 20)
+ {
+ if (string.IsNullOrWhiteSpace(isin))
+ {
+ return BadRequest(new { Message = "ISIN is required." });
+ }
+
+ try
+ {
+ var history = await _mqttBridge.SendRpcRequestAsync, GetBacktestHistoryRequest>(
+ "sim_GetBacktestHistory",
+ new GetBacktestHistoryRequest(isin, strategyKey, limit),
+ TimeSpan.FromSeconds(5)
+ );
+
+ return Ok(history ?? new List());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching backtest history for {Isin}", isin);
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ /// Full report (trades + equity curve) for one specific past backtest run, for drilling into a history entry.
+ [HttpGet("history/run/{runId:guid}")]
+ public async Task GetBacktestRunDetail(Guid runId)
+ {
+ try
+ {
+ var report = await _mqttBridge.SendRpcRequestAsync(
+ "sim_GetBacktestRunDetail",
+ new GetBacktestRunDetailRequest(runId),
+ TimeSpan.FromSeconds(5)
+ );
+
+ if (report == null)
+ {
+ return NotFound(new { Message = $"No backtest run found for RunId {runId}." });
+ }
+
+ return Ok(report);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching backtest run detail for {RunId}", runId);
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ /// Saved indicator-parameter profile for one (Isin, StrategyKey) pair, or 404 if none was ever saved.
+ [HttpGet("parameters/{isin}/{strategyKey}")]
+ public async Task GetStrategyParameters(string isin, string strategyKey)
+ {
+ if (string.IsNullOrWhiteSpace(isin) || string.IsNullOrWhiteSpace(strategyKey))
+ {
+ return BadRequest(new { Message = "ISIN and StrategyKey are required." });
+ }
+
+ try
+ {
+ var profile = await _mqttBridge.SendRpcRequestAsync(
+ "sim_GetStrategyParameters",
+ new GetStrategyParametersRequest(isin, strategyKey),
+ TimeSpan.FromSeconds(5)
+ );
+
+ if (profile == null)
+ {
+ return NotFound(new { Message = $"No saved parameter profile for {isin}/{strategyKey}." });
+ }
+
+ return Ok(profile);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error fetching strategy parameters for {Isin}/{StrategyKey}", isin, strategyKey);
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+
+ /// Saves/updates a per-asset/per-strategy indicator-parameter profile for future backtests to reuse.
+ [HttpPost("parameters")]
+ public async Task SaveStrategyParameters([FromBody] SaveStrategyParametersRequest request)
+ {
+ if (request == null || string.IsNullOrWhiteSpace(request.Isin) || string.IsNullOrWhiteSpace(request.StrategyKey))
+ {
+ return BadRequest(new { Message = "ISIN and StrategyKey are required." });
+ }
+
+ try
+ {
+ var profile = await _mqttBridge.SendRpcRequestAsync(
+ "sim_SaveStrategyParameters",
+ request,
+ TimeSpan.FromSeconds(5)
+ );
+
+ if (profile == null)
+ {
+ return StatusCode(504, new { Message = "Simulation service timed out or did not confirm the save." });
+ }
+
+ return Ok(profile);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error saving strategy parameters for {Isin}/{StrategyKey}", request.Isin, request.StrategyKey);
+ return StatusCode(500, new { Message = ex.Message });
+ }
+ }
+}
diff --git a/FinlyticBackend/Controllers/UserTradesController.cs b/FinlyticBackend/Controllers/UserTradesController.cs
index edfe796..633d58a 100644
--- a/FinlyticBackend/Controllers/UserTradesController.cs
+++ b/FinlyticBackend/Controllers/UserTradesController.cs
@@ -1,11 +1,13 @@
using System;
using System.Collections.Generic;
using System.Security.Claims;
+using System.Text.Json;
using System.Threading.Tasks;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
-using FinlyticCore.Models.Trades;
+using FinlyticCore.Dtos.Trading;
using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
@@ -16,35 +18,45 @@ namespace FinlyticBackend.Controllers;
[Route("api/v1/user/trades")]
public class UserTradesController : ControllerBase
{
- private readonly WebMqttClient _mqttClient;
+ private readonly BackendMqttBridge _mqttClient;
private readonly ILogger _logger;
- public UserTradesController(WebMqttClient mqttClient, ILogger logger)
+ public UserTradesController(BackendMqttBridge mqttClient, ILogger logger)
{
_mqttClient = mqttClient;
_logger = logger;
}
///
- /// Liest die eindeutige UserId aus den Claims des authentifizierten Bearer Tokens.
+ /// Resolves the authenticated caller's identity from the JWT NameIdentifier claim, parsed as a
+ /// exactly like the global user-validation middleware in
+ /// FinlyticBackend/Program.cs (see line ~146). There is deliberately no pseudo-identity fallback
+ /// (e.g. a shared "default_user" string): every trade in FinlyticEngine is tenant-scoped by
+ /// EngineTradeEntity.UserId, so a request whose identity cannot be established must be rejected
+ /// with 401 rather than silently attributed to a placeholder user that could leak or merge data across
+ /// tenants.
///
- private string GetUserIdFromClaims()
+ ///
+ /// The NameIdentifier claim (or its sub/nameid fallbacks) is missing or is not a
+ /// parseable .
+ ///
+ private Guid GetUserIdFromClaims()
{
- var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
- ?? User.FindFirstValue("sub")
+ var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
+ ?? User.FindFirstValue("sub")
?? User.FindFirstValue("nameid");
- if (!string.IsNullOrWhiteSpace(claimUserId))
+ if (Guid.TryParse(claimUserId, out var userId))
{
- return claimUserId;
+ return userId;
}
- _logger.LogWarning("[UserTradesController] Claim NameIdentifier not found for authenticated request. Falling back to default_user.");
- return "default_user";
+ _logger.LogWarning("[UserTradesController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
+ throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
}
///
- /// Retrieves a list of trades for the current authenticated user (including global proposals).
+ /// Retrieves a list of active trades or proposals from FinlyticEngine.
///
[HttpGet]
public async Task GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null)
@@ -53,116 +65,186 @@ public class UserTradesController : ControllerBase
{
if (!_mqttClient.IsConnected)
{
- return StatusCode(503, new { error = "MQTT Broker disconnected" });
+ return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
}
- var userId = GetUserIdFromClaims();
-
- // FIX: UserId explizit an GetTradesRequest übergeben!
- var request = new GetTradesRequest(isin, status, userId);
-
- var trades = await _mqttClient.SendRpcRequestAsync, GetTradesRequest>(
- "trades_Get",
- request,
- TimeSpan.FromSeconds(5));
-
- return Ok(trades ?? new List());
+ if (string.Equals(status, "Proposed", StringComparison.OrdinalIgnoreCase))
+ {
+ // Proposals are system-wide opportunities, not owned by any user, so no UserId is attached here.
+ var req = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
+ var proposals = await _mqttClient.SendRpcRequestAsync, GetTradeProposalsRequest>(
+ "engine_GetProposals", req, TimeSpan.FromSeconds(5));
+ return Ok(proposals ?? new List());
+ }
+ else
+ {
+ var userId = GetUserIdFromClaims();
+ var req = new GetActiveTradesRequest(UserId: userId, Mode: null);
+ var trades = await _mqttClient.SendRpcRequestAsync, GetActiveTradesRequest>(
+ "engine_GetTrades", req, TimeSpan.FromSeconds(5));
+ return Ok(trades ?? new List());
+ }
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Unauthorized(new { error = "A valid user identity is required." });
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to retrieve user trades via MQTT RPC.");
+ _logger.LogError(ex, "Failed to retrieve trades from FinlyticEngine.");
return StatusCode(500, new { error = "Internal server error while fetching trades" });
}
}
///
- /// Public endpoint to retrieve active global proposals for guest users.
- ///
- [HttpGet("/api/v1/trades/public")]
- [AllowAnonymous]
- public async Task GetPublicProposals([FromQuery] string? isin = null)
- {
- try
- {
- if (!_mqttClient.IsConnected)
- {
- return StatusCode(503, new { error = "MQTT Broker disconnected" });
- }
-
- // Status = Proposed für anonyme Öffentliche Anfragen
- var request = new GetTradesRequest(isin, "Proposed", UserId: null);
- var proposals = await _mqttClient.SendRpcRequestAsync, GetTradesRequest>(
- "trades_Get",
- request,
- TimeSpan.FromSeconds(5));
-
- return Ok(proposals ?? new List());
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to retrieve public trade proposals via MQTT RPC.");
- return Ok(new List());
- }
- }
-
- ///
- /// Accepts a proposed trade and assigns it to the current user's portfolio.
+ /// Accepts a trade proposal and converts it into an actively tracked trade in FinlyticEngine, owned by the
+ /// authenticated caller.
///
+ ///
+ /// The acceptance payload sent by the client (see TradeRepository.acceptTrade in FinlyticApp),
+ /// carrying the proposal identifier ()
+ /// plus any user-adjusted position sizing, leverage and fee details. Any userId supplied by the
+ /// client is discarded; the acceptance is always attributed to the identity from the JWT.
+ ///
+ ///
+ /// The resulting on success, or a diagnostic error response if
+ /// FinlyticEngine is unreachable or rejects the request (e.g. proposal expired, not found, or already
+ /// accepted by this same user).
+ ///
[HttpPost("accept")]
- public async Task AcceptTrade([FromBody] TradeAcceptanceDto request)
+ public async Task AcceptTrade([FromBody] FinlyticCore.Models.Trades.TradeAcceptanceDto dto)
{
+ if (dto == null || string.IsNullOrWhiteSpace(dto.TradeId) || !Guid.TryParse(dto.TradeId, out var proposalId))
+ {
+ return BadRequest(new { error = "A valid proposal id ('tradeId') is required." });
+ }
+
+ if (!_mqttClient.IsConnected)
+ {
+ return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
+ }
+
try
{
- if (!_mqttClient.IsConnected)
+ // The acceptance is always attributed to the authenticated caller, never to a client-supplied value.
+ var userId = GetUserIdFromClaims();
+
+ var acceptRequest = new AcceptTradeProposalRequest(
+ UserId: userId,
+ ProposalId: proposalId,
+ ExecutedPrice: dto.ActualEntryPrice ?? dto.EntryPrice,
+ Quantity: dto.Quantity ?? dto.PositionSize);
+
+ var acceptedTrade = await _mqttClient.SendRpcRequestAsync(
+ "engine_AcceptProposal", acceptRequest, TimeSpan.FromSeconds(10));
+
+ if (acceptedTrade != null)
{
- return StatusCode(503, new { error = "MQTT Broker disconnected" });
+ return Ok(acceptedTrade);
}
- if (string.IsNullOrWhiteSpace(request.AnalysisId) && string.IsNullOrWhiteSpace(request.TradeId))
- {
- return BadRequest(new { error = "AnalysisId or TradeId is required" });
- }
-
- // FIX: UserId felsenfest aus den authentifizierten Claims überschreiben
- request.UserId = GetUserIdFromClaims();
-
- var result = await _mqttClient.SendRpcRequestAsync(
- "trades_Accept",
- request,
- TimeSpan.FromSeconds(5));
-
- if (result != null)
- {
- return Ok(result);
- }
-
- // Fallback Fire-and-Forget
- await _mqttClient.PublishAsync($"finlytic/trades/accept/{request.Isin}", request);
- return Ok(new { status = "Accepted", analysisId = request.AnalysisId, tradeId = request.TradeId, userId = request.UserId });
+ return StatusCode(504, new { error = "FinlyticEngine did not confirm the trade acceptance in time." });
}
- catch (Exception ex)
+ catch (UnauthorizedAccessException)
{
- _logger.LogError(ex, "Failed to accept trade proposal via MQTT RPC.");
- return StatusCode(500, new { error = "Internal server error" });
+ return Unauthorized(new { error = "A valid user identity is required." });
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogWarning(ex, "Cannot accept proposal {ProposalId}: rejected by FinlyticEngine (expired, not found, or already accepted by this user).", proposalId);
+ return Conflict(new { error = "The proposal is no longer available or has already been accepted by this user." });
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Malformed RPC response while accepting proposal {ProposalId}.", proposalId);
+ return StatusCode(502, new { error = "FinlyticEngine returned an unexpected response while accepting the trade." });
}
}
///
- /// Closes an active trade.
+ /// Manually opens a trade in FinlyticEngine with no backing proposal (e.g. the user enters a position in
+ /// the Web UI that was never evaluated/scored by FinlyticEngine). This is the alternative to
+ /// : a user either accepts an existing proposal or creates a trade from scratch,
+ /// and both paths end up with one user-owned .
///
- [HttpPost("{id}/close")]
- public async Task CloseTrade(string id, [FromBody] CloseTradeRequest? request)
+ ///
+ /// The manual trade payload from the client. Any userId it carries is discarded; the trade is
+ /// always attributed to the identity from the JWT.
+ ///
+ [HttpPost("manual")]
+ public async Task CreateManualTrade([FromBody] CreateManualTradeRequest request)
+ {
+ if (request == null)
+ {
+ return Problem(title: "A request body is required.", statusCode: StatusCodes.Status400BadRequest);
+ }
+
+ if (!_mqttClient.IsConnected)
+ {
+ return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
+ }
+
+ try
+ {
+ // The trade is always attributed to the authenticated caller, never to a client-supplied value.
+ var userId = GetUserIdFromClaims();
+ var manualTradeRequest = request with { UserId = userId };
+
+ var trade = await _mqttClient.SendRpcRequestAsync(
+ "engine_CreateManualTrade", manualTradeRequest, TimeSpan.FromSeconds(10));
+
+ if (trade != null)
+ {
+ return Ok(trade);
+ }
+
+ return Problem(title: "FinlyticEngine did not confirm the manual trade in time.", statusCode: StatusCodes.Status504GatewayTimeout);
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
+ }
+ catch (ArgumentException ex)
+ {
+ _logger.LogWarning(ex, "Rejected manual trade creation: invalid request payload.");
+ return Problem(title: "The manual trade payload is invalid.", detail: ex.Message, statusCode: StatusCodes.Status400BadRequest);
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogError(ex, "Malformed RPC response while creating a manual trade.");
+ return Problem(title: "FinlyticEngine returned an unexpected response while creating the manual trade.", statusCode: StatusCodes.Status502BadGateway);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to create manual trade via FinlyticEngine.");
+ return Problem(title: "Internal server error while creating the manual trade.", statusCode: StatusCodes.Status500InternalServerError);
+ }
+ }
+
+ ///
+ /// Closes an active trade via FinlyticEngine. The trade must be owned by the authenticated caller;
+ /// FinlyticEngine enforces this server-side and reports an unknown/foreign trade identically (Rules.md
+ /// multi-tenancy requirement), so this action never leaks whether a trade ID belongs to another user.
+ ///
+ [HttpPost("{id:guid}/close")]
+ public async Task CloseTrade(Guid id, [FromBody] CloseEngineTradeRequest? request)
{
try
{
if (!_mqttClient.IsConnected)
{
- return StatusCode(503, new { error = "MQTT Broker disconnected" });
+ return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
}
- var closeReq = request ?? new CloseTradeRequest { UserExitPrice = 100.0m, CloseReason = "UserManualClose" };
- var result = await _mqttClient.SendRpcRequestAsync(
- $"trades_Close/{id}",
+ var userId = GetUserIdFromClaims();
+
+ // Any UserId/TradeId supplied by the client in the body is discarded and replaced with the
+ // authenticated identity and the route value, so a caller cannot target another user's trade.
+ var closeReq = (request ?? new CloseEngineTradeRequest(UserId: userId, TradeId: id, ClosePrice: 0m, Reason: "UserManualClose"))
+ with { UserId = userId, TradeId = id };
+
+ var result = await _mqttClient.SendRpcRequestAsync(
+ "engine_CloseTrade",
closeReq,
TimeSpan.FromSeconds(5));
@@ -171,45 +253,16 @@ public class UserTradesController : ControllerBase
return Ok(result);
}
- return Ok(new { status = "Closed", tradeId = id });
+ return NotFound(new { error = $"Trade {id} not found." });
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return Unauthorized(new { error = "A valid user identity is required." });
}
catch (Exception ex)
{
- _logger.LogError(ex, "Failed to close trade {Id} via MQTT RPC.", id);
+ _logger.LogError(ex, "Failed to close trade {Id} via FinlyticEngine.", id);
return StatusCode(500, new { error = "Internal server error while closing trade" });
}
}
-
- ///
- /// Rejects a proposed trade.
- ///
- [HttpPost("{id}/reject")]
- public async Task RejectTrade(string id, [FromBody] CloseTradeRequest? request)
- {
- try
- {
- if (!_mqttClient.IsConnected)
- {
- return StatusCode(503, new { error = "MQTT Broker disconnected" });
- }
-
- var closeReq = request ?? new CloseTradeRequest { CloseReason = "UserRejected" };
- var result = await _mqttClient.SendRpcRequestAsync(
- $"trades_Reject/{id}",
- closeReq,
- TimeSpan.FromSeconds(5));
-
- if (result != null)
- {
- return Ok(result);
- }
-
- return Ok(new { status = "Rejected", tradeId = id });
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Failed to reject trade {Id} via MQTT RPC.", id);
- return StatusCode(500, new { error = "Internal server error while rejecting trade" });
- }
- }
-}
\ No newline at end of file
+}
diff --git a/FinlyticBackend/Database/BackendDbContext.cs b/FinlyticBackend/Database/BackendDbContext.cs
index ef65988..04866a0 100644
--- a/FinlyticBackend/Database/BackendDbContext.cs
+++ b/FinlyticBackend/Database/BackendDbContext.cs
@@ -1,9 +1,16 @@
using FinlyticBackend.Entities;
+using FinlyticCore.Database;
+using FinlyticCore.Entities.Settings;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Design;
namespace FinlyticBackend.Database;
-public class BackendDbContext : DbContext
+///
+/// FinlyticBackend previously implemented no at all, unlike every other
+/// service - which is why it never had dynamic settings (logging channels etc.) to show in the admin UI.
+///
+public class BackendDbContext : DbContext, ISettingsDbContext
{
public BackendDbContext(DbContextOptions options) : base(options) { }
@@ -11,11 +18,18 @@ public class BackendDbContext : DbContext
public DbSet UserDeviceTokens => Set();
public DbSet UserFavoriteAssets => Set();
public DbSet ServiceConfigurations => Set();
+ public DbSet DynamicSettings => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
+ modelBuilder.Entity(entity =>
+ {
+ entity.HasKey(e => e.Id);
+ entity.HasIndex(e => e.Key).IsUnique();
+ });
+
modelBuilder.Entity(entity =>
{
entity.ToTable("users");
@@ -73,3 +87,19 @@ public class BackendDbContext : DbContext
});
}
}
+
+///
+/// Lets `dotnet ef migrations add` construct a without booting the full
+/// application (which fails fast at startup if JWT_SECRET_KEY/ADMIN_DEFAULT_PASSWORD aren't set - see
+/// Program.cs) - the same pattern every other service's DbContext already uses
+/// (e.g. TechnicalAnalysisDbContextFactory).
+///
+public class BackendDbContextFactory : IDesignTimeDbContextFactory
+{
+ public BackendDbContext CreateDbContext(string[] args)
+ {
+ var optionsBuilder = new DbContextOptionsBuilder();
+ optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_backend;Username=postgres;Password=postgres");
+ return new BackendDbContext(optionsBuilder.Options);
+ }
+}
diff --git a/FinlyticBackend/Dockerfile b/FinlyticBackend/Dockerfile
index b9c4d1f..50583aa 100644
--- a/FinlyticBackend/Dockerfile
+++ b/FinlyticBackend/Dockerfile
@@ -1,16 +1,22 @@
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+USER $APP_UID
+WORKDIR /app
+
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
COPY ["FinlyticBackend/FinlyticBackend.csproj", "FinlyticBackend/"]
RUN dotnet restore "FinlyticBackend/FinlyticBackend.csproj"
COPY . .
WORKDIR "/src/FinlyticBackend"
-RUN dotnet build "FinlyticBackend.csproj" -c Release -o /app/build
+RUN dotnet build "FinlyticBackend.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
-RUN dotnet publish "FinlyticBackend.csproj" -c Release -o /app/publish /p:UseAppHost=false
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "FinlyticBackend.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
-FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
+FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FinlyticBackend.dll"]
diff --git a/FinlyticBackend/Hubs/LogStreamHub.cs b/FinlyticBackend/Hubs/LogStreamHub.cs
index e0a15b7..6d19ea4 100644
--- a/FinlyticBackend/Hubs/LogStreamHub.cs
+++ b/FinlyticBackend/Hubs/LogStreamHub.cs
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -8,6 +9,7 @@ namespace FinlyticBackend.Hubs;
///
/// SignalR Hub that streams live log messages from microservices to connected web UI clients.
///
+[Authorize]
public class LogStreamHub : Hub
{
private readonly ILogger _logger;
diff --git a/FinlyticBackend/Hubs/NewsHub.cs b/FinlyticBackend/Hubs/NewsHub.cs
index 05aa0ec..36a5267 100644
--- a/FinlyticBackend/Hubs/NewsHub.cs
+++ b/FinlyticBackend/Hubs/NewsHub.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using System.Threading.Tasks;
using FinlyticCore.Dtos.News;
@@ -7,6 +8,7 @@ namespace FinlyticBackend.Hubs;
///
/// SignalR Hub for real-time news delivery to connected clients.
///
+[Authorize]
public class NewsHub : Hub
{
// Clients can call this to join specific symbol groups if needed later
diff --git a/FinlyticBackend/Hubs/SystemHealthHub.cs b/FinlyticBackend/Hubs/SystemHealthHub.cs
index 108cf6f..39e49cd 100644
--- a/FinlyticBackend/Hubs/SystemHealthHub.cs
+++ b/FinlyticBackend/Hubs/SystemHealthHub.cs
@@ -1,5 +1,6 @@
using System;
using System.Threading.Tasks;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -8,6 +9,7 @@ namespace FinlyticBackend.Hubs;
///
/// SignalR Hub broadcasting real-time system diagnostics & microservice health updates to connected clients.
///
+[Authorize]
public class SystemHealthHub : Hub
{
private readonly ILogger _logger;
diff --git a/FinlyticBackend/Hubs/TradeHub.cs b/FinlyticBackend/Hubs/TradeHub.cs
deleted file mode 100644
index e8f21d6..0000000
--- a/FinlyticBackend/Hubs/TradeHub.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using Microsoft.AspNetCore.SignalR;
-
-namespace FinlyticBackend.Hubs;
-
-///
-/// SignalR Hub broadcasting real-time trade signals, position updates, and closed trade events.
-///
-public class TradeHub : Hub
-{
- public override async Task OnConnectedAsync()
- {
- await base.OnConnectedAsync();
- }
-}
diff --git a/FinlyticBackend/Hubs/TradeRealtimeHub.cs b/FinlyticBackend/Hubs/TradeRealtimeHub.cs
deleted file mode 100644
index ad32641..0000000
--- a/FinlyticBackend/Hubs/TradeRealtimeHub.cs
+++ /dev/null
@@ -1,33 +0,0 @@
-using System;
-using System.Threading.Tasks;
-using FinlyticCore.Models.Auth;
-using Microsoft.AspNetCore.SignalR;
-using Microsoft.Extensions.Logging;
-
-namespace FinlyticBackend.Hubs;
-
-///
-/// Real-time SignalR WebSocket & Server-Sent Events (SSE) Hub streaming live trade proposals & updates to Web and Mobile clients.
-///
-public class TradeRealtimeHub : Hub
-{
- private readonly ILogger _logger;
-
- public TradeRealtimeHub(ILogger logger)
- {
- _logger = logger;
- }
-
- public override async Task OnConnectedAsync()
- {
- _logger.LogInformation("Real-time SignalR Client connected: ConnectionId={ConnectionId}, User={User}",
- Context.ConnectionId, Context.User?.Identity?.Name ?? "Anonymous");
- await base.OnConnectedAsync();
- }
-
- public override async Task OnDisconnectedAsync(Exception? exception)
- {
- _logger.LogInformation("Real-time SignalR Client disconnected: ConnectionId={ConnectionId}", Context.ConnectionId);
- await base.OnDisconnectedAsync(exception);
- }
-}
diff --git a/FinlyticBackend/Hubs/TradeStreamHub.cs b/FinlyticBackend/Hubs/TradeStreamHub.cs
new file mode 100644
index 0000000..6aff877
--- /dev/null
+++ b/FinlyticBackend/Hubs/TradeStreamHub.cs
@@ -0,0 +1,35 @@
+using System.Threading.Tasks;
+using FinlyticCore.Dtos.Bot;
+using FinlyticCore.Dtos.Trading;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.SignalR;
+
+namespace FinlyticBackend.Hubs;
+
+public interface ITradeStreamClient
+{
+ Task ReceiveTradeProposal(TradeProposalDto proposal);
+ Task ReceiveTradeUpdate(ActiveTradeDto trade);
+ Task ReceiveBotPositionUpdate(BotTradeOrderDto botOrder);
+ Task ReceivePortfolioSummary(AccountSummaryDto summary);
+}
+
+[Authorize]
+public class TradeStreamHub : Hub
+{
+ public async Task JoinAssetGroup(string isin)
+ {
+ if (!string.IsNullOrWhiteSpace(isin))
+ {
+ await Groups.AddToGroupAsync(Context.ConnectionId, isin.Trim().ToUpperInvariant());
+ }
+ }
+
+ public async Task LeaveAssetGroup(string isin)
+ {
+ if (!string.IsNullOrWhiteSpace(isin))
+ {
+ await Groups.RemoveFromGroupAsync(Context.ConnectionId, isin.Trim().ToUpperInvariant());
+ }
+ }
+}
diff --git a/FinlyticBackend/Middleware/GlobalExceptionHandler.cs b/FinlyticBackend/Middleware/GlobalExceptionHandler.cs
new file mode 100644
index 0000000..d8abbe6
--- /dev/null
+++ b/FinlyticBackend/Middleware/GlobalExceptionHandler.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Diagnostics;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace FinlyticBackend.Middleware;
+
+///
+/// Global fallback exception handler for the Gateway (Rules.md §11). Converts any unhandled exception that
+/// escapes a controller action, hub method, or other endpoint into a standardized RFC 7807 Problem Details
+/// response instead of an ad-hoc anonymous error object or a leaked stack trace.
+/// Registered via AddExceptionHandler<GlobalExceptionHandler>() and activated by
+/// app.UseExceptionHandler() in Program.cs. This only intercepts exceptions thrown while a
+/// request is being handled; it has no effect on the fail-fast startup checks in Program.cs
+/// (missing/weak JWT:SecretKey, missing ADMIN:DefaultPassword), which throw before
+/// builder.Build() - i.e. before this middleware pipeline exists - and are intentionally left
+/// unhandled so the host refuses to start.
+///
+public sealed class GlobalExceptionHandler : IExceptionHandler
+{
+ private readonly ILogger _logger;
+
+ public GlobalExceptionHandler(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ ///
+ public async ValueTask TryHandleAsync(HttpContext httpContext, Exception exception, CancellationToken cancellationToken)
+ {
+ _logger.LogError(
+ exception,
+ "Unhandled exception while processing {Method} {Path}",
+ httpContext.Request.Method,
+ httpContext.Request.Path);
+
+ httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
+
+ var problemDetailsService = httpContext.RequestServices.GetRequiredService();
+ return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext
+ {
+ HttpContext = httpContext,
+ Exception = exception,
+ ProblemDetails = new ProblemDetails
+ {
+ Status = StatusCodes.Status500InternalServerError,
+ Title = "An unexpected error occurred.",
+ Detail = "The Gateway encountered an unexpected error while processing the request.",
+ Type = "https://tools.ietf.org/html/rfc7231#section-6.6.1"
+ }
+ });
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260822131602_AddDynamicSettings.Designer.cs b/FinlyticBackend/Migrations/20260822131602_AddDynamicSettings.Designer.cs
new file mode 100644
index 0000000..fc07dc3
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260822131602_AddDynamicSettings.Designer.cs
@@ -0,0 +1,270 @@
+//
+using System;
+using FinlyticBackend.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ [DbContext(typeof(BackendDbContext))]
+ [Migration("20260822131602_AddDynamicSettings")]
+ partial class AddDynamicSettings
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("FinlyticBackend.Entities.ServiceConfigurationEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ConfigKey")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("config_key");
+
+ b.Property("ConfigValue")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("config_value");
+
+ b.Property("DataType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("data_type");
+
+ b.Property("Description")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("description");
+
+ b.Property("ServiceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("service_name");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ServiceName", "ConfigKey")
+ .IsUnique();
+
+ b.ToTable("service_configurations", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("DeviceName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("device_name");
+
+ b.Property("FcmToken")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("fcm_token");
+
+ b.Property("LastUsedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_used_at");
+
+ b.Property("RegisteredAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("registered_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("FcmToken");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("user_device_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)")
+ .HasColumnName("email");
+
+ b.Property("FullName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("full_name");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("LastLoginAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_login_at");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("RequiresPasswordChange")
+ .HasColumnType("boolean");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("role");
+
+ b.Property("ThemePreference")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("theme_preference");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Email")
+ .IsUnique();
+
+ b.HasIndex("Role");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Isin")
+ .IsRequired()
+ .HasMaxLength(30)
+ .HasColumnType("character varying(30)")
+ .HasColumnName("isin");
+
+ b.Property("SelectedTicker")
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Isin")
+ .IsUnique();
+
+ b.ToTable("user_favorite_assets", (string)null);
+ });
+
+ modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany("DeviceTokens")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserFavoriteAssetEntity", b =>
+ {
+ b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("FinlyticBackend.Entities.UserEntity", b =>
+ {
+ b.Navigation("DeviceTokens");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/20260822131602_AddDynamicSettings.cs b/FinlyticBackend/Migrations/20260822131602_AddDynamicSettings.cs
new file mode 100644
index 0000000..2695819
--- /dev/null
+++ b/FinlyticBackend/Migrations/20260822131602_AddDynamicSettings.cs
@@ -0,0 +1,43 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace FinlyticBackend.Migrations
+{
+ ///
+ public partial class AddDynamicSettings : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "DynamicSettings",
+ columns: table => new
+ {
+ Id = table.Column(type: "uuid", nullable: false),
+ Key = table.Column(type: "character varying(150)", maxLength: 150, nullable: false),
+ ValueJson = table.Column(type: "text", nullable: false),
+ ServiceIdentifier = table.Column(type: "character varying(100)", maxLength: 100, nullable: false),
+ LastUpdatedUtc = table.Column(type: "timestamp with time zone", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_DynamicSettings", x => x.Id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_DynamicSettings_Key",
+ table: "DynamicSettings",
+ column: "Key",
+ unique: true);
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "DynamicSettings");
+ }
+ }
+}
diff --git a/FinlyticBackend/Migrations/BackendDbContextModelSnapshot.cs b/FinlyticBackend/Migrations/BackendDbContextModelSnapshot.cs
index de105d6..f1feb85 100644
--- a/FinlyticBackend/Migrations/BackendDbContextModelSnapshot.cs
+++ b/FinlyticBackend/Migrations/BackendDbContextModelSnapshot.cs
@@ -204,6 +204,37 @@ namespace FinlyticBackend.Migrations
b.ToTable("user_favorite_assets", (string)null);
});
+ modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid");
+
+ b.Property("Key")
+ .IsRequired()
+ .HasMaxLength(150)
+ .HasColumnType("character varying(150)");
+
+ b.Property("LastUpdatedUtc")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("ServiceIdentifier")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("ValueJson")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Key")
+ .IsUnique();
+
+ b.ToTable("DynamicSettings");
+ });
+
modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
{
b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
diff --git a/FinlyticBackend/Program.cs b/FinlyticBackend/Program.cs
index d8dec91..a5ac082 100644
--- a/FinlyticBackend/Program.cs
+++ b/FinlyticBackend/Program.cs
@@ -4,10 +4,15 @@ using System.Threading.Tasks;
using FinlyticBackend.Controllers;
using FinlyticBackend.Database;
using FinlyticBackend.Hubs;
+using FinlyticBackend.Middleware;
using FinlyticBackend.Services;
using FinlyticBackend.Util;
+using FinlyticCore.Database;
+using FinlyticCore.Services;
using Microsoft.AspNetCore.Authentication.JwtBearer;
+
using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Http.Connections;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -21,6 +26,12 @@ var builder = WebApplication.CreateBuilder(args);
// 1. Add Controllers
builder.Services.AddControllers();
+// 1b. Standardized error responses (Rules.md §11): every unhandled exception becomes an RFC 7807
+// Problem Details response via GlobalExceptionHandler instead of a raw stack trace or an anonymous
+// error object hand-rolled per controller.
+builder.Services.AddProblemDetails();
+builder.Services.AddExceptionHandler();
+
// 2. Define CORS Policy
builder.Services.AddCors(options =>
{
@@ -34,10 +45,28 @@ builder.Services.AddCors(options =>
});
// 3. Configure JWT Authentication
-var secretKey = builder.Configuration["JWT:SecretKey"] ?? "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
+// Fail-fast: a Gateway that falls back to a repo-public signing key is worse than one that refuses to
+// start (Rules.md §12). JwtTokenService applies the same guard independently for defense in depth.
+var secretKey = builder.Configuration["JWT:SecretKey"] ?? builder.Configuration["JWT__SecretKey"];
+if (string.IsNullOrWhiteSpace(secretKey) || secretKey.Length < 32)
+{
+ throw new InvalidOperationException(
+ "JWT:SecretKey (bzw. JWT__SecretKey) ist nicht konfiguriert oder kürzer als 32 Zeichen (256 Bit). " +
+ "FinlyticBackend startet nicht ohne einen ausreichend starken, explizit konfigurierten Signing-Key.");
+}
var issuer = builder.Configuration["JWT:Issuer"] ?? "FinlyticBackend";
var audience = builder.Configuration["JWT:Audience"] ?? "FinlyticClients";
+// Fail-fast: the default Admin account must never be seeded with a hardcoded, repo-public password
+// (Rules.md §12). UserService.SeedDefaultAdminAsync applies the same guard for defense in depth.
+var adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? builder.Configuration["ADMIN__DefaultPassword"];
+if (string.IsNullOrWhiteSpace(adminDefaultPassword))
+{
+ throw new InvalidOperationException(
+ "ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " +
+ "FinlyticBackend startet nicht ohne ein explizit konfiguriertes Initial-Passwort für den Default-Admin-Account.");
+}
+
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
@@ -80,6 +109,11 @@ builder.Services.AddSignalR();
// 4. Register DB Context & Services
builder.Services.AddDbContext(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+builder.Services.AddScoped(sp => sp.GetRequiredService());
+
+// 4b. Dynamic Settings & Channel-Based Logging (previously absent for FinlyticBackend - see BackendSettingKeys).
+builder.Services.AddSingleton();
+builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
builder.Services.AddHttpClient();
builder.Services.AddHttpClient();
@@ -91,14 +125,34 @@ builder.Services.AddSingleton();
builder.Services.AddSingleton();
builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHostedService(sp => sp.GetRequiredService());
builder.Services.AddSingleton();
-builder.Services.AddHostedService(sp => sp.GetRequiredService());
-builder.Services.AddHostedService();
builder.Services.AddHostedService();
builder.Services.AddHostedService();
var app = builder.Build();
+// 0. Global exception -> Problem Details handler (Rules.md §11). Placed first so it wraps every
+// downstream stage (static files, routing, CORS, auth, endpoints): on an unhandled exception it re-invokes
+// the pipeline starting immediately after itself for the generated error response, so UseCors below still
+// runs and still attaches CORS headers to the error response. This does NOT change the CORS-critical
+// ordering documented below - UseRouting/UseCors/UseAuthentication/UseAuthorization still execute in the
+// same relative order for both the happy path and the error path.
+app.UseExceptionHandler();
+
+// 0b. Trust X-Forwarded-* from the nginx reverse proxy in front of this service, so Request.Scheme/
+// Request.Host reflect the public domain (e.g. finlytic.kleidukos.me) instead of the raw loopback
+// connection nginx makes to Kestrel (localhost:5000). Without this, any code building an absolute URL
+// from Request.Host/Scheme silently produces http://localhost:5000/... links. KnownProxies/KnownNetworks
+// are cleared because nginx runs as a sidecar on an address Kestrel can't predict (Docker bridge network).
+app.UseForwardedHeaders(new ForwardedHeadersOptions
+{
+ ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto | ForwardedHeaders.XForwardedHost,
+ KnownNetworks = { },
+ KnownProxies = { }
+});
+
// ----------------------------------------------------------------------
// MIDDLEWARE PIPELINE ORDER IS CRITICAL FOR CORS
// ----------------------------------------------------------------------
@@ -155,12 +209,13 @@ using (var scope = app.Services.CreateScope())
try
{
var context = scope.ServiceProvider.GetRequiredService();
- await context.Database.MigrateAsync();
+ var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
+ await context.MigrateWithBootstrapAsync(connStr);
var userService = scope.ServiceProvider.GetRequiredService();
- string adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? "AdminDefaultPassword2026!";
await userService.SeedDefaultAdminAsync(adminDefaultPassword);
}
+
catch (Exception ex)
{
var logger = scope.ServiceProvider.GetRequiredService>();
@@ -171,11 +226,7 @@ using (var scope = app.Services.CreateScope())
// Map Endpoints
app.MapControllers();
-app.MapHub("/hubs/trades", options =>
-{
- options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
-});
-app.MapHub("/hubs/trade-updates", options =>
+app.MapHub("/hubs/trade-stream", options =>
{
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
@@ -196,8 +247,19 @@ app.MapHub("/hubs/logs", options =>
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
});
-app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "FinlyticBackend", timestamp = DateTime.UtcNow }));
+// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): the Docker
+// healthcheck defined in compose.yaml sends an unauthenticated `GET /health` against 127.0.0.1:8080 and
+// only checks for a 200 status code. A healthcheck cannot carry a bearer token/secret, so requiring auth
+// here would make every container report unhealthy. The response body is limited to {status, service,
+// timestamp} — no business or user data — so the anonymous surface stays minimal.
+app.MapGet("/health", () => Results.Ok(new { status = "Healthy", service = "FinlyticBackend", timestamp = DateTime.UtcNow }))
+ .AllowAnonymous();
+
+// Explicit, sanctioned exception to "every route requires authentication" (Rules.md §7): this serves the
+// compiled Flutter Web SPA shell (index.html) for any unmatched route. It must stay anonymous, or the
+// browser could never load the login page in the first place - the SPA itself enforces auth client-side
+// once loaded, and every actual data-bearing API route below requires a JWT.
app.MapFallbackToFile("index.html");
await app.RunAsync();
\ No newline at end of file
diff --git a/FinlyticBackend/Project.md b/FinlyticBackend/Project.md
deleted file mode 100644
index 27bc908..0000000
--- a/FinlyticBackend/Project.md
+++ /dev/null
@@ -1,37 +0,0 @@
-# Finlytic Backend Gateway
-
-Finlytic Backend is the central ASP.NET Core Web API gateway and SignalR real-time broker for the Finlytic ecosystem. It serves external clients (`FinlyticApp`, `FinlyticWeb`) via REST endpoints and WebSockets, while interfacing internally with background microservices strictly via MQTT.
-
----
-
-## Architecture & Responsibilities
-
-1. **Single Public Entrypoint**:
- - The **only** backend microservice hosting Kestrel HTTP REST and SignalR WebSocket endpoints.
-
-2. **JWT Authentication & User Management**:
- - Manages user registration, login, JWT token issuance (`IJwtTokenService`), and role-based access control (Admin, Premium, User).
-
-3. **SignalR Real-Time Hubs**:
- - **`NewsHub`** (`/hubs/news`): Broadcasts live news articles and sentiment classifications.
- - **`TradeHub`** (`/hubs/trade-updates`): Broadcasts live trade proposals and position updates.
- - **`TradeRealtimeHub`** (`/hubs/trades`): Legacy real-time trade signals hub.
-
-4. **MQTT Bridge (`BackendMqttBridge` & `WebMqttClient`)**:
- - Subscribes to internal MQTT topics (`finlytic/news/#`, `finlytic/sentiment/#`, `finlytic/trades/#`, `finlytic/fundamentals/#`, `finlytic/ta/#`).
- - Forwards MQTT messages to connected SignalR WebSockets and executes MQTT RPC requests for REST controllers.
-
----
-
-## Feature Status
-
-### Implemented Features
-- [x] JWT Authentication & User Persistence (`BackendDbContext`).
-- [x] REST Controllers: `AuthController`, `NewsController`, `AssetsController`, `UserTradesController`, `UserFavoritesController`, `AdminController`.
-- [x] SignalR WebSockets for News & Trades (`NewsHub`, `TradeHub`).
-- [x] MQTT Bridge & RPC Gateway Client (`BackendMqttBridge`, `WebMqttClient`).
-- [x] Complete removal of mock data in REST responses.
-
-### Planned Features
-- [ ] OAuth2 / Social Login integration (Google / Apple Sign-In).
-- [ ] Two-Factor Authentication (2FA / TOTP).
diff --git a/FinlyticBackend/Services/FavoritesPriceBackgroundService.cs b/FinlyticBackend/Services/FavoritesPriceBackgroundService.cs
index c7b73cf..4944c85 100644
--- a/FinlyticBackend/Services/FavoritesPriceBackgroundService.cs
+++ b/FinlyticBackend/Services/FavoritesPriceBackgroundService.cs
@@ -33,7 +33,7 @@ public class FavoritesPriceBackgroundService(
logger.LogWarning(ex, "[FavoritesPriceBackgroundService] Error broadcasting price updates.");
}
- await Task.Delay(10000, stoppingToken);
+ await Task.Delay(2000, stoppingToken);
}
}
@@ -50,24 +50,23 @@ public class FavoritesPriceBackgroundService(
if (userFavorites.Count == 0) return;
- // 2. Pro User einfach die Kurse abfragen und senden
+ // 2. Pro User einfach die Kurse parallel abfragen und senden
foreach (var userGroup in userFavorites.GroupBy(f => f.UserId.ToString()))
{
var userId = userGroup.Key;
- var priceUpdates = new Dictionary();
+ var priceUpdates = new System.Collections.Concurrent.ConcurrentDictionary();
- foreach (var fav in userGroup)
+ var tasks = userGroup.Select(async fav =>
{
var cleanIsin = fav.Isin.Trim().ToUpperInvariant();
-
- if (!mqttClient.IsConnected) continue;
+ if (!mqttClient.IsConnected) return;
try
{
var livePrice = await mqttClient.SendRpcRequestAsync(
"tr_GetLivePrice",
new IsinRequest(cleanIsin),
- TimeSpan.FromSeconds(2)
+ TimeSpan.FromSeconds(4)
);
if (livePrice != null)
@@ -80,12 +79,14 @@ public class FavoritesPriceBackgroundService(
}
}
catch { /* Ignorieren bei Einzel-Timeout */ }
- }
+ });
- if (priceUpdates.Count > 0)
+ await Task.WhenAll(tasks);
+
+ if (!priceUpdates.IsEmpty)
{
await hubContext.Clients.Group(FavoritesPriceHub.GetGroupName(userId))
- .SendAsync("ReceiveFavoritePrices", priceUpdates, cancellationToken);
+ .SendAsync("ReceiveFavoritePrices", new Dictionary(priceUpdates), cancellationToken);
}
}
}
diff --git a/FinlyticBackend/Services/FirebaseNotificationService.cs b/FinlyticBackend/Services/FirebaseNotificationService.cs
index 046e3e1..6096dc5 100644
--- a/FinlyticBackend/Services/FirebaseNotificationService.cs
+++ b/FinlyticBackend/Services/FirebaseNotificationService.cs
@@ -4,7 +4,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
-using FinlyticCore.Models.Trades;
+using FinlyticCore.Dtos.Trading;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
@@ -17,20 +17,12 @@ public interface IFirebaseNotificationService
///
/// Sends a push notification about a new trade proposal.
///
- /// The trade proposal details.
- /// The list of FCM device tokens.
- /// A cancellation token.
- /// A task representing the asynchronous operation.
Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List fcmTokens, CancellationToken cancellationToken = default);
///
/// Sends a push notification about an update to an existing trade.
///
- /// The trade update details.
- /// The list of FCM device tokens.
- /// A cancellation token.
- /// A task representing the asynchronous operation.
- Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List fcmTokens, CancellationToken cancellationToken = default);
+ Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List fcmTokens, CancellationToken cancellationToken = default);
}
///
@@ -39,11 +31,6 @@ public class FirebaseNotificationService : IFirebaseNotificationService
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
- ///
- /// Initializes a new instance of the class.
- ///
- /// The HTTP client for making API requests.
- /// The logger instance.
public FirebaseNotificationService(HttpClient httpClient, ILogger logger)
{
_httpClient = httpClient;
@@ -51,13 +38,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
}
///
-
public async Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
- string title = $"🚀 Trade Signal: {proposal.SignalType} {proposal.Symbol}";
- string body = $"{proposal.CompanyName} ({proposal.Isin}) - Entry: ${proposal.EntryPrice:F2}, WinRate: {proposal.WinRate:F1}%. {proposal.Reasoning}";
+ string title = $"🚀 Trade Signal: {proposal.Direction} {proposal.Symbol} ({proposal.StrategyKey})";
+ string body = $"{proposal.Symbol} ({proposal.UnderlyingIsin}) - Entry: €{proposal.EntryPrice:F2}, Score: {proposal.CompositeScore:F0} Pkt. {proposal.AiValidation?.ThesisSummary}";
foreach (var token in fcmTokens)
{
@@ -66,12 +52,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
}
///
- public async Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List fcmTokens, CancellationToken cancellationToken = default)
+ public async Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List fcmTokens, CancellationToken cancellationToken = default)
{
if (fcmTokens == null || fcmTokens.Count == 0) return;
- string title = $"📊 Trade Update: {update.TradeId}";
- string body = $"Recommendation: {update.Recommendation} @ ${update.CurrentPrice:F2}. {update.Reasoning}";
+ string title = $"📊 Trade Update: {update.Symbol} (Status: {update.Status})";
+ string body = $"Status: {update.Status} @ €{update.CurrentPrice:F2}, PnL: {update.UnrealizedPnlPercent:+0.0;-0.0}% (€{update.UnrealizedPnlEur:+0.00;-0.00}).";
foreach (var token in fcmTokens)
{
diff --git a/FinlyticBackend/Services/JwtTokenService.cs b/FinlyticBackend/Services/JwtTokenService.cs
index a4c8ea6..88ac221 100644
--- a/FinlyticBackend/Services/JwtTokenService.cs
+++ b/FinlyticBackend/Services/JwtTokenService.cs
@@ -37,17 +37,19 @@ public class JwtTokenService : IJwtTokenService
public JwtTokenService(IConfiguration configuration)
{
var configuredKey = configuration["JWT:SecretKey"] ?? configuration["JWT__SecretKey"];
-
- // Guard: Mindestlänge für HMAC-SHA256 erzwingen (mindestens 32 Zeichen / 256 Bits)
+
+ // Guard: Mindestlänge für HMAC-SHA256 erzwingen (mindestens 32 Zeichen / 256 Bits).
+ // Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
+ // das mit einem repo-öffentlichen Signing-Key läuft, ist schlimmer als eines, das nicht startet.
if (string.IsNullOrWhiteSpace(configuredKey) || configuredKey.Length < 32)
{
- _secretKey = "FinlyticEnterpriseUltraSecureJwtSecretKey_2026_AtLeast32Chars!";
- }
- else
- {
- _secretKey = configuredKey;
+ throw new InvalidOperationException(
+ "JWT:SecretKey (bzw. JWT__SecretKey) ist nicht konfiguriert oder kürzer als 32 Zeichen (256 Bit). " +
+ "JwtTokenService kann ohne einen ausreichend starken, explizit konfigurierten Signing-Key nicht initialisiert werden.");
}
+ _secretKey = configuredKey;
+
_issuer = configuration["JWT:Issuer"] ?? configuration["JWT__Issuer"] ?? "FinlyticBackend";
_audience = configuration["JWT:Audience"] ?? configuration["JWT__Audience"] ?? "FinlyticClients";
_expiryDays = int.TryParse(configuration["JWT:ExpiryDays"] ?? configuration["JWT__ExpiryDays"], out var days) ? days : 7;
diff --git a/FinlyticBackend/Services/SystemHealthBackgroundService.cs b/FinlyticBackend/Services/SystemHealthBackgroundService.cs
index 6136e12..974ee0b 100644
--- a/FinlyticBackend/Services/SystemHealthBackgroundService.cs
+++ b/FinlyticBackend/Services/SystemHealthBackgroundService.cs
@@ -65,11 +65,11 @@ public class SystemHealthBackgroundService : BackgroundService
{
("FinlyticAssets", "health_Ping/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
("FinlyticNews", "health_Ping/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
- ("FinlyticTechnicalAnalysis", "health_Ping/FinlyticTechnicalAnalysis", "Technical Indicators (EMA/RSI)", "PostgreSQL ta"),
+ ("FinlyticTechnicals", "health_Ping/FinlyticTechnicals", "Technical Indicators & SMC Patterns", "PostgreSQL ta"),
("FinlyticSentiment", "health_Ping/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
- ("FinlyticAnalyzer", "health_Ping/FinlyticAnalyzer", "Multi-Layer Signal Engine", "PostgreSQL analyzer"),
- ("FinlyticTrades", "health_Ping/FinlyticTrades", "Trade Lifecycle Manager", "PostgreSQL trades"),
("FinlyticFundamentals", "health_Ping/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
+ ("FinlyticEngine", "health_Ping/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
+ ("FinlyticBot", "health_Ping/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
};
var results = new List
diff --git a/FinlyticBackend/Services/UserService.cs b/FinlyticBackend/Services/UserService.cs
index 31a3745..fecef86 100644
--- a/FinlyticBackend/Services/UserService.cs
+++ b/FinlyticBackend/Services/UserService.cs
@@ -21,9 +21,6 @@ public interface IUserService
/// Authenticates user credentials and returns JWT response.
Task AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
- /// Registers a new user account.
- Task RegisterUserAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
-
/// Changes the initial password for a user.
Task ChangeInitialPasswordAsync(Guid userId, string newPassword,
CancellationToken cancellationToken = default);
@@ -115,54 +112,6 @@ public class UserService : IUserService
};
}
- ///
- public async Task RegisterUserAsync(RegisterRequestDto request,
- CancellationToken cancellationToken = default)
- {
- using var scope = _scopeFactory.CreateScope();
- var dbContext = scope.ServiceProvider.GetRequiredService();
-
- string normalizedEmail = request.Email.Trim().ToLowerInvariant();
- bool exists = await dbContext.Users.AnyAsync(u => u.Email.ToLower() == normalizedEmail, cancellationToken);
- if (exists)
- {
- _logger.LogWarning("[{Channel}] Registration failed: Email '{Email}' is already taken.", "AuthChannel",
- request.Email);
- return null;
- }
-
- string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
-
- var newUser = new UserEntity
- {
- Email = normalizedEmail,
- PasswordHash = passwordHash,
- FullName = string.IsNullOrWhiteSpace(request.FullName) ? normalizedEmail.Split('@')[0] : request.FullName,
- Role = "User",
- ThemePreference = "fluent_dark", // Default Theme for FluentAvalonia
- IsActive = true,
- CreatedAt = DateTime.UtcNow,
- LastLoginAt = DateTime.UtcNow
- };
-
- dbContext.Users.Add(newUser);
- await dbContext.SaveChangesAsync(cancellationToken);
-
- var (token, expiresAt) = _jwtTokenService.GenerateToken(newUser);
-
- return new AuthResponseDto
- {
- Token = token,
- UserId = newUser.Id,
- Email = newUser.Email,
- FullName = newUser.FullName,
- Role = newUser.Role,
- ThemePreference = newUser.ThemePreference,
- FcmTokens = new List(),
- ExpiresAt = expiresAt
- };
- }
-
///
public async Task CreateUserByAdminAsync(CreateUserRequestDto request,
CancellationToken cancellationToken = default)
@@ -352,13 +301,21 @@ public class UserService : IUserService
if (!exists)
{
- string password = !string.IsNullOrWhiteSpace(defaultAdminPassword)
- ? defaultAdminPassword
- : "AdminDefaultPassword2026!";
+ // Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
+ // das den Default-Admin mit einem repo-öffentlichen Passwort anlegt, ist schlimmer als eines,
+ // das nicht startet. Program.cs validiert dies bereits vor dem Aufruf; dieser Guard dient als
+ // Verteidigung in der Tiefe, falls die Methode von anderer Stelle aufgerufen wird.
+ if (string.IsNullOrWhiteSpace(defaultAdminPassword))
+ {
+ throw new InvalidOperationException(
+ "ADMIN:DefaultPassword (bzw. ADMIN__DefaultPassword) ist nicht konfiguriert. " +
+ "Das Seeding des Default-Admin-Accounts kann ohne explizit konfiguriertes Passwort nicht durchgeführt werden.");
+ }
+
var adminUser = new UserEntity
{
Email = adminEmail,
- PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
+ PasswordHash = BCrypt.Net.BCrypt.HashPassword(defaultAdminPassword),
FullName = "System Administrator",
Role = "Admin",
ThemePreference = "fluent_dark",
diff --git a/FinlyticBackend/Settings/BackendSettingKeys.cs b/FinlyticBackend/Settings/BackendSettingKeys.cs
new file mode 100644
index 0000000..a78d2f8
--- /dev/null
+++ b/FinlyticBackend/Settings/BackendSettingKeys.cs
@@ -0,0 +1,19 @@
+using FinlyticCore.Models.Settings;
+
+namespace FinlyticBackend.Settings;
+
+///
+/// FinlyticBackend's own dynamic settings - previously nonexistent (it had no ISettingsDbContext/
+/// ISettingsService registration at all), which is why the admin UI's per-service settings screen never
+/// showed anything for it, unlike every other service (see EngineSettingKeys/SimulationSettingKeys
+/// for the same pattern elsewhere).
+///
+public static class BackendSettingKeys
+{
+ // --- Logging Channels ---
+ public static readonly SettingKey HealthPingChannel = new("Logging.Channel.Health", true);
+ public static readonly SettingKey MqttChannel = new("Logging.Channel.MQTT", true);
+ public static readonly SettingKey BackendChannel = new("Logging.Channel.Backend", true);
+ public static readonly SettingKey AuthChannel = new("Logging.Channel.Auth", true);
+ public static readonly SettingKey PushNotificationChannel = new("Logging.Channel.PushNotification", true);
+}
diff --git a/FinlyticBackend/Util/BackendMqttBridge.cs b/FinlyticBackend/Util/BackendMqttBridge.cs
index b6159af..fa2d91a 100644
--- a/FinlyticBackend/Util/BackendMqttBridge.cs
+++ b/FinlyticBackend/Util/BackendMqttBridge.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
+using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
@@ -7,11 +8,13 @@ using System.Threading.Tasks;
using FinlyticBackend.Database;
using FinlyticBackend.Hubs;
using FinlyticBackend.Services;
+using FinlyticBackend.Settings;
+using FinlyticCore.Dtos.Bot;
using FinlyticCore.Dtos.Logging;
using FinlyticCore.Dtos.News;
+using FinlyticCore.Dtos.Trading;
using FinlyticCore.Models;
-using FinlyticCore.Models.Auth;
-using FinlyticCore.Models.Trades;
+using FinlyticCore.Services;
using FinlyticCore.Util;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
@@ -23,85 +26,98 @@ using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Util;
///
+/// Central Managed MQTT Bridge & RPC Gateway for FinlyticBackend.
/// Subscribes to general broadcast MQTT topics and forwards them to SignalR clients and FCM push services.
///
public class BackendMqttBridge : ManagedMqttClient, IHostedService
{
- public static readonly ConcurrentDictionary FundamentalsCache = new(StringComparer.OrdinalIgnoreCase);
- public static readonly ConcurrentDictionary TechnicalsCache = new(StringComparer.OrdinalIgnoreCase);
public static readonly ConcurrentDictionary> ServiceLogsRingBuffer = new(StringComparer.OrdinalIgnoreCase);
private readonly IConfiguration _configuration;
private readonly IServiceScopeFactory _scopeFactory;
- private readonly IHubContext _hubContext;
- private readonly IHubContext _tradeHubContext;
+ private readonly IHubContext _tradeStreamHubContext;
private readonly IHubContext _newsHubContext;
private readonly IHubContext _logHubContext;
private readonly IFirebaseNotificationService _firebaseService;
private readonly ILogger _logger;
+ private readonly IFinlyticLogger _finlyticLogger;
public BackendMqttBridge(
IConfiguration configuration,
IServiceScopeFactory scopeFactory,
- IHubContext hubContext,
- IHubContext tradeHubContext,
+ IHubContext tradeStreamHubContext,
IHubContext newsHubContext,
IHubContext logHubContext,
IFirebaseNotificationService firebaseService,
- ILogger logger) : base(logger)
+ ILogger logger,
+ IFinlyticLogger finlyticLogger) : base(logger)
{
_configuration = configuration;
_scopeFactory = scopeFactory;
- _hubContext = hubContext;
- _tradeHubContext = tradeHubContext;
+ _tradeStreamHubContext = tradeStreamHubContext;
_newsHubContext = newsHubContext;
_logHubContext = logHubContext;
_firebaseService = firebaseService;
_logger = logger;
+ _finlyticLogger = finlyticLogger;
}
///
public async Task StartAsync(CancellationToken cancellationToken)
{
- var config = new MqttConfiguration
- {
- Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
- Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
- ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "finlytic_backend_bridge")}_{Guid.NewGuid()}"
- };
+ var config = MqttConfiguration.FromConfiguration(_configuration, "finlytic_backend_gateway");
- _logger.LogInformation("Starting Backend MQTT Bridge. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
+ _logger.LogInformation("Starting Backend MQTT Gateway Bridge. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
}
///
public async Task StopAsync(CancellationToken cancellationToken)
{
- _logger.LogInformation("Stopping Backend MQTT Bridge.");
+ _logger.LogInformation("Stopping Backend MQTT Gateway Bridge.");
await DisconnectAsync();
}
///
protected override async Task OnConnectedAsync()
{
- _logger.LogInformation("Backend MQTT Bridge connected. Subscribing to broadcast topics...");
-
- await SubscribeAsync("finlytic/trades/proposed/#");
- await SubscribeAsync("finlytic/trades/updates/#");
-
- // News topics
- await SubscribeAsync("services/news/completed");
- await SubscribeAsync("finlytic/news/#");
- await SubscribeAsync("finlytic/sentiment/#");
+ await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
+ "[BackendMqttBridge] Backend MQTT Gateway Bridge connected. Subscribing to broadcast topics...");
- // Fundamentals & Technicals
- await SubscribeAsync("finlytic/fundamentals/#");
- await SubscribeAsync("finlytic/assets/fundamentals/#");
- await SubscribeAsync("finlytic/technicalanalysis/#");
- await SubscribeAsync("finlytic/ta/#");
+ // RPC response stream
+ await SubscribeAsync(MqttTopics.ResponseWildcard);
+
+ // Engine & Bot streams
+ await SubscribeAsync(MqttTopics.EngineWildcard);
+ await SubscribeAsync(MqttTopics.BotWildcard);
+
+ // News & Sentiment streams
+ await SubscribeAsync(MqttTopics.NewsCompleted);
+ await SubscribeAsync(MqttTopics.NewsStreamWildcard);
+ await SubscribeAsync(MqttTopics.SentimentWildcard);
// Real-time Logs
- await SubscribeAsync("finlytic/logs/#");
+ await SubscribeAsync(MqttTopics.LogsWildcard);
+
+ // Universe RPC Endpoint for FinlyticTechnicals
+ await SubscribeRpcAsync