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.Linq;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticBackend.Settings;
|
||||||
using FinlyticBackend.Util;
|
using FinlyticBackend.Util;
|
||||||
using FinlyticCore.Dtos;
|
using FinlyticCore.Dtos;
|
||||||
using FinlyticCore.Dtos.Settings;
|
using FinlyticCore.Dtos.Settings;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using FinlyticCore.Util;
|
using FinlyticCore.Util;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -37,6 +40,15 @@ public record ServiceConfigItemResponseDto(
|
|||||||
[property: JsonPropertyName("updatedAt")] DateTime UpdatedAt
|
[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>
|
/// <summary>
|
||||||
/// DTO representing the operational health status of a microservice (AOT-compliant).
|
/// DTO representing the operational health status of a microservice (AOT-compliant).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -63,24 +75,55 @@ public class AdminSettingsController : ControllerBase
|
|||||||
{
|
{
|
||||||
["FinlyticFundamentals"] = "fundamentals",
|
["FinlyticFundamentals"] = "fundamentals",
|
||||||
["FinlyticNews"] = "news",
|
["FinlyticNews"] = "news",
|
||||||
|
["FinlyticTechnicals"] = "ta",
|
||||||
["FinlyticTechnicalAnalysis"] = "ta",
|
["FinlyticTechnicalAnalysis"] = "ta",
|
||||||
["FinlyticSentiment"] = "sentiment",
|
["FinlyticSentiment"] = "sentiment",
|
||||||
["FinlyticAnalyzer"] = "analyzer",
|
|
||||||
["FinlyticTrades"] = "trades",
|
|
||||||
["FinlyticAssets"] = "assets",
|
["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 static readonly ConcurrentDictionary<string, List<ServiceConfigItem>> _inMemorySettings = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private readonly ISettingsService _settingsService;
|
||||||
|
|
||||||
public AdminSettingsController(
|
public AdminSettingsController(
|
||||||
WebMqttClient mqttClient,
|
WebMqttClient mqttClient,
|
||||||
|
ISettingsService settingsService,
|
||||||
ILogger<AdminSettingsController> logger)
|
ILogger<AdminSettingsController> logger)
|
||||||
{
|
{
|
||||||
_mqttClient = mqttClient;
|
_mqttClient = mqttClient;
|
||||||
|
_settingsService = settingsService;
|
||||||
_logger = logger;
|
_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>
|
/// <summary>
|
||||||
/// Retrieves recent buffered in-memory logs for a specific service.
|
/// Retrieves recent buffered in-memory logs for a specific service.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -144,6 +187,10 @@ public class AdminSettingsController : ControllerBase
|
|||||||
)).ToList()
|
)).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);
|
return Ok(grouped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,13 +202,13 @@ public class AdminSettingsController : ControllerBase
|
|||||||
{
|
{
|
||||||
var servicesToCheck = new (string Name, string Channel, string Type, string Db)[]
|
var servicesToCheck = new (string Name, string Channel, string Type, string Db)[]
|
||||||
{
|
{
|
||||||
("FinlyticAssets", "health_Ping/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
|
("FinlyticAssets", $"{MqttTopics.Channels.HealthPing}/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
|
||||||
("FinlyticNews", "health_Ping/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
|
("FinlyticNews", $"{MqttTopics.Channels.HealthPing}/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
|
||||||
("FinlyticTechnicalAnalysis", "health_Ping/FinlyticTechnicalAnalysis", "Technical Indicators (EMA/RSI)", "PostgreSQL ta"),
|
("FinlyticTechnicals", $"{MqttTopics.Channels.HealthPing}/FinlyticTechnicals", "Technical Indicators & SMC Patterns", "PostgreSQL ta"),
|
||||||
("FinlyticSentiment", "health_Ping/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
|
("FinlyticSentiment", $"{MqttTopics.Channels.HealthPing}/FinlyticSentiment", "NLP Sentiment Engine", "PostgreSQL sentiment"),
|
||||||
("FinlyticAnalyzer", "health_Ping/FinlyticAnalyzer", "Multi-Layer Signal Engine", "PostgreSQL analyzer"),
|
("FinlyticFundamentals", $"{MqttTopics.Channels.HealthPing}/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
|
||||||
("FinlyticTrades", "health_Ping/FinlyticTrades", "Trade Lifecycle Manager", "PostgreSQL trades"),
|
("FinlyticEngine", $"{MqttTopics.Channels.HealthPing}/FinlyticEngine", "Strategy Screener & Signals", "PostgreSQL engine"),
|
||||||
("FinlyticFundamentals", "health_Ping/FinlyticFundamentals", "Financial Statements & Estimates", "PostgreSQL fundamentals"),
|
("FinlyticBot", $"{MqttTopics.Channels.HealthPing}/FinlyticBot", "Automated Trading Execution", "PostgreSQL bot"),
|
||||||
};
|
};
|
||||||
|
|
||||||
var results = new List<ServiceHealthStatusDto>
|
var results = new List<ServiceHealthStatusDto>
|
||||||
@@ -231,6 +278,11 @@ public class AdminSettingsController : ControllerBase
|
|||||||
[HttpGet("{serviceName}")]
|
[HttpGet("{serviceName}")]
|
||||||
public async Task<IActionResult> GetServiceSettings(string 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)
|
if (ServiceRpcPrefixes.TryGetValue(serviceName, out var prefix) && _mqttClient.IsConnected)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -281,7 +333,17 @@ public class AdminSettingsController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (updatedValues == null || !updatedValues.Any())
|
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);
|
_logger.LogInformation("[AdminSettings] Transmitting {Count} config settings to microservice '{ServiceName}' via MQTT", updatedValues.Count, serviceName);
|
||||||
@@ -307,16 +369,11 @@ public class AdminSettingsController : ControllerBase
|
|||||||
)).ToList();
|
)).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
string topic = $"services/config/updated/{serviceName}";
|
// Settings propagation happens entirely over the {prefix}_settings_Update RPC call above.
|
||||||
var payload = new ServiceConfigUpdatePayload(
|
// There used to be an additional fire-and-forget MQTT publish to a per-service config-updated
|
||||||
ServiceName: serviceName,
|
// topic here, but no service in the fleet ever subscribed to it (dead legacy code predating the
|
||||||
Timestamp: DateTime.UtcNow,
|
// RPC mechanism) - removed rather than kept as a no-op broadcast.
|
||||||
Settings: updatedValues.ToDictionary(kv => kv.Key, kv => kv.Value?.ToString() ?? "")
|
mqttPublished = updated != null && updated.Count > 0;
|
||||||
);
|
|
||||||
|
|
||||||
await _mqttClient.PublishAsync(topic, payload);
|
|
||||||
mqttPublished = true;
|
|
||||||
_logger.LogInformation("[AdminSettings] Broadcasted config update event to MQTT topic '{Topic}' for microservice persistence.", topic);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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);
|
_logger.LogError(ex, "[AdminSettings] Failed to publish MQTT config update event for service '{ServiceName}'", serviceName);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new ServiceSettingsUpdateResultDto(
|
||||||
{
|
Message: $"Settings successfully transmitted to microservice {serviceName}.",
|
||||||
message = $"Settings successfully transmitted to microservice {serviceName}.",
|
MqttDispatched: mqttPublished
|
||||||
mqttDispatched = mqttPublished
|
));
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,14 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Security.Claims;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FinlyticBackend.Util;
|
using FinlyticBackend.Util;
|
||||||
using FinlyticCore.Dtos;
|
using FinlyticCore.Dtos;
|
||||||
using FinlyticCore.Dtos.Fundamentals;
|
using FinlyticCore.Dtos.Trading;
|
||||||
using FinlyticCore.Dtos.Sentiment;
|
|
||||||
using FinlyticCore.Dtos.TechnicalAnalysis;
|
|
||||||
using FinlyticCore.Models.Analyzer;
|
|
||||||
using FinlyticCore.Models.Trades;
|
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Cors;
|
using Microsoft.AspNetCore.Cors;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -40,152 +37,123 @@ public record AnalyzeRequest(
|
|||||||
[EnableCors("AllowAll")]
|
[EnableCors("AllowAll")]
|
||||||
public class AnalyzeController : ControllerBase
|
public class AnalyzeController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly WebMqttClient _mqttClient;
|
private readonly BackendMqttBridge _mqttClient;
|
||||||
private readonly ILogger<AnalyzeController> _logger;
|
private readonly ILogger<AnalyzeController> _logger;
|
||||||
|
|
||||||
public AnalyzeController(WebMqttClient mqttClient, ILogger<AnalyzeController> logger)
|
public AnalyzeController(BackendMqttBridge mqttClient, ILogger<AnalyzeController> logger)
|
||||||
{
|
{
|
||||||
_mqttClient = mqttClient;
|
_mqttClient = mqttClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[HttpPost("manual")]
|
[HttpPost("manual")]
|
||||||
public async Task<IActionResult> TriggerManualAnalysis([FromBody] AnalyzeRequest request)
|
public async Task<IActionResult> TriggerManualAnalysis([FromBody] AnalyzeRequest request)
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(request?.Isin) && string.IsNullOrWhiteSpace(request?.Symbol))
|
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)
|
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
|
try
|
||||||
{
|
{
|
||||||
var response = await _mqttClient.SendRpcRequestAsync<ManualAnalysisResponseDto, ManualAnalysisRpcRequest>(
|
string targetIsin = (request.Isin ?? request.Symbol ?? string.Empty).Trim().ToUpperInvariant();
|
||||||
"analyzer_TriggerManual", rpcRequest, TimeSpan.FromSeconds(10));
|
|
||||||
|
|
||||||
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "[AnalyzeController] RPC analyzer_TriggerManual timed out or failed for ISIN '{Isin}'.", targetIsin);
|
_logger.LogError(ex, "Failed to trigger manual analysis via FinlyticEngine.");
|
||||||
}
|
return Problem(title: "Internal server error triggering analysis.", statusCode: StatusCodes.Status500InternalServerError);
|
||||||
|
|
||||||
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." });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches the currently active trade proposals from the FinlyticTrades service.
|
/// Fetches the currently active trade proposals from FinlyticEngine.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("proposals")]
|
[HttpGet("proposals")]
|
||||||
public async Task<IActionResult> GetActiveProposals()
|
public async Task<IActionResult> GetActiveProposals()
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
if (!_mqttClient.IsConnected)
|
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
|
try
|
||||||
var request = new GetTradesRequest(Isin: null, Status: "Proposed", UserId: null);
|
{
|
||||||
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
var request = new GetTradeProposalsRequest(OnlyActive: true, Limit: 50);
|
||||||
"trades_Get", request, TimeSpan.FromSeconds(4));
|
var proposals = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradeProposalsRequest>(
|
||||||
|
"engine_GetProposals", request, TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
return Ok(proposals ?? new List<TradeProposalDto>());
|
return Ok(proposals ?? new List<TradeProposalDto>());
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to fetch active proposals via MQTT RPC.");
|
_logger.LogError(ex, "Failed to fetch active proposals from FinlyticEngine.");
|
||||||
return StatusCode(500, new { error = "Internal server error fetching proposals." });
|
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.Text.Json.Serialization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FinlyticAssets.Models;
|
|
||||||
using FinlyticAssets.Util;
|
using FinlyticAssets.Util;
|
||||||
using FinlyticBackend.Database;
|
using FinlyticBackend.Database;
|
||||||
using FinlyticBackend.Util;
|
using FinlyticBackend.Util;
|
||||||
@@ -268,6 +267,42 @@ public class AssetsController : ControllerBase
|
|||||||
return NotFound(new { message = $"Keine technische Analyse für Asset '{normalizedSymbol}' verfügbar." });
|
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>
|
/// <summary>
|
||||||
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
|
/// Liest verfügbare Derivate (Knock-Outs, Optionsscheine etc.) für ein Basiswert-Asset via FinlyticAssets MQTT RPC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -304,7 +339,7 @@ public class AssetsController : ControllerBase
|
|||||||
ForceRefresh: forceRefresh
|
ForceRefresh: forceRefresh
|
||||||
);
|
);
|
||||||
|
|
||||||
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<AssetDto>, GetDerivativesRequest>(
|
var rpcResult = await _mqttClient.SendRpcRequestAsync<List<DerivativeDto>, GetDerivativesRequest>(
|
||||||
"assets_GetDerivatives",
|
"assets_GetDerivatives",
|
||||||
req,
|
req,
|
||||||
TimeSpan.FromSeconds(30)
|
TimeSpan.FromSeconds(30)
|
||||||
@@ -312,7 +347,7 @@ public class AssetsController : ControllerBase
|
|||||||
|
|
||||||
if (rpcResult != null)
|
if (rpcResult != null)
|
||||||
{
|
{
|
||||||
var derivatives = rpcResult.OfType<DerivativeDto>().ToList();
|
var derivatives = rpcResult;
|
||||||
|
|
||||||
if (minLeverage.HasValue)
|
if (minLeverage.HasValue)
|
||||||
{
|
{
|
||||||
@@ -365,6 +400,10 @@ public class AssetsController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
|
/// 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>
|
/// </summary>
|
||||||
[HttpGet("/api/v1/logo/{isin}")]
|
[HttpGet("/api/v1/logo/{isin}")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
|||||||
@@ -21,17 +21,15 @@ public record UserProfileResponseDto(
|
|||||||
[property: JsonPropertyName("role")] string Role
|
[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(
|
public record ChangeInitialPasswordDto(
|
||||||
[property: JsonPropertyName("userId")] Guid UserId,
|
|
||||||
[property: JsonPropertyName("newPassword")] string NewPassword
|
[property: JsonPropertyName("newPassword")] string NewPassword
|
||||||
);
|
);
|
||||||
|
|
||||||
public class ForgotPasswordRequestDto
|
|
||||||
{
|
|
||||||
[JsonPropertyName("email")]
|
|
||||||
public string Email { get; set; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/v1")]
|
[Route("api/v1")]
|
||||||
public class AuthController : ControllerBase
|
public class AuthController : ControllerBase
|
||||||
@@ -45,7 +43,13 @@ public class AuthController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Authenticates a user and returns a JWT token.
|
/// 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>
|
/// </summary>
|
||||||
|
[AllowAnonymous]
|
||||||
[HttpPost("auth/login")]
|
[HttpPost("auth/login")]
|
||||||
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
|
public async Task<IActionResult> Login([FromBody] LoginRequestDto request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -64,17 +68,29 @@ public class AuthController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
[HttpPost("auth/change-initial-password")]
|
[HttpPost("auth/change-initial-password")]
|
||||||
public async Task<IActionResult> ChangeInitialPassword([FromBody] ChangeInitialPasswordDto request, CancellationToken cancellationToken)
|
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)
|
if (!changed)
|
||||||
{
|
{
|
||||||
return BadRequest(new { error = "Password change failed. User not found or password change not required." });
|
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.Linq;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FinlyticAssets.Models;
|
using FinlyticCore.Models.Assets;
|
||||||
using FinlyticBackend.Util;
|
using FinlyticBackend.Util;
|
||||||
using FinlyticCore.Dtos;
|
using FinlyticCore.Dtos;
|
||||||
using FinlyticCore.Dtos.Fundamentals;
|
using FinlyticCore.Dtos.Fundamentals;
|
||||||
@@ -90,7 +90,7 @@ public class CalendarController : ControllerBase
|
|||||||
EventType: eventType,
|
EventType: eventType,
|
||||||
EventDate: eventDate,
|
EventDate: eventDate,
|
||||||
Date: eventDate.ToString("o"),
|
Date: eventDate.ToString("o"),
|
||||||
Isin: e.Isin,
|
Isin: eventIsin,
|
||||||
Ticker: ticker,
|
Ticker: ticker,
|
||||||
Description: $"{eventType} - {companyName}",
|
Description: $"{eventType} - {companyName}",
|
||||||
Details: $"Termin für {companyName} am {eventDate:dd.MM.yyyy}",
|
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
|
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>(
|
var articles = await _mqttClient.SendRpcRequestAsync<List<NewsArticleDto>, DailyNewsRequest>(
|
||||||
"news_Get",
|
"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;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using System.Text.Json;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FinlyticBackend.Util;
|
using FinlyticBackend.Util;
|
||||||
using FinlyticCore.Dtos;
|
using FinlyticCore.Dtos;
|
||||||
using FinlyticCore.Models.Trades;
|
using FinlyticCore.Dtos.Trading;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -16,35 +18,45 @@ namespace FinlyticBackend.Controllers;
|
|||||||
[Route("api/v1/user/trades")]
|
[Route("api/v1/user/trades")]
|
||||||
public class UserTradesController : ControllerBase
|
public class UserTradesController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly WebMqttClient _mqttClient;
|
private readonly BackendMqttBridge _mqttClient;
|
||||||
private readonly ILogger<UserTradesController> _logger;
|
private readonly ILogger<UserTradesController> _logger;
|
||||||
|
|
||||||
public UserTradesController(WebMqttClient mqttClient, ILogger<UserTradesController> logger)
|
public UserTradesController(BackendMqttBridge mqttClient, ILogger<UserTradesController> logger)
|
||||||
{
|
{
|
||||||
_mqttClient = mqttClient;
|
_mqttClient = mqttClient;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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)
|
var claimUserId = User.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||||
?? User.FindFirstValue("sub")
|
?? User.FindFirstValue("sub")
|
||||||
?? User.FindFirstValue("nameid");
|
?? 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.");
|
_logger.LogWarning("[UserTradesController] Claim NameIdentifier missing or not a valid GUID for an authenticated request.");
|
||||||
return "default_user";
|
throw new UnauthorizedAccessException("The request does not carry a valid user identity claim.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null)
|
public async Task<IActionResult> GetUserTrades([FromQuery] string? isin = null, [FromQuery] string? status = null)
|
||||||
@@ -53,116 +65,186 @@ public class UserTradesController : ControllerBase
|
|||||||
{
|
{
|
||||||
if (!_mqttClient.IsConnected)
|
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();
|
var userId = GetUserIdFromClaims();
|
||||||
|
var req = new GetActiveTradesRequest(UserId: userId, Mode: null);
|
||||||
// FIX: UserId explizit an GetTradesRequest übergeben!
|
var trades = await _mqttClient.SendRpcRequestAsync<List<ActiveTradeDto>, GetActiveTradesRequest>(
|
||||||
var request = new GetTradesRequest(isin, status, userId);
|
"engine_GetTrades", req, TimeSpan.FromSeconds(5));
|
||||||
|
return Ok(trades ?? new List<ActiveTradeDto>());
|
||||||
var trades = await _mqttClient.SendRpcRequestAsync<List<TradeProposalDto>, GetTradesRequest>(
|
}
|
||||||
"trades_Get",
|
}
|
||||||
request,
|
catch (UnauthorizedAccessException)
|
||||||
TimeSpan.FromSeconds(5));
|
{
|
||||||
|
return Unauthorized(new { error = "A valid user identity is required." });
|
||||||
return Ok(trades ?? new List<TradeProposalDto>());
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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" });
|
return StatusCode(500, new { error = "Internal server error while fetching trades" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public endpoint to retrieve active global proposals for guest users.
|
/// Accepts a trade proposal and converts it into an actively tracked trade in FinlyticEngine, owned by the
|
||||||
/// </summary>
|
/// authenticated caller.
|
||||||
[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.
|
|
||||||
/// </summary>
|
/// </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")]
|
[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)
|
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
|
var acceptRequest = new AcceptTradeProposalRequest(
|
||||||
request.UserId = GetUserIdFromClaims();
|
UserId: userId,
|
||||||
|
ProposalId: proposalId,
|
||||||
|
ExecutedPrice: dto.ActualEntryPrice ?? dto.EntryPrice,
|
||||||
|
Quantity: dto.Quantity ?? dto.PositionSize);
|
||||||
|
|
||||||
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, TradeAcceptanceDto>(
|
var acceptedTrade = await _mqttClient.SendRpcRequestAsync<ActiveTradeDto, AcceptTradeProposalRequest>(
|
||||||
"trades_Accept",
|
"engine_AcceptProposal", acceptRequest, TimeSpan.FromSeconds(10));
|
||||||
request,
|
|
||||||
TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
if (result != null)
|
if (acceptedTrade != null)
|
||||||
{
|
{
|
||||||
return Ok(result);
|
return Ok(acceptedTrade);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback Fire-and-Forget
|
return StatusCode(504, new { error = "FinlyticEngine did not confirm the trade acceptance in time." });
|
||||||
await _mqttClient.PublishAsync($"finlytic/trades/accept/{request.Isin}", request);
|
|
||||||
return Ok(new { status = "Accepted", analysisId = request.AnalysisId, tradeId = request.TradeId, userId = request.UserId });
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (UnauthorizedAccessException)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to accept trade proposal via MQTT RPC.");
|
return Unauthorized(new { error = "A valid user identity is required." });
|
||||||
return StatusCode(500, new { error = "Internal server error" });
|
}
|
||||||
|
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>
|
/// <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>
|
/// </summary>
|
||||||
[HttpPost("{id}/close")]
|
/// <param name="request">
|
||||||
public async Task<IActionResult> CloseTrade(string id, [FromBody] CloseTradeRequest? 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
|
try
|
||||||
{
|
{
|
||||||
if (!_mqttClient.IsConnected)
|
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 userId = GetUserIdFromClaims();
|
||||||
var result = await _mqttClient.SendRpcRequestAsync<TradeProposalDto, CloseTradeRequest>(
|
|
||||||
$"trades_Close/{id}",
|
// 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,
|
closeReq,
|
||||||
TimeSpan.FromSeconds(5));
|
TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
@@ -171,45 +253,16 @@ public class UserTradesController : ControllerBase
|
|||||||
return Ok(result);
|
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)
|
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" });
|
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 FinlyticBackend.Entities;
|
||||||
|
using FinlyticCore.Database;
|
||||||
|
using FinlyticCore.Entities.Settings;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Design;
|
||||||
|
|
||||||
namespace FinlyticBackend.Database;
|
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) { }
|
public BackendDbContext(DbContextOptions<BackendDbContext> options) : base(options) { }
|
||||||
|
|
||||||
@@ -11,11 +18,18 @@ public class BackendDbContext : DbContext
|
|||||||
public DbSet<UserDeviceTokenEntity> UserDeviceTokens => Set<UserDeviceTokenEntity>();
|
public DbSet<UserDeviceTokenEntity> UserDeviceTokens => Set<UserDeviceTokenEntity>();
|
||||||
public DbSet<UserFavoriteAssetEntity> UserFavoriteAssets => Set<UserFavoriteAssetEntity>();
|
public DbSet<UserFavoriteAssetEntity> UserFavoriteAssets => Set<UserFavoriteAssetEntity>();
|
||||||
public DbSet<ServiceConfigurationEntity> ServiceConfigurations => Set<ServiceConfigurationEntity>();
|
public DbSet<ServiceConfigurationEntity> ServiceConfigurations => Set<ServiceConfigurationEntity>();
|
||||||
|
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
base.OnModelCreating(modelBuilder);
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SettingEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => e.Id);
|
||||||
|
entity.HasIndex(e => e.Key).IsUnique();
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<UserEntity>(entity =>
|
modelBuilder.Entity<UserEntity>(entity =>
|
||||||
{
|
{
|
||||||
entity.ToTable("users");
|
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
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
|
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
|
||||||
COPY ["FinlyticBackend/FinlyticBackend.csproj", "FinlyticBackend/"]
|
COPY ["FinlyticBackend/FinlyticBackend.csproj", "FinlyticBackend/"]
|
||||||
RUN dotnet restore "FinlyticBackend/FinlyticBackend.csproj"
|
RUN dotnet restore "FinlyticBackend/FinlyticBackend.csproj"
|
||||||
COPY . .
|
COPY . .
|
||||||
WORKDIR "/src/FinlyticBackend"
|
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
|
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
|
WORKDIR /app
|
||||||
COPY --from=publish /app/publish .
|
COPY --from=publish /app/publish .
|
||||||
ENTRYPOINT ["dotnet", "FinlyticBackend.dll"]
|
ENTRYPOINT ["dotnet", "FinlyticBackend.dll"]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ namespace FinlyticBackend.Hubs;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// SignalR Hub that streams live log messages from microservices to connected web UI clients.
|
/// SignalR Hub that streams live log messages from microservices to connected web UI clients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
public class LogStreamHub : Hub
|
public class LogStreamHub : Hub
|
||||||
{
|
{
|
||||||
private readonly ILogger<LogStreamHub> _logger;
|
private readonly ILogger<LogStreamHub> _logger;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FinlyticCore.Dtos.News;
|
using FinlyticCore.Dtos.News;
|
||||||
@@ -7,6 +8,7 @@ namespace FinlyticBackend.Hubs;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// SignalR Hub for real-time news delivery to connected clients.
|
/// SignalR Hub for real-time news delivery to connected clients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
public class NewsHub : Hub
|
public class NewsHub : Hub
|
||||||
{
|
{
|
||||||
// Clients can call this to join specific symbol groups if needed later
|
// Clients can call this to join specific symbol groups if needed later
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
@@ -8,6 +9,7 @@ namespace FinlyticBackend.Hubs;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// SignalR Hub broadcasting real-time system diagnostics & microservice health updates to connected clients.
|
/// SignalR Hub broadcasting real-time system diagnostics & microservice health updates to connected clients.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
[Authorize]
|
||||||
public class SystemHealthHub : Hub
|
public class SystemHealthHub : Hub
|
||||||
{
|
{
|
||||||
private readonly ILogger<SystemHealthHub> _logger;
|
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);
|
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 =>
|
modelBuilder.Entity("FinlyticBackend.Entities.UserDeviceTokenEntity", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
|
b.HasOne("FinlyticBackend.Entities.UserEntity", "User")
|
||||||
|
|||||||
+73
-11
@@ -4,10 +4,15 @@ using System.Threading.Tasks;
|
|||||||
using FinlyticBackend.Controllers;
|
using FinlyticBackend.Controllers;
|
||||||
using FinlyticBackend.Database;
|
using FinlyticBackend.Database;
|
||||||
using FinlyticBackend.Hubs;
|
using FinlyticBackend.Hubs;
|
||||||
|
using FinlyticBackend.Middleware;
|
||||||
using FinlyticBackend.Services;
|
using FinlyticBackend.Services;
|
||||||
using FinlyticBackend.Util;
|
using FinlyticBackend.Util;
|
||||||
|
using FinlyticCore.Database;
|
||||||
|
using FinlyticCore.Services;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
|
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
using Microsoft.AspNetCore.Http.Connections;
|
using Microsoft.AspNetCore.Http.Connections;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
@@ -21,6 +26,12 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// 1. Add Controllers
|
// 1. Add Controllers
|
||||||
builder.Services.AddControllers();
|
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
|
// 2. Define CORS Policy
|
||||||
builder.Services.AddCors(options =>
|
builder.Services.AddCors(options =>
|
||||||
{
|
{
|
||||||
@@ -34,10 +45,28 @@ builder.Services.AddCors(options =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 3. Configure JWT Authentication
|
// 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 issuer = builder.Configuration["JWT:Issuer"] ?? "FinlyticBackend";
|
||||||
var audience = builder.Configuration["JWT:Audience"] ?? "FinlyticClients";
|
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 =>
|
builder.Services.AddAuthentication(options =>
|
||||||
{
|
{
|
||||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||||
@@ -80,6 +109,11 @@ builder.Services.AddSignalR();
|
|||||||
// 4. Register DB Context & Services
|
// 4. Register DB Context & Services
|
||||||
builder.Services.AddDbContext<BackendDbContext>(options =>
|
builder.Services.AddDbContext<BackendDbContext>(options =>
|
||||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
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<UserTradesController>();
|
||||||
builder.Services.AddHttpClient<UserFavoritesController>();
|
builder.Services.AddHttpClient<UserFavoritesController>();
|
||||||
@@ -91,14 +125,34 @@ builder.Services.AddSingleton<IJwtTokenService, JwtTokenService>();
|
|||||||
builder.Services.AddSingleton<IUserService, UserService>();
|
builder.Services.AddSingleton<IUserService, UserService>();
|
||||||
builder.Services.AddSingleton<IFirebaseNotificationService, FirebaseNotificationService>();
|
builder.Services.AddSingleton<IFirebaseNotificationService, FirebaseNotificationService>();
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<BackendMqttBridge>();
|
||||||
|
builder.Services.AddHostedService<BackendMqttBridge>(sp => sp.GetRequiredService<BackendMqttBridge>());
|
||||||
builder.Services.AddSingleton<WebMqttClient>();
|
builder.Services.AddSingleton<WebMqttClient>();
|
||||||
builder.Services.AddHostedService<WebMqttClient>(sp => sp.GetRequiredService<WebMqttClient>());
|
|
||||||
builder.Services.AddHostedService<BackendMqttBridge>();
|
|
||||||
builder.Services.AddHostedService<SystemHealthBackgroundService>();
|
builder.Services.AddHostedService<SystemHealthBackgroundService>();
|
||||||
builder.Services.AddHostedService<FavoritesPriceBackgroundService>();
|
builder.Services.AddHostedService<FavoritesPriceBackgroundService>();
|
||||||
|
|
||||||
var app = builder.Build();
|
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
|
// MIDDLEWARE PIPELINE ORDER IS CRITICAL FOR CORS
|
||||||
// ----------------------------------------------------------------------
|
// ----------------------------------------------------------------------
|
||||||
@@ -155,12 +209,13 @@ using (var scope = app.Services.CreateScope())
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var context = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
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>();
|
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
|
||||||
string adminDefaultPassword = builder.Configuration["ADMIN:DefaultPassword"] ?? "AdminDefaultPassword2026!";
|
|
||||||
await userService.SeedDefaultAdminAsync(adminDefaultPassword);
|
await userService.SeedDefaultAdminAsync(adminDefaultPassword);
|
||||||
}
|
}
|
||||||
|
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<Program>>();
|
||||||
@@ -171,11 +226,7 @@ using (var scope = app.Services.CreateScope())
|
|||||||
// Map Endpoints
|
// Map Endpoints
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.MapHub<TradeRealtimeHub>("/hubs/trades", options =>
|
app.MapHub<TradeStreamHub>("/hubs/trade-stream", options =>
|
||||||
{
|
|
||||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
|
||||||
});
|
|
||||||
app.MapHub<TradeHub>("/hubs/trade-updates", options =>
|
|
||||||
{
|
{
|
||||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
||||||
});
|
});
|
||||||
@@ -196,8 +247,19 @@ app.MapHub<LogStreamHub>("/hubs/logs", options =>
|
|||||||
options.Transports = HttpTransportType.WebSockets | HttpTransportType.ServerSentEvents;
|
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");
|
app.MapFallbackToFile("index.html");
|
||||||
|
|
||||||
await app.RunAsync();
|
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.");
|
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;
|
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()))
|
foreach (var userGroup in userFavorites.GroupBy(f => f.UserId.ToString()))
|
||||||
{
|
{
|
||||||
var userId = userGroup.Key;
|
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();
|
var cleanIsin = fav.Isin.Trim().ToUpperInvariant();
|
||||||
|
if (!mqttClient.IsConnected) return;
|
||||||
if (!mqttClient.IsConnected) continue;
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var livePrice = await mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
|
var livePrice = await mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
|
||||||
"tr_GetLivePrice",
|
"tr_GetLivePrice",
|
||||||
new IsinRequest(cleanIsin),
|
new IsinRequest(cleanIsin),
|
||||||
TimeSpan.FromSeconds(2)
|
TimeSpan.FromSeconds(4)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (livePrice != null)
|
if (livePrice != null)
|
||||||
@@ -80,12 +79,14 @@ public class FavoritesPriceBackgroundService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* Ignorieren bei Einzel-Timeout */ }
|
catch { /* Ignorieren bei Einzel-Timeout */ }
|
||||||
}
|
});
|
||||||
|
|
||||||
if (priceUpdates.Count > 0)
|
await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
if (!priceUpdates.IsEmpty)
|
||||||
{
|
{
|
||||||
await hubContext.Clients.Group(FavoritesPriceHub.GetGroupName(userId))
|
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.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FinlyticCore.Models.Trades;
|
using FinlyticCore.Dtos.Trading;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace FinlyticBackend.Services;
|
namespace FinlyticBackend.Services;
|
||||||
@@ -17,20 +17,12 @@ public interface IFirebaseNotificationService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a push notification about a new trade proposal.
|
/// Sends a push notification about a new trade proposal.
|
||||||
/// </summary>
|
/// </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);
|
Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a push notification about an update to an existing trade.
|
/// Sends a push notification about an update to an existing trade.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="update">The trade update details.</param>
|
Task SendTradeUpdateNotificationAsync(ActiveTradeDto update, List<string> fcmTokens, CancellationToken cancellationToken = default);
|
||||||
/// <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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -39,11 +31,6 @@ public class FirebaseNotificationService : IFirebaseNotificationService
|
|||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly ILogger<FirebaseNotificationService> _logger;
|
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)
|
public FirebaseNotificationService(HttpClient httpClient, ILogger<FirebaseNotificationService> logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient;
|
_httpClient = httpClient;
|
||||||
@@ -51,13 +38,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|
||||||
public async Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default)
|
public async Task SendTradeProposalNotificationAsync(TradeProposalDto proposal, List<string> fcmTokens, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (fcmTokens == null || fcmTokens.Count == 0) return;
|
if (fcmTokens == null || fcmTokens.Count == 0) return;
|
||||||
|
|
||||||
string title = $"🚀 Trade Signal: {proposal.SignalType} {proposal.Symbol}";
|
string title = $"🚀 Trade Signal: {proposal.Direction} {proposal.Symbol} ({proposal.StrategyKey})";
|
||||||
string body = $"{proposal.CompanyName} ({proposal.Isin}) - Entry: ${proposal.EntryPrice:F2}, WinRate: {proposal.WinRate:F1}%. {proposal.Reasoning}";
|
string body = $"{proposal.Symbol} ({proposal.UnderlyingIsin}) - Entry: €{proposal.EntryPrice:F2}, Score: {proposal.CompositeScore:F0} Pkt. {proposal.AiValidation?.ThesisSummary}";
|
||||||
|
|
||||||
foreach (var token in fcmTokens)
|
foreach (var token in fcmTokens)
|
||||||
{
|
{
|
||||||
@@ -66,12 +52,12 @@ public class FirebaseNotificationService : IFirebaseNotificationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <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;
|
if (fcmTokens == null || fcmTokens.Count == 0) return;
|
||||||
|
|
||||||
string title = $"📊 Trade Update: {update.TradeId}";
|
string title = $"📊 Trade Update: {update.Symbol} (Status: {update.Status})";
|
||||||
string body = $"Recommendation: {update.Recommendation} @ ${update.CurrentPrice:F2}. {update.Reasoning}";
|
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)
|
foreach (var token in fcmTokens)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -38,15 +38,17 @@ public class JwtTokenService : IJwtTokenService
|
|||||||
{
|
{
|
||||||
var configuredKey = configuration["JWT:SecretKey"] ?? configuration["JWT__SecretKey"];
|
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)
|
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;
|
_secretKey = configuredKey;
|
||||||
}
|
|
||||||
|
|
||||||
_issuer = configuration["JWT:Issuer"] ?? configuration["JWT__Issuer"] ?? "FinlyticBackend";
|
_issuer = configuration["JWT:Issuer"] ?? configuration["JWT__Issuer"] ?? "FinlyticBackend";
|
||||||
_audience = configuration["JWT:Audience"] ?? configuration["JWT__Audience"] ?? "FinlyticClients";
|
_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"),
|
("FinlyticAssets", "health_Ping/FinlyticAssets", "Asset Catalog & Scraper", "PostgreSQL assets"),
|
||||||
("FinlyticNews", "health_Ping/FinlyticNews", "News RSS Scraper & AI", "PostgreSQL news"),
|
("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"),
|
("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"),
|
("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>
|
var results = new List<ServiceHealthStatusDto>
|
||||||
|
|||||||
@@ -21,9 +21,6 @@ public interface IUserService
|
|||||||
/// <summary>Authenticates user credentials and returns JWT response.</summary>
|
/// <summary>Authenticates user credentials and returns JWT response.</summary>
|
||||||
Task<AuthResponseDto?> AuthenticateAsync(LoginRequestDto request, CancellationToken cancellationToken = default);
|
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>
|
/// <summary>Changes the initial password for a user.</summary>
|
||||||
Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
|
Task<bool> ChangeInitialPasswordAsync(Guid userId, string newPassword,
|
||||||
CancellationToken cancellationToken = default);
|
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 />
|
/// <inheritdoc />
|
||||||
public async Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request,
|
public async Task<UserDto?> CreateUserByAdminAsync(CreateUserRequestDto request,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
@@ -352,13 +301,21 @@ public class UserService : IUserService
|
|||||||
|
|
||||||
if (!exists)
|
if (!exists)
|
||||||
{
|
{
|
||||||
string password = !string.IsNullOrWhiteSpace(defaultAdminPassword)
|
// Fail-fast statt Fallback auf einen im Repo öffentlichen Literal (Rules.md §12): ein Gateway,
|
||||||
? defaultAdminPassword
|
// das den Default-Admin mit einem repo-öffentlichen Passwort anlegt, ist schlimmer als eines,
|
||||||
: "AdminDefaultPassword2026!";
|
// 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
|
var adminUser = new UserEntity
|
||||||
{
|
{
|
||||||
Email = adminEmail,
|
Email = adminEmail,
|
||||||
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
|
PasswordHash = BCrypt.Net.BCrypt.HashPassword(defaultAdminPassword),
|
||||||
FullName = "System Administrator",
|
FullName = "System Administrator",
|
||||||
Role = "Admin",
|
Role = "Admin",
|
||||||
ThemePreference = "fluent_dark",
|
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;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -7,11 +8,13 @@ using System.Threading.Tasks;
|
|||||||
using FinlyticBackend.Database;
|
using FinlyticBackend.Database;
|
||||||
using FinlyticBackend.Hubs;
|
using FinlyticBackend.Hubs;
|
||||||
using FinlyticBackend.Services;
|
using FinlyticBackend.Services;
|
||||||
|
using FinlyticBackend.Settings;
|
||||||
|
using FinlyticCore.Dtos.Bot;
|
||||||
using FinlyticCore.Dtos.Logging;
|
using FinlyticCore.Dtos.Logging;
|
||||||
using FinlyticCore.Dtos.News;
|
using FinlyticCore.Dtos.News;
|
||||||
|
using FinlyticCore.Dtos.Trading;
|
||||||
using FinlyticCore.Models;
|
using FinlyticCore.Models;
|
||||||
using FinlyticCore.Models.Auth;
|
using FinlyticCore.Services;
|
||||||
using FinlyticCore.Models.Trades;
|
|
||||||
using FinlyticCore.Util;
|
using FinlyticCore.Util;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -23,85 +26,98 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace FinlyticBackend.Util;
|
namespace FinlyticBackend.Util;
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// Subscribes to general broadcast MQTT topics and forwards them to SignalR clients and FCM push services.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
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);
|
public static readonly ConcurrentDictionary<string, ConcurrentQueue<LogMessageDto>> ServiceLogsRingBuffer = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly IHubContext<TradeRealtimeHub, ITradeClient> _hubContext;
|
private readonly IHubContext<TradeStreamHub, ITradeStreamClient> _tradeStreamHubContext;
|
||||||
private readonly IHubContext<TradeHub> _tradeHubContext;
|
|
||||||
private readonly IHubContext<NewsHub> _newsHubContext;
|
private readonly IHubContext<NewsHub> _newsHubContext;
|
||||||
private readonly IHubContext<LogStreamHub> _logHubContext;
|
private readonly IHubContext<LogStreamHub> _logHubContext;
|
||||||
private readonly IFirebaseNotificationService _firebaseService;
|
private readonly IFirebaseNotificationService _firebaseService;
|
||||||
private readonly ILogger<BackendMqttBridge> _logger;
|
private readonly ILogger<BackendMqttBridge> _logger;
|
||||||
|
private readonly IFinlyticLogger<BackendMqttBridge> _finlyticLogger;
|
||||||
|
|
||||||
public BackendMqttBridge(
|
public BackendMqttBridge(
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
IHubContext<TradeRealtimeHub, ITradeClient> hubContext,
|
IHubContext<TradeStreamHub, ITradeStreamClient> tradeStreamHubContext,
|
||||||
IHubContext<TradeHub> tradeHubContext,
|
|
||||||
IHubContext<NewsHub> newsHubContext,
|
IHubContext<NewsHub> newsHubContext,
|
||||||
IHubContext<LogStreamHub> logHubContext,
|
IHubContext<LogStreamHub> logHubContext,
|
||||||
IFirebaseNotificationService firebaseService,
|
IFirebaseNotificationService firebaseService,
|
||||||
ILogger<BackendMqttBridge> logger) : base(logger)
|
ILogger<BackendMqttBridge> logger,
|
||||||
|
IFinlyticLogger<BackendMqttBridge> finlyticLogger) : base(logger)
|
||||||
{
|
{
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_hubContext = hubContext;
|
_tradeStreamHubContext = tradeStreamHubContext;
|
||||||
_tradeHubContext = tradeHubContext;
|
|
||||||
_newsHubContext = newsHubContext;
|
_newsHubContext = newsHubContext;
|
||||||
_logHubContext = logHubContext;
|
_logHubContext = logHubContext;
|
||||||
_firebaseService = firebaseService;
|
_firebaseService = firebaseService;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_finlyticLogger = finlyticLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var config = new MqttConfiguration
|
var config = MqttConfiguration.FromConfiguration(_configuration, "finlytic_backend_gateway");
|
||||||
{
|
|
||||||
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()}"
|
|
||||||
};
|
|
||||||
|
|
||||||
_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);
|
await ConnectAsync(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task StopAsync(CancellationToken cancellationToken)
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Stopping Backend MQTT Bridge.");
|
_logger.LogInformation("Stopping Backend MQTT Gateway Bridge.");
|
||||||
await DisconnectAsync();
|
await DisconnectAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override async Task OnConnectedAsync()
|
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/#");
|
// RPC response stream
|
||||||
await SubscribeAsync("finlytic/trades/updates/#");
|
await SubscribeAsync(MqttTopics.ResponseWildcard);
|
||||||
|
|
||||||
// News topics
|
// Engine & Bot streams
|
||||||
await SubscribeAsync("services/news/completed");
|
await SubscribeAsync(MqttTopics.EngineWildcard);
|
||||||
await SubscribeAsync("finlytic/news/#");
|
await SubscribeAsync(MqttTopics.BotWildcard);
|
||||||
await SubscribeAsync("finlytic/sentiment/#");
|
|
||||||
|
|
||||||
// Fundamentals & Technicals
|
// News & Sentiment streams
|
||||||
await SubscribeAsync("finlytic/fundamentals/#");
|
await SubscribeAsync(MqttTopics.NewsCompleted);
|
||||||
await SubscribeAsync("finlytic/assets/fundamentals/#");
|
await SubscribeAsync(MqttTopics.NewsStreamWildcard);
|
||||||
await SubscribeAsync("finlytic/technicalanalysis/#");
|
await SubscribeAsync(MqttTopics.SentimentWildcard);
|
||||||
await SubscribeAsync("finlytic/ta/#");
|
|
||||||
|
|
||||||
// Real-time Logs
|
// 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 />
|
/// <inheritdoc />
|
||||||
@@ -111,39 +127,33 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (topic.StartsWith("finlytic/logs/", StringComparison.OrdinalIgnoreCase))
|
if (topic.StartsWith(MqttTopics.LogsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
await HandleLogMessageAsync(payloadStr);
|
await HandleLogMessageAsync(payloadStr);
|
||||||
}
|
}
|
||||||
else if (topic.StartsWith("finlytic/trades/proposed/", StringComparison.OrdinalIgnoreCase) ||
|
else if (topic.StartsWith(MqttTopics.EngineProposalsPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
topic.StartsWith("finlytic/trades/update", 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) ||
|
else if (topic.StartsWith(MqttTopics.BotTradesPrefix, StringComparison.OrdinalIgnoreCase))
|
||||||
topic.StartsWith("finlytic/news/", StringComparison.OrdinalIgnoreCase) ||
|
{
|
||||||
topic.StartsWith("finlytic/sentiment/", 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);
|
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)
|
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
|
// Keep buffer capped at 250 entries
|
||||||
while (queue.Count > 250 && queue.TryDequeue(out _)) { }
|
while (queue.Count > 250 && queue.TryDequeue(out _)) { }
|
||||||
|
|
||||||
// Broadcast to SignalR clients
|
// Broadcast once to all connected SignalR admin logs clients
|
||||||
await _logHubContext.Clients.Group(serviceKey).SendAsync("ReceiveLogMessage", logDto);
|
|
||||||
await _logHubContext.Clients.All.SendAsync("ReceiveLogMessage", logDto);
|
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);
|
var proposal = JsonSerializer.Deserialize<TradeProposalDto>(payloadStr);
|
||||||
if (proposal == null) return;
|
if (proposal == null) return;
|
||||||
|
|
||||||
await _hubContext.Clients.All.OnTradeProposed(proposal);
|
await _tradeStreamHubContext.Clients.All.ReceiveTradeProposal(proposal);
|
||||||
await _tradeHubContext.Clients.All.SendAsync("ReceiveTradeUpdate", proposal);
|
if (!string.IsNullOrWhiteSpace(proposal.UnderlyingIsin))
|
||||||
_logger.LogInformation("Broadcasted Trade Proposal {AnalysisId} via SignalR.", proposal.AnalysisId);
|
{
|
||||||
|
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();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
||||||
var fcmTokens = await dbContext.UserDeviceTokens.AsNoTracking().Select(t => t.FcmToken).ToListAsync();
|
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);
|
await _firebaseService.SendTradeProposalNotificationAsync(proposal, fcmTokens);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
catch (Exception ex)
|
||||||
private async Task HandleTradeUpdateAsync(string payloadStr)
|
|
||||||
{
|
{
|
||||||
var update = JsonSerializer.Deserialize<TradeHourlyUpdateDto>(payloadStr);
|
await _finlyticLogger.LogWarningAsync(BackendSettingKeys.PushNotificationChannel, ex,
|
||||||
if (update == null) return;
|
"[BackendMqttBridge] Failed to send FCM push notification for proposal {ProposalId}.", proposal.ProposalId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _hubContext.Clients.All.OnTradeUpdated(update);
|
private async Task HandleEngineTradeUpdateAsync(string payloadStr)
|
||||||
await _tradeHubContext.Clients.All.SendAsync("ReceiveTradeUpdate", update);
|
{
|
||||||
|
var trade = JsonSerializer.Deserialize<ActiveTradeDto>(payloadStr);
|
||||||
|
if (trade == null) return;
|
||||||
|
|
||||||
if (string.Equals(update.Recommendation, "Close", StringComparison.OrdinalIgnoreCase) ||
|
await _tradeStreamHubContext.Clients.All.ReceiveTradeUpdate(trade);
|
||||||
string.Equals(update.Recommendation, "AdjustSL", StringComparison.OrdinalIgnoreCase))
|
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();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
||||||
@@ -200,9 +234,30 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
|||||||
|
|
||||||
if (fcmTokens.Count > 0)
|
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)
|
private async Task HandleNewsArticleAsync(string payloadStr)
|
||||||
@@ -211,39 +266,24 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
|
|||||||
if (article == null) return;
|
if (article == null) return;
|
||||||
|
|
||||||
await _newsHubContext.Clients.All.SendAsync("ReceiveNewArticle", article);
|
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);
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var root = jsonDoc.RootElement.Clone();
|
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
|
||||||
|
|
||||||
string? isin = root.TryGetProperty("isin", out var isinProp) ? isinProp.GetString() : null;
|
var isins = await dbContext.UserFavoriteAssets
|
||||||
string? ticker = root.TryGetProperty("primaryTicker", out var tickerProp)
|
.AsNoTracking()
|
||||||
? tickerProp.GetString()
|
.Select(f => f.Isin)
|
||||||
: (root.TryGetProperty("ticker", out var tProp) ? tProp.GetString() : null);
|
.Where(isin => !string.IsNullOrWhiteSpace(isin))
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(isin)) FundamentalsCache[isin] = root;
|
await _finlyticLogger.LogInfoAsync(BackendSettingKeys.MqttChannel,
|
||||||
if (!string.IsNullOrEmpty(ticker)) FundamentalsCache[ticker] = root;
|
"[BackendMqttBridge] Responded to backend_GetAggregatedFavorites with {Count} unique ISINs. [CorrelationId: {CorrelationId}]", isins.Count, correlationId);
|
||||||
|
return isins;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,91 +1,44 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using FinlyticCore.Models;
|
|
||||||
using FinlyticCore.Util;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace FinlyticBackend.Util;
|
namespace FinlyticBackend.Util;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public class WebMqttClient : ManagedMqttClient, IHostedService
|
public class WebMqttClient : IHostedService
|
||||||
{
|
{
|
||||||
private readonly ILogger<WebMqttClient> _logger;
|
private readonly BackendMqttBridge _bridge;
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
|
|
||||||
public WebMqttClient(ILogger<WebMqttClient> logger, IConfiguration configuration) : base(logger)
|
public bool IsConnected => _bridge.IsConnected;
|
||||||
|
|
||||||
|
public WebMqttClient(BackendMqttBridge bridge)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_bridge = bridge;
|
||||||
_configuration = configuration;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task StartAsync(CancellationToken cancellationToken)
|
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
{
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
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()}"
|
|
||||||
};
|
|
||||||
|
|
||||||
_logger.LogInformation("Starting Web MQTT RPC Client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
|
public Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(string channel, TRequest request, TimeSpan timeout)
|
||||||
await ConnectAsync(config);
|
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.");
|
return _bridge.SendRpcRequestAsync<TResponse, TRequest>(channel, request);
|
||||||
await DisconnectAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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...");
|
return _bridge.PublishAsync(topic, payload);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
_flutter.loader.load({
|
||||||
serviceWorkerSettings: {
|
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