using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticAssets.Database; using FinlyticAssets.Entities; using FinlyticAssets.Util; using FinlyticCore.Dtos.TradeRepublic; using FinlyticCore.Services; using FinlyticCore.Services.TradeRepublic; using Microsoft.EntityFrameworkCore; namespace FinlyticAssets.Services; /// /// Defines database operations for managing Trade Republic asset entities and on-demand derivatives. /// 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 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 IFinlyticLogger _finlyticLogger; public AssetsDbService(AssetsDbContext context, IFinlyticLogger finlyticLogger, ITradeRepublicService tradeRepublicService) { _context = context; _finlyticLogger = finlyticLogger; _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) + (a.Name.Length > 3 ? 5 : 0) }) .OrderByDescending(x => x.Score) .Take(limit) .Select(x => x.Asset) .ToList(); return scored; } /// Inherits documentation from interface. public async Task> GetAllValidAssetsAsync() { var cutoff = DateTime.UtcNow.AddDays(-90); return await _context.TradeRepublicAssets .AsNoTracking() .Where(a => a.LastUpdatedAt >= cutoff) .ToListAsync(); } /// Inherits documentation from interface. public async Task> GetAssetsByIsinAsync(string isin) { return await _context.TradeRepublicAssets .AsNoTracking() .Include(a => a.Tags) .Where(a => a.Isin == isin) .ToListAsync(); } /// Inherits documentation from interface. public async Task> GetValidAssetsByIsinAsync(string isin) { var cutoff = DateTime.UtcNow.AddDays(-90); 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 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) { await _finlyticLogger.LogTraceAsync(SettingKeys.AssetsChannel, "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) { await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "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; } await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "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.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)) { await _finlyticLogger.LogTraceAsync(SettingKeys.AssetsChannel, "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) { await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "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 { await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "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.Tags.Select(t => t.Id).Order().SequenceEqual(mappedTags.Select(t => t.Id).Order()) || HasSubtypeChanges(existingEntity, dto)) { existingEntity.Name = dto.Name; existingEntity.Type = dto.Type; existingEntity.InstrumentCategory = dto.InstrumentCategory; existingEntity.HasCfd = dto.HasCfd; existingEntity.LastUpdatedAt = now; existingEntity.Tags = mappedTags; UpdateSubtypeProperties(existingEntity, dto); _context.TradeRepublicAssets.Update(existingEntity); isChanged = true; } } if (isChanged) changedCount++; } await _context.SaveChangesAsync(); return changedCount; } private static AssetEntity MapDtoToEntity(TradeRepublicAsset dto) { return dto switch { TradeRepublicStock stock => new StockEntity { Isin = stock.Isin, Name = stock.Name, Type = stock.Type, InstrumentCategory = stock.InstrumentCategory, HasCfd = stock.HasCfd, DerivativeProductCategories = stock.DerivativeProductCategories?.ToList() ?? new List() }, TradeRepublicEtf etf => new EtfEntity { Isin = etf.Isin, Name = etf.Name, Type = etf.Type, InstrumentCategory = etf.InstrumentCategory, HasCfd = etf.HasCfd, DerivativeProductCategories = etf.DerivativeProductCategories?.ToList() ?? new List() }, TradeRepublicSynthetic syn => new SyntheticEntity { Isin = syn.Isin, Name = syn.Name, Type = syn.Type, InstrumentCategory = syn.InstrumentCategory, HasCfd = syn.HasCfd, DerivativeProductCategories = syn.DerivativeProductCategories?.ToList() ?? new List() }, _ => new StockEntity { Isin = dto.Isin, Name = dto.Name, Type = dto.Type, InstrumentCategory = dto.InstrumentCategory, HasCfd = dto.HasCfd } }; } private static void UpdateSubtypeProperties(AssetEntity entity, TradeRepublicAsset dto) { switch (entity) { case StockEntity stock when dto is TradeRepublicStock s: stock.DerivativeProductCategories = s.DerivativeProductCategories?.ToList() ?? new List(); break; case EtfEntity etf when dto is TradeRepublicEtf e: etf.DerivativeProductCategories = e.DerivativeProductCategories?.ToList() ?? new List(); break; case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto: syn.DerivativeProductCategories = synDto.DerivativeProductCategories?.ToList() ?? new List(); break; } } private static bool HasSubtypeChanges(AssetEntity entity, TradeRepublicAsset dto) { switch (entity) { case StockEntity stock when dto is TradeRepublicStock s: return !(stock.DerivativeProductCategories?.SequenceEqual(s.DerivativeProductCategories ?? Array.Empty()) ?? (s.DerivativeProductCategories == null || !s.DerivativeProductCategories.Any())); case EtfEntity etf when dto is TradeRepublicEtf e: return !(etf.DerivativeProductCategories?.SequenceEqual(e.DerivativeProductCategories ?? Array.Empty()) ?? (e.DerivativeProductCategories == null || !e.DerivativeProductCategories.Any())); case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto: return !(syn.DerivativeProductCategories?.SequenceEqual(synDto.DerivativeProductCategories ?? Array.Empty()) ?? (synDto.DerivativeProductCategories == null || !synDto.DerivativeProductCategories.Any())); default: return false; } } /// Inherits documentation from interface. public async Task> FindAffectedActiveAssetsAsync(string searchQuery) { var cutoff = DateTime.UtcNow.AddDays(-90); string cleanQuery = searchQuery.Trim().ToLowerInvariant(); return await _context.TradeRepublicAssets .AsNoTracking() .Include(a => a.Tags) .Where(a => a.LastUpdatedAt >= cutoff && ( a.Isin.ToLower().Contains(cleanQuery) || a.Name.ToLower().Contains(cleanQuery) )) .Take(25) .ToListAsync(); } /// Inherits documentation from interface. public async Task DeleteAssetAsync(string isin) { var asset = await _context.TradeRepublicAssets.FirstOrDefaultAsync(a => a.Isin == isin); if (asset == null) { await _finlyticLogger.LogWarningAsync(SettingKeys.AssetsChannel, "[AssetsDbService] Delete execution cancelled. Asset with ISIN {Isin} does not exist.", isin); return false; } _context.TradeRepublicAssets.Remove(asset); await _context.SaveChangesAsync(); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetsDbService] Asset with ISIN {Isin} has been successfully deleted.", 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; string trAfter = !string.IsNullOrEmpty(after) ? after : (pageIndex > 0 ? pageIndex.ToString() : "0"); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetsDbService] Fetching derivatives for {Isin} (OptionType: {Option}, Leverage: {Lev}, Page: {Page}, TR-After: {After})", 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(); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetsDbService] TR returned {Count} derivatives for {Isin} (Cursors.After: {NextAfter})", 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.Derivatives .Where(d => isins.Contains(d.Isin)) .ToDictionaryAsync(d => d.Isin, cancellationToken); List resultEntities = new(); foreach (var item in fetchedItems) { DateTime? expiryDate = null; if (!string.IsNullOrWhiteSpace(item.Expiry) && DateTime.TryParse(item.Expiry, out var parsedExp)) { expiryDate = parsedExp.ToUniversalTime(); } if (existingDerivatives.TryGetValue(item.Isin, out var existing)) { existing.Name = !string.IsNullOrWhiteSpace(item.ProductCategoryName) ? item.ProductCategoryName : item.Isin; existing.UnderlyingIsin = underlyingIsin; existing.Strike = item.Strike ?? 0m; existing.Barrier = item.Barrier ?? 0m; existing.Leverage = item.Leverage ?? 0m; existing.Expiry = expiryDate; existing.OptionType = targetOptionType; existing.ProductCategoryName = item.ProductCategoryName; existing.NextGenProductCategoryName = item.NextGenProductCategoryName; existing.Issuer = item.Issuer; existing.IssuerDisplayName = item.IssuerDisplayName; existing.Size = item.Size; existing.Factor = item.Factor; existing.Delta = item.Delta; existing.Currency = item.Currency; existing.LastUpdatedAt = now; _context.Derivatives.Update(existing); resultEntities.Add(existing); } else { var newDeriv = new DerivativeEntity { Isin = item.Isin, Name = !string.IsNullOrWhiteSpace(item.ProductCategoryName) ? item.ProductCategoryName : item.Isin, UnderlyingIsin = underlyingIsin, Strike = item.Strike ?? 0m, Barrier = item.Barrier ?? 0m, Leverage = item.Leverage ?? 0m, Expiry = expiryDate, OptionType = targetOptionType, ProductCategoryName = item.ProductCategoryName, NextGenProductCategoryName = item.NextGenProductCategoryName, Issuer = item.Issuer, IssuerDisplayName = item.IssuerDisplayName, Size = item.Size, Factor = item.Factor, Delta = item.Delta, Currency = item.Currency, LastUpdatedAt = now, CreatedAt = now }; await _context.Derivatives.AddAsync(newDeriv, cancellationToken); resultEntities.Add(newDeriv); } } await _context.SaveChangesAsync(cancellationToken); return resultEntities.Where(d => d.Barrier > 0 && d.Leverage > 0).ToList(); } var dbQuery = _context.Derivatives .AsNoTracking() .Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType && d.Barrier > 0 && d.Leverage > 0); if (targetLeverage.HasValue && targetLeverage.Value > 0) { dbQuery = dbQuery.OrderBy(d => Math.Abs(d.Leverage - targetLeverage.Value)); } return await dbQuery .Take(pageSize) .ToListAsync(cancellationToken); } }