feat(Assets): update asset services and background workers
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAssets.Models;
|
||||
using FinlyticCore.Models.Assets;
|
||||
using FinlyticCore.Models.TradeRepublic;
|
||||
using FinlyticCore.Services.TradeRepublic;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAssets.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A background service that runs a continuous asset synchronization loop,
|
||||
/// scanning Trade Republic to retrieve, update, and index all supported asset types.
|
||||
/// </summary>
|
||||
public class AssetScannerBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<AssetScannerBackgroundService> _logger;
|
||||
|
||||
private AssetsCount? _assetsCount;
|
||||
private AssetsCount? _currAssetsCount;
|
||||
|
||||
public AssetScannerBackgroundService(IServiceScopeFactory serviceScopeFactory, ILogger<AssetScannerBackgroundService> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] AssetScannerBackgroundService has started.", "AssetsChannel");
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
|
||||
_logger.LogInformation("[{Channel}] Building initial asset index on service startup...", "AssetsChannel");
|
||||
await indexService.ReCreateIndexFileAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Failed to build initial asset index on startup. Continuing service execution.", "AssetsChannel");
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceScopeFactory.CreateScope();
|
||||
var tradeRepublicService = scope.ServiceProvider.GetRequiredService<ITradeRepublicService>();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
var assetsDbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
|
||||
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
|
||||
|
||||
_logger.LogInformation("[{Channel}] Requesting total asset counts from Trade Republic...", "AssetsChannel");
|
||||
_assetsCount = await tradeRepublicService.GetAssetsCount(stoppingToken);
|
||||
_currAssetsCount = new AssetsCount();
|
||||
|
||||
var initSettings = await settingsService.GetSettings();
|
||||
var isRecoveryMode = initSettings.CurrentScanningPage > 0;
|
||||
|
||||
foreach (var type in Enum.GetValues<AssetType>())
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
if (isRecoveryMode)
|
||||
{
|
||||
if (type != initSettings.CurrentScanningType)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Recovery: {AssetType} was already processed. Skipping.", "AssetsChannel", type);
|
||||
continue;
|
||||
}
|
||||
isRecoveryMode = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var settings = await settingsService.GetSettings();
|
||||
settings.CurrentScanningType = type;
|
||||
settings.CurrentScanningPage = 0;
|
||||
await settingsService.SaveSettings(settings);
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] Processing asset type: {AssetType}...", "AssetsChannel", type);
|
||||
await HandleAssetType(type, tradeRepublicService, settingsService, assetsDbService, indexService, stoppingToken);
|
||||
|
||||
var currentSettings = await settingsService.GetSettings();
|
||||
var delaySeconds = currentSettings.FinishedInitialScan
|
||||
? currentSettings.AssetUpdateTypeDelay
|
||||
: currentSettings.InitAssetUpdateTypeDelay;
|
||||
|
||||
if (delaySeconds > 0)
|
||||
{
|
||||
var jitter = Random.Shared.Next(0, Math.Min(15, delaySeconds));
|
||||
_logger.LogInformation("[{Channel}] Waiting {Delay}s before next asset type ({Type}).", "AssetsChannel", delaySeconds + jitter, type);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds + jitter), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
var finalSettings = await settingsService.GetSettings();
|
||||
finalSettings.CurrentScanningPage = 0;
|
||||
|
||||
if (!finalSettings.FinishedInitialScan && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Initial scan successfully completed. Switching FinishedInitialScan to true.", "AssetsChannel");
|
||||
finalSettings.FinishedInitialScan = true;
|
||||
}
|
||||
|
||||
await settingsService.SaveSettings(finalSettings);
|
||||
|
||||
_logger.LogInformation("[{Channel}] Full scan cycle completed. Waiting 1 minute before starting the next cycle.", "AssetsChannel");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
catch (Exception e) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(e, "[{Channel}] An unhandled exception occurred in AssetScannerBackgroundService. Retrying in 10 seconds.", "AssetsChannel");
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } catch { /* Ignore */ }
|
||||
}
|
||||
} while (!stoppingToken.IsCancellationRequested);
|
||||
|
||||
_logger.LogInformation("[{Channel}] AssetScannerBackgroundService is stopping.", "AssetsChannel");
|
||||
}
|
||||
|
||||
private async Task HandleAssetType(
|
||||
AssetType type,
|
||||
ITradeRepublicService tradeRepublicService,
|
||||
ISettingsDbService settingsDbService,
|
||||
IAssetsDbService assetsDbService,
|
||||
IAssetsIndexService indexService,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
var totalCount = _assetsCount?.GetCountFromType(type) ?? 0;
|
||||
if (totalCount == 0)
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] No assets found for type {AssetType}.", "AssetsChannel", type);
|
||||
return;
|
||||
}
|
||||
|
||||
_currAssetsCount ??= new AssetsCount();
|
||||
var currentItemOffset = 0;
|
||||
|
||||
var settings = await settingsDbService.GetSettings();
|
||||
var pageSize = Math.Clamp(settings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : settings.TradeRepublicMaxRequestPageSize, 1, 100);
|
||||
|
||||
if (settings.CurrentScanningType == type && settings.CurrentScanningPage > 0)
|
||||
{
|
||||
currentItemOffset = (settings.CurrentScanningPage - 1) * pageSize;
|
||||
_logger.LogInformation("[{Channel}] Resuming full scan for {AssetType} from Page {Page} (Calculated Offset: {Offset}).",
|
||||
"AssetsChannel", type, settings.CurrentScanningPage, currentItemOffset);
|
||||
}
|
||||
|
||||
while (currentItemOffset < totalCount && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var currentSettings = await settingsDbService.GetSettings();
|
||||
pageSize = Math.Clamp(currentSettings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : currentSettings.TradeRepublicMaxRequestPageSize, 1, 100);
|
||||
|
||||
var currentPage = (currentItemOffset / pageSize) + 1;
|
||||
|
||||
currentSettings.CurrentScanningType = type;
|
||||
currentSettings.CurrentScanningPage = currentPage;
|
||||
await settingsDbService.SaveSettings(currentSettings);
|
||||
|
||||
_logger.LogDebug("Fetching {AssetType} - Page {Page}. Numerical Offset: {Offset}/{Total}",
|
||||
type, currentPage, currentItemOffset, totalCount);
|
||||
|
||||
var assets = await tradeRepublicService.GetAssets(type, currentPage, pageSize, stoppingToken);
|
||||
|
||||
// Keine Ergebnisse geliefert -> Katalogende erreicht
|
||||
if (assets?.Results == null || assets.Results.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Fetch for {AssetType} (Page {Page}) returned no results. Reached end of available assets.", "AssetsChannel", type, currentPage);
|
||||
currentSettings.CurrentScanningPage = 0;
|
||||
await settingsDbService.SaveSettings(currentSettings);
|
||||
break;
|
||||
}
|
||||
|
||||
await ProcessAssets(assets.Results, assetsDbService, indexService, stoppingToken);
|
||||
_currAssetsCount.SetCountOfType(type, _currAssetsCount.GetCountFromType(type) + assets.Results.Count);
|
||||
|
||||
currentItemOffset += assets.Results.Count;
|
||||
|
||||
// Unvollständige Seite -> Letzte Seite abgearbeitet
|
||||
if (assets.Results.Count < pageSize)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Reached the last page for {AssetType}.", "AssetsChannel", type);
|
||||
currentSettings.CurrentScanningPage = 0;
|
||||
await settingsDbService.SaveSettings(currentSettings);
|
||||
break;
|
||||
}
|
||||
|
||||
var delaySeconds = currentSettings.FinishedInitialScan
|
||||
? currentSettings.BatchAssetUpdateDelay
|
||||
: currentSettings.InitBatchAssetUpdateDelay;
|
||||
|
||||
if (delaySeconds > 0)
|
||||
{
|
||||
// Angemessener Jitter (0 bis max. 5 Sek. bzw. kleiner als delaySeconds)
|
||||
var maxJitter = Math.Min(5, delaySeconds);
|
||||
var jitter = Random.Shared.Next(0, maxJitter + 1);
|
||||
_logger.LogDebug("Waiting {Delay} seconds before the next batch.", delaySeconds + jitter);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds + jitter), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] Finished scanning {AssetType}. Total scanned in this cycle: {Count}/{Total}",
|
||||
"AssetsChannel", type, _currAssetsCount.GetCountFromType(type), totalCount);
|
||||
}
|
||||
|
||||
private async Task ProcessAssets(
|
||||
IList<TradeRepublicAsset> assets,
|
||||
IAssetsDbService assetsDbService,
|
||||
IAssetsIndexService indexService,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
if (assets == null || assets.Count == 0) return;
|
||||
|
||||
var changedRows = await assetsDbService.AddOrUpdateAssetsAsync(assets);
|
||||
_logger.LogInformation("[{Channel}] [Scan] {Count} assets passed to the DB service. {Changed} modifications/inserts executed.", "AssetsChannel", assets.Count, changedRows);
|
||||
|
||||
if (changedRows > 0)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] Database modifications detected. Recreating the asset index file...", "AssetsChannel");
|
||||
await indexService.ReCreateIndexFileAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
using FinlyticAssets.Database;
|
||||
using FinlyticAssets.Entities;
|
||||
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||
using FinlyticCore.Entities.Assets;
|
||||
using FinlyticCore.Models.TradeRepublic;
|
||||
using FinlyticCore.Services.TradeRepublic;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace FinlyticAssets.Services;
|
||||
@@ -11,63 +12,15 @@ namespace FinlyticAssets.Services;
|
||||
/// </summary>
|
||||
public interface IAssetsDbService
|
||||
{
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all active assets from the database that have been updated within the last 14 days, including their associated tags.
|
||||
/// Assets older than 14 days are filtered out as they are considered de-listed or inactive.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains a list of all active <see cref="AssetEntity"/> instances.</returns>
|
||||
public Task<List<AssetEntity>> GetAllValidAssetsAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Adds a new asset or updates an existing one based on the composite key of ISIN and InstrumentType.
|
||||
/// </summary>
|
||||
/// <param name="dtoAsset">The incoming asset data transfer object from the API.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains <c>true</c> if the asset was updated or created; otherwise, <c>false</c>.</returns>
|
||||
public Task<bool> AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset);
|
||||
|
||||
/// <summary>
|
||||
/// Batches processing for a collection of asset items, returning the total amount of modified or newly added entries.
|
||||
/// </summary>
|
||||
/// <param name="dtoAssets">The collection of incoming asset objects to process.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains the count of changed or added entries.</returns>
|
||||
public Task<int> AddOrUpdateAssetsAsync(IEnumerable<TradeRepublicAsset> dtoAssets);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all assets matching a specific International Securities Identification Number (ISIN).
|
||||
/// Can return multiple entities (e.g., both the stock and the derivative tracking asset for the same ISIN).
|
||||
/// </summary>
|
||||
/// <param name="isin">The ISIN value to look up.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains a list of matching <see cref="AssetEntity"/> instances.</returns>
|
||||
public Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin);
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves all assets matching a specific ISIN only if they have been updated within the last 14 days.
|
||||
/// Assets older than 14 days are considered de-listed or inactive.
|
||||
/// </summary>
|
||||
/// <param name="isin">The ISIN value to look up.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains a list of matching active <see cref="AssetEntity"/> instances.</returns>
|
||||
public Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin);
|
||||
|
||||
/// <summary>
|
||||
/// Scans the database for active assets matching a combined, comma-separated search query.
|
||||
/// The search applies an AND-logic approach where every extracted keyword must be found within an asset's ISIN, name, or tags.
|
||||
/// Evaluates local records first and utilizes a targeted JIT-fallback to the external API for any search term formatted as a valid, completely unknown ISIN.
|
||||
/// </summary>
|
||||
/// <param name="searchQuery">A comma-separated string containing the keywords, ISINs, or tags to search for (e.g., "Siemens, Medic" or "IE00B4L5Y983, ETF").</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains a list of all affected active <see cref="AssetEntity"/> instances.</returns>
|
||||
public Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes all asset records associated with a specific ISIN from the database.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use with caution. For standard maintenance and handling de-listed instruments,
|
||||
/// rely on the 14-day recency filter provided by <see cref="GetValidAssetsByIsinAsync"/> instead of hard deletion.
|
||||
/// </remarks>
|
||||
/// <param name="isin">The ISIN value of the assets to remove.</param>
|
||||
/// <returns>A task that represents the asynchronous operation. The task result contains <c>true</c> if any records were successfully deleted; otherwise, <c>false</c>.</returns>
|
||||
public Task UpdateAssetImageIdAsync(string isin, string imageId);
|
||||
public Task<bool> DeleteAssetAsync(string isin);
|
||||
public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -76,33 +29,77 @@ public class AssetsDbService : IAssetsDbService
|
||||
private readonly AssetsDbContext _context;
|
||||
private readonly ITradeRepublicService _tradeRepublicService;
|
||||
private readonly ILogger<AssetsDbService> _logger;
|
||||
private readonly Random _random = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AssetsDbService"/> class.
|
||||
/// </summary>
|
||||
public AssetsDbService(AssetsDbContext context, ILogger<AssetsDbService> logger,
|
||||
ITradeRepublicService tradeRepublicService)
|
||||
public AssetsDbService(AssetsDbContext context, ILogger<AssetsDbService> logger, ITradeRepublicService tradeRepublicService)
|
||||
{
|
||||
_context = context;
|
||||
_logger = logger;
|
||||
_tradeRepublicService = tradeRepublicService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<AssetEntity>> GetAllValidAssetsAsync()
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||
|
||||
var existingEntity = await _context.TradeRepublicAssets
|
||||
var cutoff = DateTime.UtcNow.AddDays(-90);
|
||||
|
||||
var validAssets = await _context.TradeRepublicAssets
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Tags)
|
||||
.Where(a => a.UpdateAt >= cutoff)
|
||||
.Where(a => a.LastUpdatedAt >= cutoff && !string.IsNullOrEmpty(a.Name))
|
||||
.ToListAsync();
|
||||
|
||||
return existingEntity;
|
||||
if (validAssets.Count == 0) return [];
|
||||
|
||||
var scored = validAssets
|
||||
.Select(a => new
|
||||
{
|
||||
Asset = a,
|
||||
Score = (a.Tags?.Count ?? 0) * 10
|
||||
+ (a.HasCfd ? 5 : 0)
|
||||
+ (string.IsNullOrEmpty(a.ImageId) ? 0 : 15)
|
||||
+ (a.Name.Length > 3 ? 5 : 0)
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenByDescending(x => x.Asset.LastUpdatedAt)
|
||||
.ToList();
|
||||
|
||||
var result = new List<AssetEntity>();
|
||||
var grouped = scored.GroupBy(x => x.Asset.Type).ToList();
|
||||
|
||||
int index = 0;
|
||||
while (result.Count < limit && grouped.Any(g => g.Any()))
|
||||
{
|
||||
bool addedAny = false;
|
||||
foreach (var group in grouped)
|
||||
{
|
||||
var item = group.Skip(index).FirstOrDefault();
|
||||
if (item != null)
|
||||
{
|
||||
result.Add(item.Asset);
|
||||
addedAny = true;
|
||||
if (result.Count >= limit) break;
|
||||
}
|
||||
}
|
||||
index++;
|
||||
if (!addedAny) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<List<AssetEntity>> GetAllValidAssetsAsync()
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-90);
|
||||
|
||||
return await _context.TradeRepublicAssets
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Tags)
|
||||
.Where(a => a.LastUpdatedAt >= cutoff)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<bool> AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset)
|
||||
{
|
||||
var existingEntity = await _context.TradeRepublicAssets
|
||||
@@ -110,7 +107,20 @@ public class AssetsDbService : IAssetsDbService
|
||||
.FirstOrDefaultAsync(a => a.Isin == dtoAsset.Isin);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var nextUpdateScheduledAt = await CalculateNextUpdateDateAsync(now);
|
||||
|
||||
var mappedTags = new List<TagEntity>();
|
||||
foreach (var tagDto in dtoAsset.Tags ?? Array.Empty<TradeRepublicTag>())
|
||||
{
|
||||
var existingTag = await _context.TradeRepublicTags.FirstOrDefaultAsync(t => t.Id == tagDto.Id);
|
||||
if (existingTag == null)
|
||||
{
|
||||
_logger.LogTrace("Creating missing asset tag object in storage cache context. ID: {TagId}", tagDto.Id);
|
||||
existingTag = new TagEntity { Id = tagDto.Id, Name = tagDto.Name, Type = tagDto.Type };
|
||||
await _context.TradeRepublicTags.AddAsync(existingTag);
|
||||
}
|
||||
|
||||
mappedTags.Add(existingTag);
|
||||
}
|
||||
|
||||
if (existingEntity == null)
|
||||
{
|
||||
@@ -118,53 +128,125 @@ public class AssetsDbService : IAssetsDbService
|
||||
|
||||
var newEntity = MapDtoToEntity(dtoAsset);
|
||||
newEntity.LastUpdatedAt = now;
|
||||
newEntity.UpdateAt = nextUpdateScheduledAt;
|
||||
|
||||
newEntity.Tags = await MapTagsAsync(dtoAsset.Tags);
|
||||
newEntity.Tags = mappedTags;
|
||||
|
||||
await _context.TradeRepublicAssets.AddAsync(newEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Asset with ISIN {Isin} exists. Merging properties and updating database record.",
|
||||
dtoAsset.Isin);
|
||||
_logger.LogDebug("Asset with ISIN {Isin} exists. Merging properties and updating database record.", dtoAsset.Isin);
|
||||
|
||||
existingEntity.Name = dtoAsset.Name;
|
||||
existingEntity.Type = dtoAsset.Type;
|
||||
existingEntity.InstrumentCategory = dtoAsset.InstrumentCategory;
|
||||
existingEntity.HasCfd = dtoAsset.HasCfd;
|
||||
existingEntity.ImageId = dtoAsset.ImageId;
|
||||
|
||||
existingEntity.LastUpdatedAt = now;
|
||||
existingEntity.UpdateAt = nextUpdateScheduledAt;
|
||||
|
||||
UpdateSubtypeProperties(existingEntity, dtoAsset);
|
||||
existingEntity.Tags = await MapTagsAsync(dtoAsset.Tags);
|
||||
existingEntity.Tags = mappedTags;
|
||||
|
||||
_context.TradeRepublicAssets.Update(existingEntity);
|
||||
await _context.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<int> AddOrUpdateAssetsAsync(IEnumerable<TradeRepublicAsset> dtoAssets)
|
||||
{
|
||||
var assetsList = dtoAssets.ToList();
|
||||
if (assetsList.Count == 0) return 0;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
int changedCount = 0;
|
||||
|
||||
foreach (var dto in dtoAssets)
|
||||
var isins = assetsList.Select(a => a.Isin).Distinct().ToList();
|
||||
var existingAssets = await _context.TradeRepublicAssets
|
||||
.Include(a => a.Tags)
|
||||
.Where(a => isins.Contains(a.Isin))
|
||||
.ToDictionaryAsync(a => a.Isin);
|
||||
|
||||
var tagIds = assetsList
|
||||
.SelectMany(a => a.Tags ?? Array.Empty<TradeRepublicTag>())
|
||||
.Select(t => t.Id)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var tagCache = await _context.TradeRepublicTags
|
||||
.Where(t => tagIds.Contains(t.Id))
|
||||
.ToDictionaryAsync(t => t.Id);
|
||||
|
||||
foreach (var dto in assetsList)
|
||||
{
|
||||
var isChanged = await AddOrUpdateAssetAsync(dto);
|
||||
var isChanged = false;
|
||||
var existingEntity = existingAssets.GetValueOrDefault(dto.Isin);
|
||||
|
||||
var mappedTags = new List<TagEntity>();
|
||||
foreach (var tagDto in dto.Tags ?? Array.Empty<TradeRepublicTag>())
|
||||
{
|
||||
if (!tagCache.TryGetValue(tagDto.Id, out var tagEntity))
|
||||
{
|
||||
_logger.LogTrace("Creating missing asset tag object in storage cache context. ID: {TagId}", tagDto.Id);
|
||||
tagEntity = new TagEntity { Id = tagDto.Id, Name = tagDto.Name, Type = tagDto.Type };
|
||||
await _context.TradeRepublicTags.AddAsync(tagEntity);
|
||||
tagCache.Add(tagDto.Id, tagEntity);
|
||||
}
|
||||
mappedTags.Add(tagEntity);
|
||||
}
|
||||
|
||||
if (existingEntity == null)
|
||||
{
|
||||
_logger.LogDebug("Asset with ISIN {Isin} not found. Mapping and creating a new entity.", dto.Isin);
|
||||
|
||||
var newEntity = MapDtoToEntity(dto);
|
||||
newEntity.LastUpdatedAt = now;
|
||||
newEntity.Tags = mappedTags;
|
||||
|
||||
await _context.TradeRepublicAssets.AddAsync(newEntity);
|
||||
isChanged = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Asset with ISIN {Isin} exists. Merging properties and updating database record.", dto.Isin);
|
||||
|
||||
if (existingEntity.Name != dto.Name ||
|
||||
existingEntity.Type != dto.Type ||
|
||||
existingEntity.InstrumentCategory != dto.InstrumentCategory ||
|
||||
existingEntity.HasCfd != dto.HasCfd ||
|
||||
existingEntity.ImageId != dto.ImageId ||
|
||||
!existingEntity.Tags.SequenceEqual(mappedTags))
|
||||
{
|
||||
existingEntity.Name = dto.Name;
|
||||
existingEntity.Type = dto.Type;
|
||||
existingEntity.InstrumentCategory = dto.InstrumentCategory;
|
||||
existingEntity.HasCfd = dto.HasCfd;
|
||||
existingEntity.ImageId = dto.ImageId;
|
||||
existingEntity.LastUpdatedAt = now;
|
||||
|
||||
UpdateSubtypeProperties(existingEntity, dto);
|
||||
existingEntity.Tags = mappedTags;
|
||||
|
||||
_context.TradeRepublicAssets.Update(existingEntity);
|
||||
isChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isChanged)
|
||||
{
|
||||
changedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
if (changedCount > 0)
|
||||
{
|
||||
await _context.SaveChangesAsync();
|
||||
}
|
||||
|
||||
return changedCount;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin)
|
||||
{
|
||||
var localAssets = await _context.TradeRepublicAssets
|
||||
@@ -172,26 +254,30 @@ public class AssetsDbService : IAssetsDbService
|
||||
.Where(a => a.Isin == isin)
|
||||
.ToListAsync();
|
||||
|
||||
if (localAssets.Any())
|
||||
if (localAssets.Count > 0)
|
||||
{
|
||||
return localAssets;
|
||||
}
|
||||
|
||||
// JIT-Fetch via API
|
||||
var trAssetDto = await _tradeRepublicService.GetAsset(isin);
|
||||
if (trAssetDto != null)
|
||||
if (trAssetDto?.Results != null && trAssetDto.Results.Count > 0)
|
||||
{
|
||||
foreach (var asset in trAssetDto.Results)
|
||||
{
|
||||
_ = await AddOrUpdateAssetAsync(asset);
|
||||
await AddOrUpdateAssetAsync(asset);
|
||||
}
|
||||
|
||||
return await GetAssetsByIsinAsync(isin);
|
||||
return await _context.TradeRepublicAssets
|
||||
.Include(a => a.Tags)
|
||||
.Where(a => a.Isin == isin)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||
@@ -199,23 +285,20 @@ public class AssetsDbService : IAssetsDbService
|
||||
return await _context.TradeRepublicAssets
|
||||
.AsNoTracking()
|
||||
.Include(a => a.Tags)
|
||||
.Where(a => a.Isin == isin && a.UpdateAt >= cutoff)
|
||||
.Where(a => a.Isin == isin && a.LastUpdatedAt >= cutoff)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchQuery))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(searchQuery)) return [];
|
||||
|
||||
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||
|
||||
var searchTerms = searchQuery
|
||||
var searchTerms = searchQuery
|
||||
.Split(',')
|
||||
.Select(t => t.Trim().ToLower())
|
||||
.Select(t => t.Trim())
|
||||
.Where(t => !string.IsNullOrEmpty(t))
|
||||
.Distinct()
|
||||
.ToList();
|
||||
@@ -224,17 +307,17 @@ public class AssetsDbService : IAssetsDbService
|
||||
|
||||
var query = _context.TradeRepublicAssets
|
||||
.AsNoTracking()
|
||||
.Where(a => a.UpdateAt >= cutoff)
|
||||
.Where(a => a.LastUpdatedAt >= cutoff)
|
||||
.Include(a => a.Tags)
|
||||
.AsQueryable();
|
||||
|
||||
|
||||
foreach (var term in searchTerms)
|
||||
{
|
||||
var lowerTerm = term.ToLower();
|
||||
query = query.Where(a =>
|
||||
a.Isin.ToLower().Contains(term) ||
|
||||
a.Name.ToLower().Contains(term) ||
|
||||
a.Tags.Any(tag => tag.Name.ToLower().Contains(term)));
|
||||
a.Isin.ToLower().Contains(lowerTerm) ||
|
||||
a.Name.ToLower().Contains(lowerTerm) ||
|
||||
a.Tags.Any(tag => tag.Name.ToLower().Contains(lowerTerm)));
|
||||
}
|
||||
|
||||
var localAssets = await query.ToListAsync();
|
||||
@@ -244,12 +327,12 @@ public class AssetsDbService : IAssetsDbService
|
||||
.Select(t => t.ToUpper())
|
||||
.ToList();
|
||||
|
||||
if (possibleIsins.Any())
|
||||
if (possibleIsins.Count > 0)
|
||||
{
|
||||
var foundIsins = localAssets.Select(a => a.Isin).ToHashSet();
|
||||
var missingIsins = possibleIsins.Where(isin => !foundIsins.Contains(isin)).ToList();
|
||||
|
||||
if (missingIsins.Any())
|
||||
if (missingIsins.Count > 0)
|
||||
{
|
||||
var fetchedNewAsset = false;
|
||||
foreach (var missingIsin in missingIsins)
|
||||
@@ -276,57 +359,42 @@ public class AssetsDbService : IAssetsDbService
|
||||
return localAssets;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task UpdateAssetImageIdAsync(string isin, string imageId)
|
||||
{
|
||||
var existingAssets = await _context.TradeRepublicAssets
|
||||
.Where(a => a.Isin == isin)
|
||||
.ToListAsync();
|
||||
|
||||
if (existingAssets.Count > 0)
|
||||
{
|
||||
foreach (var asset in existingAssets)
|
||||
{
|
||||
asset.ImageId = imageId;
|
||||
}
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("[{Channel}] Updated ImageId for ISIN {Isin} in database to '{ImageId}'", "AssetsChannel", isin, imageId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<bool> DeleteAssetAsync(string isin)
|
||||
{
|
||||
var asset = await _context.TradeRepublicAssets.FirstOrDefaultAsync(a => a.Isin == isin);
|
||||
if (asset == null)
|
||||
{
|
||||
_logger.LogWarning("Delete execution cancelled. Asset with ISIN {Isin} does not exist.", isin);
|
||||
_logger.LogWarning("[{Channel}] Delete execution cancelled. Asset with ISIN {Isin} does not exist.", "AssetsChannel", isin);
|
||||
return false;
|
||||
}
|
||||
|
||||
_context.TradeRepublicAssets.Remove(asset);
|
||||
await _context.SaveChangesAsync();
|
||||
_logger.LogInformation("Asset with ISIN {Isin} has been successfully deleted.", isin);
|
||||
_logger.LogInformation("[{Channel}] Asset with ISIN {Isin} has been successfully deleted.", "AssetsChannel", isin);
|
||||
return true;
|
||||
}
|
||||
|
||||
#region Helper & Mapping Methods
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the next synchronization/update date for an asset using configured parameters and a random offset.
|
||||
/// </summary>
|
||||
/// <param name="baseDate">The baseline date to add the offsets to.</param>
|
||||
/// <returns>A task representing the asynchronous operation, returning a DateTime representing the next scheduled update time (UTC).</returns>
|
||||
private async Task<DateTime> CalculateNextUpdateDateAsync(DateTime baseDate)
|
||||
{
|
||||
var settings = await _context.Set<Settings>().FirstOrDefaultAsync() ?? new Settings();
|
||||
|
||||
int randomDays = _random.Next(settings.MinRandomUpdateDay, settings.MaxRandomUpdateDay + 1);
|
||||
var targetDate = baseDate.AddDays(randomDays);
|
||||
|
||||
int randomHour = _random.Next(settings.UpdateDayTimeStart, settings.UpdateDayTimeStop);
|
||||
int randomMinute = _random.Next(0, 60);
|
||||
int randomSecond = _random.Next(0, 60);
|
||||
|
||||
return new DateTime(
|
||||
targetDate.Year,
|
||||
targetDate.Month,
|
||||
targetDate.Day,
|
||||
randomHour,
|
||||
randomMinute,
|
||||
randomSecond,
|
||||
DateTimeKind.Utc
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a raw Trade Republic asset data transfer object (DTO) to its matching database entity subtype.
|
||||
/// </summary>
|
||||
/// <param name="dto">The source Trade Republic asset data transfer object.</param>
|
||||
/// <returns>A newly created subtype instance of <see cref="AssetEntity"/> mapped with the DTO properties.</returns>
|
||||
/// <exception cref="NotSupportedException">Thrown when the DTO type is unrecognized or unsupported.</exception>
|
||||
private AssetEntity MapDtoToEntity(TradeRepublicAsset dto)
|
||||
{
|
||||
AssetEntity entity = dto switch
|
||||
@@ -356,12 +424,6 @@ public class AssetsDbService : IAssetsDbService
|
||||
return PopulateBaseProperties(entity, dto);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates common base properties of a database asset entity using a Trade Republic DTO.
|
||||
/// </summary>
|
||||
/// <param name="entity">The target database entity.</param>
|
||||
/// <param name="dto">The source Trade Republic DTO.</param>
|
||||
/// <returns>The updated database asset entity.</returns>
|
||||
private AssetEntity PopulateBaseProperties(AssetEntity entity, TradeRepublicAsset dto)
|
||||
{
|
||||
entity.Name = dto.Name;
|
||||
@@ -372,11 +434,6 @@ public class AssetsDbService : IAssetsDbService
|
||||
return entity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges/updates the subtype-specific properties from a Trade Republic DTO into an existing database entity.
|
||||
/// </summary>
|
||||
/// <param name="entity">The existing database entity to update.</param>
|
||||
/// <param name="dto">The source Trade Republic DTO.</param>
|
||||
private void UpdateSubtypeProperties(AssetEntity entity, TradeRepublicAsset dto)
|
||||
{
|
||||
switch (entity)
|
||||
@@ -409,29 +466,5 @@ public class AssetsDbService : IAssetsDbService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a list of Trade Republic tags to database entity instances, registering new tags in the database context if they do not yet exist.
|
||||
/// </summary>
|
||||
/// <param name="dtos">The read-only collection of Trade Republic tags.</param>
|
||||
/// <returns>A task representing the asynchronous operation, returning the list of mapped database tag entities.</returns>
|
||||
private async Task<List<TagEntity>> MapTagsAsync(IReadOnlyList<TradeRepublicTag> dtos)
|
||||
{
|
||||
var tags = new List<TagEntity>();
|
||||
foreach (var tagDto in dtos)
|
||||
{
|
||||
var existingTag = await _context.TradeRepublicTags.FirstOrDefaultAsync(t => t.Id == tagDto.Id);
|
||||
if (existingTag == null)
|
||||
{
|
||||
_logger.LogTrace("Creating missing asset tag object in storage cache context. ID: {TagId}", tagDto.Id);
|
||||
existingTag = new TagEntity { Id = tagDto.Id, Name = tagDto.Name, Type = tagDto.Type };
|
||||
await _context.TradeRepublicTags.AddAsync(existingTag);
|
||||
}
|
||||
|
||||
tags.Add(existingTag);
|
||||
}
|
||||
|
||||
return tags;
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
using FinlyticAssets.Models;
|
||||
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||
using FinlyticCore.Models.Assets;
|
||||
|
||||
namespace FinlyticAssets.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A background service that runs a continuous asset synchronization loop,
|
||||
/// scanning Trade Republic to retrieve, update, and index all supported asset types.
|
||||
/// </summary>
|
||||
public class AssetsFullScanService : BackgroundService
|
||||
{
|
||||
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||
private readonly ILogger<AssetsFullScanService> _logger;
|
||||
|
||||
private AssetsCount? _assetsCount;
|
||||
private AssetsCount? _currAssetsCount;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AssetsFullScanService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="serviceScopeFactory">Factory used to create service scopes for database and API requests.</param>
|
||||
/// <param name="logger">Logger for service lifecycle and scanning progress messages.</param>
|
||||
public AssetsFullScanService(IServiceScopeFactory serviceScopeFactory, ILogger<AssetsFullScanService> logger)
|
||||
{
|
||||
_serviceScopeFactory = serviceScopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes the background scanning task, handling initial startup, recovery, and periodic full-scan cycles.
|
||||
/// </summary>
|
||||
/// <param name="stoppingToken">Triggered when the host is shutting down.</param>
|
||||
/// <returns>A task that represents the background operation.</returns>
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("AssetsFullScanService has started.");
|
||||
|
||||
try
|
||||
{
|
||||
using (var scope = _serviceScopeFactory.CreateScope())
|
||||
{
|
||||
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
|
||||
_logger.LogInformation("Building initial asset index on service startup...");
|
||||
await indexService.ReCreateIndexFileAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to build initial asset index on startup. Continuing service execution.");
|
||||
}
|
||||
|
||||
do
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var scope = _serviceScopeFactory.CreateScope())
|
||||
{
|
||||
var tradeRepublicService = scope.ServiceProvider.GetRequiredService<ITradeRepublicService>();
|
||||
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||
var assetsDbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
|
||||
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
|
||||
|
||||
_logger.LogInformation("Requesting total asset counts from Trade Republic...");
|
||||
_assetsCount = await tradeRepublicService.GetAssetsCount(stoppingToken);
|
||||
_currAssetsCount = new AssetsCount();
|
||||
|
||||
var initSettings = await settingsService.GetSettings();
|
||||
var isRecoveryMode = initSettings.CurrentScanningPage > 0;
|
||||
|
||||
foreach (var type in Enum.GetValues<AssetType>())
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
if (isRecoveryMode)
|
||||
{
|
||||
if (type != initSettings.CurrentScanningType)
|
||||
{
|
||||
_logger.LogInformation("Recovery: {AssetType} was already processed. Skipping.", type);
|
||||
continue;
|
||||
}
|
||||
isRecoveryMode = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
var settings = await settingsService.GetSettings();
|
||||
settings.CurrentScanningType = type;
|
||||
settings.CurrentScanningPage = 0;
|
||||
await settingsService.SaveSettings(settings);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Processing asset type: {AssetType}...", type);
|
||||
await HandleAssetType(type, tradeRepublicService, settingsService, assetsDbService, indexService, stoppingToken);
|
||||
|
||||
var currentSettings = await settingsService.GetSettings();
|
||||
var delaySeconds = currentSettings.FinishedInitialScan
|
||||
? currentSettings.AssetUpdateTypeDelay
|
||||
: currentSettings.InitAssetUpdateTypeDelay;
|
||||
|
||||
var jitter = Random.Shared.Next(0, 480);
|
||||
_logger.LogDebug("Waiting {Delay} seconds before the next asset type.", delaySeconds + jitter);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds + jitter), stoppingToken);
|
||||
}
|
||||
|
||||
var finalSettings = await settingsService.GetSettings();
|
||||
finalSettings.CurrentScanningPage = 0;
|
||||
|
||||
if (!finalSettings.FinishedInitialScan && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogInformation("Initial scan successfully completed. Switching FinishedInitialScan to true.");
|
||||
finalSettings.FinishedInitialScan = true;
|
||||
}
|
||||
|
||||
await settingsService.SaveSettings(finalSettings);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Full scan cycle completed. Waiting 1 minute before starting the next cycle.");
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
_logger.LogError(e, "An unhandled exception occurred in AssetsFullScanService. Retrying in 10 seconds.");
|
||||
try { await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } catch { /* Ignore */ }
|
||||
}
|
||||
} while (!stoppingToken.IsCancellationRequested);
|
||||
|
||||
_logger.LogInformation("AssetsFullScanService is stopping.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the scanning process for a specific asset type, iterating through its paginated results.
|
||||
/// </summary>
|
||||
/// <param name="type">The type of assets to process (e.g., Stock, Crypto).</param>
|
||||
/// <param name="tradeRepublicService">Service to fetch asset data from Trade Republic.</param>
|
||||
/// <param name="settingsDbService">Service to read and write application state/settings.</param>
|
||||
/// <param name="assetsDbService">Service to insert or update assets in the database.</param>
|
||||
/// <param name="indexService">Service to recreate the local index file if needed.</param>
|
||||
/// <param name="stoppingToken">Cancellation token monitored for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
private async Task HandleAssetType(AssetType type, ITradeRepublicService tradeRepublicService,
|
||||
ISettingsDbService settingsDbService, IAssetsDbService assetsDbService, IAssetsIndexService indexService, CancellationToken stoppingToken)
|
||||
{
|
||||
var totalCount = _assetsCount?.GetCountFromType(type) ?? 0;
|
||||
if (totalCount == 0)
|
||||
{
|
||||
_logger.LogWarning("No assets found for type {AssetType}.", type);
|
||||
return;
|
||||
}
|
||||
|
||||
_currAssetsCount ??= new AssetsCount();
|
||||
var currentItemOffset = 0;
|
||||
|
||||
var settings = await settingsDbService.GetSettings();
|
||||
if (settings.CurrentScanningType == type && settings.CurrentScanningPage > 0)
|
||||
{
|
||||
var pageSize = settings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : settings.TradeRepublicMaxRequestPageSize;
|
||||
if (pageSize > 100) pageSize = 100;
|
||||
|
||||
currentItemOffset = (settings.CurrentScanningPage - 1) * pageSize;
|
||||
_logger.LogInformation("Resuming full scan for {AssetType} from Page {Page} (Offset: {Offset}).",
|
||||
type, settings.CurrentScanningPage, currentItemOffset);
|
||||
}
|
||||
|
||||
while (currentItemOffset < totalCount && !stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var currentSettings = await settingsDbService.GetSettings();
|
||||
var pageSize = currentSettings.TradeRepublicMaxRequestPageSize;
|
||||
if (pageSize <= 0 || pageSize > 100) pageSize = 100;
|
||||
|
||||
var currentPage = (currentItemOffset / pageSize) + 1;
|
||||
|
||||
currentSettings.CurrentScanningType = type;
|
||||
currentSettings.CurrentScanningPage = currentPage;
|
||||
await settingsDbService.SaveSettings(currentSettings);
|
||||
|
||||
_logger.LogDebug("Fetching {AssetType} - Page {Page} (Size: {PageSize}). Offset: {Offset}/{Total}",
|
||||
type, currentPage, pageSize, currentItemOffset, totalCount);
|
||||
|
||||
var assets = await tradeRepublicService.GetAssets(type, currentPage, pageSize, stoppingToken);
|
||||
|
||||
if (assets?.Results == null || assets.Results.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Fetch for {AssetType} (Page {Page}) returned no results. Retrying in 5 seconds...", type, currentPage);
|
||||
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
await ProcessAssets(assets.Results, assetsDbService, indexService,stoppingToken);
|
||||
_currAssetsCount.SetCountOfType(type, _currAssetsCount.GetCountFromType(type) + assets.Results.Count);
|
||||
|
||||
currentItemOffset = currentPage * pageSize;
|
||||
|
||||
if (assets.Results.Count < pageSize)
|
||||
{
|
||||
_logger.LogInformation("Reached the last page for {AssetType}.", type);
|
||||
break;
|
||||
}
|
||||
|
||||
var delaySeconds = currentSettings.FinishedInitialScan
|
||||
? currentSettings.BatchAssetUpdateDelay
|
||||
: currentSettings.InitBatchAssetUpdateDelay;
|
||||
|
||||
var jitter = Random.Shared.Next(0, 360);
|
||||
_logger.LogDebug("Waiting {Delay} seconds before the next batch.", delaySeconds + jitter);
|
||||
await Task.Delay(TimeSpan.FromSeconds(delaySeconds + jitter), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a list of fetched Trade Republic assets, updates them in the database,
|
||||
/// and triggers an index recreation if changes were detected.
|
||||
/// </summary>
|
||||
/// <param name="assets">The list of Trade Republic assets to process.</param>
|
||||
/// <param name="assetsDbService">Service to insert or update assets in the database.</param>
|
||||
/// <param name="indexService">Service to recreate the local index file.</param>
|
||||
/// <param name="stoppingToken">Cancellation token monitored for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
private async Task ProcessAssets(IList<TradeRepublicAsset> assets, IAssetsDbService assetsDbService, IAssetsIndexService indexService, CancellationToken stoppingToken)
|
||||
{
|
||||
if (assets == null || assets.Count == 0) return;
|
||||
|
||||
var changedRows = await assetsDbService.AddOrUpdateAssetsAsync(assets);
|
||||
_logger.LogInformation("[Scan] {Count} assets passed to the DB service. {Changed} modifications/inserts executed.", assets.Count, changedRows);
|
||||
|
||||
if (changedRows > 0)
|
||||
{
|
||||
_logger.LogInformation("Database modifications detected. Recreating the asset index file...");
|
||||
await indexService.ReCreateIndexFileAsync(stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ using FinlyticAssets.Util;
|
||||
namespace FinlyticAssets.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Provides methods for generating and maintaining the indexed asset reference file used for pre-filtering.
|
||||
/// Provides methods for generating the indexed asset reference file and downloading local asset logos strictly on demand.
|
||||
/// </summary>
|
||||
public interface IAssetsIndexService
|
||||
{
|
||||
@@ -15,16 +15,22 @@ public interface IAssetsIndexService
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public Task ReCreateIndexFileAsync(CancellationToken cancellationToken);
|
||||
public Task ReCreateIndexFileAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Strictly On Demand: Downloads and saves the logo SVG for a requested ISIN into the local assets/logos folder.
|
||||
/// </summary>
|
||||
public Task<string?> DownloadAndSaveLogoAsync(string isin, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Implements the <see cref="IAssetsIndexService"/> to maintain local asset index references.
|
||||
/// Implements the <see cref="IAssetsIndexService"/> to maintain local asset index references and logo file storage.
|
||||
/// </summary>
|
||||
public class AssetsIndexService : IAssetsIndexService
|
||||
{
|
||||
private readonly ILogger<AssetsIndexService> _logger;
|
||||
private readonly IAssetsDbService _assetsDbService;
|
||||
private static readonly HttpClient _httpClient = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AssetsIndexService"/> class.
|
||||
@@ -37,19 +43,26 @@ public class AssetsIndexService : IAssetsIndexService
|
||||
_assetsDbService = assetsDbService;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task ReCreateIndexFileAsync(CancellationToken cancellationToken)
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task ReCreateIndexFileAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var assets = await _assetsDbService.GetAllValidAssetsAsync();
|
||||
if (assets == null || !assets.Any())
|
||||
{
|
||||
_logger.LogWarning("No valid assets found in the database to index.");
|
||||
_logger.LogWarning("[{Channel}] No valid assets found in the database to index.", "AssetsChannel");
|
||||
return;
|
||||
}
|
||||
|
||||
var indexAssets = assets.Select(a => new AssetIndex(a.Isin, a.Name)).ToList();
|
||||
var indexAssets = assets
|
||||
.DistinctBy(a => a.Isin)
|
||||
.Select(a => {
|
||||
string cleanIsin = a.Isin.Trim().ToUpperInvariant();
|
||||
// Point directly to our own local backend logo endpoint
|
||||
string imageUrl = $"/api/v1/logo/{cleanIsin}";
|
||||
return new AssetIndex(cleanIsin, a.Name, imageUrl);
|
||||
}).ToList();
|
||||
|
||||
var directoryPath = Volumes.IndexRelativePath;
|
||||
var filePath = Path.Combine(directoryPath, "index.json");
|
||||
@@ -65,23 +78,68 @@ public class AssetsIndexService : IAssetsIndexService
|
||||
await JsonSerializer.SerializeAsync(fileStream, indexAssets, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Successfully recreated asset index file with {Count} entries at {Path}",
|
||||
indexAssets.Count, filePath);
|
||||
_logger.LogInformation("[{Channel}] Successfully recreated asset index file with {Count} entries pointing to local logos at {Path}",
|
||||
"AssetsChannel", indexAssets.Count, filePath);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Disk I/O error occurred while writing the asset index file.");
|
||||
_logger.LogError(ex, "[{Channel}] Disk I/O error occurred while writing the asset index file.", "AssetsChannel");
|
||||
throw;
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to serialize the asset index data to JSON.");
|
||||
_logger.LogError(ex, "[{Channel}] Failed to serialize the asset index data to JSON.", "AssetsChannel");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "An unexpected error occurred while recreating the asset index file.");
|
||||
_logger.LogError(ex, "[{Channel}] An unexpected error occurred while recreating the asset index file.", "AssetsChannel");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<string?> DownloadAndSaveLogoAsync(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isin)) return null;
|
||||
|
||||
string cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
string directoryPath = Volumes.LogosRelativePath;
|
||||
string filePath = Path.Combine(directoryPath, $"{cleanIsin}.svg");
|
||||
|
||||
if (!Directory.Exists(directoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
}
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
return filePath;
|
||||
}
|
||||
|
||||
string targetUrl = $"https://assets.traderepublic.com/img/logos/{cleanIsin}/v2/dark.min.svg";
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, targetUrl);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
request.Headers.TryAddWithoutValidation("Accept", "image/svg+xml,image/*,*/*");
|
||||
request.Headers.TryAddWithoutValidation("Referer", "https://traderepublic.com/");
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
byte[] data = await response.Content.ReadAsByteArrayAsync(cancellationToken);
|
||||
await File.WriteAllBytesAsync(filePath, data, cancellationToken);
|
||||
_logger.LogInformation("[{Channel}] Successfully saved logo SVG for ISIN {Isin} to {Path} on demand", "AssetsChannel", cleanIsin, filePath);
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Failed to download logo for ISIN {Isin} from {Url}", "AssetsChannel", cleanIsin, targetUrl);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticAssets.Util;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticAssets.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Dedicated background service that periodically scans for missing asset logos in the local storage directory
|
||||
/// and fetches them in batches from Trade Republic CDN.
|
||||
/// Swaps missing/404 logos with a clean SVG placeholder image and triggers ReCreateIndexFileAsync.
|
||||
/// </summary>
|
||||
public class LogoFetcherBackgroundService : BackgroundService
|
||||
{
|
||||
private readonly ILogger<LogoFetcherBackgroundService> _logger;
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string PlaceholderSvg = """
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<rect width="100" height="100" rx="30" fill="#1E293B"/>
|
||||
<path d="M 30 65 L 45 45 L 60 55 L 75 35" fill="none" stroke="#10B981" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="75" cy="35" r="5" fill="#06B6D4"/>
|
||||
</svg>
|
||||
""";
|
||||
|
||||
public LogoFetcherBackgroundService(
|
||||
ILogger<LogoFetcherBackgroundService> logger,
|
||||
IServiceScopeFactory scopeFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_scopeFactory = scopeFactory;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
|
||||
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "image/svg+xml,image/*,*/*");
|
||||
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Referer", "https://traderepublic.com/");
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("[{Channel}] LogoFetcherBackgroundService started. Will fetch missing logos periodically.", "AssetsChannel");
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessMissingLogosBatchAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error occurred while executing logo batch fetch.", "AssetsChannel");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] LogoFetcherBackgroundService stopped.", "AssetsChannel");
|
||||
}
|
||||
|
||||
private async Task ProcessMissingLogosBatchAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
|
||||
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
|
||||
|
||||
var validAssets = await dbService.GetAllValidAssetsAsync();
|
||||
if (validAssets == null || !validAssets.Any()) return;
|
||||
|
||||
string directoryPath = Volumes.LogosRelativePath;
|
||||
if (!Directory.Exists(directoryPath))
|
||||
{
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
}
|
||||
|
||||
// ✅ Prüft sowohl DB-Eintrag ALS AUCH, ob die Datei bereits lokal existiert
|
||||
var missingIsins = validAssets
|
||||
.Select(a => a.Isin?.Trim().ToUpperInvariant())
|
||||
.Where(isin => !string.IsNullOrEmpty(isin))
|
||||
.Distinct()
|
||||
.Where(isin => !File.Exists(Path.Combine(directoryPath, $"{isin}.svg")))
|
||||
.ToList();
|
||||
|
||||
if (missingIsins.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("All asset logos are downloaded and up to date.");
|
||||
return;
|
||||
}
|
||||
|
||||
var batchToFetch = missingIsins.Take(60).ToList();
|
||||
_logger.LogInformation("[{Channel}] Found {Count} missing logos on disk. Fetching bulk batch of {BatchSize} logos...", "AssetsChannel", missingIsins.Count, batchToFetch.Count);
|
||||
|
||||
int successCount = 0;
|
||||
|
||||
foreach (var isin in batchToFetch)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
string targetUrl = $"https://assets.traderepublic.com/img/logos/{isin}/v2/dark.min.svg";
|
||||
string filePath = Path.Combine(directoryPath, $"{isin}.svg");
|
||||
string dbImageEndpoint = $"/api/v1/logo/{isin}";
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.GetAsync(targetUrl, stoppingToken);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
byte[] data = await response.Content.ReadAsByteArrayAsync(stoppingToken);
|
||||
await File.WriteAllBytesAsync(filePath, data, stoppingToken);
|
||||
successCount++;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] Logo not found on CDN for ISIN {Isin} (HTTP {StatusCode}). Saving SVG placeholder.", "AssetsChannel", isin, response.StatusCode);
|
||||
byte[] placeholderData = Encoding.UTF8.GetBytes(PlaceholderSvg);
|
||||
await File.WriteAllBytesAsync(filePath, placeholderData, stoppingToken);
|
||||
successCount++;
|
||||
}
|
||||
|
||||
await dbService.UpdateAssetImageIdAsync(isin, dbImageEndpoint);
|
||||
}
|
||||
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Exception while downloading logo for ISIN {Isin} from {Url}. Saving SVG placeholder.", "AssetsChannel", isin, targetUrl);
|
||||
try
|
||||
{
|
||||
byte[] placeholderData = Encoding.UTF8.GetBytes(PlaceholderSvg);
|
||||
await File.WriteAllBytesAsync(filePath, placeholderData, stoppingToken);
|
||||
successCount++;
|
||||
|
||||
await dbService.UpdateAssetImageIdAsync(isin, dbImageEndpoint);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// Kurze Pause gegen Rate Limiting
|
||||
await Task.Delay(50, stoppingToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("[{Channel}] Batch fetch complete. Successfully processed {SuccessCount}/{BatchSize} logos. Remaining missing: {Remaining}",
|
||||
"AssetsChannel", successCount, batchToFetch.Count, missingIsins.Count - batchToFetch.Count);
|
||||
|
||||
if (successCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await indexService.ReCreateIndexFileAsync(stoppingToken);
|
||||
_logger.LogInformation("[{Channel}] Successfully updated index.json after logo batch fetch.", "AssetsChannel");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[{Channel}] Failed to update index.json after logo batch fetch.", "AssetsChannel");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using FinlyticAssets.Util;
|
||||
using FinlyticCore.Models;
|
||||
|
||||
namespace FinlyticAssets.Services;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted service responsible for managing the lifecycle of the MQTT client connection
|
||||
/// when the application starts up and shuts down.
|
||||
/// </summary>
|
||||
public class MqttConnectionService : IHostedService
|
||||
{
|
||||
private readonly AssetsMqttClient _mqttClient;
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MqttConnectionService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="mqttClient">The MQTT client wrapper instance.</param>
|
||||
/// <param name="configuration">The application configuration provider.</param>
|
||||
public MqttConnectionService(AssetsMqttClient mqttClient, IConfiguration configuration)
|
||||
{
|
||||
_mqttClient = mqttClient;
|
||||
_configuration = configuration;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the MQTT client connection using settings resolved from configuration.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous start operation.</returns>
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var config = new MqttConfiguration()
|
||||
{
|
||||
Host = _configuration["MQTT__Host"]!,
|
||||
Port = Convert.ToInt32(_configuration["MQTT__Port"]!),
|
||||
ClientId = $"{_configuration["MQTT__ClientId"]!}_{Guid.NewGuid()}"
|
||||
};
|
||||
|
||||
await _mqttClient.ConnectAsync(config);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops and disconnects the MQTT client connection.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A task representing the asynchronous stop operation.</returns>
|
||||
public async Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _mqttClient.DisconnectAsync();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
using FinlyticAssets.Database;
|
||||
using FinlyticAssets.Database;
|
||||
using FinlyticAssets.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -28,6 +28,11 @@ public interface ISettingsDbService
|
||||
/// and tracked <see cref="Settings"/> instance.
|
||||
/// </returns>
|
||||
public Task<Settings> SaveSettings(Settings settings);
|
||||
|
||||
/// <summary>
|
||||
/// Updates settings from a key-value dictionary received via Admin Panel MQTT events.
|
||||
/// </summary>
|
||||
public Task UpdateSettingsFromDictionary(Dictionary<string, string> dictionary);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -47,25 +52,23 @@ public class SettingsDbService : ISettingsDbService
|
||||
_context = context;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<Settings> GetSettings()
|
||||
{
|
||||
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
|
||||
|
||||
if (settings == null)
|
||||
{
|
||||
settings = new Settings
|
||||
{
|
||||
Id = Guid.NewGuid()
|
||||
};
|
||||
|
||||
await SaveSettings(settings);
|
||||
settings = new Settings { Id = Guid.NewGuid() };
|
||||
_context.Settings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
_context.ChangeTracker.Clear();
|
||||
}
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task<Settings> SaveSettings(Settings settings)
|
||||
{
|
||||
var existing = await _context.Settings.FirstOrDefaultAsync();
|
||||
@@ -76,16 +79,47 @@ public class SettingsDbService : ISettingsDbService
|
||||
{
|
||||
settings.Id = Guid.NewGuid();
|
||||
}
|
||||
await _context.Settings.AddAsync(settings);
|
||||
_context.Settings.Add(settings);
|
||||
await _context.SaveChangesAsync();
|
||||
return settings;
|
||||
}
|
||||
else
|
||||
{
|
||||
_context.Entry(existing).CurrentValues.SetValues(settings);
|
||||
|
||||
existing.FinishedInitialScan = settings.FinishedInitialScan;
|
||||
existing.TradeRepublicMaxRequestPageSize = settings.TradeRepublicMaxRequestPageSize;
|
||||
existing.AssetUpdateTypeDelay = settings.AssetUpdateTypeDelay;
|
||||
existing.InitAssetUpdateTypeDelay = settings.InitAssetUpdateTypeDelay;
|
||||
existing.InitBatchAssetUpdateDelay = settings.InitBatchAssetUpdateDelay;
|
||||
existing.BatchAssetUpdateDelay = settings.BatchAssetUpdateDelay;
|
||||
existing.CurrentScanningType = settings.CurrentScanningType;
|
||||
existing.CurrentScanningPage = settings.CurrentScanningPage;
|
||||
_context.Settings.Update(existing);
|
||||
await _context.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Inherits documentation from interface.</summary>
|
||||
public async Task UpdateSettingsFromDictionary(Dictionary<string, string> dictionary)
|
||||
{
|
||||
var settings = await GetSettings();
|
||||
|
||||
foreach (var (key, value) in dictionary)
|
||||
{
|
||||
if (string.Equals(key, "TradeRepublicMaxRequestPageSize", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var ps))
|
||||
settings.TradeRepublicMaxRequestPageSize = ps;
|
||||
else if (string.Equals(key, "AssetUpdateTypeDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var autd))
|
||||
settings.AssetUpdateTypeDelay = autd;
|
||||
else if (string.Equals(key, "InitAssetUpdateTypeDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var iautd))
|
||||
settings.InitAssetUpdateTypeDelay = iautd;
|
||||
else if (string.Equals(key, "BatchAssetUpdateDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var baud))
|
||||
settings.BatchAssetUpdateDelay = baud;
|
||||
else if (string.Equals(key, "InitBatchAssetUpdateDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var ibaud))
|
||||
settings.InitBatchAssetUpdateDelay = ibaud;
|
||||
else if (string.Equals(key, "FinishedInitialScan", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var fis))
|
||||
settings.FinishedInitialScan = fis;
|
||||
}
|
||||
|
||||
await SaveSettings(settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
using System.Timers;
|
||||
using FinlyticAssets.Models;
|
||||
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||
using FinlyticAssets.Util;
|
||||
using FinlyticCore.Models.Assets;
|
||||
|
||||
namespace FinlyticAssets.Services;
|
||||
|
||||
public interface ITradeRepublicService
|
||||
{
|
||||
/// <summary>
|
||||
/// Holt die Anzahl der Assets pro Typ für die Paginierung des Initial-Scans.
|
||||
/// </summary>
|
||||
public Task<AssetsCount> GetAssetsCount(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Holt eine spezifische Seite an Assets für den Initial-Scan.
|
||||
/// </summary>
|
||||
public Task<TradeRepublicAssetResponse?> GetAssets(AssetType type, int page, int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Holt die aktuellen Stammdaten für eine spezifische ISIN (Gezieltes Update).
|
||||
/// Gibt null zurück, wenn das Asset bei TR nicht mehr existiert.
|
||||
/// </summary>
|
||||
public Task<TradeRepublicAssetResponse?> GetAsset(string isin, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides a managed service to interact with the Trade Republic API via WebSockets,
|
||||
/// featuring an automatic inactivity timeout to mimic human behavior.
|
||||
/// </summary>
|
||||
public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
{
|
||||
private readonly TradeRepublicClient _client;
|
||||
private readonly ILogger<TradeRepublicService> _logger;
|
||||
private readonly System.Timers.Timer _inactivityTimer;
|
||||
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TradeRepublicService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">The underlying managed WebSocket client.</param>
|
||||
/// <param name="logger">The logger instance.</param>
|
||||
public TradeRepublicService(TradeRepublicClient client, ILogger<TradeRepublicService> logger)
|
||||
{
|
||||
_client = client;
|
||||
_logger = logger;
|
||||
|
||||
_inactivityTimer = new System.Timers.Timer(TimeSpan.FromSeconds(461).TotalMilliseconds);
|
||||
_inactivityTimer.AutoReset = false;
|
||||
_inactivityTimer.Elapsed += OnInactivityTimeout;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures that the Trade Republic WebSocket client is connected, initiating a new connection if necessary.
|
||||
/// Also handles resetting the inactivity timer.
|
||||
/// </summary>
|
||||
/// <returns>A task representing the asynchronous operation.</returns>
|
||||
private async Task EnsureConnectedAsync()
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_inactivityTimer.Stop();
|
||||
|
||||
if (!_client.IsConnected)
|
||||
{
|
||||
_logger.LogInformation("Trade Republic API is not connected. Establishing automated connection...");
|
||||
|
||||
// Nutzt den boolschen Rückgabewert von InitAsync
|
||||
bool connected = await _client.InitAsync();
|
||||
|
||||
if (connected)
|
||||
{
|
||||
_logger.LogInformation("Successfully connected to Trade Republic API.");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Trade Republic API connection initialization failed (InitAsync returned false).");
|
||||
}
|
||||
}
|
||||
|
||||
_inactivityTimer.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to establish a connection to the Trade Republic API.");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the total count of available assets grouped by their types.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>An <see cref="AssetsCount"/> object containing the metrics.</returns>
|
||||
public async Task<AssetsCount> GetAssetsCount(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureConnectedAsync();
|
||||
|
||||
var counts = new AssetsCount();
|
||||
|
||||
foreach (var type in Enum.GetValues<AssetType>())
|
||||
{
|
||||
var reqData = new TradeRepublicSearchData()
|
||||
{
|
||||
Query = "",
|
||||
Page = 1,
|
||||
PageSize = 1,
|
||||
Filter =
|
||||
[
|
||||
new TradeRepublicFilter("type", type.ToString().ToLowerInvariant()),
|
||||
new TradeRepublicFilter("jurisdiction", "DE"),
|
||||
]
|
||||
};
|
||||
|
||||
|
||||
|
||||
var request = new TradeRepublicSearchRequest(Data: reqData);
|
||||
var response =
|
||||
await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request);
|
||||
|
||||
var count = response?.ResultCount ?? 0;
|
||||
|
||||
counts.SetCountOfType(type, count);
|
||||
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(320), cancellationToken);
|
||||
}
|
||||
|
||||
return counts;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a paginated chunk of assets filtered by a specific type.
|
||||
/// </summary>
|
||||
/// <param name="type">The type of assets to retrieve (e.g., Stock, Etf).</param>
|
||||
/// <param name="page">The zero-based page index.</param>
|
||||
/// <param name="pageSize">The number of elements per page.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="TradeRepublicAssetResponse"/> containing the elements, or null if the request fails.</returns>
|
||||
public async Task<TradeRepublicAssetResponse?> GetAssets(AssetType type, int page, int pageSize,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await EnsureConnectedAsync();
|
||||
|
||||
var reqData = new TradeRepublicSearchData()
|
||||
{
|
||||
Query = "",
|
||||
Page = page,
|
||||
PageSize = pageSize,
|
||||
Filter =
|
||||
[
|
||||
new TradeRepublicFilter("type", type.ToString().ToLowerInvariant()),
|
||||
new TradeRepublicFilter("jurisdiction", "DE"),
|
||||
]
|
||||
};
|
||||
|
||||
var request = new TradeRepublicSearchRequest(Data: reqData);
|
||||
|
||||
return await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves the static metadata for a single specific asset via its ISIN.
|
||||
/// </summary>
|
||||
/// <param name="isin">The International Securities Identification Number of the target asset.</param>
|
||||
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||
/// <returns>A <see cref="TradeRepublicAssetResponse"/> containing instrument details, or null if the asset is not found.</returns>
|
||||
public async Task<TradeRepublicAssetResponse?> GetAsset(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureConnectedAsync();
|
||||
|
||||
|
||||
var reqData = new TradeRepublicSearchData()
|
||||
{
|
||||
Query = isin,
|
||||
Page = 1,
|
||||
PageSize = 1,
|
||||
Filter =
|
||||
[
|
||||
new TradeRepublicFilter("jurisdiction", "DE"),
|
||||
]
|
||||
};
|
||||
|
||||
var request = new TradeRepublicSearchRequest(Data: reqData);
|
||||
|
||||
return await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error while fetching asset metadata for ISIN {Isin}", isin);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event handler executed when the inactivity timer expires.
|
||||
/// Gracefully disconnects the WebSocket client.
|
||||
/// </summary>
|
||||
/// <param name="sender">The source of the event.</param>
|
||||
/// <param name="e">An EventData object that contains the event data.</param>
|
||||
private async void OnInactivityTimeout(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _lock.WaitAsync();
|
||||
|
||||
if (!_client.IsConnected) return;
|
||||
|
||||
_logger.LogInformation("No active requests detected for 5 minutes. Automatically disconnecting WebSocket.");
|
||||
await _client.DisconnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during automatic inactivity disconnect procedure.");
|
||||
//ignore
|
||||
}
|
||||
finally
|
||||
{
|
||||
_lock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the underlying timer and synchronization primitives.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
_inactivityTimer.Dispose();
|
||||
_lock.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user