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.Services.Yahoo; using FinlyticFundamentals.Database; using FinlyticFundamentals.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; namespace FinlyticFundamentals.Services; public interface IFundamentalsDbService { /// /// Gets the fundamental data for a given ISIN. /// If a specific ticker is provided, the resolution pipeline prioritizes/fetches only that ticker. /// /// The ISIN identifier of the asset. /// Optional specific ticker symbol (e.g., "APC.DE"). If omitted, tickers are resolved automatically. /// If true, forces a full static scrape for profile, financials, and executives. /// Cancellation token. /// The mapped or null if unavailable. Task GetFundamentalsAsync( string isin, string? ticker = null, bool forceRefresh = false, CancellationToken cancellationToken = default); /// /// Gets all upcoming and historic corporate events (e.g., earnings releases, ex-dividend dates). /// /// Cancellation token. /// A list of corporate events sorted chronologically. Task> GetAllEventsAsync(CancellationToken cancellationToken = default); /// /// Gets corporate events for a specific month. /// 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 IHtmlFallbackScraper _fallbackScraper; private readonly YahooFinanceClient _yahooClient; private readonly ILogger _logger; public FundamentalsDbService( IServiceScopeFactory scopeFactory, IYahooFinanceScraper scraper, IHtmlFallbackScraper fallbackScraper, YahooFinanceClient yahooClient, ILogger logger) { _scopeFactory = scopeFactory; _scraper = scraper; _fallbackScraper = fallbackScraper; _yahooClient = yahooClient; _logger = logger; } /// 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(); using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var isinLock = IsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1)); await isinLock.WaitAsync(cancellationToken); try { // 1. Aus DB laden var entity = await LoadEntityGraphAsync(context, cleanIsin, cancellationToken); // Statische Daten älter als 30 Tage oder forced? bool needsStaticScrape = entity == null || forceRefresh || (DateTime.UtcNow - entity.LastStaticUpdatedAt).TotalDays > 30; if (needsStaticScrape) { entity = await ExecuteFullScrapeAndPersistAsync(context, cleanIsin, requestedTicker, entity, cancellationToken); } else { // Statik ist frisch -> Prüfen ob requested Ticker existiert oder neu nachgeladen werden muss entity = await EnsureTickerDataUpToDateAsync(context, cleanIsin, requestedTicker, entity!, cancellationToken); } return entity != null ? MapToDto(entity, requestedTicker) : null; } catch (Exception ex) { _logger.LogError(ex, "[{Channel}] Failed to process fundamentals for ISIN {Isin}", "FundamentalsChannel", cleanIsin); // Fallback auf Datenbankstand, falls vorhanden var fallback = await LoadEntityGraphAsync(context, cleanIsin, cancellationToken); return fallback != null ? MapToDto(fallback, requestedTicker) : null; } finally { isinLock.Release(); } } #region Internal Logic Pipelines /// /// Stellt sicher, dass der angeforderte Ticker existiert und dessen Live-Preise frisch sind (TTL: 15 Minuten). /// private async Task EnsureTickerDataUpToDateAsync( FundamentalsDbContext context, string isin, string? requestedTicker, AssetFundamentalsEntity entity, CancellationToken cancellationToken) { var targetTickerSymbol = requestedTicker ?? (entity.TickerFundamentals.FirstOrDefault(t => t.Ticker == entity.PrimaryTicker)?.Ticker ?? entity.TickerFundamentals.FirstOrDefault()?.Ticker); // Fall A: Ticker noch gar nicht in DB -> Einzel-Scrape für diesen Ticker durchführen if (!string.IsNullOrEmpty(targetTickerSymbol) && !entity.TickerFundamentals.Any(t => t.Ticker.Equals(targetTickerSymbol, StringComparison.OrdinalIgnoreCase))) { _logger.LogInformation("[{Channel}] Targeted ticker '{Ticker}' missing in DB for ISIN {Isin}. Fetching on-demand...", "FundamentalsChannel", targetTickerSymbol, isin); var scraped = await _scraper.ScrapeFundamentalsAsync(isin, targetTickerSymbol, cancellationToken); if (scraped?.TickerData != null) { entity.TickerFundamentals.Add(scraped.TickerData); await context.SaveChangesAsync(cancellationToken); } return entity; } // Fall B: Ticker existiert -> Prüfen ob Live-Kurs älter als 15 Minuten ist var targetTickerEntity = entity.TickerFundamentals.FirstOrDefault(t => t.Ticker.Equals(targetTickerSymbol, StringComparison.OrdinalIgnoreCase)); if (targetTickerEntity != null && (DateTime.UtcNow - targetTickerEntity.LastUpdatedAt).TotalMinutes > 15) { _logger.LogInformation("[{Channel}] Quote expired for ticker '{Ticker}'. Refreshing live price...", "FundamentalsChannel", targetTickerSymbol); var quotesResponse = await _yahooClient.GetQuotesAsync(new[] { targetTickerEntity.Ticker }, cancellationToken); var liveQuote = quotesResponse?.QuoteResponse?.Result?.FirstOrDefault(); if (liveQuote != null) { targetTickerEntity.CurrentPrice = (decimal?)liveQuote.RegularMarketPrice ?? targetTickerEntity.CurrentPrice; targetTickerEntity.DayChangeAbsolute = (decimal?)liveQuote.RegularMarketChange ?? targetTickerEntity.DayChangeAbsolute; targetTickerEntity.DayChangePercent = (decimal?)liveQuote.RegularMarketChangePercent ?? targetTickerEntity.DayChangePercent; targetTickerEntity.FiftyTwoWeekHigh = (decimal?)liveQuote.FiftyTwoWeekHigh ?? targetTickerEntity.FiftyTwoWeekHigh; targetTickerEntity.FiftyTwoWeekLow = (decimal?)liveQuote.FiftyTwoWeekLow ?? targetTickerEntity.FiftyTwoWeekLow; targetTickerEntity.MarketCapitalization = (decimal?)liveQuote.MarketCap ?? targetTickerEntity.MarketCapitalization; targetTickerEntity.LastUpdatedAt = DateTime.UtcNow; entity.LastUpdatedAt = DateTime.UtcNow; await context.SaveChangesAsync(cancellationToken); } } return entity; } /// /// Führt ein vollständiges Scraping der Bilanzen und Ticker durch und speichert das Ergebnis ab. /// private async Task ExecuteFullScrapeAndPersistAsync( FundamentalsDbContext context, string isin, string? requestedTicker, AssetFundamentalsEntity? existingEntity, CancellationToken cancellationToken) { _logger.LogInformation("[{Channel}] Initiating full static scrape for ISIN {Isin}...", "FundamentalsChannel", isin); List tickers = new(); if (!string.IsNullOrWhiteSpace(requestedTicker)) { tickers.Add(requestedTicker); } else { tickers = await _scraper.ResolveAllTickersFromIsinAsync(isin, cancellationToken); if (existingEntity?.TickerFundamentals != null) { foreach (var tf in existingEntity.TickerFundamentals) { if (!tickers.Contains(tf.Ticker, StringComparer.OrdinalIgnoreCase)) tickers.Add(tf.Ticker); } } } if (tickers.Count == 0) { _logger.LogWarning("[YahooFallbackScraper] No tickers resolved for ISIN {Isin}. Fallback scraper cannot be invoked without a ticker.", isin); return existingEntity; } var primaryTicker = tickers[0]; _logger.LogInformation("[YahooFallbackScraper] Primary ticker resolved: '{Ticker}' for ISIN {Isin}", primaryTicker, isin); var scraped = await _scraper.ScrapeFundamentalsAsync(isin, primaryTicker, cancellationToken); bool needsFallback = IsDataIncomplete(scraped, _logger); _logger.LogInformation("[YahooFallbackScraper] Primary scrape completeness check for '{Ticker}': scrapedIsNull={ScrapedIsNull}, needsFallback={NeedsFallback}", primaryTicker, scraped == null, needsFallback); if (needsFallback) { _logger.LogInformation("[YahooFallbackScraper] Executing Playwright Fallback Scraper for ticker '{Ticker}' (ISIN: {Isin})...", primaryTicker, isin); var fallbackData = await _fallbackScraper.ScrapeFallbackAsync(isin, primaryTicker, cancellationToken); if (fallbackData != null) { _logger.LogInformation("[YahooFallbackScraper] Fallback scraper returned data for {Ticker}. MarketCap={MarketCap}, EV={EV}, Sector='{Sector}'", primaryTicker, fallbackData.TickerData?.MarketCapitalization, fallbackData.TickerData?.EnterpriseValue, fallbackData.Fundamentals?.Sector); if (scraped == null) { _logger.LogInformation("[YahooFallbackScraper] Primary scraped data was null. Using entirely Playwright fallback data for {Ticker}...", primaryTicker); scraped = fallbackData; } else { _logger.LogInformation("[YahooFallbackScraper] Merging Playwright fallback data into primary scraped data for {Ticker}...", primaryTicker); // Merge fallback into scraped MergeFundamentals(scraped, fallbackData); } } else { _logger.LogWarning("[YahooFallbackScraper] Fallback scraper returned NULL for {Ticker}!", primaryTicker); } } // ------------------------------ if (scraped == null) return existingEntity; var tickerEntities = new List { scraped.TickerData }; // Sekundär-Ticker parallel laden (nur wenn kein spezifischer Ticker verlangt war) if (string.IsNullOrWhiteSpace(requestedTicker) && tickers.Count > 1) { var altTasks = tickers.Skip(1).Take(4).Select(async alt => { try { return await _scraper.ScrapeFundamentalsAsync(isin, alt, cancellationToken); } catch { return null; } }); var altResults = await Task.WhenAll(altTasks); foreach (var alt in altResults) { if (alt?.TickerData != null) tickerEntities.Add(alt.TickerData); } } // DB Upsert try { await SaveOrUpdateFundamentalsAsync(context, isin, primaryTicker, scraped, tickerEntities, cancellationToken); } catch (DbUpdateException ex) when (ex.InnerException is Npgsql.NpgsqlException npgEx && npgEx.SqlState == "23505") { context.ChangeTracker.Clear(); await SaveOrUpdateFundamentalsAsync(context, isin, primaryTicker, scraped, tickerEntities, cancellationToken); } return await LoadEntityGraphAsync(context, isin, cancellationToken); } private static void MergeFundamentals(ScrapedFundamentalsData target, ScrapedFundamentalsData source) { var t = target.TickerData; var s = source.TickerData; // Kennzahlen & Ratios if (t.MarketCapitalization == 0 && s.MarketCapitalization > 0) t.MarketCapitalization = s.MarketCapitalization; if ((t.EnterpriseValue == 0) && s.EnterpriseValue > 0) t.EnterpriseValue = s.EnterpriseValue; t.PeRatioTrailing ??= s.PeRatioTrailing; t.PeRatioForward ??= s.PeRatioForward; t.PegRatio ??= s.PegRatio; t.PbRatio ??= s.PbRatio; t.PsRatio ??= s.PsRatio; t.EvToEbitda ??= s.EvToEbitda; t.EvToRevenue ??= s.EvToRevenue; // Margen t.GrossMargin ??= s.GrossMargin; t.OperatingMargin ??= s.OperatingMargin; t.NetProfitMargin ??= s.NetProfitMargin; t.ReturnOnEquity ??= s.ReturnOnEquity; t.ReturnOnAssets ??= s.ReturnOnAssets; // Preise & Dividenden if (t.FiftyTwoWeekHigh == 0 && s.FiftyTwoWeekHigh > 0) t.FiftyTwoWeekHigh = s.FiftyTwoWeekHigh; if (t.FiftyTwoWeekLow == 0 && s.FiftyTwoWeekLow > 0) t.FiftyTwoWeekLow = s.FiftyTwoWeekLow; if ((!t.DividendYield.HasValue || t.DividendYield == 0) && s.DividendYield > 0) t.DividendYield = s.DividendYield; // Stammdaten if (string.IsNullOrWhiteSpace(target.Fundamentals.Sector)) target.Fundamentals.Sector = source.Fundamentals.Sector; if (string.IsNullOrWhiteSpace(target.Fundamentals.Industry)) target.Fundamentals.Industry = source.Fundamentals.Industry; if (!target.Fundamentals.Employees.HasValue) target.Fundamentals.Employees = source.Fundamentals.Employees; if (string.IsNullOrWhiteSpace(target.Fundamentals.BusinessSummary)) target.Fundamentals.BusinessSummary = source.Fundamentals.BusinessSummary; } private static bool IsDataIncomplete(ScrapedFundamentalsData? data, ILogger logger) { if (data == null || data.TickerData == null) { logger.LogWarning("[YahooFallbackScraper] IsDataIncomplete -> TRUE (scraped data or TickerData is NULL)"); return true; } var td = data.TickerData; var f = data.Fundamentals; int missingCriticalFields = 0; // 1. Absolute Must-Haves (sofortiger Fallback wenn 0) if (td.MarketCapitalization == 0) { logger.LogWarning("[YahooFallbackScraper] IsDataIncomplete -> TRUE (MarketCapitalization is 0)"); return true; } if (td.FiftyTwoWeekHigh == 0 || td.FiftyTwoWeekLow == 0) { logger.LogWarning("[YahooFallbackScraper] IsDataIncomplete -> TRUE (52WeekHigh={High} or 52WeekLow={Low} is 0)", td.FiftyTwoWeekHigh, td.FiftyTwoWeekLow); return true; } // 2. Bewertung & Ratios (Zähle fehlende Metriken) // KGV: Trailing ODER Forward muss vorhanden sein, sonst zählt die KGV-Bewertung als fehlend if ((!td.PeRatioTrailing.HasValue || td.PeRatioTrailing == 0) && (!td.PeRatioForward.HasValue || td.PeRatioForward == 0)) missingCriticalFields++; if (!td.PbRatio.HasValue || td.PbRatio == 0) missingCriticalFields++; if (!td.PsRatio.HasValue || td.PsRatio == 0) missingCriticalFields++; if (td.EnterpriseValue == 0) missingCriticalFields++; // 3. Margen & Profitabilität if (!td.GrossMargin.HasValue) missingCriticalFields++; if (!td.OperatingMargin.HasValue) missingCriticalFields++; if (!td.NetProfitMargin.HasValue) missingCriticalFields++; // 4. Stammdaten if (string.IsNullOrWhiteSpace(f.Sector)) missingCriticalFields++; if (string.IsNullOrWhiteSpace(f.Industry)) missingCriticalFields++; // Wenn 2 oder mehr der wichtigen Kennzahlen fehlen, gilt die Quelle als unvollständig bool isIncomplete = missingCriticalFields >= 2; logger.LogInformation("[YahooFallbackScraper] IsDataIncomplete total missingCriticalFields={Count} (threshold >= 2 -> isIncomplete={Result})", missingCriticalFields, isIncomplete); return isIncomplete; } #endregion #region Data Access & Mapping Helpers private static Task LoadEntityGraphAsync(FundamentalsDbContext context, string isin, CancellationToken ct) { return context.AssetFundamentals .AsNoTracking() .Include(f => f.Executives) .Include(f => f.FinancialStatements) .Include(f => f.Estimates) .Include(f => f.TickerFundamentals) .FirstOrDefaultAsync(f => f.Isin == isin, ct); } private async Task SaveOrUpdateFundamentalsAsync( FundamentalsDbContext context, string isin, string primaryTicker, ScrapedFundamentalsData scraped, List tickerEntities, CancellationToken cancellationToken) { var entity = await context.AssetFundamentals.FirstOrDefaultAsync(f => f.Isin == isin, cancellationToken); if (entity == null) { entity = scraped.Fundamentals; entity.Isin = isin; entity.PrimaryTicker = primaryTicker; entity.Executives = scraped.Executives; entity.FinancialStatements = scraped.Statements; entity.Estimates = scraped.Estimates; entity.TickerFundamentals = new List(); foreach (var ex in entity.Executives) { ex.Isin = isin; if (ex.Id == Guid.Empty) ex.Id = Guid.NewGuid(); } foreach (var stmt in entity.FinancialStatements) { stmt.Isin = isin; if (stmt.Id == Guid.Empty) stmt.Id = Guid.NewGuid(); } context.AssetFundamentals.Add(entity); } else { entity.PrimaryTicker = primaryTicker; entity.CompanyName = !string.IsNullOrWhiteSpace(scraped.Fundamentals.CompanyName) ? scraped.Fundamentals.CompanyName : entity.CompanyName; entity.BusinessSummary = !string.IsNullOrWhiteSpace(scraped.Fundamentals.BusinessSummary) ? scraped.Fundamentals.BusinessSummary : entity.BusinessSummary; entity.Sector = !string.IsNullOrWhiteSpace(scraped.Fundamentals.Sector) ? scraped.Fundamentals.Sector : entity.Sector; entity.Industry = !string.IsNullOrWhiteSpace(scraped.Fundamentals.Industry) ? scraped.Fundamentals.Industry : entity.Industry; entity.Country = !string.IsNullOrWhiteSpace(scraped.Fundamentals.Country) ? scraped.Fundamentals.Country : entity.Country; entity.Employees = scraped.Fundamentals.Employees ?? entity.Employees; entity.PercentHeldByInstitutions = scraped.Fundamentals.PercentHeldByInstitutions ?? entity.PercentHeldByInstitutions; entity.PercentHeldByInsiders = scraped.Fundamentals.PercentHeldByInsiders ?? entity.PercentHeldByInsiders; entity.ShortRatio = scraped.Fundamentals.ShortRatio ?? entity.ShortRatio; entity.ShortPercentOfFloat = scraped.Fundamentals.ShortPercentOfFloat ?? entity.ShortPercentOfFloat; if (!string.IsNullOrWhiteSpace(scraped.Fundamentals.ConsensusRating) && !scraped.Fundamentals.ConsensusRating.Equals("none", StringComparison.OrdinalIgnoreCase)) entity.ConsensusRating = scraped.Fundamentals.ConsensusRating; entity.PriceTargetLow = scraped.Fundamentals.PriceTargetLow ?? entity.PriceTargetLow; entity.PriceTargetHigh = scraped.Fundamentals.PriceTargetHigh ?? entity.PriceTargetHigh; entity.PriceTargetMedian = scraped.Fundamentals.PriceTargetMedian ?? entity.PriceTargetMedian; entity.PriceTargetMean = scraped.Fundamentals.PriceTargetMean ?? entity.PriceTargetMean; entity.ExDividendDate = scraped.Fundamentals.ExDividendDate ?? entity.ExDividendDate; entity.NextEarningsDate = scraped.Fundamentals.NextEarningsDate ?? entity.NextEarningsDate; entity.LastStaticUpdatedAt = DateTime.UtcNow; entity.LastUpdatedAt = DateTime.UtcNow; // Executives & Statements aktualisieren if (scraped.Executives.Count > 0) { await context.CompanyExecutives.Where(e => e.Isin == isin).ExecuteDeleteAsync(cancellationToken); foreach (var exec in scraped.Executives) { exec.Isin = isin; if (exec.Id == Guid.Empty) exec.Id = Guid.NewGuid(); context.CompanyExecutives.Add(exec); } } if (scraped.Statements.Count > 0) { var existingStmts = await context.FinancialStatements.Where(s => s.Isin == isin).ToListAsync(cancellationToken); foreach (var stmt in scraped.Statements) { var existingStmt = existingStmts.FirstOrDefault(s => s.PeriodType == stmt.PeriodType && s.EndDate.Date == stmt.EndDate.Date); if (existingStmt == null) { stmt.Isin = isin; if (stmt.Id == Guid.Empty) stmt.Id = Guid.NewGuid(); context.FinancialStatements.Add(stmt); } else { existingStmt.TotalRevenue = stmt.TotalRevenue ?? existingStmt.TotalRevenue; existingStmt.CostOfRevenue = stmt.CostOfRevenue ?? existingStmt.CostOfRevenue; existingStmt.GrossProfit = stmt.GrossProfit ?? existingStmt.GrossProfit; existingStmt.OperatingExpenses = stmt.OperatingExpenses ?? existingStmt.OperatingExpenses; existingStmt.OperatingIncome = stmt.OperatingIncome ?? existingStmt.OperatingIncome; existingStmt.Ebitda = stmt.Ebitda ?? existingStmt.Ebitda; existingStmt.NetIncome = stmt.NetIncome ?? existingStmt.NetIncome; existingStmt.CashAndCashEquivalents = stmt.CashAndCashEquivalents ?? existingStmt.CashAndCashEquivalents; existingStmt.TotalCurrentAssets = stmt.TotalCurrentAssets ?? existingStmt.TotalCurrentAssets; existingStmt.CurrentLiabilities = stmt.CurrentLiabilities ?? existingStmt.CurrentLiabilities; existingStmt.LongTermDebt = stmt.LongTermDebt ?? existingStmt.LongTermDebt; existingStmt.TotalLiabilities = stmt.TotalLiabilities ?? existingStmt.TotalLiabilities; existingStmt.TotalStockholdersEquity = stmt.TotalStockholdersEquity ?? existingStmt.TotalStockholdersEquity; existingStmt.OperatingCashFlow = stmt.OperatingCashFlow ?? existingStmt.OperatingCashFlow; existingStmt.InvestingCashFlow = stmt.InvestingCashFlow ?? existingStmt.InvestingCashFlow; existingStmt.CapitalExpenditures = stmt.CapitalExpenditures ?? existingStmt.CapitalExpenditures; existingStmt.FinancingCashFlow = stmt.FinancingCashFlow ?? existingStmt.FinancingCashFlow; existingStmt.FreeCashFlow = stmt.FreeCashFlow ?? existingStmt.FreeCashFlow; } } } } // Ticker-Fundamentaldaten aktualisieren foreach (var t in tickerEntities) { t.Isin = isin; var existingTicker = await context.TickerFundamentals.FirstOrDefaultAsync(tf => tf.Ticker == t.Ticker, cancellationToken); if (existingTicker == null) { context.TickerFundamentals.Add(t); } else { existingTicker.Exchange = !string.IsNullOrEmpty(t.Exchange) ? t.Exchange : existingTicker.Exchange; existingTicker.TradingCurrency = !string.IsNullOrEmpty(t.TradingCurrency) ? t.TradingCurrency : existingTicker.TradingCurrency; existingTicker.CurrentPrice = t.CurrentPrice > 0 ? t.CurrentPrice : existingTicker.CurrentPrice; existingTicker.DayChangeAbsolute = t.DayChangeAbsolute != 0 ? t.DayChangeAbsolute : existingTicker.DayChangeAbsolute; existingTicker.DayChangePercent = t.DayChangePercent != 0 ? t.DayChangePercent : existingTicker.DayChangePercent; existingTicker.FiftyTwoWeekHigh = t.FiftyTwoWeekHigh > 0 ? t.FiftyTwoWeekHigh : existingTicker.FiftyTwoWeekHigh; existingTicker.FiftyTwoWeekLow = t.FiftyTwoWeekLow > 0 ? t.FiftyTwoWeekLow : existingTicker.FiftyTwoWeekLow; existingTicker.MarketCapitalization = t.MarketCapitalization > 0 ? t.MarketCapitalization : existingTicker.MarketCapitalization; existingTicker.EnterpriseValue = t.EnterpriseValue > 0 ? t.EnterpriseValue : existingTicker.EnterpriseValue; existingTicker.PeRatioTrailing = t.PeRatioTrailing ?? existingTicker.PeRatioTrailing; existingTicker.PeRatioForward = t.PeRatioForward ?? existingTicker.PeRatioForward; existingTicker.PegRatio = t.PegRatio ?? existingTicker.PegRatio; existingTicker.PbRatio = t.PbRatio ?? existingTicker.PbRatio; existingTicker.PsRatio = t.PsRatio ?? existingTicker.PsRatio; existingTicker.EvToEbitda = t.EvToEbitda ?? existingTicker.EvToEbitda; existingTicker.EvToRevenue = t.EvToRevenue ?? existingTicker.EvToRevenue; existingTicker.GrossMargin = t.GrossMargin ?? existingTicker.GrossMargin; existingTicker.OperatingMargin = t.OperatingMargin ?? existingTicker.OperatingMargin; existingTicker.NetProfitMargin = t.NetProfitMargin ?? existingTicker.NetProfitMargin; existingTicker.ReturnOnEquity = t.ReturnOnEquity ?? existingTicker.ReturnOnEquity; existingTicker.ReturnOnAssets = t.ReturnOnAssets ?? existingTicker.ReturnOnAssets; existingTicker.DebtToEquity = t.DebtToEquity ?? existingTicker.DebtToEquity; existingTicker.CurrentRatio = t.CurrentRatio ?? existingTicker.CurrentRatio; existingTicker.QuickRatio = t.QuickRatio ?? existingTicker.QuickRatio; existingTicker.DividendYield = t.DividendYield ?? existingTicker.DividendYield; existingTicker.PayoutRatio = t.PayoutRatio ?? existingTicker.PayoutRatio; existingTicker.ExDividendDate = t.ExDividendDate ?? existingTicker.ExDividendDate; existingTicker.LastUpdatedAt = DateTime.UtcNow; } } await context.SaveChangesAsync(cancellationToken); } private static AssetFundamentalsDto MapToDto(AssetFundamentalsEntity entity, string? requestedTicker) { var targetTicker = entity.TickerFundamentals?.FirstOrDefault(t => t.Ticker.Equals(requestedTicker, StringComparison.OrdinalIgnoreCase)) ?? entity.TickerFundamentals?.FirstOrDefault(t => t.Ticker.Equals(entity.PrimaryTicker, StringComparison.OrdinalIgnoreCase)) ?? entity.TickerFundamentals?.FirstOrDefault(); var selectedTickerSymbol = targetTicker?.Ticker ?? requestedTicker ?? entity.PrimaryTicker; return new AssetFundamentalsDto { Isin = entity.Isin, PrimaryTicker = entity.PrimaryTicker, Ticker = selectedTickerSymbol, CompanyName = entity.CompanyName, Exchange = targetTicker?.Exchange, TradingCurrency = targetTicker?.TradingCurrency, BusinessSummary = entity.BusinessSummary, Sector = entity.Sector, Industry = entity.Industry, Country = entity.Country, Employees = entity.Employees, CurrentPrice = targetTicker?.CurrentPrice ?? 0, DayChangeAbsolute = targetTicker?.DayChangeAbsolute ?? 0, DayChangePercent = targetTicker?.DayChangePercent ?? 0, FiftyTwoWeekHigh = targetTicker?.FiftyTwoWeekHigh ?? 0, FiftyTwoWeekLow = targetTicker?.FiftyTwoWeekLow ?? 0, MarketCapitalization = targetTicker?.MarketCapitalization ?? 0, EnterpriseValue = targetTicker?.EnterpriseValue ?? 0, PeRatioTrailing = targetTicker?.PeRatioTrailing, PeRatioForward = targetTicker?.PeRatioForward, PegRatio = targetTicker?.PegRatio, PbRatio = targetTicker?.PbRatio, PsRatio = targetTicker?.PsRatio, EvToEbitda = targetTicker?.EvToEbitda, EvToRevenue = targetTicker?.EvToRevenue, GrossMargin = targetTicker?.GrossMargin, OperatingMargin = targetTicker?.OperatingMargin, NetProfitMargin = targetTicker?.NetProfitMargin, ReturnOnEquity = targetTicker?.ReturnOnEquity, ReturnOnAssets = targetTicker?.ReturnOnAssets, ReturnOnInvestedCapital = targetTicker?.ReturnOnInvestedCapital, DebtToEquity = targetTicker?.DebtToEquity, CurrentRatio = targetTicker?.CurrentRatio, QuickRatio = targetTicker?.QuickRatio, InterestCoverage = targetTicker?.InterestCoverage, DividendYield = targetTicker?.DividendYield, PayoutRatio = targetTicker?.PayoutRatio, ExDividendDate = entity.ExDividendDate ?? targetTicker?.ExDividendDate, NextEarningsDate = entity.NextEarningsDate, PercentHeldByInstitutions = entity.PercentHeldByInstitutions, PercentHeldByInsiders = entity.PercentHeldByInsiders, ShortRatio = entity.ShortRatio, ShortPercentOfFloat = entity.ShortPercentOfFloat, ConsensusRating = entity.ConsensusRating, PriceTargetLow = entity.PriceTargetLow, PriceTargetHigh = entity.PriceTargetHigh, PriceTargetMedian = entity.PriceTargetMedian, PriceTargetMean = entity.PriceTargetMean, LastUpdatedAt = entity.LastUpdatedAt, Executives = entity.Executives.Select(e => new CompanyExecutiveDto { Name = e.Name, Title = e.Title, Age = e.Age, Compensation = e.Compensation }).ToList(), FinancialStatements = entity.FinancialStatements.Select(s => new FinancialStatementDto { PeriodType = s.PeriodType, EndDate = s.EndDate, TotalRevenue = s.TotalRevenue, CostOfRevenue = s.CostOfRevenue, GrossProfit = s.GrossProfit, OperatingExpenses = s.OperatingExpenses, OperatingIncome = s.OperatingIncome, Ebitda = s.Ebitda, NetIncome = s.NetIncome, EpsBasic = s.EpsBasic, EpsDiluted = s.EpsDiluted, CashAndCashEquivalents = s.CashAndCashEquivalents, AccountsReceivable = s.AccountsReceivable, Inventory = s.Inventory, TotalCurrentAssets = s.TotalCurrentAssets, TotalNonCurrentAssets = s.TotalNonCurrentAssets, CurrentLiabilities = s.CurrentLiabilities, LongTermDebt = s.LongTermDebt, TotalLiabilities = s.TotalLiabilities, TotalStockholdersEquity = s.TotalStockholdersEquity, OperatingCashFlow = s.OperatingCashFlow, InvestingCashFlow = s.InvestingCashFlow, CapitalExpenditures = s.CapitalExpenditures, FinancingCashFlow = s.FinancingCashFlow, FreeCashFlow = s.FreeCashFlow }).OrderByDescending(s => s.EndDate).ToList(), Estimates = entity.Estimates.Select(e => new ForwardEstimateDto { Period = e.Period, ExpectedRevenue = e.ExpectedRevenue, ExpectedEps = e.ExpectedEps, ExpectedGrowthRate = e.ExpectedGrowthRate }).ToList(), AvailableTickers = entity.TickerFundamentals.Select(t => new TickerDto { Ticker = t.Ticker, Exchange = t.Exchange, TradingCurrency = t.TradingCurrency, CurrentPrice = t.CurrentPrice, DayChangeAbsolute = t.DayChangeAbsolute, DayChangePercent = t.DayChangePercent, FiftyTwoWeekHigh = t.FiftyTwoWeekHigh, FiftyTwoWeekLow = t.FiftyTwoWeekLow, MarketCapitalization = t.MarketCapitalization, EnterpriseValue = t.EnterpriseValue, PeRatioTrailing = t.PeRatioTrailing, PeRatioForward = t.PeRatioForward, PegRatio = t.PegRatio, PbRatio = t.PbRatio, PsRatio = t.PsRatio, EvToEbitda = t.EvToEbitda, EvToRevenue = t.EvToRevenue, GrossMargin = t.GrossMargin, OperatingMargin = t.OperatingMargin, NetProfitMargin = t.NetProfitMargin, ReturnOnEquity = t.ReturnOnEquity, ReturnOnAssets = t.ReturnOnAssets, ReturnOnInvestedCapital = t.ReturnOnInvestedCapital, DebtToEquity = t.DebtToEquity, CurrentRatio = t.CurrentRatio, QuickRatio = t.QuickRatio, InterestCoverage = t.InterestCoverage, DividendYield = t.DividendYield, PayoutRatio = t.PayoutRatio, ExDividendDate = t.ExDividendDate ?? entity.ExDividendDate }).ToList() }; } /// public async Task> GetAllEventsAsync(CancellationToken cancellationToken = default) { using var scope = _scopeFactory.CreateScope(); var context = scope.ServiceProvider.GetRequiredService(); var now = DateTime.UtcNow; var startOfToday = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0, DateTimeKind.Utc); var endOfYear = new DateTime(now.Year, 12, 31, 23, 59, 59, DateTimeKind.Utc); var entities = await context.AssetFundamentals .AsNoTracking() .Where(f => (f.NextEarningsDate.HasValue && f.NextEarningsDate.Value >= startOfToday && f.NextEarningsDate.Value <= endOfYear) || (f.ExDividendDate.HasValue && f.ExDividendDate.Value >= startOfToday && f.ExDividendDate.Value <= endOfYear)) .ToListAsync(cancellationToken); var events = new List(); foreach (var entity in entities) { var companyName = string.IsNullOrWhiteSpace(entity.CompanyName) ? entity.PrimaryTicker : entity.CompanyName; if (entity.NextEarningsDate.HasValue) { events.Add(new CorporateEventDto { Isin = entity.Isin, Ticker = entity.PrimaryTicker, CompanyName = companyName, EventType = "Quartalsergebnis", Date = entity.NextEarningsDate.Value }); } if (entity.ExDividendDate.HasValue) { events.Add(new CorporateEventDto { Isin = entity.Isin, Ticker = entity.PrimaryTicker, CompanyName = companyName, EventType = "Ex-Dividendentag", Date = entity.ExDividendDate.Value }); } } return events.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); _logger.LogInformation("[FundamentalsDbService] Querying events between {Start} and {End}", startOfMonth, startOfNextMonth); var entities = await context.AssetFundamentals .AsNoTracking() .Where(f => (f.NextEarningsDate != null && f.NextEarningsDate >= startOfMonth && f.NextEarningsDate < startOfNextMonth) || (f.ExDividendDate != null && f.ExDividendDate >= startOfMonth && f.ExDividendDate < startOfNextMonth)) .ToListAsync(cancellationToken); _logger.LogInformation("[FundamentalsDbService] Found {Count} entities.", entities.Count); var events = new List(); foreach (var entity in entities) { var companyName = string.IsNullOrWhiteSpace(entity.CompanyName) ? entity.PrimaryTicker : entity.CompanyName; if (entity.NextEarningsDate != null && entity.NextEarningsDate >= startOfMonth && entity.NextEarningsDate < startOfNextMonth) { events.Add(new CorporateEventDto { Isin = entity.Isin, Ticker = entity.PrimaryTicker, CompanyName = companyName, EventType = "Quartalsergebnis", Date = entity.NextEarningsDate.Value }); } if (entity.ExDividendDate != null && entity.ExDividendDate >= startOfMonth && entity.ExDividendDate < startOfNextMonth) { events.Add(new CorporateEventDto { Isin = entity.Isin, Ticker = entity.PrimaryTicker, CompanyName = companyName, EventType = "Ex-Dividendentag", Date = entity.ExDividendDate.Value }); } } _logger.LogInformation("[FundamentalsDbService] Returning {Count} total events.", events.Count); return events.OrderBy(e => e.Date).ToList(); } #endregion }