feat(backend): add admin evaluation history, engine, simulation, bot API controllers and global exception middleware
This commit is contained in:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Admin-only Web UI endpoint over FinlyticEngine's persisted evaluation history
|
||||
/// (<c>engine_evaluation_snapshots</c>), 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 <see cref="AdminUserController"/>/<see cref="AdminSettingsController"/>
|
||||
/// pattern: <c>[Authorize(Roles = "Admin")]</c> at the controller level, a thin pass-through to FinlyticEngine
|
||||
/// over MQTT RPC, and <see cref="ControllerBase.Problem"/> (never an anonymous object) on failure.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/v1/admin/evaluations")]
|
||||
[Authorize(Roles = "Admin")]
|
||||
[EnableCors("AllowAll")]
|
||||
public class AdminEvaluationHistoryController : ControllerBase
|
||||
{
|
||||
private readonly WebMqttClient _mqttClient;
|
||||
private readonly ILogger<AdminEvaluationHistoryController> _logger;
|
||||
|
||||
public AdminEvaluationHistoryController(WebMqttClient mqttClient, ILogger<AdminEvaluationHistoryController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a filtered, paginated page of evaluation-history rows plus a summary of the same (unpaginated)
|
||||
/// filtered set - see <see cref="GetEvaluationHistoryRequest"/>/<see cref="GetEvaluationHistoryResponse"/>
|
||||
/// for the exact filter and aggregation semantics. All query parameters are optional; omitting a filter
|
||||
/// means "do not restrict on this field".
|
||||
/// </summary>
|
||||
/// <param name="fromUtc">Inclusive lower bound on <c>EvaluatedAtUtc</c>.</param>
|
||||
/// <param name="toUtc">Inclusive upper bound on <c>EvaluatedAtUtc</c>.</param>
|
||||
/// <param name="outcome">Restricts results to a single <see cref="OutcomeReason"/>.</param>
|
||||
/// <param name="triggerSource">Restricts results to a single <see cref="TriggerSource"/>.</param>
|
||||
/// <param name="search">Case-sensitive substring search against both ISIN and Symbol.</param>
|
||||
/// <param name="page">1-based page number (defaults to 1; values below 1 are treated as 1 server-side).</param>
|
||||
/// <param name="pageSize">
|
||||
/// Requested page size (defaults to 50; server-side clamped to at most 200 by FinlyticEngine to prevent an
|
||||
/// unbounded response).
|
||||
/// </param>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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<GetEvaluationHistoryResponse, GetEvaluationHistoryRequest>(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("watchlist")]
|
||||
public async Task<IActionResult> GetWatchlist()
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return Problem(title: "FinlyticTechnicals is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = await _mqttClient.SendRpcRequestAsync<List<WatchlistEntryDto>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the last <paramref name="limit"/> technical-analysis setups computed for <paramref name="isin"/>
|
||||
/// (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.
|
||||
/// </summary>
|
||||
[HttpGet("watchlist/{isin}/history")]
|
||||
public async Task<IActionResult> 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<List<StrategyResultDto>, 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
public record ServiceSettingsUpdateResultDto(
|
||||
[property: JsonPropertyName("message")] string Message,
|
||||
[property: JsonPropertyName("mqttDispatched")] bool MqttDispatched
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// DTO representing the operational health status of a microservice (AOT-compliant).
|
||||
/// </summary>
|
||||
@@ -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"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// FinlyticBackend is the RPC *caller* for every entry in <see cref="ServiceRpcPrefixes"/> 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 (<see cref="BackendSettingKeys"/>) are
|
||||
/// instead read directly, in-process, via <see cref="_settingsService"/> - see the special-casing in
|
||||
/// <see cref="GetAllSettings"/>/<see cref="GetServiceSettings"/>/<see cref="UpdateServiceSettings"/>.
|
||||
/// </summary>
|
||||
private const string BackendServiceName = "FinlyticBackend";
|
||||
|
||||
private static readonly ConcurrentDictionary<string, List<ServiceConfigItem>> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ISettingsService _settingsService;
|
||||
|
||||
public AdminSettingsController(
|
||||
WebMqttClient mqttClient,
|
||||
ISettingsService settingsService,
|
||||
ILogger<AdminSettingsController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_settingsService = settingsService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<List<ServiceConfigItemResponseDto>> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves recent buffered in-memory logs for a specific service.
|
||||
/// </summary>
|
||||
@@ -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<ServiceHealthStatusDto>
|
||||
@@ -231,6 +278,11 @@ public class AdminSettingsController : ControllerBase
|
||||
[HttpGet("{serviceName}")]
|
||||
public async Task<IActionResult> 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
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<AnalyzeController> _logger;
|
||||
|
||||
public AnalyzeController(WebMqttClient mqttClient, ILogger<AnalyzeController> logger)
|
||||
public AnalyzeController(BackendMqttBridge mqttClient, ILogger<AnalyzeController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>NameIdentifier</c> claim, exactly like
|
||||
/// <c>EngineController.GetUserIdFromClaims</c>. Every manual evaluation triggered through this controller
|
||||
/// is tagged with this identity as <c>EngineEvaluationSnapshotEntity.TriggeredByUserId</c>, so a request
|
||||
/// whose identity cannot be established must be rejected rather than silently recorded as anonymous.
|
||||
/// </summary>
|
||||
/// <exception cref="UnauthorizedAccessException">
|
||||
/// The <c>NameIdentifier</c> claim (or its <c>sub</c>/<c>nameid</c> fallbacks) is missing or is not a
|
||||
/// parseable <see cref="Guid"/>.
|
||||
/// </exception>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers an on-demand analysis for an asset via FinlyticEngine.
|
||||
/// Always returns <c>200 OK</c> with a full <see cref="AssetEvaluationResultDto"/> body — the analysis
|
||||
/// pipeline now reports the real, already-computed scores and AI reasoning even when it did not produce a
|
||||
/// proposal (<c>Proposal == null</c>), so there is no longer a "silent" <c>204 No Content</c> outcome for a
|
||||
/// completed-but-rejected evaluation (Rules.md §4). <c>204</c> 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.
|
||||
/// </summary>
|
||||
[HttpPost("manual")]
|
||||
public async Task<IActionResult> TriggerManualAnalysis([FromBody] AnalyzeRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
|
||||
{
|
||||
return BadRequest(new { error = "ISIN or Symbol is required" });
|
||||
return Problem(title: "ISIN or Symbol is required.", statusCode: StatusCodes.Status400BadRequest);
|
||||
}
|
||||
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "Analysis service is currently unavailable." });
|
||||
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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(
|
||||
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
|
||||
);
|
||||
|
||||
// 2. Ausführen des RPC Triggers am FinlyticAnalyzer (mit typisierter Response)
|
||||
try
|
||||
{
|
||||
var response = await _mqttClient.SendRpcRequestAsync<ManualAnalysisResponseDto, ManualAnalysisRpcRequest>(
|
||||
"analyzer_TriggerManual", rpcRequest, TimeSpan.FromSeconds(10));
|
||||
string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant();
|
||||
|
||||
if (response != null)
|
||||
// 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();
|
||||
|
||||
var evalRequest = new EvaluateAssetRequest(
|
||||
Isin: targetIsin,
|
||||
UserId: userId,
|
||||
Ticker: null,
|
||||
ForceAiEvaluation: true
|
||||
);
|
||||
|
||||
var evaluation = await _mqttClient.SendRpcRequestAsync<AssetEvaluationResultDto, EvaluateAssetRequest>(
|
||||
"engine_EvaluateIsin", evalRequest, TimeSpan.FromSeconds(15));
|
||||
|
||||
if (evaluation == null)
|
||||
{
|
||||
return Ok(response);
|
||||
// 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(evaluation);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Problem(title: "A valid user identity is required.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[AnalyzeController] RPC analyzer_TriggerManual timed out or failed for ISIN '{Isin}'.", targetIsin);
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
status = "AnalysisTriggered",
|
||||
isin = rpcRequest.Isin,
|
||||
message = "Manuelle KI-Analyse wurde gestartet, verarbeitet Ergebnisse im Hintergrund."
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the currently active trade proposals from the FinlyticTrades service.
|
||||
/// Fetches the currently active trade proposals from FinlyticEngine.
|
||||
/// </summary>
|
||||
[HttpGet("proposals")]
|
||||
public async Task<IActionResult> GetActiveProposals()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_mqttClient.IsConnected)
|
||||
{
|
||||
return StatusCode(503, new { error = "Analysis service is currently unavailable." });
|
||||
return Problem(title: "FinlyticEngine is currently unreachable.", statusCode: StatusCodes.Status503ServiceUnavailable);
|
||||
}
|
||||
|
||||
// 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<List<TradeProposalDto>, GetTradesRequest>(
|
||||
"trades_Get", request, TimeSpan.FromSeconds(4));
|
||||
try
|
||||
{
|
||||
var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
|
||||
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
||||
"engine_GetProposals", request, TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||
}
|
||||
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<TechnicalAnalysisDto?> FetchTaDataAsync(IsinRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _mqttClient.SendRpcRequestAsync<TechnicalAnalysisDto, IsinRequest>(
|
||||
"ta_GetAnalysis", request, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private async Task<AssetFundamentalsDto?> FetchFundamentalsDataAsync(IsinRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _mqttClient.SendRpcRequestAsync<AssetFundamentalsDto, IsinRequest>(
|
||||
"fundamentals_Get", request, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
|
||||
private async Task<IsinSentimentSummaryDto?> FetchSentimentDataAsync(IsinRequest request)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _mqttClient.SendRpcRequestAsync<IsinSentimentSummaryDto, IsinRequest>(
|
||||
"sentiment_GetIsin", request, TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
}
|
||||
@@ -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." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest den aktuellen Live-Kurs eines Assets oder Derivats via TR MQTT RPC.
|
||||
/// </summary>
|
||||
[HttpGet("{isin}/live")]
|
||||
public async Task<IActionResult> 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<LivePriceDto, IsinRequest>(
|
||||
"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." });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
|
||||
/// </summary>
|
||||
@@ -304,7 +339,7 @@ public class AssetsController : ControllerBase
|
||||
ForceRefresh: forceRefresh
|
||||
);
|
||||
|
||||
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetDerivativesRequest>(
|
||||
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<DerivativeDto>, GetDerivativesRequest>(
|
||||
"assets_GetDerivatives",
|
||||
req,
|
||||
TimeSpan.FromSeconds(30)
|
||||
@@ -312,7 +347,7 @@ public class AssetsController : ControllerBase
|
||||
|
||||
if (rpcResult != null)
|
||||
{
|
||||
var derivatives = rpcResult.OfType<DerivativeDto>().ToList();
|
||||
var derivatives = rpcResult;
|
||||
|
||||
if (minLeverage.HasValue)
|
||||
{
|
||||
@@ -365,6 +400,10 @@ public class AssetsController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>Authorization</c> header by default. Keeping this endpoint
|
||||
/// anonymous lets every image loader render it without special-casing headers.
|
||||
/// </summary>
|
||||
[HttpGet("/api/v1/logo/{isin}")]
|
||||
[AllowAnonymous]
|
||||
|
||||
@@ -21,17 +21,15 @@ public record UserProfileResponseDto(
|
||||
[property: JsonPropertyName("role")] string Role
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AuthController.ChangeInitialPassword"/>),
|
||||
/// not from this payload, so it deliberately carries no user identifier.
|
||||
/// </summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// 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 (<c>GET /health</c>), the static asset-logo endpoint (<c>GET /api/v1/logo/{isin}</c>,
|
||||
/// which image loaders cannot attach a bearer token to), and the Flutter Web SPA fallback file.
|
||||
/// </summary>
|
||||
[AllowAnonymous]
|
||||
[HttpPost("auth/login")]
|
||||
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -64,17 +68,29 @@ public class AuthController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Login"/> issues one immediately, carrying <c>RequiresPasswordChange</c> 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.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
[HttpPost("auth/change-initial-password")]
|
||||
public async Task<IActionResult> 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." });
|
||||
|
||||
@@ -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<BotController> _logger;
|
||||
|
||||
public BotController(BackendMqttBridge mqttBridge, ILogger<BotController> logger)
|
||||
{
|
||||
_mqttBridge = mqttBridge;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> GetStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
var status = await _mqttBridge.SendRpcRequestAsync<BotStatusDto, object>(
|
||||
"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<IActionResult> GetActivePositions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var positions = await _mqttBridge.SendRpcRequestAsync<List<BotTradeOrderDto>, object>(
|
||||
"bot_GetPositions",
|
||||
new object(),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
return Ok(positions ?? new List<BotTradeOrderDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting active bot positions");
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("portfolio/summary")]
|
||||
public async Task<IActionResult> GetPortfolioSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
var summary = await _mqttBridge.SendRpcRequestAsync<AccountSummaryDto, object>(
|
||||
"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<IActionResult> 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<BotTradeOrderDto, ExecuteProposalRequest>(
|
||||
"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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emergency-closes every open FinlyticBot paper-trading position. Served by FinlyticBot's
|
||||
/// <c>bot_PanicClose</c> RPC handler (<c>FinlyticBot.Util.BotMqttClient.HandlePanicCloseRpcAsync</c>),
|
||||
/// 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
|
||||
/// <see cref="PanicCloseResultDto.SkippedCount"/> rather than silently counted as closed (Rules.md §4).
|
||||
/// </summary>
|
||||
[HttpPost("orders/panic-close")]
|
||||
public async Task<IActionResult> PanicCloseAllPositions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await _mqttBridge.SendRpcRequestAsync<PanicCloseResultDto, object>(
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates FinlyticBot's dynamic settings. Routed through the same generic
|
||||
/// <c>Dictionary<string, object?></c> -> <c>{prefix}_settings_Update</c> ->
|
||||
/// <c>List<DynamicSettingDto></c> contract every other microservice uses (see
|
||||
/// <c>AdminSettingsController.UpdateServiceSettings</c>) rather than a bespoke, nonexistent
|
||||
/// "bot_UpdateSettings" channel with a mismatched <see cref="BotStatusDto"/> contract. FinlyticBot is a
|
||||
/// single shared instance with no per-tenant settings, so the generic mechanism applies directly.
|
||||
/// </summary>
|
||||
[HttpPost("settings/update")]
|
||||
public async Task<IActionResult> 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<string, object?>();
|
||||
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<List<DynamicSettingDto>, Dictionary<string, object?>>(
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}",
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves global statistics aggregated from all microservices.
|
||||
/// Currently returns mocked data for UI development.
|
||||
/// </summary>
|
||||
[HttpGet("stats")]
|
||||
public IActionResult GetGlobalStats()
|
||||
{
|
||||
return Ok(new
|
||||
{
|
||||
totalAssetsLoaded = 12450,
|
||||
totalNewsArticles = 8340,
|
||||
activeTrades = 12,
|
||||
systemUptimeHours = 342,
|
||||
sentimentAnalysesCompleted = 45000,
|
||||
technicalAnalysesCompleted = 120000
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<EngineController> _logger;
|
||||
|
||||
public EngineController(WebMqttClient mqttClient, ILogger<EngineController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the authenticated caller's identity from the JWT <c>NameIdentifier</c> claim, parsed as a
|
||||
/// <see cref="Guid"/> exactly like the global user-validation middleware in
|
||||
/// <c>FinlyticBackend/Program.cs</c>. There is no pseudo-identity fallback: every trade in FinlyticEngine
|
||||
/// is tenant-scoped by <c>EngineTradeEntity.UserId</c>, so a request whose identity cannot be established
|
||||
/// must be rejected with 401.
|
||||
/// </summary>
|
||||
/// <exception cref="UnauthorizedAccessException">
|
||||
/// The <c>NameIdentifier</c> claim (or its <c>sub</c>/<c>nameid</c> fallbacks) is missing or is not a
|
||||
/// parseable <see cref="Guid"/>.
|
||||
/// </exception>
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpGet("proposals")]
|
||||
public async Task<IActionResult> 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<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
||||
"engine_GetProposals", request, TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||
}
|
||||
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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves active trades (positions) owned by the authenticated caller and being tracked or managed by
|
||||
/// FinlyticEngine.
|
||||
/// </summary>
|
||||
[HttpGet("trades")]
|
||||
public async Task<IActionResult> 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<List<ActiveTradeDto>, GetActiveTradesRequest>(
|
||||
"engine_GetTrades", request, TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(trades ?? new List<ActiveTradeDto>());
|
||||
}
|
||||
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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers an immediate full-pipeline evaluation (FTA + Sentiment + Fundamentals + AI Gate + Knock-Out Resolver) for an asset.
|
||||
/// Always returns <c>200 OK</c> with a full <see cref="AssetEvaluationResultDto"/> body — including the real
|
||||
/// scores and AI reasoning when the evaluation did not clear the bar for a proposal (<c>Proposal == null</c>),
|
||||
/// instead of the previous anonymous placeholder object (Rules.md §3/§4).
|
||||
/// </summary>
|
||||
[HttpPost("evaluate")]
|
||||
public async Task<IActionResult> 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<AssetEvaluationResultDto, EvaluateAssetRequest>(
|
||||
"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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds an executed fill (partial buy or scale-in) to an existing active trade owned by the authenticated
|
||||
/// caller, triggering dynamic buy-in recalculation.
|
||||
/// </summary>
|
||||
[HttpPost("trades/{tradeId:guid}/fills")]
|
||||
public async Task<IActionResult> 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<ActiveTradeDto, AddTradeFillRequest>(
|
||||
"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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manually or algorithmically adjusts the Stop-Loss of an active trade owned by the authenticated caller.
|
||||
/// </summary>
|
||||
[HttpPut("trades/{tradeId:guid}/stoploss")]
|
||||
public async Task<IActionResult> 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<ActiveTradeDto, UpdateTradeStopLossRequest>(
|
||||
"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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes an active trade owned by the authenticated caller at a specific market or exit price.
|
||||
/// </summary>
|
||||
[HttpPost("trades/{tradeId:guid}/close")]
|
||||
public async Task<IActionResult> 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<ActiveTradeDto, CloseEngineTradeRequest>(
|
||||
"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." });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<List<NewsArticleDto>, DailyNewsRequest>(
|
||||
"news_Get",
|
||||
|
||||
@@ -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<SimulationController> _logger;
|
||||
|
||||
public SimulationController(BackendMqttBridge mqttBridge, ILogger<SimulationController> logger)
|
||||
{
|
||||
_mqttBridge = mqttBridge;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpPost("run")]
|
||||
public async Task<IActionResult> 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<BacktestReportDto, BacktestRequestDto>(
|
||||
"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<IActionResult> GetStrategyMatrix(string isin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isin))
|
||||
{
|
||||
return BadRequest(new { Message = "ISIN is required." });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var matrix = await _mqttBridge.SendRpcRequestAsync<List<StrategyAssetReliabilityDto>, IsinRequest>(
|
||||
"sim_GetMatrixForAsset",
|
||||
new IsinRequest(isin, null, false),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
return Ok(matrix ?? new List<StrategyAssetReliabilityDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching strategy matrix for {Isin}", isin);
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lightweight history of past backtest runs for an ISIN, optionally narrowed to one strategy - every run
|
||||
/// is already persisted server-side (<c>sim_RunBacktest</c>) but was previously only reachable indirectly
|
||||
/// via the reliability matrix, never queryable as a history in its own right.
|
||||
/// </summary>
|
||||
[HttpGet("history/{isin}")]
|
||||
public async Task<IActionResult> 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<List<BacktestHistoryEntryDto>, GetBacktestHistoryRequest>(
|
||||
"sim_GetBacktestHistory",
|
||||
new GetBacktestHistoryRequest(isin, strategyKey, limit),
|
||||
TimeSpan.FromSeconds(5)
|
||||
);
|
||||
|
||||
return Ok(history ?? new List<BacktestHistoryEntryDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching backtest history for {Isin}", isin);
|
||||
return StatusCode(500, new { Message = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Full report (trades + equity curve) for one specific past backtest run, for drilling into a history entry.</summary>
|
||||
[HttpGet("history/run/{runId:guid}")]
|
||||
public async Task<IActionResult> GetBacktestRunDetail(Guid runId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var report = await _mqttBridge.SendRpcRequestAsync<BacktestReportDto, GetBacktestRunDetailRequest>(
|
||||
"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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Saved indicator-parameter profile for one (Isin, StrategyKey) pair, or 404 if none was ever saved.</summary>
|
||||
[HttpGet("parameters/{isin}/{strategyKey}")]
|
||||
public async Task<IActionResult> 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<StrategyParameterProfileDto, GetStrategyParametersRequest>(
|
||||
"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 });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Saves/updates a per-asset/per-strategy indicator-parameter profile for future backtests to reuse.</summary>
|
||||
[HttpPost("parameters")]
|
||||
public async Task<IActionResult> 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<StrategyParameterProfileDto, SaveStrategyParametersRequest>(
|
||||
"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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<UserTradesController> _logger;
|
||||
|
||||
public UserTradesController(WebMqttClient mqttClient, ILogger<UserTradesController> logger)
|
||||
public UserTradesController(BackendMqttBridge mqttClient, ILogger<UserTradesController> logger)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Liest die eindeutige UserId aus den Claims des authentifizierten Bearer Tokens.
|
||||
/// Resolves the authenticated caller's identity from the JWT <c>NameIdentifier</c> claim, parsed as a
|
||||
/// <see cref="Guid"/> exactly like the global user-validation middleware in
|
||||
/// <c>FinlyticBackend/Program.cs</c> (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
|
||||
/// <c>EngineTradeEntity.UserId</c>, 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.
|
||||
/// </summary>
|
||||
private string GetUserIdFromClaims()
|
||||
/// <exception cref="UnauthorizedAccessException">
|
||||
/// The <c>NameIdentifier</c> claim (or its <c>sub</c>/<c>nameid</c> fallbacks) is missing or is not a
|
||||
/// parseable <see cref="Guid"/>.
|
||||
/// </exception>
|
||||
private Guid GetUserIdFromClaims()
|
||||
{
|
||||
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.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a list of trades for the current authenticated user (including global proposals).
|
||||
/// Retrieves a list of active trades or proposals from FinlyticEngine.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> 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." });
|
||||
}
|
||||
|
||||
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<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
||||
"engine_GetProposals", req, TimeSpan.FromSeconds(5));
|
||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||
}
|
||||
else
|
||||
{
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// FIX: UserId explizit an GetTradesRequest übergeben!
|
||||
var request = new GetTradesRequest(isin, status, userId);
|
||||
|
||||
var trades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
||||
"trades_Get",
|
||||
request,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(trades ?? new List<TradeProposalDto>());
|
||||
var req = new GetActiveTradesRequest(UserId: userId, Mode: null);
|
||||
var trades = await _mqttClient.SendRpcRequestAsync<List<ActiveTradeDto>, GetActiveTradesRequest>(
|
||||
"engine_GetTrades", req, TimeSpan.FromSeconds(5));
|
||||
return Ok(trades ?? new List<ActiveTradeDto>());
|
||||
}
|
||||
}
|
||||
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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public endpoint to retrieve active global proposals for guest users.
|
||||
/// </summary>
|
||||
[HttpGet("/api/v1/trades/public")]
|
||||
[AllowAnonymous]
|
||||
public async Task<IActionResult> 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<List<TradeProposalDto>, GetTradesRequest>(
|
||||
"trades_Get",
|
||||
request,
|
||||
TimeSpan.FromSeconds(5));
|
||||
|
||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to retrieve public trade proposals via MQTT RPC.");
|
||||
return Ok(new List<TradeProposalDto>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="dto">
|
||||
/// The acceptance payload sent by the client (see <c>TradeRepository.acceptTrade</c> in FinlyticApp),
|
||||
/// carrying the proposal identifier (<see cref="FinlyticCore.Models.Trades.TradeAcceptanceDto.TradeId"/>)
|
||||
/// plus any user-adjusted position sizing, leverage and fee details. Any <c>userId</c> supplied by the
|
||||
/// client is discarded; the acceptance is always attributed to the identity from the JWT.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The resulting <see cref="ActiveTradeDto"/> 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).
|
||||
/// </returns>
|
||||
[HttpPost("accept")]
|
||||
public async Task<IActionResult> AcceptTrade([FromBody] TradeAcceptanceDto request)
|
||||
public async Task<IActionResult> AcceptTrade([FromBody] FinlyticCore.Models.Trades.TradeAcceptanceDto dto)
|
||||
{
|
||||
try
|
||||
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 = "MQTT Broker disconnected" });
|
||||
return StatusCode(503, new { error = "FinlyticEngine is currently unreachable." });
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.AnalysisId) && string.IsNullOrWhiteSpace(request.TradeId))
|
||||
try
|
||||
{
|
||||
return BadRequest(new { error = "AnalysisId or TradeId is required" });
|
||||
}
|
||||
// The acceptance is always attributed to the authenticated caller, never to a client-supplied value.
|
||||
var userId = GetUserIdFromClaims();
|
||||
|
||||
// FIX: UserId felsenfest aus den authentifizierten Claims überschreiben
|
||||
request.UserId = GetUserIdFromClaims();
|
||||
var acceptRequest = new AcceptTradeProposalRequest(
|
||||
UserId: userId,
|
||||
ProposalId: proposalId,
|
||||
ExecutedPrice: dto.ActualEntryPrice ?? dto.EntryPrice,
|
||||
Quantity: dto.Quantity ?? dto.PositionSize);
|
||||
|
||||
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, TradeAcceptanceDto>(
|
||||
"trades_Accept",
|
||||
request,
|
||||
TimeSpan.FromSeconds(5));
|
||||
var acceptedTrade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, AcceptTradeProposalRequest>(
|
||||
"engine_AcceptProposal", acceptRequest, TimeSpan.FromSeconds(10));
|
||||
|
||||
if (result != null)
|
||||
if (acceptedTrade != null)
|
||||
{
|
||||
return Ok(result);
|
||||
return Ok(acceptedTrade);
|
||||
}
|
||||
|
||||
// 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." });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="AcceptTrade"/>: a user either accepts an existing proposal or creates a trade from scratch,
|
||||
/// and both paths end up with one user-owned <see cref="ActiveTradeDto"/>.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/close")]
|
||||
public async Task<IActionResult> CloseTrade(string id, [FromBody] CloseTradeRequest? request)
|
||||
/// <param name="request">
|
||||
/// The manual trade payload from the client. Any <c>userId</c> it carries is discarded; the trade is
|
||||
/// always attributed to the identity from the JWT.
|
||||
/// </param>
|
||||
[HttpPost("manual")]
|
||||
public async Task<IActionResult> 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<ActiveTradeDto, CreateManualTradeRequest>(
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[HttpPost("{id:guid}/close")]
|
||||
public async Task<IActionResult> 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<TradeProposalDto, CloseTradeRequest>(
|
||||
$"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<ActiveTradeDto, CloseEngineTradeRequest>(
|
||||
"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" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rejects a proposed trade.
|
||||
/// </summary>
|
||||
[HttpPost("{id}/reject")]
|
||||
public async Task<IActionResult> 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<TradeProposalDto, CloseTradeRequest>(
|
||||
$"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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// FinlyticBackend previously implemented no <see cref="ISettingsDbContext"/> at all, unlike every other
|
||||
/// service - which is why it never had dynamic settings (logging channels etc.) to show in the admin UI.
|
||||
/// </summary>
|
||||
public class BackendDbContext : DbContext, ISettingsDbContext
|
||||
{
|
||||
public BackendDbContext(DbContextOptions<BackendDbContext> options) : base(options) { }
|
||||
|
||||
@@ -11,11 +18,18 @@ public class BackendDbContext : DbContext
|
||||
public DbSet<UserDeviceTokenEntity> UserDeviceTokens => Set<UserDeviceTokenEntity>();
|
||||
public DbSet<UserFavoriteAssetEntity> UserFavoriteAssets => Set<UserFavoriteAssetEntity>();
|
||||
public DbSet<ServiceConfigurationEntity> ServiceConfigurations => Set<ServiceConfigurationEntity>();
|
||||
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<SettingEntity>(entity =>
|
||||
{
|
||||
entity.HasKey(e => e.Id);
|
||||
entity.HasIndex(e => e.Key).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserEntity>(entity =>
|
||||
{
|
||||
entity.ToTable("users");
|
||||
@@ -73,3 +87,19 @@ public class BackendDbContext : DbContext
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lets `dotnet ef migrations add` construct a <see cref="BackendDbContext"/> 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. <c>TechnicalAnalysisDbContextFactory</c>).
|
||||
/// </summary>
|
||||
public class BackendDbContextFactory : IDesignTimeDbContextFactory<BackendDbContext>
|
||||
{
|
||||
public BackendDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var optionsBuilder = new DbContextOptionsBuilder<BackendDbContext>();
|
||||
optionsBuilder.UseNpgsql("Host=localhost;Database=finlytic_backend;Username=postgres;Password=postgres");
|
||||
return new BackendDbContext(optionsBuilder.Options);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// SignalR Hub that streams live log messages from microservices to connected web UI clients.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class LogStreamHub : Hub
|
||||
{
|
||||
private readonly ILogger<LogStreamHub> _logger;
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// SignalR Hub for real-time news delivery to connected clients.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class NewsHub : Hub
|
||||
{
|
||||
// Clients can call this to join specific symbol groups if needed later
|
||||
|
||||
@@ -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;
|
||||
/// <summary>
|
||||
/// SignalR Hub broadcasting real-time system diagnostics & microservice health updates to connected clients.
|
||||
/// </summary>
|
||||
[Authorize]
|
||||
public class SystemHealthHub : Hub
|
||||
{
|
||||
private readonly ILogger<SystemHealthHub> _logger;
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace FinlyticBackend.Hubs;
|
||||
|
||||
/// <summary>
|
||||
/// SignalR Hub broadcasting real-time trade signals, position updates, and closed trade events.
|
||||
/// </summary>
|
||||
public class TradeHub : Hub
|
||||
{
|
||||
public override async Task OnConnectedAsync()
|
||||
{
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Real-time SignalR WebSocket & Server-Sent Events (SSE) Hub streaming live trade proposals & updates to Web and Mobile clients.
|
||||
/// </summary>
|
||||
public class TradeRealtimeHub : Hub<ITradeClient>
|
||||
{
|
||||
private readonly ILogger<TradeRealtimeHub> _logger;
|
||||
|
||||
public TradeRealtimeHub(ILogger<TradeRealtimeHub> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<ITradeStreamClient>
|
||||
{
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c>AddExceptionHandler<GlobalExceptionHandler>()</c> and activated by
|
||||
/// <c>app.UseExceptionHandler()</c> in <c>Program.cs</c>. This only intercepts exceptions thrown while a
|
||||
/// request is being handled; it has no effect on the fail-fast startup checks in <c>Program.cs</c>
|
||||
/// (missing/weak <c>JWT:SecretKey</c>, missing <c>ADMIN:DefaultPassword</c>), which throw before
|
||||
/// <c>builder.Build()</c> - i.e. before this middleware pipeline exists - and are intentionally left
|
||||
/// unhandled so the host refuses to start.
|
||||
/// </summary>
|
||||
public sealed class GlobalExceptionHandler : IExceptionHandler
|
||||
{
|
||||
private readonly ILogger<GlobalExceptionHandler> _logger;
|
||||
|
||||
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> 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<IProblemDetailsService>();
|
||||
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"
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ConfigKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("config_key");
|
||||
|
||||
b.Property<string>("ConfigValue")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("config_value");
|
||||
|
||||
b.Property<string>("DataType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)")
|
||||
.HasColumnName("data_type");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("ServiceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("service_name");
|
||||
|
||||
b.Property<DateTime>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("DeviceName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("device_name");
|
||||
|
||||
b.Property<string>("FcmToken")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)")
|
||||
.HasColumnName("fcm_token");
|
||||
|
||||
b.Property<DateTime>("LastUsedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_used_at");
|
||||
|
||||
b.Property<DateTime>("RegisteredAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("registered_at");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("FullName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)")
|
||||
.HasColumnName("full_name");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean")
|
||||
.HasColumnName("is_active");
|
||||
|
||||
b.Property<DateTime?>("LastLoginAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_login_at");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("password_hash");
|
||||
|
||||
b.Property<bool>("RequiresPasswordChange")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)")
|
||||
.HasColumnName("role");
|
||||
|
||||
b.Property<string>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Isin")
|
||||
.IsRequired()
|
||||
.HasMaxLength(30)
|
||||
.HasColumnType("character varying(30)")
|
||||
.HasColumnName("isin");
|
||||
|
||||
b.Property<string>("SelectedTicker")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<DateTime>("LastUpdatedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ServiceIdentifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FinlyticBackend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddDynamicSettings : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "DynamicSettings",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
|
||||
ValueJson = table.Column<string>(type: "text", nullable: false),
|
||||
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
|
||||
LastUpdatedUtc = table.Column<DateTime>(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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "DynamicSettings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -204,6 +204,37 @@ namespace FinlyticBackend.Migrations
|
||||
b.ToTable("user_favorite_assets", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Key")
|
||||
.IsRequired()
|
||||
.HasMaxLength(150)
|
||||
.HasColumnType("character varying(150)");
|
||||
|
||||
b.Property<DateTime>("LastUpdatedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ServiceIdentifier")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(100)");
|
||||
|
||||
b.Property<string>("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")
|
||||
|
||||
+73
-11
@@ -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<GlobalExceptionHandler>();
|
||||
|
||||
// 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<BackendDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
builder.Services.AddScoped<ISettingsDbContext>(sp => sp.GetRequiredService<BackendDbContext>());
|
||||
|
||||
// 4b. Dynamic Settings & Channel-Based Logging (previously absent for FinlyticBackend - see BackendSettingKeys).
|
||||
builder.Services.AddSingleton<ISettingsService, SettingsService>();
|
||||
builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>));
|
||||
|
||||
builder.Services.AddHttpClient<UserTradesController>();
|
||||
builder.Services.AddHttpClient<UserFavoritesController>();
|
||||
@@ -91,14 +125,34 @@ builder.Services.AddSingleton<IJwtTokenService, JwtTokenService>();
|
||||
builder.Services.AddSingleton<IUserService, UserService>();
|
||||
builder.Services.AddSingleton<IFirebaseNotificationService, FirebaseNotificationService>();
|
||||
|
||||
builder.Services.AddSingleton<BackendMqttBridge>();
|
||||
builder.Services.AddHostedService<BackendMqttBridge>(sp => sp.GetRequiredService<BackendMqttBridge>());
|
||||
builder.Services.AddSingleton<WebMqttClient>();
|
||||
builder.Services.AddHostedService<WebMqttClient>(sp => sp.GetRequiredService<WebMqttClient>());
|
||||
builder.Services.AddHostedService<BackendMqttBridge>();
|
||||
builder.Services.AddHostedService<SystemHealthBackgroundService>();
|
||||
builder.Services.AddHostedService<FavoritesPriceBackgroundService>();
|
||||
|
||||
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<BackendDbContext>();
|
||||
await context.Database.MigrateAsync();
|
||||
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
|
||||
await context.MigrateWithBootstrapAsync(connStr);
|
||||
|
||||
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
|
||||
string adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? "AdminDefaultPassword2026!";
|
||||
await userService.SeedDefaultAdminAsync(adminDefaultPassword);
|
||||
}
|
||||
|
||||
catch (Exception ex)
|
||||
{
|
||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||
@@ -171,11 +226,7 @@ using (var scope = app.Services.CreateScope())
|
||||
// Map Endpoints
|
||||
app.MapControllers();
|
||||
|
||||
app.MapHub<TradeRealtimeHub>("/hubs/trades", options =>
|
||||
{
|
||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
||||
});
|
||||
app.MapHub<TradeHub>("/hubs/trade-updates", options =>
|
||||
app.MapHub<TradeStreamHub>("/hubs/trade-stream", options =>
|
||||
{
|
||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
||||
});
|
||||
@@ -196,8 +247,19 @@ app.MapHub<LogStreamHub>("/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();
|
||||
@@ -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).
|
||||
@@ -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<string, object>();
|
||||
var priceUpdates = new System.Collections.Concurrent.ConcurrentDictionary<string, object>();
|
||||
|
||||
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<LivePriceDto, IsinRequest>(
|
||||
"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<string, object>(priceUpdates), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
/// <summary>
|
||||
/// Sends a push notification about a new trade proposal.
|
||||
/// </summary>
|
||||
/// <param name="proposal">The trade proposal details.</param>
|
||||
/// <param name="fcmTokens">The list of FCM device tokens.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sends a push notification about an update to an existing trade.
|
||||
/// </summary>
|
||||
/// <param name="update">The trade update details.</param>
|
||||
/// <param name="fcmTokens">The list of FCM device tokens.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
|
||||
Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -39,11 +31,6 @@ public class FirebaseNotificationService : IFirebaseNotificationService
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<FirebaseNotificationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="FirebaseNotificationService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="httpClient">The HTTP client for making API requests.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public FirebaseNotificationService(HttpClient httpClient, ILogger<FirebaseNotificationService> logger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
@@ -51,13 +38,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
public async Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> 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
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SendTradeUpdateNotificationAsync(TradeHourlyUpdateDto update, List<string> fcmTokens, CancellationToken cancellationToken = default)
|
||||
public async Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List<string> 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)
|
||||
{
|
||||
|
||||
@@ -38,15 +38,17 @@ public class JwtTokenService : IJwtTokenService
|
||||
{
|
||||
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!";
|
||||
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.");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
_secretKey = configuredKey;
|
||||
}
|
||||
|
||||
_issuer = configuration["JWT:Issuer"] ?? configuration["JWT__Issuer"] ?? "FinlyticBackend";
|
||||
_audience = configuration["JWT:Audience"] ?? configuration["JWT__Audience"] ?? "FinlyticClients";
|
||||
|
||||
@@ -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<ServiceHealthStatusDto>
|
||||
|
||||
@@ -21,9 +21,6 @@ public interface IUserService
|
||||
/// <summary>Authenticates user credentials and returns JWT response.</summary>
|
||||
Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Registers a new user account.</summary>
|
||||
Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Changes the initial password for a user.</summary>
|
||||
Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
|
||||
CancellationToken cancellationToken = default);
|
||||
@@ -115,54 +112,6 @@ public class UserService : IUserService
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AuthResponseDto?> RegisterUserAsync(RegisterRequestDto request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
||||
|
||||
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<string>(),
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UserDto?> 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",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
using FinlyticCore.Models.Settings;
|
||||
|
||||
namespace FinlyticBackend.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// FinlyticBackend's own dynamic settings - previously nonexistent (it had no <c>ISettingsDbContext</c>/
|
||||
/// <c>ISettingsService</c> registration at all), which is why the admin UI's per-service settings screen never
|
||||
/// showed anything for it, unlike every other service (see <c>EngineSettingKeys</c>/<c>SimulationSettingKeys</c>
|
||||
/// for the same pattern elsewhere).
|
||||
/// </summary>
|
||||
public static class BackendSettingKeys
|
||||
{
|
||||
// --- Logging Channels ---
|
||||
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||
public static readonly SettingKey<bool> BackendChannel = new("Logging.Channel.Backend", true);
|
||||
public static readonly SettingKey<bool> AuthChannel = new("Logging.Channel.Auth", true);
|
||||
public static readonly SettingKey<bool> PushNotificationChannel = new("Logging.Channel.PushNotification", true);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Central Managed MQTT Bridge & RPC Gateway for FinlyticBackend.
|
||||
/// Subscribes to general broadcast MQTT topics and forwards them to SignalR clients and FCM push services.
|
||||
/// </summary>
|
||||
public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
||||
{
|
||||
public static readonly ConcurrentDictionary<string, JsonElement> FundamentalsCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
public static readonly ConcurrentDictionary<string, JsonElement> TechnicalsCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
public static readonly ConcurrentDictionary<string, ConcurrentQueue<LogMessageDto>> ServiceLogsRingBuffer = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly IHubContext<TradeRealtimeHub, ITradeClient> _hubContext;
|
||||
private readonly IHubContext<TradeHub> _tradeHubContext;
|
||||
private readonly IHubContext<TradeStreamHub, ITradeStreamClient> _tradeStreamHubContext;
|
||||
private readonly IHubContext<NewsHub> _newsHubContext;
|
||||
private readonly IHubContext<LogStreamHub> _logHubContext;
|
||||
private readonly IFirebaseNotificationService _firebaseService;
|
||||
private readonly ILogger<BackendMqttBridge> _logger;
|
||||
private readonly IFinlyticLogger<BackendMqttBridge> _finlyticLogger;
|
||||
|
||||
public BackendMqttBridge(
|
||||
IConfiguration configuration,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
IHubContext<TradeRealtimeHub, ITradeClient> hubContext,
|
||||
IHubContext<TradeHub> tradeHubContext,
|
||||
IHubContext<TradeStreamHub, ITradeStreamClient> tradeStreamHubContext,
|
||||
IHubContext<NewsHub> newsHubContext,
|
||||
IHubContext<LogStreamHub> logHubContext,
|
||||
IFirebaseNotificationService firebaseService,
|
||||
ILogger<BackendMqttBridge> logger) : base(logger)
|
||||
ILogger<BackendMqttBridge> logger,
|
||||
IFinlyticLogger<BackendMqttBridge> finlyticLogger) : base(logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_scopeFactory = scopeFactory;
|
||||
_hubContext = hubContext;
|
||||
_tradeHubContext = tradeHubContext;
|
||||
_tradeStreamHubContext = tradeStreamHubContext;
|
||||
_newsHubContext = newsHubContext;
|
||||
_logHubContext = logHubContext;
|
||||
_firebaseService = firebaseService;
|
||||
_logger = logger;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Stopping Backend MQTT Bridge.");
|
||||
_logger.LogInformation("Stopping Backend MQTT Gateway Bridge.");
|
||||
await DisconnectAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task OnConnectedAsync()
|
||||
{
|
||||
_logger.LogInformation("Backend MQTT Bridge connected. Subscribing to broadcast topics...");
|
||||
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
|
||||
"[BackendMqttBridge] Backend MQTT Gateway Bridge connected. Subscribing to broadcast topics...");
|
||||
|
||||
await SubscribeAsync("finlytic/trades/proposed/#");
|
||||
await SubscribeAsync("finlytic/trades/updates/#");
|
||||
// RPC response stream
|
||||
await SubscribeAsync(MqttTopics.ResponseWildcard);
|
||||
|
||||
// News topics
|
||||
await SubscribeAsync("services/news/completed");
|
||||
await SubscribeAsync("finlytic/news/#");
|
||||
await SubscribeAsync("finlytic/sentiment/#");
|
||||
// Engine & Bot streams
|
||||
await SubscribeAsync(MqttTopics.EngineWildcard);
|
||||
await SubscribeAsync(MqttTopics.BotWildcard);
|
||||
|
||||
// Fundamentals & Technicals
|
||||
await SubscribeAsync("finlytic/fundamentals/#");
|
||||
await SubscribeAsync("finlytic/assets/fundamentals/#");
|
||||
await SubscribeAsync("finlytic/technicalanalysis/#");
|
||||
await SubscribeAsync("finlytic/ta/#");
|
||||
// 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<object, List<string>>(
|
||||
MqttTopics.RequestFilter(MqttTopics.Channels.BackendGetAggregatedFavorites),
|
||||
HandleGetAggregatedFavoritesRpcAsync);
|
||||
|
||||
// FinlyticBackend previously never broadcast its OWN structured logs at all (it only relayed other
|
||||
// services' logs received on MqttTopics.LogsWildcard, subscribed above) - it never used
|
||||
// IFinlyticLogger<T>, so FinlyticLogBroadcaster.Broadcast was never invoked for anything happening
|
||||
// inside FinlyticBackend itself. This hook publishes FinlyticBackend's own logs onto the exact same
|
||||
// finlytic/logs/FinlyticBackend topic every other service publishes to, which the wildcard
|
||||
// subscription above then picks straight back up and relays into LogStreamHub like any other
|
||||
// service's messages - no separate "local echo" mechanism needed.
|
||||
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
|
||||
{
|
||||
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticBackend", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await PublishAsync(MqttTopics.Logs("FinlyticBackend"), logDto);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -111,39 +127,33 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
||||
|
||||
try
|
||||
{
|
||||
if (topic.StartsWith("finlytic/logs/", StringComparison.OrdinalIgnoreCase))
|
||||
if (topic.StartsWith(MqttTopics.LogsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await HandleLogMessageAsync(payloadStr);
|
||||
}
|
||||
else if (topic.StartsWith("finlytic/trades/proposed/", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith("finlytic/trades/update", StringComparison.OrdinalIgnoreCase))
|
||||
else if (topic.StartsWith(MqttTopics.EngineProposalsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await HandleTradeProposalAsync(payloadStr);
|
||||
await HandleEngineProposalAsync(payloadStr);
|
||||
}
|
||||
else if (topic.StartsWith("finlytic/trades/updates/", StringComparison.OrdinalIgnoreCase))
|
||||
else if (topic.StartsWith(MqttTopics.EngineTradesPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await HandleTradeUpdateAsync(payloadStr);
|
||||
await HandleEngineTradeUpdateAsync(payloadStr);
|
||||
}
|
||||
else if (topic.Equals("services/news/completed", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith("finlytic/news/", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith("finlytic/sentiment/", StringComparison.OrdinalIgnoreCase))
|
||||
else if (topic.StartsWith(MqttTopics.BotTradesPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await HandleBotTradeUpdateAsync(payloadStr);
|
||||
}
|
||||
else if (topic.Equals(MqttTopics.NewsCompleted, StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith(MqttTopics.NewsPrefix, StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith(MqttTopics.SentimentPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
await HandleNewsArticleAsync(payloadStr);
|
||||
}
|
||||
else if (topic.StartsWith("finlytic/fundamentals/", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith("finlytic/assets/fundamentals/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleFundamentalsCache(topic, payloadStr);
|
||||
}
|
||||
else if (topic.StartsWith("finlytic/technicalanalysis/", StringComparison.OrdinalIgnoreCase) ||
|
||||
topic.StartsWith("finlytic/ta/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
HandleTechnicalsCache(topic, payloadStr);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing message in Backend MQTT Bridge on topic {Topic}", topic);
|
||||
await _finlyticLogger.LogErrorAsync(BackendSettingKeys.BackendChannel, ex,
|
||||
"[BackendMqttBridge] Error processing message on topic {Topic}", topic);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,20 +169,29 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
||||
// Keep buffer capped at 250 entries
|
||||
while (queue.Count > 250 && queue.TryDequeue(out _)) { }
|
||||
|
||||
// Broadcast to SignalR clients
|
||||
await _logHubContext.Clients.Group(serviceKey).SendAsync("ReceiveLogMessage", logDto);
|
||||
// Broadcast once to all connected SignalR admin logs clients
|
||||
await _logHubContext.Clients.All.SendAsync("ReceiveLogMessage", logDto);
|
||||
}
|
||||
|
||||
private async Task HandleTradeProposalAsync(string payloadStr)
|
||||
private async Task HandleEngineProposalAsync(string payloadStr)
|
||||
{
|
||||
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr);
|
||||
if (proposal == null) return;
|
||||
|
||||
await _hubContext.Clients.All.OnTradeProposed(proposal);
|
||||
await _tradeHubContext.Clients.All.SendAsync("ReceiveTradeUpdate", proposal);
|
||||
_logger.LogInformation("Broadcasted Trade Proposal {AnalysisId} via SignalR.", proposal.AnalysisId);
|
||||
await _tradeStreamHubContext.Clients.All.ReceiveTradeProposal(proposal);
|
||||
if (!string.IsNullOrWhiteSpace(proposal.UnderlyingIsin))
|
||||
{
|
||||
await _tradeStreamHubContext.Clients.Group(proposal.UnderlyingIsin.ToUpperInvariant()).ReceiveTradeProposal(proposal);
|
||||
}
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
|
||||
"[BackendMqttBridge] Broadcasted FinlyticEngine Trade Proposal {ProposalId} for {Isin} via SignalR TradeStreamHub.", proposal.ProposalId, proposal.UnderlyingIsin);
|
||||
|
||||
// FCM Push Notification for High Score Proposals (Score >= 75)
|
||||
if (proposal.CompositeScore >= 75)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
||||
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
|
||||
@@ -182,17 +201,32 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
||||
await _firebaseService.SendTradeProposalNotificationAsync(proposal, fcmTokens);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleTradeUpdateAsync(string payloadStr)
|
||||
catch (Exception ex)
|
||||
{
|
||||
var update = JsonSerializer.Deserialize<TradeHourlyUpdateDto>(payloadStr);
|
||||
if (update == null) return;
|
||||
await _finlyticLogger.LogWarningAsync(BackendSettingKeys.PushNotificationChannel, ex,
|
||||
"[BackendMqttBridge] Failed to send FCM push notification for proposal {ProposalId}.", proposal.ProposalId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await _hubContext.Clients.All.OnTradeUpdated(update);
|
||||
await _tradeHubContext.Clients.All.SendAsync("ReceiveTradeUpdate", update);
|
||||
private async Task HandleEngineTradeUpdateAsync(string payloadStr)
|
||||
{
|
||||
var trade = JsonSerializer.Deserialize<ActiveTradeDto>(payloadStr);
|
||||
if (trade == null) return;
|
||||
|
||||
if (string.Equals(update.Recommendation, "Close", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(update.Recommendation, "AdjustSL", StringComparison.OrdinalIgnoreCase))
|
||||
await _tradeStreamHubContext.Clients.All.ReceiveTradeUpdate(trade);
|
||||
if (!string.IsNullOrWhiteSpace(trade.UnderlyingIsin))
|
||||
{
|
||||
await _tradeStreamHubContext.Clients.Group(trade.UnderlyingIsin.ToUpperInvariant()).ReceiveTradeUpdate(trade);
|
||||
}
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
|
||||
"[BackendMqttBridge] Broadcasted FinlyticEngine Trade Update {TradeId} (Status: {Status}) via SignalR TradeStreamHub.", trade.TradeId, trade.Status);
|
||||
|
||||
// Push notification on important state transitions (e.g. stopped out or TP2 hit)
|
||||
if (trade.Status == TradeStatus.StoppedOut || trade.Status == TradeStatus.Tp2Hit || trade.Status == TradeStatus.Closed)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
||||
@@ -200,9 +234,30 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
||||
|
||||
if (fcmTokens.Count > 0)
|
||||
{
|
||||
await _firebaseService.SendTradeUpdateNotificationAsync(update, fcmTokens);
|
||||
await _firebaseService.SendTradeUpdateNotificationAsync(trade, fcmTokens);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(BackendSettingKeys.PushNotificationChannel, ex,
|
||||
"[BackendMqttBridge] Failed to send FCM push notification for trade update {TradeId}.", trade.TradeId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleBotTradeUpdateAsync(string payloadStr)
|
||||
{
|
||||
var botTrade = JsonSerializer.Deserialize<BotTradeOrderDto>(payloadStr, DefaultJsonOptions);
|
||||
if (botTrade != null)
|
||||
{
|
||||
await _tradeStreamHubContext.Clients.All.ReceiveBotPositionUpdate(botTrade);
|
||||
if (!string.IsNullOrWhiteSpace(botTrade.Isin))
|
||||
{
|
||||
await _tradeStreamHubContext.Clients.Group(botTrade.Isin.ToUpperInvariant()).ReceiveBotPositionUpdate(botTrade);
|
||||
}
|
||||
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
|
||||
"[BackendMqttBridge] Broadcasted Bot trade update for {Isin} ({Symbol}, Status: {Status}) via SignalR TradeStreamHub.", botTrade.Isin, botTrade.Symbol, botTrade.Status);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleNewsArticleAsync(string payloadStr)
|
||||
@@ -211,39 +266,24 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
||||
if (article == null) return;
|
||||
|
||||
await _newsHubContext.Clients.All.SendAsync("ReceiveNewArticle", article);
|
||||
_logger.LogInformation("Broadcasted live news item '{Title}' over SignalR NewsHub.", article.Title);
|
||||
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.BackendChannel,
|
||||
"[BackendMqttBridge] Broadcasted live news item '{Title}' over SignalR NewsHub.", article.Title);
|
||||
}
|
||||
|
||||
private void HandleFundamentalsCache(string topic, string payloadStr)
|
||||
private async Task<List<string>> HandleGetAggregatedFavoritesRpcAsync(object? _, string correlationId)
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(payloadStr);
|
||||
var root = jsonDoc.RootElement.Clone();
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
||||
|
||||
string? isin = root.TryGetProperty("isin", out var isinProp) ? isinProp.GetString() : null;
|
||||
string? ticker = root.TryGetProperty("primaryTicker", out var tickerProp)
|
||||
? tickerProp.GetString()
|
||||
: (root.TryGetProperty("ticker", out var tProp) ? tProp.GetString() : null);
|
||||
var isins = await dbContext.UserFavoriteAssets
|
||||
.AsNoTracking()
|
||||
.Select(f => f.Isin)
|
||||
.Where(isin => !string.IsNullOrWhiteSpace(isin))
|
||||
.Distinct()
|
||||
.ToListAsync();
|
||||
|
||||
if (!string.IsNullOrEmpty(isin)) FundamentalsCache[isin] = root;
|
||||
if (!string.IsNullOrEmpty(ticker)) FundamentalsCache[ticker] = root;
|
||||
|
||||
string topicKey = topic.Split('/').LastOrDefault() ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(topicKey)) FundamentalsCache[topicKey] = root;
|
||||
|
||||
_logger.LogInformation("Cached fundamentals payload from MQTT topic {Topic}.", topic);
|
||||
}
|
||||
|
||||
private void HandleTechnicalsCache(string topic, string payloadStr)
|
||||
{
|
||||
using var jsonDoc = JsonDocument.Parse(payloadStr);
|
||||
var root = jsonDoc.RootElement.Clone();
|
||||
|
||||
string? symbol = root.TryGetProperty("symbol", out var sProp) ? sProp.GetString() : null;
|
||||
if (!string.IsNullOrEmpty(symbol)) TechnicalsCache[symbol] = root;
|
||||
|
||||
string topicKey = topic.Split('/').LastOrDefault() ?? string.Empty;
|
||||
if (!string.IsNullOrEmpty(topicKey)) TechnicalsCache[topicKey] = root;
|
||||
|
||||
_logger.LogInformation("Cached technical analysis payload from MQTT topic {Topic}.", topic);
|
||||
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
|
||||
"[BackendMqttBridge] Responded to backend_GetAggregatedFavorites with {Count} unique ISINs. [CorrelationId: {CorrelationId}]", isins.Count, correlationId);
|
||||
return isins;
|
||||
}
|
||||
}
|
||||
@@ -1,91 +1,44 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticBackend.Util;
|
||||
|
||||
/// <summary>
|
||||
/// Managed MQTT client for web gateway endpoints enabling RPC communication with background microservices.
|
||||
/// Backward-compatible bridge forwarding RPC requests to the primary singleton <see cref="BackendMqttBridge"/>.
|
||||
/// Prevents secondary MQTT connection instantiations in the gateway process.
|
||||
/// </summary>
|
||||
public class WebMqttClient : ManagedMqttClient, IHostedService
|
||||
public class WebMqttClient : IHostedService
|
||||
{
|
||||
private readonly ILogger<WebMqttClient> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly BackendMqttBridge _bridge;
|
||||
|
||||
public WebMqttClient(ILogger<WebMqttClient> logger, IConfiguration configuration) : base(logger)
|
||||
public bool IsConnected => _bridge.IsConnected;
|
||||
|
||||
public WebMqttClient(BackendMqttBridge bridge)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_bridge = bridge;
|
||||
}
|
||||
|
||||
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_rpc")}_{Guid.NewGuid()}"
|
||||
};
|
||||
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
_logger.LogInformation("Starting Web MQTT RPC Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
||||
await ConnectAsync(config);
|
||||
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest request, TimeSpan timeout)
|
||||
where TResponse : class
|
||||
where TRequest : class
|
||||
{
|
||||
return _bridge.SendRpcRequestAsync<TResponse, TRequest>(channel, request, timeout);
|
||||
}
|
||||
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest request)
|
||||
where TResponse : class
|
||||
where TRequest : class
|
||||
{
|
||||
_logger.LogInformation("Stopping Web MQTT RPC Client.");
|
||||
await DisconnectAsync();
|
||||
return _bridge.SendRpcRequestAsync<TResponse, TRequest>(channel, request);
|
||||
}
|
||||
|
||||
protected override async Task OnConnectedAsync()
|
||||
public Task PublishAsync<T>(string topic, T payload)
|
||||
{
|
||||
_logger.LogInformation("Web MQTT RPC client connected. Subscribing to RPC response channels...");
|
||||
await SubscribeAsync("services/response/news_Get/#");
|
||||
await SubscribeAsync("services/response/news_GetDaily/#");
|
||||
await SubscribeAsync("services/response/news_GetById/#");
|
||||
await SubscribeAsync("services/response/sentiment_GetArticle/#");
|
||||
await SubscribeAsync("services/response/sentiment_GetIsin/#");
|
||||
await SubscribeAsync("services/response/sentiment_Analyze/#");
|
||||
await SubscribeAsync("services/response/fundamentals_Get/#");
|
||||
await SubscribeAsync("services/response/events_GetAll/#");
|
||||
await SubscribeAsync("services/response/events_GetByMonth/#");
|
||||
await SubscribeAsync("services/response/ta_GetAnalysis/#");
|
||||
await SubscribeAsync("services/response/tr_GetLivePrice/#");
|
||||
await SubscribeAsync("services/response/assets_Get/#");
|
||||
await SubscribeAsync("services/response/assets_Search/#");
|
||||
await SubscribeAsync("services/response/assets_GetDiscovery/#");
|
||||
await SubscribeAsync("services/response/assets_GetDerivatives/#");
|
||||
await SubscribeAsync("services/response/trades_Get/#");
|
||||
await SubscribeAsync("services/response/trades_Close/#");
|
||||
await SubscribeAsync("services/response/trades_Reject/#");
|
||||
await SubscribeAsync("services/response/trades_Accept/#");
|
||||
await SubscribeAsync("services/response/analyzer_TriggerManual/#");
|
||||
await SubscribeAsync("services/response/health_Ping/#");
|
||||
|
||||
// Settings RPC response channels for all microservices
|
||||
await SubscribeAsync("services/response/fundamentals_settings_GetAll/#");
|
||||
await SubscribeAsync("services/response/fundamentals_settings_Update/#");
|
||||
await SubscribeAsync("services/response/news_settings_GetAll/#");
|
||||
await SubscribeAsync("services/response/news_settings_Update/#");
|
||||
await SubscribeAsync("services/response/ta_settings_GetAll/#");
|
||||
await SubscribeAsync("services/response/ta_settings_Update/#");
|
||||
await SubscribeAsync("services/response/sentiment_settings_GetAll/#");
|
||||
await SubscribeAsync("services/response/sentiment_settings_Update/#");
|
||||
await SubscribeAsync("services/response/analyzer_settings_GetAll/#");
|
||||
await SubscribeAsync("services/response/analyzer_settings_Update/#");
|
||||
await SubscribeAsync("services/response/trades_settings_GetAll/#");
|
||||
await SubscribeAsync("services/response/trades_settings_Update/#");
|
||||
await SubscribeAsync("services/response/assets_settings_GetAll/#");
|
||||
await SubscribeAsync("services/response/assets_settings_Update/#");
|
||||
}
|
||||
|
||||
protected override Task OnMessageReceivedAsync(string topic, string payload)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return _bridge.PublishAsync(topic, payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Database=finlytic_backend;Username=postgres;Password=postgres"
|
||||
},
|
||||
"MQTT": {
|
||||
"Host": "localhost",
|
||||
"Port": 1883,
|
||||
"ClientId": "finlytic_backend"
|
||||
},
|
||||
"JWT": {
|
||||
"Issuer": "FinlyticBackend",
|
||||
"Audience": "FinlyticClients"
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,6 @@ _flutter.buildConfig = {"engineRevision":"0cd610717bde95fd88343c64f81c11ba4e5c00
|
||||
|
||||
_flutter.loader.load({
|
||||
serviceWorkerSettings: {
|
||||
serviceWorkerVersion: "422571355" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
||||
serviceWorkerVersion: "516861606" /* Flutter's service worker is deprecated and will be removed in a future Flutter release. */
|
||||
}
|
||||
});
|
||||
|
||||
+86088
-82394
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user