refactor: save current workspace state including FinlyticAnalyzer fixes, FinlyticApp trade route alignment, and DTO audit documentation
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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/#");
|
||||
|
||||
Reference in New Issue
Block a user