This commit is contained in:
2026-07-19 12:13:14 +02:00
commit 6337e63a77
46 changed files with 4411 additions and 0 deletions
+437
View File
@@ -0,0 +1,437 @@
using FinlyticAssets.Database;
using FinlyticAssets.Entities;
using FinlyticAssets.Models.DataToObject.TradeRepublic;
using FinlyticCore.Entities.Assets;
using Microsoft.EntityFrameworkCore;
namespace FinlyticAssets.Services;
/// <summary>
/// Defines database operations for managing Trade Republic asset entities.
/// </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<bool> DeleteAssetAsync(string isin);
}
/// <inheritdoc />
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)
{
_context = context;
_logger = logger;
_tradeRepublicService = tradeRepublicService;
}
/// <inheritdoc />
public async Task<List<AssetEntity>> GetAllValidAssetsAsync()
{
var cutoff = DateTime.UtcNow.AddDays(-14);
var existingEntity = await _context.TradeRepublicAssets
.Include(a => a.Tags)
.Where(a => a.UpdateAt >= cutoff)
.ToListAsync();
return existingEntity;
}
/// <inheritdoc />
public async Task<bool> AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset)
{
var existingEntity = await _context.TradeRepublicAssets
.Include(a => a.Tags)
.FirstOrDefaultAsync(a => a.Isin == dtoAsset.Isin);
var now = DateTime.UtcNow;
var nextUpdateScheduledAt = await CalculateNextUpdateDateAsync(now);
if (existingEntity == null)
{
_logger.LogDebug("Asset with ISIN {Isin} not found. Mapping and creating a new entity.", dtoAsset.Isin);
var newEntity = MapDtoToEntity(dtoAsset);
newEntity.LastUpdatedAt = now;
newEntity.UpdateAt = nextUpdateScheduledAt;
newEntity.Tags = await MapTagsAsync(dtoAsset.Tags);
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);
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);
_context.TradeRepublicAssets.Update(existingEntity);
await _context.SaveChangesAsync();
return true;
}
/// <inheritdoc />
public async Task<int> AddOrUpdateAssetsAsync(IEnumerable<TradeRepublicAsset> dtoAssets)
{
int changedCount = 0;
foreach (var dto in dtoAssets)
{
var isChanged = await AddOrUpdateAssetAsync(dto);
if (isChanged)
{
changedCount++;
}
}
return changedCount;
}
/// <inheritdoc />
public async Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin)
{
var localAssets = await _context.TradeRepublicAssets
.Include(a => a.Tags)
.Where(a => a.Isin == isin)
.ToListAsync();
if (localAssets.Any())
{
return localAssets;
}
var trAssetDto = await _tradeRepublicService.GetAsset(isin);
if (trAssetDto != null)
{
foreach (var asset in trAssetDto.Results)
{
_ = await AddOrUpdateAssetAsync(asset);
}
return await GetAssetsByIsinAsync(isin);
}
return [];
}
/// <inheritdoc />
public async Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin)
{
var cutoff = DateTime.UtcNow.AddDays(-14);
return await _context.TradeRepublicAssets
.AsNoTracking()
.Include(a => a.Tags)
.Where(a => a.Isin == isin && a.UpdateAt >= cutoff)
.ToListAsync();
}
/// <inheritdoc />
public async Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery)
{
if (string.IsNullOrWhiteSpace(searchQuery))
{
return [];
}
var cutoff = DateTime.UtcNow.AddDays(-14);
var searchTerms = searchQuery
.Split(',')
.Select(t => t.Trim().ToLower())
.Where(t => !string.IsNullOrEmpty(t))
.Distinct()
.ToList();
if (searchTerms.Count == 0) return [];
var query = _context.TradeRepublicAssets
.AsNoTracking()
.Where(a => a.UpdateAt >= cutoff)
.Include(a => a.Tags)
.AsQueryable();
foreach (var term in searchTerms)
{
query = query.Where(a =>
a.Isin.ToLower().Contains(term) ||
a.Name.ToLower().Contains(term) ||
a.Tags.Any(tag => tag.Name.ToLower().Contains(term)));
}
var localAssets = await query.ToListAsync();
var possibleIsins = searchTerms
.Where(t => t.Length == 12 && char.IsLetter(t[0]) && char.IsLetter(t[1]))
.Select(t => t.ToUpper())
.ToList();
if (possibleIsins.Any())
{
var foundIsins = localAssets.Select(a => a.Isin).ToHashSet();
var missingIsins = possibleIsins.Where(isin => !foundIsins.Contains(isin)).ToList();
if (missingIsins.Any())
{
var fetchedNewAsset = false;
foreach (var missingIsin in missingIsins)
{
var trAssetDto = await _tradeRepublicService.GetAsset(missingIsin);
if (trAssetDto?.Results != null)
{
foreach (var asset in trAssetDto.Results)
{
await AddOrUpdateAssetAsync(asset);
}
fetchedNewAsset = true;
}
}
if (fetchedNewAsset)
{
return await query.ToListAsync();
}
}
}
return localAssets;
}
/// <inheritdoc />
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);
return false;
}
_context.TradeRepublicAssets.Remove(asset);
await _context.SaveChangesAsync();
_logger.LogInformation("Asset with ISIN {Isin} has been successfully deleted.", 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
{
TradeRepublicStock stock => new StockEntity
{ Isin = stock.Isin, DerivativeProductCategories = stock.DerivativeProductCategories.ToList() },
TradeRepublicCrypto crypto => new CryptoEntity
{ Isin = crypto.Isin, Subtitle = crypto.Subtitle, SearchSubtitle = crypto.SearchSubtitle },
TradeRepublicEtf etf => new EtfEntity
{
Isin = etf.Isin, EtfDescription = etf.EtfDescription, MappedEtfIndexName = etf.MappedEtfIndexName,
Subtitle = etf.Subtitle, SearchSubtitle = etf.SearchSubtitle,
DerivativeProductCategories = etf.DerivativeProductCategories.ToList()
},
TradeRepublicSynthetic synth => new SyntheticEntity
{ Isin = synth.Isin, DerivativeProductCategories = synth.DerivativeProductCategories.ToList() },
TradeRepublicBond bond => new BondEntity
{ Isin = bond.Isin, BondIssuerName = bond.BondIssuerName, SearchSubtitle = bond.SearchSubtitle },
TradeRepublicDerivative deriv => new DerivativeEntity
{
Isin = deriv.Isin, UnderlyingIsin = deriv.UnderlyingIsin,
DerivativeProductCategories = deriv.DerivativeProductCategories.ToList()
},
_ => throw new NotSupportedException($"Type {dto.GetType().Name} is not supported.")
};
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;
entity.Type = dto.Type;
entity.InstrumentCategory = dto.InstrumentCategory;
entity.HasCfd = dto.HasCfd;
entity.ImageId = dto.ImageId;
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)
{
case StockEntity stockEntity when dto is TradeRepublicStock stockDto:
stockEntity.DerivativeProductCategories = stockDto.DerivativeProductCategories.ToList();
break;
case CryptoEntity cryptoEntity when dto is TradeRepublicCrypto cryptoDto:
cryptoEntity.Subtitle = cryptoDto.Subtitle;
cryptoEntity.SearchSubtitle = cryptoDto.SearchSubtitle;
break;
case EtfEntity etfEntity when dto is TradeRepublicEtf etfDto:
etfEntity.DerivativeProductCategories = etfDto.DerivativeProductCategories.ToList();
etfEntity.EtfDescription = etfDto.EtfDescription;
etfEntity.MappedEtfIndexName = etfDto.MappedEtfIndexName;
etfEntity.Subtitle = etfDto.Subtitle;
etfEntity.SearchSubtitle = etfDto.SearchSubtitle;
break;
case SyntheticEntity synthEntity when dto is TradeRepublicSynthetic synthDto:
synthEntity.DerivativeProductCategories = synthDto.DerivativeProductCategories.ToList();
break;
case BondEntity bondEntity when dto is TradeRepublicBond bondDto:
bondEntity.BondIssuerName = bondDto.BondIssuerName;
bondEntity.SearchSubtitle = bondDto.SearchSubtitle;
break;
case DerivativeEntity derivEntity when dto is TradeRepublicDerivative derivDto:
derivEntity.DerivativeProductCategories = derivDto.DerivativeProductCategories.ToList();
derivEntity.UnderlyingIsin = derivDto.UnderlyingIsin;
break;
}
}
/// <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
}
@@ -0,0 +1,231 @@
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);
}
}
}
@@ -0,0 +1,87 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using FinlyticAssets.Models;
using FinlyticAssets.Util;
namespace FinlyticAssets.Services;
/// <summary>
/// Provides methods for generating and maintaining the indexed asset reference file used for pre-filtering.
/// </summary>
public interface IAssetsIndexService
{
/// <summary>
/// Recreates the index file containing basic asset identifiers (ISIN and Name) for all active, valid assets.
/// </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);
}
/// <summary>
/// Implements the <see cref="IAssetsIndexService"/> to maintain local asset index references.
/// </summary>
public class AssetsIndexService : IAssetsIndexService
{
private readonly ILogger<AssetsIndexService> _logger;
private readonly IAssetsDbService _assetsDbService;
/// <summary>
/// Initializes a new instance of the <see cref="AssetsIndexService"/> class.
/// </summary>
/// <param name="logger">The logger for documenting indexing events and errors.</param>
/// <param name="assetsDbService">The database service to query the assets from.</param>
public AssetsIndexService(ILogger<AssetsIndexService> logger, IAssetsDbService assetsDbService)
{
_logger = logger;
_assetsDbService = assetsDbService;
}
/// <inheritdoc />
public async Task ReCreateIndexFileAsync(CancellationToken cancellationToken)
{
try
{
var assets = await _assetsDbService.GetAllValidAssetsAsync();
if (assets == null || !assets.Any())
{
_logger.LogWarning("No valid assets found in the database to index.");
return;
}
var indexAssets = assets.Select(a => new AssetIndex(a.Isin, a.Name)).ToList();
var directoryPath = Volumes.IndexRelativePath;
var filePath = Path.Combine(directoryPath, "index.json");
if (!Directory.Exists(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096,
useAsync: true))
{
await JsonSerializer.SerializeAsync(fileStream, indexAssets, cancellationToken: cancellationToken);
}
_logger.LogInformation("Successfully recreated asset index file with {Count} entries at {Path}",
indexAssets.Count, filePath);
}
catch (IOException ex)
{
_logger.LogError(ex, "Disk I/O error occurred while writing the asset index file.");
throw;
}
catch (JsonException ex)
{
_logger.LogError(ex, "Failed to serialize the asset index data to JSON.");
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "An unexpected error occurred while recreating the asset index file.");
throw;
}
}
}
@@ -0,0 +1,52 @@
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();
}
}
@@ -0,0 +1,91 @@
using FinlyticAssets.Database;
using FinlyticAssets.Entities;
using Microsoft.EntityFrameworkCore;
namespace FinlyticAssets.Services;
/// <summary>
/// Defines the business logic for managing global application settings.
/// Supports retrieving and updating (upserting) the central single-row configuration record.
/// </summary>
public interface ISettingsDbService
{
/// <summary>
/// Retrieves the current global settings from the database.
/// </summary>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the current <see cref="Settings"/>.
/// If no settings exist in the database yet, a new instance initialized with default values is returned.
/// </returns>
public Task<Settings> GetSettings();
/// <summary>
/// Persists the provided settings by updating the existing record or inserting the first one if the table is empty.
/// </summary>
/// <param name="settings">The new configuration values to be persisted.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the freshly saved
/// and tracked <see cref="Settings"/> instance.
/// </returns>
public Task<Settings> SaveSettings(Settings settings);
}
/// <summary>
/// Implements the <see cref="ISettingsDbService"/> utilizing Entity Framework Core.
/// This service is designed for a single-row table architecture to maintain stateful global configurations.
/// </summary>
public class SettingsDbService : ISettingsDbService
{
private readonly AssetsDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="SettingsDbService"/> class with the required database context.
/// </summary>
/// <param name="context">The EF Core context used to access the assets database.</param>
public SettingsDbService(AssetsDbContext context)
{
_context = context;
}
/// <inheritdoc />
public async Task<Settings> GetSettings()
{
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
if (settings == null)
{
settings = new Settings
{
Id = Guid.NewGuid()
};
await SaveSettings(settings);
}
return settings;
}
/// <inheritdoc />
public async Task<Settings> SaveSettings(Settings settings)
{
var existing = await _context.Settings.FirstOrDefaultAsync();
if (existing == null)
{
if (settings.Id == Guid.Empty)
{
settings.Id = Guid.NewGuid();
}
await _context.Settings.AddAsync(settings);
await _context.SaveChangesAsync();
return settings;
}
else
{
_context.Entry(existing).CurrentValues.SetValues(settings);
await _context.SaveChangesAsync();
return existing;
}
}
}
@@ -0,0 +1,240 @@
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);
}
}