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 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); 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} | EnableHtmlFallback: {Html}", cleanIsin, requestedTicker ?? "NULL", forceRefresh, enableHtmlFallback); // 2. Entitäten aus DB laden var assetData = await context.AssetData .Include(a => a.AvailableTickers) .Include(a => a.KeyExecutives) .Include(a => a.AssetEvents) .FirstOrDefaultAsync(a => a.Isin == cleanIsin, cancellationToken); var fundamentalData = await context.FundamentalData .FirstOrDefaultAsync(f => f.Isin == cleanIsin, cancellationToken); // 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 fundamentalsExpired = fundamentalData == null || (DateTime.UtcNow - fundamentalData.LastUpdatedUtc).TotalDays > validityDays; // Wenn ein expliziter Ticker übergeben wurde und sich vom gespeicherten unterscheidet, // müssen Asset-Daten und Fundamentals mit dem neuen Ticker neu abgerufen werden. bool tickerChanged = !string.IsNullOrWhiteSpace(requestedTicker) && assetData?.PrimaryTicker != null && !string.Equals(assetData.PrimaryTicker.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase); bool shouldUpdateAssetData = assetDataMissing || effectiveForceRefresh || tickerChanged; bool shouldUpdateExecutives = executivesMissing || effectiveForceRefresh; bool shouldUpdateFundamentals = fundamentalsExpired || effectiveForceRefresh || tickerChanged; if (shouldUpdateAssetData || shouldUpdateExecutives || shouldUpdateFundamentals) { // --- 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 (Null-safe) --- TickerInfoDto primaryTicker; if (!string.IsNullOrWhiteSpace(requestedTicker)) { var match = assetData?.AvailableTickers? .FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)); if (match != null) { primaryTicker = new TickerInfoDto { Ticker = match.Ticker, Exchange = !string.IsNullOrWhiteSpace(match.Exchange) ? match.Exchange : GetExchangeDisplayName(match.Ticker) }; } else if (assetData?.PrimaryTicker != null && string.Equals(assetData.PrimaryTicker.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)) { primaryTicker = new TickerInfoDto { Ticker = assetData.PrimaryTicker.Ticker, Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange) ? assetData.PrimaryTicker.Exchange : GetExchangeDisplayName(assetData.PrimaryTicker.Ticker) }; } else { primaryTicker = new TickerInfoDto { Ticker = requestedTicker, Exchange = GetExchangeDisplayName(requestedTicker) }; } } else if (assetData?.PrimaryTicker != null && !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Ticker)) { primaryTicker = new TickerInfoDto { Ticker = assetData.PrimaryTicker.Ticker, Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange) ? assetData.PrimaryTicker.Exchange : GetExchangeDisplayName(assetData.PrimaryTicker.Ticker) }; } else { var resolved = await _scraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken); primaryTicker = resolved != null && !string.IsNullOrWhiteSpace(resolved.Ticker) ? resolved : new TickerInfoDto { Ticker = cleanIsin, Exchange = "Unknown" }; } if (string.IsNullOrWhiteSpace(primaryTicker.Exchange)) { primaryTicker = new TickerInfoDto { Ticker = primaryTicker.Ticker, Exchange = GetExchangeDisplayName(primaryTicker.Ticker) }; } await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-TICKER-RESOLVED] Ticker aufgelöst zu: '{Ticker}' (Exchange: '{Exchange}') für ISIN {Isin}", primaryTicker.Ticker, primaryTicker.Exchange ?? "Unknown", cleanIsin); // --- STEP 3 & 4: Yahoo Finance API & HTML Fallback über Scraper --- YahooQuoteSummaryModulesDto? modulesDto = null; if (!string.IsNullOrWhiteSpace(primaryTicker.Ticker) && primaryTicker.Ticker != cleanIsin) { modulesDto = await _scraper.GetQuoteSummaryModulesAsync( primaryTicker.Ticker, forceHtmlScrape: false, cancellationToken: cancellationToken); } else { await _finlyticLogger.LogWarningAsync(SettingKeys.FundamentalsChannel, "[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", primaryTicker.Ticker); } // --- Update AssetDataEntity --- if (shouldUpdateAssetData) { if (assetData == null) { assetData = new AssetDataEntity { Isin = cleanIsin, PrimaryTicker = new TickerEntity { Ticker = primaryTicker.Ticker, Exchange = primaryTicker.Exchange ?? "Unknown" }, KeyExecutives = new List(), AssetEvents = new List() }; context.AssetData.Add(assetData); } string trName = trDetails?.Company?.Name ?? string.Empty; string trDescription = trDetails?.Company?.Description ?? string.Empty; string fallbackName = modulesDto?.QuoteType?.ShortName ?? modulesDto?.QuoteType?.LongName ?? primaryTicker.Ticker; assetData.Name = !string.IsNullOrWhiteSpace(trName) ? trName : fallbackName; assetData.Description = !string.IsNullOrWhiteSpace(trDescription) ? trDescription : (modulesDto?.AssetProfile?.LongBusinessSummary ?? string.Empty); assetData.PrimaryTicker = new TickerEntity { Ticker = primaryTicker.Ticker, Exchange = primaryTicker.Exchange ?? "Unknown" }; var tickers = await _scraper.ResolveAllTickersFromIsinAsync(cleanIsin, cancellationToken); if (!tickers.Any(t => string.Equals(t.Ticker, primaryTicker.Ticker, StringComparison.OrdinalIgnoreCase))) { tickers.Insert(0, primaryTicker); } assetData.AvailableTickers.Clear(); foreach (var a in tickers) { 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}'", assetData.Name, assetData.PrimaryTicker.Ticker); } // --- Process Trade Republic Corporate Events --- if (trDetails != null && (shouldUpdateAssetData || effectiveForceRefresh) && assetData != null) { 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) { assetData.AssetEvents.Add(new AssetEventEntity { AssetDataIsin = cleanIsin, Ticker = new TickerEntity { Ticker = primaryTicker.Ticker, Exchange = primaryTicker.Exchange ?? "Unknown" }, Type = evtType, Date = evtDate }); } } } // --- Process Modules DTO (Executives & Fundamental Data) --- if (modulesDto != null) { // Update KeyExecutives if (shouldUpdateExecutives && assetData != null) { // 1. Alte Executives direkt in der DB löschen (bypasses Change Tracker) await context.KeyExecutives .Where(e => e.AssetDataIsin == cleanIsin) .ExecuteDeleteAsync(cancellationToken); // 2. ALLE tracked KeyExecutiveEntity-Einträge aus dem Change Tracker entfernen // (nicht nur die in der Navigation-Collection — der Tracker kann mehr halten) foreach (var entry in context.ChangeTracker.Entries() .Where(e => e.Entity.AssetDataIsin == cleanIsin) .ToList()) { entry.State = EntityState.Detached; } // 3. Navigation-Collection zurücksetzen assetData.KeyExecutives = new List(); // 4. Neue Executives aufbauen und direkt über den DbSet hinzufügen if (modulesDto.AssetProfile?.CompanyOfficers != null) { foreach (var officer in modulesDto.AssetProfile.CompanyOfficers) { 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) }; context.KeyExecutives.Add(newExec); assetData.KeyExecutives.Add(newExec); } } } await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, "[DEBUG-EXECUTIVES-SAVED] {Count} Executives zu DB hinzugefügt.", assetData.KeyExecutives.Count); } // Update FundamentalDataEntity if (shouldUpdateFundamentals) { if (fundamentalData == null) { fundamentalData = new FundamentalDataEntity { Isin = cleanIsin, AssetDataIsin = cleanIsin }; context.FundamentalData.Add(fundamentalData); } fundamentalData.Ticker = new TickerEntity { Ticker = primaryTicker.Ticker, Exchange = primaryTicker.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?.ToList() ?? new List(); 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)) .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.Select(e => new KeyExecutiveDto { Id = e.Id, Name = e.Name, Title = e.Title, Payment = e.Payment }).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"; } }