refactor: save current workspace state including FinlyticAnalyzer fixes, FinlyticApp trade route alignment, and DTO audit documentation

This commit is contained in:
2026-08-12 18:30:42 +02:00
parent a9553e9fbf
commit 3d8af3940b
163 changed files with 3421 additions and 1751 deletions
@@ -271,16 +271,14 @@ public class AssetsController : ControllerBase
/// <summary>
/// Serviert das SVG-Logo direkt aus dem gemounteten Docker Volume (Volumes.LogosRelativePath).
/// </summary>
[HttpGet("logo/{isin}")]
[HttpGet("/api/v1/logo/{isin}")]
[AllowAnonymous]
public async Task<IActionResult> GetAssetLogo([FromRoute] string isin)
{
if (string.IsNullOrWhiteSpace(isin)) return NotFound();
string cleanIsin = isin.Trim().ToUpperInvariant();
// Path Traversal Guard
string safeFileName = string.Concat(cleanIsin.Where(c => char.IsLetterOrDigit(c) || c == '_' || c == '-')) + ".svg";
string logoPath = Path.Combine(Volumes.LogosRelativePath, safeFileName);
string logoPath = Path.Combine(Volumes.LogosRelativePath, $"{isin}.svg");
if (System.IO.File.Exists(logoPath))
{
@@ -13,27 +13,6 @@ using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Controllers;
/// <summary>
/// Response DTO for corporate calendar events (AOT-compliant).
/// </summary>
public record CalendarEventResponseDto(
[property: JsonPropertyName("id")] string Id,
[property: JsonPropertyName("symbol")] string Symbol,
[property: JsonPropertyName("companyName")]
string CompanyName,
[property: JsonPropertyName("eventType")]
string EventType,
[property: JsonPropertyName("eventDate")]
DateTime EventDate,
[property: JsonPropertyName("date")] string Date,
[property: JsonPropertyName("isin")] string Isin,
[property: JsonPropertyName("ticker")] string Ticker,
[property: JsonPropertyName("description")]
string Description,
[property: JsonPropertyName("details")]
string Details,
[property: JsonPropertyName("image")] string Image
);
[ApiController]
[Authorize]
@@ -52,22 +31,26 @@ public class CalendarController : ControllerBase
/// <summary>
/// Retrieves corporate calendar events with optional filters.
/// </summary>
[HttpGet("events")]
[HttpGet("events/{year:int?}/{month:int?}")]
public async Task<IActionResult> GetCorporateCalendar(
int? year = null,
int? month = null,
[FromQuery] string? category = null,
[FromQuery] DateTime? date = null,
[FromQuery] string? symbol = null,
[FromQuery] string? isin = null)
{
string? activeSymbol = !string.IsNullOrWhiteSpace(symbol) ? symbol.Trim() : isin?.Trim();
int targetYear = year ?? DateTime.UtcNow.Year;
int targetMonth = month ?? DateTime.UtcNow.Month;
try
{
if (_mqttClient.IsConnected)
{
var rawEvents = await _mqttClient.SendRpcRequestAsync<List<CorporateEventDto>, EmptyRequest>(
"events_GetAll",
new EmptyRequest(),
var rawEvents = await _mqttClient.SendRpcRequestAsync<List<CorporateEventDto>, GetEventsByMonthRequest>(
"events_GetByMonth",
new GetEventsByMonthRequest(targetYear, targetMonth),
TimeSpan.FromSeconds(4)
);
@@ -50,12 +50,6 @@ public class NewsController : ControllerBase
effectiveStatus = "Analyzed";
}
string? dateStr = !string.IsNullOrWhiteSpace(date) ? date.Trim() : null;
if (string.Equals(dateStr, "today", StringComparison.OrdinalIgnoreCase))
{
dateStr = DateTime.UtcNow.ToString("yyyy-MM-dd");
}
try
{
if (_mqttClient.IsConnected)
@@ -64,11 +58,13 @@ public class NewsController : ControllerBase
Limit: pageSize,
Offset: (page - 1) * pageSize,
Isin: activeSymbol,
Date: dateStr,
Date: DateTime.TryParse(date, out var dateTime) ? dateTime : null,
Status: effectiveStatus,
Query: query,
HasSentiment: hasSentiment
);
_logger.LogInformation("[payload] " + payload.Date.ToString());
var articles = await _mqttClient.SendRpcRequestAsync<List<NewsArticleDto>, DailyNewsRequest>(
"news_Get",
+52 -3
View File
@@ -1,5 +1,7 @@
using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
@@ -8,24 +10,71 @@ namespace FinlyticBackend.Hubs;
/// <summary>
/// SignalR Hub streaming real-time stock prices & daily % growth updates for favorite assets every 10 seconds.
/// </summary>
[Authorize]
public class FavoritesPriceHub : Hub
{
private readonly ILogger<FavoritesPriceHub> _logger;
// Speichert thread-sicher, wie viele aktive Verbindungen ein User hat (UserId -> ConnectionCount)
private static readonly ConcurrentDictionary<string, int> ActiveUserConnections = new();
public FavoritesPriceHub(ILogger<FavoritesPriceHub> logger)
{
_logger = logger;
}
/// <summary>
/// Liefert eine Übersicht aller aktuell mit dem Hub verbundenen User-IDs.
/// </summary>
public static string[] GetActiveUserIds() => [.. ActiveUserConnections.Keys];
public override async Task OnConnectedAsync()
{
_logger.LogInformation("[FavoritesPriceHub] SignalR client connected: ConnectionId={ConnectionId}", Context.ConnectionId);
var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId))
{
// Füge die Verbindung der benutzerspezifischen Gruppe hinzu
await Groups.AddToGroupAsync(Context.ConnectionId, GetGroupName(userId));
ActiveUserConnections.AddOrUpdate(userId, 1, (_, count) => count + 1);
_logger.LogInformation("[FavoritesPriceHub] User '{UserId}' connected (ConnectionId={ConnectionId}). Active connections for user: {Count}",
userId, Context.ConnectionId, ActiveUserConnections[userId]);
}
else
{
_logger.LogWarning("[FavoritesPriceHub] Anonymous SignalR client connected without UserIdentifier: ConnectionId={ConnectionId}", Context.ConnectionId);
}
await base.OnConnectedAsync();
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_logger.LogInformation("[FavoritesPriceHub] SignalR client disconnected: ConnectionId={ConnectionId}", Context.ConnectionId);
var userId = Context.UserIdentifier;
if (!string.IsNullOrEmpty(userId))
{
await Groups.RemoveFromGroupAsync(Context.ConnectionId, GetGroupName(userId));
ActiveUserConnections.AddOrUpdate(userId, 0, (_, count) =>
{
var newCount = count - 1;
return newCount < 0 ? 0 : newCount;
});
// Wenn keine aktiven Verbindungen mehr bestehen, aus Dictionary entfernen
if (ActiveUserConnections.TryGetValue(userId, out var remainingCount) && remainingCount <= 0)
{
ActiveUserConnections.TryRemove(userId, out _);
}
_logger.LogInformation("[FavoritesPriceHub] User '{UserId}' disconnected (ConnectionId={ConnectionId})", userId, Context.ConnectionId);
}
await base.OnDisconnectedAsync(exception);
}
}
public static string GetGroupName(string userId) => $"User_{userId.Trim()}";
}
@@ -1,135 +1,92 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticBackend.Database;
using FinlyticBackend.Entities;
using FinlyticBackend.Hubs;
using FinlyticBackend.Util;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.TechnicalAnalysis;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace FinlyticBackend.Services;
/// <summary>
/// Background service that periodically (every 10 seconds) queries FinlyticTechnicalAnalysis over MQTT RPC
/// to retrieve current close prices and daily % growth for all favorited assets, and broadcasts the updates
/// via SignalR to connected clients.
/// </summary>
public class FavoritesPriceBackgroundService : BackgroundService
public class FavoritesPriceBackgroundService(
IHubContext<FavoritesPriceHub> hubContext,
WebMqttClient mqttClient,
IServiceScopeFactory scopeFactory,
ILogger<FavoritesPriceBackgroundService> logger) : BackgroundService
{
private readonly IHubContext<FavoritesPriceHub> _hubContext;
private readonly WebMqttClient _mqttClient;
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<FavoritesPriceBackgroundService> _logger;
private readonly Random _random = new();
public FavoritesPriceBackgroundService(
IHubContext<FavoritesPriceHub> hubContext,
WebMqttClient mqttClient,
IServiceScopeFactory scopeFactory,
ILogger<FavoritesPriceBackgroundService> logger)
{
_hubContext = hubContext;
_mqttClient = mqttClient;
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("[FavoritesPriceBackgroundService] Started 10-second periodic price & daily growth stream.");
await Task.Delay(4000, stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
var priceUpdates = await FetchFavoritePricesAsync(stoppingToken);
if (priceUpdates.Count > 0)
var activeUserIds = FavoritesPriceHub.GetActiveUserIds();
if (activeUserIds.Length > 0)
{
await _hubContext.Clients.All.SendAsync("ReceiveFavoritePrices", priceUpdates, cancellationToken: stoppingToken);
await ProcessActiveUserPricesAsync(activeUserIds, stoppingToken);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[FavoritesPriceBackgroundService] Error broadcasting 10s favorite price updates.");
logger.LogWarning(ex, "[FavoritesPriceBackgroundService] Error broadcasting price updates.");
}
await Task.Delay(10000, stoppingToken);
}
}
private async Task<Dictionary<string, object>> FetchFavoritePricesAsync(CancellationToken cancellationToken)
private async Task ProcessActiveUserPricesAsync(string[] activeUserIds, CancellationToken cancellationToken)
{
var priceMap = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
List<UserFavoriteAssetEntity> favorites = new();
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
try
// 1. Direkt per EF nach aktiven Usern filtern & laden
var userFavorites = await db.UserFavoriteAssets
.AsNoTracking()
.Where(f => activeUserIds.Contains(f.UserId.ToString()))
.ToListAsync(cancellationToken);
if (userFavorites.Count == 0) return;
// 2. Pro User einfach die Kurse abfragen und senden
foreach (var userGroup in userFavorites.GroupBy(f => f.UserId.ToString()))
{
using var scope = _scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<BackendDbContext>();
favorites = await dbContext.UserFavoriteAssets
.AsNoTracking()
.ToListAsync(cancellationToken);
}
catch { }
var userId = userGroup.Key;
var priceUpdates = new Dictionary<string, object>();
// De-duplicate by ISIN (preferring entries that have SelectedTicker)
var dedupedFavorites = favorites
.GroupBy(f => f.Isin.Trim().ToUpperInvariant())
.Select(g => g.OrderByDescending(f => !string.IsNullOrEmpty(f.SelectedTicker)).First())
.ToList();
foreach (var fav in dedupedFavorites)
{
string cleanIsin = fav.Isin.Trim().ToUpperInvariant();
if (string.IsNullOrWhiteSpace(cleanIsin)) continue;
string querySymbol = !string.IsNullOrWhiteSpace(fav.SelectedTicker) ? fav.SelectedTicker.Trim().ToUpperInvariant() : cleanIsin;
double currentPrice = 0.0;
double dailyChangePercent = 0.0;
bool resolvedFromTa = false;
try
foreach (var fav in userGroup)
{
if (_mqttClient.IsConnected)
var cleanIsin = fav.Isin.Trim().ToUpperInvariant();
if (!mqttClient.IsConnected) continue;
try
{
var livePriceDto = await _mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
var livePrice = await mqttClient.SendRpcRequestAsync<LivePriceDto, IsinRequest>(
"tr_GetLivePrice",
new IsinRequest(querySymbol),
new IsinRequest(cleanIsin),
TimeSpan.FromSeconds(2)
);
if (livePriceDto != null)
if (livePrice != null)
{
currentPrice = (double)livePriceDto.CurrentPrice;
dailyChangePercent = (double)livePriceDto.DailyChangePercent;
resolvedFromTa = true;
priceUpdates[cleanIsin] = new
{
currentPrice = (double)livePrice.CurrentPrice,
dailyChangePercent = (double)livePrice.DailyChangePercent
};
}
}
catch { /* Ignorieren bei Einzel-Timeout */ }
}
catch { }
if (resolvedFromTa)
if (priceUpdates.Count > 0)
{
priceMap[cleanIsin] = new
{
isin = cleanIsin,
symbol = querySymbol,
currentPrice = currentPrice,
dailyChangePercent = dailyChangePercent
};
await hubContext.Clients.Group(FavoritesPriceHub.GetGroupName(userId))
.SendAsync("ReceiveFavoritePrices", priceUpdates, cancellationToken);
}
}
return priceMap;
}
}
}
@@ -94,6 +94,7 @@ public class BackendMqttBridge : ManagedMqttClient, IHostedService
await SubscribeAsync("finlytic/assets/fundamentals/#");
await SubscribeAsync("finlytic/technicalanalysis/#");
await SubscribeAsync("finlytic/ta/#");
}
/// <inheritdoc />
+1
View File
@@ -51,6 +51,7 @@ public class WebMqttClient : ManagedMqttClient, IHostedService
await SubscribeAsync("services/response/sentiment_GetIsin/#");
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/#");