472 lines
20 KiB
C#
472 lines
20 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Defines database operations for managing Trade Republic asset entities and on-demand derivatives.
|
|
/// </summary>
|
|
public interface IAssetsDbService
|
|
{
|
|
public Task<List<AssetEntity>> GetAllValidAssetsAsync();
|
|
public Task<bool> AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset);
|
|
public Task<int> AddOrUpdateAssetsAsync(IEnumerable<TradeRepublicAsset> dtoAssets);
|
|
public Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin);
|
|
public Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin);
|
|
public Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery);
|
|
public Task<bool> DeleteAssetAsync(string isin);
|
|
public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15);
|
|
public Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public class AssetsDbService : IAssetsDbService
|
|
{
|
|
private readonly AssetsDbContext _context;
|
|
private readonly ITradeRepublicService _tradeRepublicService;
|
|
private readonly IFinlyticLogger<AssetsDbService> _finlyticLogger;
|
|
|
|
public AssetsDbService(AssetsDbContext context, IFinlyticLogger<AssetsDbService> finlyticLogger, ITradeRepublicService tradeRepublicService)
|
|
{
|
|
_context = context;
|
|
_finlyticLogger = finlyticLogger;
|
|
_tradeRepublicService = tradeRepublicService;
|
|
}
|
|
|
|
/// <summary>Inherits documentation from interface.</summary>
|
|
public async Task<List<AssetEntity>> 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;
|
|
}
|
|
|
|
/// <summary>Inherits documentation from interface.</summary>
|
|
public async Task<List<AssetEntity>> GetAllValidAssetsAsync()
|
|
{
|
|
var cutoff = DateTime.UtcNow.AddDays(-90);
|
|
return await _context.TradeRepublicAssets
|
|
.AsNoTracking()
|
|
.Where(a => a.LastUpdatedAt >= cutoff)
|
|
.ToListAsync();
|
|
}
|
|
|
|
/// <summary>Inherits documentation from interface.</summary>
|
|
public async Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin)
|
|
{
|
|
return await _context.TradeRepublicAssets
|
|
.AsNoTracking()
|
|
.Include(a => a.Tags)
|
|
.Where(a => a.Isin == isin)
|
|
.ToListAsync();
|
|
}
|
|
|
|
/// <summary>Inherits documentation from interface.</summary>
|
|
public async Task<List<AssetEntity>> 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();
|
|
}
|
|
|
|
/// <summary>Inherits documentation from interface.</summary>
|
|
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 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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <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;
|
|
|
|
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 = 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))
|
|
{
|
|
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<string>()
|
|
},
|
|
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<string>()
|
|
},
|
|
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<string>()
|
|
},
|
|
_ => 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<string>();
|
|
break;
|
|
case EtfEntity etf when dto is TradeRepublicEtf e:
|
|
etf.DerivativeProductCategories = e.DerivativeProductCategories?.ToList() ?? new List<string>();
|
|
break;
|
|
case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto:
|
|
syn.DerivativeProductCategories = synDto.DerivativeProductCategories?.ToList() ?? new List<string>();
|
|
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<string>()) ?? (s.DerivativeProductCategories == null || !s.DerivativeProductCategories.Any()));
|
|
case EtfEntity etf when dto is TradeRepublicEtf e:
|
|
return !(etf.DerivativeProductCategories?.SequenceEqual(e.DerivativeProductCategories ?? Array.Empty<string>()) ?? (e.DerivativeProductCategories == null || !e.DerivativeProductCategories.Any()));
|
|
case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto:
|
|
return !(syn.DerivativeProductCategories?.SequenceEqual(synDto.DerivativeProductCategories ?? Array.Empty<string>()) ?? (synDto.DerivativeProductCategories == null || !synDto.DerivativeProductCategories.Any()));
|
|
default:
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// <summary>Inherits documentation from interface.</summary>
|
|
public async Task<List<AssetEntity>> 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();
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
/// <summary>Inherits documentation from interface.</summary>
|
|
public async Task<List<DerivativeEntity>> 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<TradeRepublicDerivativeItemDto>();
|
|
|
|
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<DerivativeEntity> 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);
|
|
}
|
|
} |