using FinlyticAssets.Database; using FinlyticAssets.Entities; using FinlyticAssets.Models.DataToObject.TradeRepublic; using FinlyticCore.Entities.Assets; using Microsoft.EntityFrameworkCore; namespace FinlyticAssets.Services; /// /// Defines database operations for managing Trade Republic asset entities. /// public interface IAssetsDbService { /// /// 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. /// /// A task that represents the asynchronous operation. The task result contains a list of all active instances. public Task> GetAllValidAssetsAsync(); /// /// Adds a new asset or updates an existing one based on the composite key of ISIN and InstrumentType. /// /// The incoming asset data transfer object from the API. /// A task that represents the asynchronous operation. The task result contains true if the asset was updated or created; otherwise, false. public Task AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset); /// /// Batches processing for a collection of asset items, returning the total amount of modified or newly added entries. /// /// The collection of incoming asset objects to process. /// A task that represents the asynchronous operation. The task result contains the count of changed or added entries. public Task AddOrUpdateAssetsAsync(IEnumerable dtoAssets); /// /// 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). /// /// The ISIN value to look up. /// A task that represents the asynchronous operation. The task result contains a list of matching instances. public Task> GetAssetsByIsinAsync(string isin); /// /// 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. /// /// The ISIN value to look up. /// A task that represents the asynchronous operation. The task result contains a list of matching active instances. public Task> GetValidAssetsByIsinAsync(string isin); /// /// 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. /// /// A comma-separated string containing the keywords, ISINs, or tags to search for (e.g., "Siemens, Medic" or "IE00B4L5Y983, ETF"). /// A task that represents the asynchronous operation. The task result contains a list of all affected active instances. public Task> FindAffectedActiveAssetsAsync(string searchQuery); /// /// Deletes all asset records associated with a specific ISIN from the database. /// /// /// Use with caution. For standard maintenance and handling de-listed instruments, /// rely on the 14-day recency filter provided by instead of hard deletion. /// /// The ISIN value of the assets to remove. /// A task that represents the asynchronous operation. The task result contains true if any records were successfully deleted; otherwise, false. public Task DeleteAssetAsync(string isin); } /// public class AssetsDbService : IAssetsDbService { private readonly AssetsDbContext _context; private readonly ITradeRepublicService _tradeRepublicService; private readonly ILogger _logger; private readonly Random _random = new(); /// /// Initializes a new instance of the class. /// public AssetsDbService(AssetsDbContext context, ILogger logger, ITradeRepublicService tradeRepublicService) { _context = context; _logger = logger; _tradeRepublicService = tradeRepublicService; } /// public async Task> GetAllValidAssetsAsync() { var cutoff = DateTime.UtcNow.AddDays(-14); var existingEntity = await _context.TradeRepublicAssets .Include(a => a.Tags) .Where(a => a.UpdateAt >= cutoff) .ToListAsync(); return existingEntity; } /// public async Task 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; } /// public async Task AddOrUpdateAssetsAsync(IEnumerable dtoAssets) { int changedCount = 0; foreach (var dto in dtoAssets) { var isChanged = await AddOrUpdateAssetAsync(dto); if (isChanged) { changedCount++; } } return changedCount; } /// public async Task> 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 []; } /// public async Task> 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(); } /// public async Task> 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; } /// public async Task 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 /// /// Calculates the next synchronization/update date for an asset using configured parameters and a random offset. /// /// The baseline date to add the offsets to. /// A task representing the asynchronous operation, returning a DateTime representing the next scheduled update time (UTC). private async Task CalculateNextUpdateDateAsync(DateTime baseDate) { var settings = await _context.Set().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 ); } /// /// Maps a raw Trade Republic asset data transfer object (DTO) to its matching database entity subtype. /// /// The source Trade Republic asset data transfer object. /// A newly created subtype instance of mapped with the DTO properties. /// Thrown when the DTO type is unrecognized or unsupported. 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); } /// /// Populates common base properties of a database asset entity using a Trade Republic DTO. /// /// The target database entity. /// The source Trade Republic DTO. /// The updated database asset entity. 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; } /// /// Merges/updates the subtype-specific properties from a Trade Republic DTO into an existing database entity. /// /// The existing database entity to update. /// The source Trade Republic DTO. 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; } } /// /// Maps a list of Trade Republic tags to database entity instances, registering new tags in the database context if they do not yet exist. /// /// The read-only collection of Trade Republic tags. /// A task representing the asynchronous operation, returning the list of mapped database tag entities. private async Task> MapTagsAsync(IReadOnlyList dtos) { var tags = new List(); 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 }