using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FinlyticCore.Dtos.Fundamentals; using FinlyticCore.Dtos.TradeRepublic; using FinlyticCore.Dtos.Yahoo; using FinlyticCore.Models.Settings; using FinlyticCore.Services; using FinlyticCore.Services.TradeRepublic; using FinlyticCore.Services.Yahoo; using FinlyticFundamentals.Database; using FinlyticFundamentals.Entities; using FinlyticFundamentals.Util; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FinlyticFundamentals.Services; public interface IFundamentalsDbService { Task GetFundamentalsAsync( string isin, string? ticker = null, bool forceRefresh = false, CancellationToken cancellationToken = default); Task> GetAllEventsAsync(CancellationToken cancellationToken = default); Task> GetEventsByMonthAsync(int year, int month, CancellationToken cancellationToken = default); } public class FundamentalsDbService : IFundamentalsDbService { private static readonly ConcurrentDictionary IsinLocks = new(); private readonly IServiceScopeFactory _scopeFactory; private readonly IYahooFinanceScraper _scraper; private readonly ITradeRepublicService _tradeRepublicService; private readonly IFinlyticLogger _finlyticLogger; public FundamentalsDbService( IServiceScopeFactory scopeFactory, IYahooFinanceScraper scraper, ITradeRepublicService tradeRepublicService, IFinlyticLogger finlyticLogger) { _scopeFactory = scopeFactory; _scraper = scraper; _tradeRepublicService = tradeRepublicService; _finlyticLogger = finlyticLogger; } /// public async Task GetFundamentalsAsync( string isin, string? ticker = null, bool forceRefresh = false, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(isin)) return null; var cleanIsin = isin.Trim().ToUpperInvariant(); var requestedTicker = ticker?.Trim().ToUpperInvariant(); var isinLock = IsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1)); await isinLock.WaitAsync(cancellationToken); try { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var settingsService = scope.ServiceProvider.GetRequiredService(); // 1. Dynamic Settings lesen bool allowForceRefresh = await settingsService.GetSettingAsync(SettingKeys.AllowForceRefresh, cancellationToken); bool enableHtmlFallback = await settingsService.GetSettingAsync(SettingKeys.EnableHtmlFallback, cancellationToken); bool forceHtmlFallback = await settingsService.GetSettingAsync(SettingKeys.ForceHtmlFallback, cancellationToken); int validityDays = await settingsService.GetSettingAsync(SettingKeys.FundamentalDataValidityDays, cancellationToken); bool effectiveForceRefresh = forceRefresh && allowForceRefresh; await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-START] GetFundamentalsAsync für ISIN: {Isin} | Ticker: {Ticker} | ForceRefresh: {Force} | EnableHtml: {Html} | ForceHtml: {ForceHtml}", cleanIsin, requestedTicker ?? "NULL", forceRefresh, enableHtmlFallback, forceHtmlFallback); // 2. Entitäten aus DB laden var assetData = await context.AssetData .Include(a => a.AvailableTickers) .Include(a => a.KeyExecutives) .Include(a => a.AssetEvents) .Include(a => a.FundamentalData) .FirstOrDefaultAsync(a => a.Isin == cleanIsin, cancellationToken); string targetTicker = !string.IsNullOrWhiteSpace(requestedTicker) ? requestedTicker : (assetData?.PrimaryTicker?.Ticker ?? string.Empty); var fundamentalData = assetData?.FundamentalData? .FirstOrDefault(f => !string.IsNullOrWhiteSpace(targetTicker) && string.Equals(f.Ticker.Ticker, targetTicker, StringComparison.OrdinalIgnoreCase)) ?? (string.IsNullOrWhiteSpace(requestedTicker) ? assetData?.FundamentalData?.FirstOrDefault() : null); // 3. Prüfen, was aktualisiert werden muss bool assetDataMissing = assetData == null || string.IsNullOrWhiteSpace(assetData.Name); bool executivesMissing = assetData == null || assetData.KeyExecutives == null || assetData.KeyExecutives.Count == 0; bool fundamentalsMissingOrExpired = fundamentalData == null || (fundamentalData.MarketCap == null && fundamentalData.TrailingPe == null) || (DateTime.UtcNow - fundamentalData.LastUpdatedUtc).TotalDays > validityDays; bool tickersCorruptOrMissing = assetData?.AvailableTickers == null || assetData.AvailableTickers.Count == 0 || assetData.AvailableTickers.Any(t => t.Ticker != null && t.Ticker.Contains(cleanIsin, StringComparison.OrdinalIgnoreCase)); bool shouldUpdate = assetDataMissing || executivesMissing || fundamentalsMissingOrExpired || tickersCorruptOrMissing || effectiveForceRefresh || forceHtmlFallback; if (!shouldUpdate && fundamentalData != null) { await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[FundamentalsDbService] Returning valid cached fundamental data for ISIN {Isin} (Ticker: {Ticker}, Updated: {UpdatedUtc}). External API fetch skipped.", cleanIsin, fundamentalData.Ticker.Ticker, fundamentalData.LastUpdatedUtc.ToString("o")); } else { // --- STEP 1: Trade Republic Details --- TradeRepublicStockDetailsResponse? trDetails = null; try { trDetails = await _tradeRepublicService.GetStockDetailsAsync(cleanIsin, cancellationToken); } catch (Exception ex) { await _finlyticLogger.LogWarningAsync(SettingKeys.FundamentalsChannel, ex, "[DEBUG-TR-ERROR] Could not fetch Trade Republic details for {Isin}", cleanIsin); } // --- STEP 2: Ticker auflösen (Der Primary Ticker ist IMMER der 1. von Yahoo Finance) --- var resolvedTickers = await _scraper.ResolveAllTickersFromIsinAsync(cleanIsin, cancellationToken); var yahooPrimaryTicker = resolvedTickers.FirstOrDefault() ?? (assetData?.PrimaryTicker != null && !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Ticker) ? new TickerInfoDto { Ticker = assetData.PrimaryTicker.Ticker, Exchange = assetData.PrimaryTicker.Exchange ?? "Unknown" } : new TickerInfoDto { Ticker = cleanIsin, Exchange = "Unknown" }); if (string.IsNullOrWhiteSpace(yahooPrimaryTicker.Exchange)) { yahooPrimaryTicker = new TickerInfoDto { Ticker = yahooPrimaryTicker.Ticker, Exchange = GetExchangeDisplayName(yahooPrimaryTicker.Ticker) }; } // Der activeQueryTicker wird für die aktuelle Kurs- und Modulabfrage verwendet (z. B. wenn der User im Web UI einen bestimmten Börsenplatz wählt) TickerInfoDto activeQueryTicker; if (!string.IsNullOrWhiteSpace(requestedTicker)) { var matchDto = resolvedTickers.FirstOrDefault(t => string.Equals(t.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)); var matchEntity = assetData?.AvailableTickers?.FirstOrDefault(t => string.Equals(t.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)); if (matchDto != null) { activeQueryTicker = new TickerInfoDto { Ticker = matchDto.Ticker, Exchange = !string.IsNullOrWhiteSpace(matchDto.Exchange) ? matchDto.Exchange : GetExchangeDisplayName(matchDto.Ticker) }; } else if (matchEntity != null) { activeQueryTicker = new TickerInfoDto { Ticker = matchEntity.Ticker, Exchange = !string.IsNullOrWhiteSpace(matchEntity.Exchange) ? matchEntity.Exchange : GetExchangeDisplayName(matchEntity.Ticker) }; } else { activeQueryTicker = new TickerInfoDto { Ticker = requestedTicker, Exchange = GetExchangeDisplayName(requestedTicker) }; } } else { activeQueryTicker = yahooPrimaryTicker; } if (string.IsNullOrWhiteSpace(activeQueryTicker.Exchange)) { activeQueryTicker = new TickerInfoDto { Ticker = activeQueryTicker.Ticker, Exchange = GetExchangeDisplayName(activeQueryTicker.Ticker) }; } await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-TICKER-RESOLVED] PrimaryTicker: '{Primary}' | ActiveQueryTicker: '{Active}' für ISIN {Isin}", yahooPrimaryTicker.Ticker, activeQueryTicker.Ticker, cleanIsin); // --- STEP 3 & 4: Yahoo Finance API & HTML Fallback über Scraper --- // Profile (Sektor, Industrie, Vorstände) wird gescrapt, wenn weder in DB noch in TR Vorstände/Beschreibungen vorliegen bool hasProfileInDb = assetData != null && !string.IsNullOrWhiteSpace(assetData.Description) && assetData.KeyExecutives != null && assetData.KeyExecutives.Count > 0; bool hasCeoInTr = trDetails?.Company != null && !string.IsNullOrWhiteSpace(trDetails.Company.CeoName); bool needProfile = !hasProfileInDb && !hasCeoInTr; YahooQuoteSummaryModulesDto? modulesDto = null; if (!string.IsNullOrWhiteSpace(activeQueryTicker.Ticker) && activeQueryTicker.Ticker != cleanIsin) { modulesDto = await _scraper.GetQuoteSummaryModulesAsync( activeQueryTicker.Ticker, forceHtmlScrape: forceHtmlFallback, includeProfile: needProfile, cancellationToken: cancellationToken); } else { await _finlyticLogger.LogWarningAsync(SettingKeys.FundamentalsChannel, "[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", activeQueryTicker.Ticker); } // Falls der Sekundär-Ticker (z. B. APC.DE) überhaupt keine Daten liefert, nutze den PrimaryTicker (z. B. AAPL) als Fallback if (modulesDto == null && !string.IsNullOrWhiteSpace(yahooPrimaryTicker.Ticker) && yahooPrimaryTicker.Ticker != activeQueryTicker.Ticker && yahooPrimaryTicker.Ticker != cleanIsin) { await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-FALLBACK-PRIMARY] Sekundär-Ticker '{Active}' lieferte keine Daten. Versuche PrimaryTicker '{Primary}'...", activeQueryTicker.Ticker, yahooPrimaryTicker.Ticker); modulesDto = await _scraper.GetQuoteSummaryModulesAsync( yahooPrimaryTicker.Ticker, forceHtmlScrape: forceHtmlFallback, includeProfile: needProfile, cancellationToken: cancellationToken); } // --- Update AssetDataEntity --- if (assetData == null) { assetData = new AssetDataEntity { Isin = cleanIsin, PrimaryTicker = new TickerEntity { Ticker = yahooPrimaryTicker.Ticker, Exchange = yahooPrimaryTicker.Exchange ?? "Unknown" }, KeyExecutives = new List(), AssetEvents = new List() }; context.AssetData.Add(assetData); } string trName = trDetails?.Company?.Name?.Trim() ?? string.Empty; string trDescription = trDetails?.Company?.Description?.Trim() ?? string.Empty; string yahooName = modulesDto?.QuoteType?.LongName?.Trim() ?? modulesDto?.QuoteType?.ShortName?.Trim() ?? string.Empty; string yahooDesc = modulesDto?.AssetProfile?.LongBusinessSummary?.Trim() ?? string.Empty; // Name nur aktualisieren, wenn ein echter Name vorliegt (Bestandsdaten niemals mit ISIN/Ticker überschreiben) if (!string.IsNullOrWhiteSpace(trName)) { assetData.Name = trName; } else if (!string.IsNullOrWhiteSpace(yahooName)) { assetData.Name = yahooName; } else if (string.IsNullOrWhiteSpace(assetData.Name)) { assetData.Name = !string.IsNullOrWhiteSpace(activeQueryTicker.Ticker) ? activeQueryTicker.Ticker : cleanIsin; } // Description nur aktualisieren, wenn neue Beschreibung vorhanden ist if (!string.IsNullOrWhiteSpace(trDescription)) { assetData.Description = trDescription; } else if (!string.IsNullOrWhiteSpace(yahooDesc)) { assetData.Description = yahooDesc; } // PrimaryTicker aktualisieren falls vorhanden if (!string.IsNullOrWhiteSpace(yahooPrimaryTicker.Ticker) && yahooPrimaryTicker.Ticker != cleanIsin) { assetData.PrimaryTicker = new TickerEntity { Ticker = yahooPrimaryTicker.Ticker, Exchange = yahooPrimaryTicker.Exchange ?? "Unknown" }; } // AvailableTickers aktualisieren (nur echte Börsenticker, keine ISINs) var validTickers = resolvedTickers .Where(t => !string.IsNullOrWhiteSpace(t.Ticker) && !t.Ticker.Contains(cleanIsin, StringComparison.OrdinalIgnoreCase)) .ToList(); if (validTickers.Count > 0) { if (!validTickers.Any(t => string.Equals(t.Ticker, yahooPrimaryTicker.Ticker, StringComparison.OrdinalIgnoreCase))) { validTickers.Insert(0, yahooPrimaryTicker); } assetData.AvailableTickers.Clear(); foreach (var a in validTickers) { assetData.AvailableTickers.Add(new TickerEntity { Ticker = a.Ticker, Exchange = !string.IsNullOrWhiteSpace(a.Exchange) ? a.Exchange : GetExchangeDisplayName(a.Ticker) }); } } await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-ASSET-SAVED] AssetData gesetzt -> Name: '{Name}' | PrimaryTicker: '{Ticker}' | AvailableTickers: {Count}", assetData.Name, assetData.PrimaryTicker?.Ticker ?? "NULL", assetData.AvailableTickers.Count); // --- Process Trade Republic Corporate Events --- if (trDetails != null && assetData != null) { await context.AssetEvents .Where(e => e.AssetDataIsin == cleanIsin) .ExecuteDeleteAsync(cancellationToken); foreach (var entry in context.ChangeTracker.Entries() .Where(e => e.Entity.AssetDataIsin == cleanIsin) .ToList()) { entry.State = EntityState.Detached; } assetData.AssetEvents = new List(); var trEventList = new List(); if (trDetails.Events != null) trEventList.AddRange(trDetails.Events); if (trDetails.PastEvents != null) trEventList.AddRange(trDetails.PastEvents); foreach (var trEvt in trEventList) { if (!trEvt.Timestamp.HasValue) continue; var evtDate = DateTimeOffset.FromUnixTimeMilliseconds(trEvt.Timestamp.Value).UtcDateTime; var evtType = trEvt.Type ?? trEvt.Title ?? "EVENT"; bool isDuplicate = assetData.AssetEvents.Any(e => e.Date.Date == evtDate.Date && (string.Equals(e.Type, evtType, StringComparison.OrdinalIgnoreCase) || (trEvt.Title != null && string.Equals(e.Type, trEvt.Title, StringComparison.OrdinalIgnoreCase)))); if (!isDuplicate) { var newEvent = new AssetEventEntity { AssetDataIsin = cleanIsin, Ticker = new TickerEntity { Ticker = yahooPrimaryTicker.Ticker, Exchange = yahooPrimaryTicker.Exchange ?? "Unknown" }, Type = evtType, Date = evtDate }; context.AssetEvents.Add(newEvent); assetData.AssetEvents.Add(newEvent); } } } // --- Process Modules DTO (Executives & Fundamental Data) --- if (modulesDto != null || trDetails?.Company != null) { // Update KeyExecutives wenn Executives aus TR oder Yahoo vorliegen var yahooOfficers = modulesDto?.AssetProfile?.CompanyOfficers; bool hasTrOfficers = trDetails?.Company != null && !string.IsNullOrWhiteSpace(trDetails.Company.CeoName); if (((yahooOfficers != null && yahooOfficers.Count > 0) || hasTrOfficers) && assetData != null) { await context.KeyExecutives .Where(e => e.AssetDataIsin == cleanIsin) .ExecuteDeleteAsync(cancellationToken); foreach (var entry in context.ChangeTracker.Entries() .Where(e => e.Entity.AssetDataIsin == cleanIsin) .ToList()) { entry.State = EntityState.Detached; } assetData.KeyExecutives = new List(); if (yahooOfficers != null && yahooOfficers.Count > 0) { int sortIdx = 0; foreach (var officer in yahooOfficers) { if (!string.IsNullOrWhiteSpace(officer.Name)) { var newExec = new KeyExecutiveEntity { AssetDataIsin = cleanIsin, Name = officer.Name, Title = officer.Title ?? string.Empty, Payment = officer.TotalPay?.Fmt ?? (officer.TotalPay?.Raw?.ToString() ?? string.Empty), SortOrder = sortIdx++ }; context.KeyExecutives.Add(newExec); assetData.KeyExecutives.Add(newExec); } } } else if (hasTrOfficers && trDetails?.Company != null) { int sortIdx = 0; if (!string.IsNullOrWhiteSpace(trDetails.Company.CeoName)) { var ceo = new KeyExecutiveEntity { AssetDataIsin = cleanIsin, Name = trDetails.Company.CeoName, Title = "CEO", Payment = string.Empty, SortOrder = sortIdx++ }; context.KeyExecutives.Add(ceo); assetData.KeyExecutives.Add(ceo); } if (!string.IsNullOrWhiteSpace(trDetails.Company.CfoName)) { var cfo = new KeyExecutiveEntity { AssetDataIsin = cleanIsin, Name = trDetails.Company.CfoName, Title = "CFO", Payment = string.Empty, SortOrder = sortIdx++ }; context.KeyExecutives.Add(cfo); assetData.KeyExecutives.Add(cfo); } if (!string.IsNullOrWhiteSpace(trDetails.Company.CooName)) { var coo = new KeyExecutiveEntity { AssetDataIsin = cleanIsin, Name = trDetails.Company.CooName, Title = "COO", Payment = string.Empty, SortOrder = sortIdx++ }; context.KeyExecutives.Add(coo); assetData.KeyExecutives.Add(coo); } } await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-EXECUTIVES-SAVED] {Count} Executives zu DB hinzugefügt.", assetData.KeyExecutives.Count); } // Update FundamentalDataEntity if (modulesDto != null && (modulesDto.SummaryDetail != null || modulesDto.DefaultKeyStatistics != null || modulesDto.FinancialData != null)) { if (fundamentalData == null || !string.Equals(fundamentalData.Ticker.Ticker, activeQueryTicker.Ticker, StringComparison.OrdinalIgnoreCase)) { fundamentalData = assetData?.FundamentalData? .FirstOrDefault(f => string.Equals(f.Ticker.Ticker, activeQueryTicker.Ticker, StringComparison.OrdinalIgnoreCase)); } if (fundamentalData == null) { fundamentalData = new FundamentalDataEntity { Id = Guid.NewGuid(), AssetDataIsin = cleanIsin }; context.FundamentalData.Add(fundamentalData); assetData?.FundamentalData.Add(fundamentalData); } fundamentalData.Ticker = new TickerEntity { Ticker = activeQueryTicker.Ticker, Exchange = activeQueryTicker.Exchange ?? "Unknown" }; fundamentalData.MarketCap = (decimal?)modulesDto.SummaryDetail?.MarketCap?.Raw; fundamentalData.EnterpriseValue = (decimal?)modulesDto.DefaultKeyStatistics?.EnterpriseValue?.Raw; fundamentalData.TrailingPe = (decimal?)modulesDto.SummaryDetail?.TrailingPE?.Raw; fundamentalData.ForwardPe = (decimal?)modulesDto.DefaultKeyStatistics?.ForwardPE?.Raw ?? (decimal?)modulesDto.SummaryDetail?.ForwardPE?.Raw; fundamentalData.PegRatio = (decimal?)modulesDto.DefaultKeyStatistics?.PegRatio?.Raw; fundamentalData.PriceToSales = (decimal?)modulesDto.SummaryDetail?.PriceToSalesTrailing12Months?.Raw; fundamentalData.PriceToBook = (decimal?)modulesDto.DefaultKeyStatistics?.PriceToBook?.Raw; fundamentalData.EvToEbitda = (decimal?)modulesDto.DefaultKeyStatistics?.EnterpriseToEbitda?.Raw; fundamentalData.TotalRevenue = (decimal?)modulesDto.FinancialData?.TotalRevenue?.Raw; fundamentalData.RevenueGrowthYoY = (decimal?)modulesDto.FinancialData?.RevenueGrowth?.Raw; fundamentalData.GrossProfit = (decimal?)modulesDto.FinancialData?.GrossMargins?.Raw ?? (decimal?)modulesDto.FinancialData?.GrossProfits?.Raw; fundamentalData.OperatingIncome = (decimal?)modulesDto.FinancialData?.OperatingMargins?.Raw; fundamentalData.Ebitda = (decimal?)modulesDto.FinancialData?.Ebitda?.Raw; fundamentalData.NetIncome = (decimal?)modulesDto.FinancialData?.ProfitMargins?.Raw; fundamentalData.DilutedEps = (decimal?)modulesDto.DefaultKeyStatistics?.TrailingEps?.Raw; fundamentalData.TotalCash = (decimal?)modulesDto.FinancialData?.TotalCash?.Raw; fundamentalData.TotalDebt = (decimal?)modulesDto.FinancialData?.TotalDebt?.Raw; fundamentalData.DebtToEquity = (decimal?)modulesDto.FinancialData?.DebtToEquity?.Raw; fundamentalData.CurrentRatio = (decimal?)modulesDto.FinancialData?.CurrentRatio?.Raw; fundamentalData.OperatingCashFlow = (decimal?)modulesDto.FinancialData?.OperatingCashflow?.Raw; fundamentalData.FreeCashFlow = (decimal?)modulesDto.FinancialData?.FreeCashflow?.Raw; fundamentalData.ReturnOnEquity = (decimal?)modulesDto.FinancialData?.ReturnOnEquity?.Raw; fundamentalData.ReturnOnAssets = (decimal?)modulesDto.FinancialData?.ReturnOnAssets?.Raw; fundamentalData.ForwardDividendYield = (decimal?)modulesDto.SummaryDetail?.DividendYield?.Raw; fundamentalData.PayoutRatio = (decimal?)modulesDto.SummaryDetail?.PayoutRatio?.Raw; fundamentalData.FiftyTwoWeekHigh = (decimal?)modulesDto.SummaryDetail?.FiftyTwoWeekHigh?.Raw; fundamentalData.FiftyTwoWeekLow = (decimal?)modulesDto.SummaryDetail?.FiftyTwoWeekLow?.Raw; fundamentalData.ConsensusRating = modulesDto.FinancialData?.RecommendationKey; fundamentalData.PriceTargetLow = (decimal?)modulesDto.FinancialData?.TargetLowPrice?.Raw; fundamentalData.PriceTargetMean = (decimal?)modulesDto.FinancialData?.TargetMeanPrice?.Raw; fundamentalData.PriceTargetHigh = (decimal?)modulesDto.FinancialData?.TargetHighPrice?.Raw; fundamentalData.PercentHeldByInstitutions = (decimal?)modulesDto.DefaultKeyStatistics?.HeldPercentInstitutions?.Raw; fundamentalData.PercentHeldByInsiders = (decimal?)modulesDto.DefaultKeyStatistics?.HeldPercentInsiders?.Raw; fundamentalData.ShortPercentOfFloat = (decimal?)modulesDto.DefaultKeyStatistics?.ShortPercentOfFloat?.Raw; fundamentalData.ShortRatio = (decimal?)modulesDto.DefaultKeyStatistics?.ShortRatio?.Raw; fundamentalData.LastUpdatedUtc = DateTime.UtcNow; await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-FUNDAMENTALS-SAVED] FundamentalData gesetzt -> MarketCap: {MC} | PE: {PE}", fundamentalData.MarketCap ?? (object)"null", fundamentalData.TrailingPe ?? (object)"null"); } } try { await context.SaveChangesAsync(cancellationToken); } catch (Microsoft.EntityFrameworkCore.DbUpdateConcurrencyException ex) { foreach (var entry in ex.Entries) { await _finlyticLogger.LogErrorAsync(SettingKeys.FundamentalsChannel, "[DEBUG-CONCURRENCY-FAIL] Failed to save entity: {EntityType}, State: {State}", entry.Entity.GetType().Name, entry.State.ToString()); } throw; } } if (assetData == null) return null; var executivesList = (assetData.KeyExecutives ?? Enumerable.Empty()) .OrderBy(e => e.SortOrder > 0 ? e.SortOrder : GetExecutiveRank(e.Title)) .ThenBy(e => GetExecutiveRank(e.Title)) .ToList(); var eventsList = assetData.AssetEvents?.ToList() ?? new List(); return MapToDto(assetData, fundamentalData, executivesList, eventsList); } finally { isinLock.Release(); } } /// public async Task> GetAllEventsAsync(CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var events = await context.AssetEvents .Include(e => e.AssetData) .AsNoTracking() .ToListAsync(cancellationToken); return events.Select(e => new CorporateEventDto { Id = e.Id, Isin = e.AssetData.Isin, Ticker = e.Ticker != null ? new TickerInfoDto { Ticker = e.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(e.Ticker.Exchange) ? e.Ticker.Exchange : GetExchangeDisplayName(e.Ticker.Ticker) } : new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" }, CompanyName = e.AssetData.Name, Type = e.Type, Date = e.Date }).OrderBy(e => e.Date).ToList(); } /// public async Task> GetEventsByMonthAsync(int year, int month, CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var startOfMonth = new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Utc); var startOfNextMonth = startOfMonth.AddMonths(1); var events = await context.AssetEvents .Include(e => e.AssetData) .AsNoTracking() .Where(e => e.Date >= startOfMonth && e.Date < startOfNextMonth) .ToListAsync(cancellationToken); return events.Select(e => new CorporateEventDto { Id = e.Id, Isin = e.AssetData.Isin, CompanyName = e.AssetData.Name, Ticker = e.Ticker != null ? new TickerInfoDto { Ticker = e.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(e.Ticker.Exchange) ? e.Ticker.Exchange : GetExchangeDisplayName(e.Ticker.Ticker) } : new TickerInfoDto { Ticker = "Unknown", Exchange = "Unknown" }, Type = e.Type, Date = e.Date }).OrderBy(e => e.Date).ToList(); } private static AssetFundamentalsDto MapToDto( AssetDataEntity assetData, FundamentalDataEntity? fundData, List executives, List events) { var tickerEntities = assetData.AvailableTickers != null && assetData.AvailableTickers.Count > 0 ? assetData.AvailableTickers : (assetData.PrimaryTicker != null ? new List { assetData.PrimaryTicker } : new List()); var tickerDtos = tickerEntities .Where(t => t != null && !string.IsNullOrWhiteSpace(t.Ticker) && !t.Ticker.Contains(assetData.Isin, StringComparison.OrdinalIgnoreCase)) .Select(a => new TickerInfoDto { Ticker = a.Ticker, Exchange = !string.IsNullOrWhiteSpace(a.Exchange) ? a.Exchange : GetExchangeDisplayName(a.Ticker) }) .ToList(); var primaryTickerDto = assetData.PrimaryTicker != null && !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Ticker) ? new TickerInfoDto { Ticker = assetData.PrimaryTicker.Ticker, Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange) ? assetData.PrimaryTicker.Exchange : GetExchangeDisplayName(assetData.PrimaryTicker.Ticker) } : (tickerDtos.FirstOrDefault() ?? new TickerInfoDto { Ticker = assetData.Isin, Exchange = "Unknown" }); if (!tickerDtos.Any(t => string.Equals(t.Ticker, primaryTickerDto.Ticker, StringComparison.OrdinalIgnoreCase))) { tickerDtos.Insert(0, primaryTickerDto); } return new AssetFundamentalsDto { Asset = new AssetHeaderDto { Isin = assetData.Isin, Name = assetData.Name, Description = assetData.Description, PrimaryTicker = primaryTickerDto, AvailableTickers = tickerDtos }, Fundamentals = fundData != null ? new FundamentalDataDto { Ticker = fundData.Ticker != null && !string.IsNullOrWhiteSpace(fundData.Ticker.Ticker) ? new TickerInfoDto { Ticker = fundData.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(fundData.Ticker.Exchange) ? fundData.Ticker.Exchange : GetExchangeDisplayName(fundData.Ticker.Ticker) } : primaryTickerDto, MarketCap = fundData.MarketCap, EnterpriseValue = fundData.EnterpriseValue, TrailingPe = fundData.TrailingPe, ForwardPe = fundData.ForwardPe, PegRatio = fundData.PegRatio, PriceToSales = fundData.PriceToSales, PriceToBook = fundData.PriceToBook, EvToEbitda = fundData.EvToEbitda, TotalRevenue = fundData.TotalRevenue, RevenueGrowthYoY = fundData.RevenueGrowthYoY, GrossProfit = fundData.GrossProfit, OperatingIncome = fundData.OperatingIncome, Ebitda = fundData.Ebitda, NetIncome = fundData.NetIncome, DilutedEps = fundData.DilutedEps, TotalCash = fundData.TotalCash, TotalDebt = fundData.TotalDebt, DebtToEquity = fundData.DebtToEquity, CurrentRatio = fundData.CurrentRatio, OperatingCashFlow = fundData.OperatingCashFlow, FreeCashFlow = fundData.FreeCashFlow, ReturnOnEquity = fundData.ReturnOnEquity, ReturnOnAssets = fundData.ReturnOnAssets, ForwardDividendYield = fundData.ForwardDividendYield, PayoutRatio = fundData.PayoutRatio, FiftyTwoWeekHigh = fundData.FiftyTwoWeekHigh, FiftyTwoWeekLow = fundData.FiftyTwoWeekLow, ConsensusRating = fundData.ConsensusRating, PriceTargetLow = fundData.PriceTargetLow, PriceTargetMean = fundData.PriceTargetMean, PriceTargetHigh = fundData.PriceTargetHigh, PercentHeldByInstitutions = fundData.PercentHeldByInstitutions, PercentHeldByInsiders = fundData.PercentHeldByInsiders, ShortPercentOfFloat = fundData.ShortPercentOfFloat, ShortRatio = fundData.ShortRatio, LastUpdatedUtc = fundData.LastUpdatedUtc } : null, Executives = executives .OrderBy(e => e.SortOrder > 0 ? e.SortOrder : GetExecutiveRank(e.Title)) .ThenBy(e => GetExecutiveRank(e.Title)) .Select(e => new KeyExecutiveDto { Id = e.Id, Name = e.Name, Title = e.Title, Payment = e.Payment, SortOrder = e.SortOrder }).ToList(), Events = events.Select(e => new CorporateEventDto { Id = e.Id, Ticker = e.Ticker != null && !string.IsNullOrWhiteSpace(e.Ticker.Ticker) ? new TickerInfoDto { Ticker = e.Ticker.Ticker, Exchange = !string.IsNullOrWhiteSpace(e.Ticker.Exchange) ? e.Ticker.Exchange : GetExchangeDisplayName(e.Ticker.Ticker) } : primaryTickerDto, Type = e.Type, Date = e.Date }).ToList(), LastUpdatedAt = fundData?.LastUpdatedUtc ?? DateTime.UtcNow }; } /// /// Leitet den Anzeigenamen der Börse aus dem Ticker-Suffix ab. /// private static string GetExchangeDisplayName(string symbol) { if (string.IsNullOrWhiteSpace(symbol)) return "Unknown"; if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return "Xetra"; if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) return "Frankfurt"; if (symbol.EndsWith(".STU", StringComparison.OrdinalIgnoreCase) || symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase)) return "Stuttgart"; if (symbol.EndsWith(".HM", StringComparison.OrdinalIgnoreCase)) return "Hamburg"; if (symbol.EndsWith(".MU", StringComparison.OrdinalIgnoreCase)) return "München"; if (symbol.EndsWith(".DU", StringComparison.OrdinalIgnoreCase)) return "Düsseldorf"; if (symbol.EndsWith(".BE", StringComparison.OrdinalIgnoreCase)) return "Berlin"; if (symbol.EndsWith(".L", StringComparison.OrdinalIgnoreCase)) return "London"; if (symbol.EndsWith(".PA", StringComparison.OrdinalIgnoreCase)) return "Paris"; if (symbol.EndsWith(".AS", StringComparison.OrdinalIgnoreCase)) return "Amsterdam"; if (symbol.EndsWith(".MI", StringComparison.OrdinalIgnoreCase)) return "Mailand"; if (symbol.EndsWith(".MC", StringComparison.OrdinalIgnoreCase)) return "Madrid"; if (symbol.EndsWith(".SW", StringComparison.OrdinalIgnoreCase)) return "Zürich"; if (symbol.EndsWith(".TO", StringComparison.OrdinalIgnoreCase)) return "Toronto"; if (symbol.EndsWith(".AX", StringComparison.OrdinalIgnoreCase)) return "Sydney"; if (symbol.EndsWith(".T", StringComparison.OrdinalIgnoreCase)) return "Tokyo"; if (symbol.EndsWith(".HK", StringComparison.OrdinalIgnoreCase)) return "Hong Kong"; // Kein Suffix -> US-Börse (NASDAQ / NYSE) if (!symbol.Contains('.')) return "US"; return "Other"; } private static int GetExecutiveRank(string title) { if (string.IsNullOrWhiteSpace(title)) return 99; var t = title.ToUpperInvariant(); if (t.Contains("CEO") || t.Contains("CHIEF EXECUTIVE") || t.Contains("VORSTANDSVORSITZEND") || t.Contains("MANAGING DIRECTOR")) return 1; if (t.Contains("CFO") || t.Contains("CHIEF FINANCIAL") || t.Contains("FINANZVORSTAND")) return 2; if (t.Contains("COO") || t.Contains("CHIEF OPERATING")) return 3; if (t.Contains("CTO") || t.Contains("CHIEF TECHNOLOGY") || t.Contains("CIO") || t.Contains("CHIEF INFORMATION")) return 4; if (t.Contains("CMO") || t.Contains("CHIEF MARKETING") || t.Contains("CHIEF COMMERCIAL")) return 5; if (t.Contains("PRESIDENT") || t.Contains("EXECUTIVE VICE PRESIDENT") || t.Contains("EVP") || t.Contains("GENERAL COUNSEL") || t.Contains("CHIEF LEGAL")) return 6; if (t.Contains("SENIOR VICE PRESIDENT") || t.Contains("SVP") || t.Contains("VICE PRESIDENT") || t.Contains("VP")) return 7; if (t.Contains("DIRECTOR") || t.Contains("AUFSICHTSRAT") || t.Contains("VORSTAND") || t.Contains("BOARD")) return 8; return 10; } }