using FinlyticAssets.Database; using FinlyticAssets.Entities; using FinlyticCore.Dtos.TradeRepublic; using FinlyticCore.Services.TradeRepublic; using Microsoft.EntityFrameworkCore; namespace FinlyticAssets.Services; /// /// Defines database operations for managing Trade Republic asset entities. /// public interface IAssetsDbService { public Task> GetAllValidAssetsAsync(); public Task AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset); public Task AddOrUpdateAssetsAsync(IEnumerable dtoAssets); public Task> GetAssetsByIsinAsync(string isin); public Task> GetValidAssetsByIsinAsync(string isin); public Task> FindAffectedActiveAssetsAsync(string searchQuery); public Task UpdateAssetImageIdAsync(string isin, string imageId); public Task DeleteAssetAsync(string isin); public Task> GetDiscoveryAssetsAsync(int limit = 15); public Task> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default); } /// public class AssetsDbService : IAssetsDbService { private readonly AssetsDbContext _context; private readonly ITradeRepublicService _tradeRepublicService; private readonly ILogger _logger; public AssetsDbService(AssetsDbContext context, ILogger logger, ITradeRepublicService tradeRepublicService) { _context = context; _logger = logger; _tradeRepublicService = tradeRepublicService; } /// Inherits documentation from interface. public async Task> GetDiscoveryAssetsAsync(int limit = 15) { var cutoff = DateTime.UtcNow.AddDays(-90); var validAssets = await _context.TradeRepublicAssets .AsNoTracking() .Include(a => a.Tags) .Where(a => a.LastUpdatedAt >= cutoff && !string.IsNullOrEmpty(a.Name)) .ToListAsync(); 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(); 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; } /// Inherits documentation from interface. public async Task> GetAllValidAssetsAsync() { var cutoff = DateTime.UtcNow.AddDays(-90); return await _context.TradeRepublicAssets .AsNoTracking() .Include(a => a.Tags) .Where(a => a.LastUpdatedAt >= cutoff) .ToListAsync(); } /// Inherits documentation from interface. 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 mappedTags = new List(); foreach (var tagDto in dtoAsset.Tags ?? Array.Empty()) { 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) { _logger.LogDebug("Asset with ISIN {Isin} not found. Mapping and creating a new entity.", dtoAsset.Isin); var newEntity = MapDtoToEntity(dtoAsset); newEntity.LastUpdatedAt = now; 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); existingEntity.Name = dtoAsset.Name; existingEntity.Type = dtoAsset.Type; existingEntity.InstrumentCategory = dtoAsset.InstrumentCategory; existingEntity.HasCfd = dtoAsset.HasCfd; existingEntity.ImageId = dtoAsset.ImageId; existingEntity.LastUpdatedAt = now; UpdateSubtypeProperties(existingEntity, dtoAsset); existingEntity.Tags = mappedTags; _context.TradeRepublicAssets.Update(existingEntity); await _context.SaveChangesAsync(); return true; } /// Inherits documentation from interface. public async Task AddOrUpdateAssetsAsync(IEnumerable dtoAssets) { var assetsList = dtoAssets.ToList(); if (assetsList.Count == 0) return 0; var now = DateTime.UtcNow; int changedCount = 0; 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()) .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 = false; var existingEntity = existingAssets.GetValueOrDefault(dto.Isin); var mappedTags = new List(); foreach (var tagDto in dto.Tags ?? Array.Empty()) { 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; } /// Inherits documentation from interface. public async Task> GetAssetsByIsinAsync(string isin) { var localAssets = await _context.TradeRepublicAssets .Include(a => a.Tags) .Where(a => a.Isin == isin) .ToListAsync(); if (localAssets.Count > 0) { return localAssets; } // JIT-Fetch via API var trAssetDto = await _tradeRepublicService.GetAsset(isin); if (trAssetDto?.Results != null && trAssetDto.Results.Count > 0) { foreach (var asset in trAssetDto.Results) { await AddOrUpdateAssetAsync(asset); } return await _context.TradeRepublicAssets .Include(a => a.Tags) .Where(a => a.Isin == isin) .ToListAsync(); } return []; } /// Inherits documentation from interface. 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.LastUpdatedAt >= cutoff) .ToListAsync(); } /// Inherits documentation from interface. 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()) .Where(t => !string.IsNullOrEmpty(t)) .Distinct() .ToList(); if (searchTerms.Count == 0) return []; var query = _context.TradeRepublicAssets .AsNoTracking() .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(lowerTerm) || a.Name.ToLower().Contains(lowerTerm) || a.Tags.Any(tag => tag.Name.ToLower().Contains(lowerTerm))); } 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.Count > 0) { var foundIsins = localAssets.Select(a => a.Isin).ToHashSet(); var missingIsins = possibleIsins.Where(isin => !foundIsins.Contains(isin)).ToList(); if (missingIsins.Count > 0) { 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; } /// Inherits documentation from interface. 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); } } /// Inherits documentation from interface. public async Task DeleteAssetAsync(string isin) { var asset = await _context.TradeRepublicAssets.FirstOrDefaultAsync(a => a.Isin == isin); if (asset == null) { _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("[{Channel}] Asset with ISIN {Isin} has been successfully deleted.", "AssetsChannel", isin); return true; } /// Inherits documentation from interface. public async Task> GetDerivativesByUnderlyingAsync( string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default) { var targetOptionType = optionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? OptionType.Short : OptionType.Long; string cleanOptionType = optionType.Equals("short", StringComparison.OrdinalIgnoreCase) ? "short" : "long"; const int pageSize = 50; int pageIndex = Math.Max(0, page ?? 0); decimal levQuery = targetLeverage.HasValue && targetLeverage.Value > 0 ? targetLeverage.Value : 0m; // Trade Republic uses page index (0, 1, 2, 3...) for the 'after' pagination parameter in derivatives string trAfter = !string.IsNullOrEmpty(after) ? after : (pageIndex > 0 ? pageIndex.ToString() : "0"); _logger.LogInformation("[{Channel}] Fetching derivatives for {Isin} (OptionType: {Option}, Leverage: {Lev}, Page: {Page}, TR-After: {After})", "AssetsChannel", underlyingIsin, cleanOptionType, levQuery, pageIndex, trAfter); var trReq = new TradeRepublicDerivativesRequest( Underlying: underlyingIsin, OptionType: cleanOptionType, ProductCategory: "knockOutProduct", Leverage: levQuery, SortBy: "leverage", SortDirection: "asc", PageSize: pageSize, After: trAfter); var trResponse = await _tradeRepublicService.GetDerivativesAsync(trReq, cancellationToken); var fetchedItems = trResponse?.Results ?? new List(); _logger.LogInformation("[{Channel}] TR returned {Count} derivatives for {Isin} (Cursors.After: {NextAfter})", "AssetsChannel", fetchedItems.Count, underlyingIsin, trResponse?.Cursors?.After ?? "null"); if (fetchedItems.Count > 0) { var now = DateTime.UtcNow; var isins = fetchedItems.Select(r => r.Isin).ToList(); var existingDerivatives = await _context.TradeRepublicAssets .OfType() .Where(d => isins.Contains(d.Isin)) .ToDictionaryAsync(d => d.Isin, cancellationToken); List resultEntities = new(); foreach (var item in fetchedItems) { if (!existingDerivatives.TryGetValue(item.Isin, out var entity)) { entity = new DerivativeEntity { Isin = item.Isin, InstrumentCategory = "derivative", Type = "derivative" }; await _context.TradeRepublicAssets.AddAsync(entity, cancellationToken); } bool isShortItem = string.Equals(item.OptionType, "short", StringComparison.OrdinalIgnoreCase) || string.Equals(item.OptionType, "put", StringComparison.OrdinalIgnoreCase) || item.OptionType.Contains("short", StringComparison.OrdinalIgnoreCase) || item.OptionType.Contains("put", StringComparison.OrdinalIgnoreCase) || item.OptionType.Contains("bear", StringComparison.OrdinalIgnoreCase); entity.UnderlyingIsin = underlyingIsin; entity.OptionType = isShortItem ? OptionType.Short : OptionType.Long; entity.ProductCategoryName = item.ProductCategoryName; entity.NextGenProductCategoryName = item.NextGenProductCategoryName; entity.Strike = item.Strike ?? 0m; entity.Barrier = item.Barrier ?? 0m; entity.Leverage = item.Leverage ?? 0m; entity.Size = item.Size; entity.Factor = item.Factor; entity.Delta = item.Delta; entity.Currency = item.Currency ?? "EUR"; entity.Expiry = DateTime.TryParse(item.Expiry, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.AdjustToUniversal | System.Globalization.DateTimeStyles.AssumeUniversal, out var exp) ? DateTime.SpecifyKind(exp, DateTimeKind.Utc) : (DateTime?)null; entity.Issuer = item.Issuer; entity.IssuerDisplayName = item.IssuerDisplayName; entity.IssuerImageId = item.IssuerImageId; entity.ImageId = item.ImageId; entity.Name = $"{item.IssuerDisplayName} {item.NextGenProductCategoryName} ({(isShortItem ? "SHORT" : "LONG")})"; entity.LastUpdatedAt = now; resultEntities.Add(entity); } await _context.SaveChangesAsync(cancellationToken); return resultEntities; } // Fallback: Query from DB if Trade Republic returned 0 or was unreachable var dbQuery = _context.TradeRepublicAssets .OfType() .AsNoTracking() .Include(a => a.Tags) .Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType); if (levQuery > 0) { dbQuery = dbQuery.Where(d => d.Leverage >= (levQuery - 0.2m)); } var results = await dbQuery .OrderBy(d => d.Leverage) .Skip(pageIndex * pageSize) .Take(pageSize) .ToListAsync(cancellationToken); return results; } #region Helper & Mapping Methods 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); } 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; } 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; } } #endregion }