Files
Finlytic/FinlyticFundamentals/Services/FundamentalsDbService.cs
T

847 lines
45 KiB
C#

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<AssetFundamentalsDto?> GetFundamentalsAsync(
string isin,
string? ticker = null,
bool forceRefresh = false,
CancellationToken cancellationToken = default);
Task<List<CorporateEventDto>> GetAllEventsAsync(CancellationToken cancellationToken = default);
Task<List<CorporateEventDto>> GetEventsByMonthAsync(int year, int month,
CancellationToken cancellationToken = default);
}
public class FundamentalsDbService : IFundamentalsDbService
{
private static readonly KeyedLockPool LockPool = new();
private readonly IServiceScopeFactory _scopeFactory;
private readonly IYahooFinanceScraper _scraper;
private readonly ITradeRepublicService _tradeRepublicService;
private readonly IFinlyticLogger<FundamentalsDbService> _finlyticLogger;
public FundamentalsDbService(
IServiceScopeFactory scopeFactory,
IYahooFinanceScraper scraper,
ITradeRepublicService tradeRepublicService,
IFinlyticLogger<FundamentalsDbService> finlyticLogger)
{
_scopeFactory = scopeFactory;
_scraper = scraper;
_tradeRepublicService = tradeRepublicService;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
public async Task<AssetFundamentalsDto?> 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 (await LockPool.LockAsync(cleanIsin, cancellationToken))
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
// 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<KeyExecutiveEntity>(),
AssetEvents = new List<AssetEventEntity>()
};
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<AssetEventEntity>()
.Where(e => e.Entity.AssetDataIsin == cleanIsin)
.ToList())
{
entry.State = EntityState.Detached;
}
assetData.AssetEvents = new List<AssetEventEntity>();
var trEventList = new List<TradeRepublicEventDto>();
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);
}
}
// Structured Trade Republic dividend data (ExpectedDividend + historical Dividends) carries
// a real ExDate per entry - a far more reliable "this is a dividend" signal than matching
// the generic Events/PastEvents feed's free-text Type/Title strings above, whose exact
// wording for dividend entries is not guaranteed. The canonical "Dividend" Type here is
// fully controlled by this codebase (not guessed from TR's free text), so
// AssetFundamentalsDto.DaysToNextExDividend can match on it reliably (Rules.md §4).
var trDividendList = new List<TradeRepublicDividendDto>();
if (trDetails.ExpectedDividend != null) trDividendList.Add(trDetails.ExpectedDividend);
if (trDetails.Dividends != null) trDividendList.AddRange(trDetails.Dividends);
foreach (var div in trDividendList)
{
if (string.IsNullOrWhiteSpace(div.ExDate) ||
!DateTime.TryParse(div.ExDate, System.Globalization.CultureInfo.InvariantCulture,
System.Globalization.DateTimeStyles.None, out var exDate))
{
continue;
}
bool isDuplicateDividend = assetData.AssetEvents.Any(e =>
e.Date.Date == exDate.Date && string.Equals(e.Type, "Dividend", StringComparison.OrdinalIgnoreCase));
if (!isDuplicateDividend)
{
var newDividendEvent = new AssetEventEntity
{
AssetDataIsin = cleanIsin,
Ticker = new TickerEntity
{
Ticker = yahooPrimaryTicker.Ticker,
Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
},
Type = "Dividend",
Date = exDate.Date
};
context.AssetEvents.Add(newDividendEvent);
assetData.AssetEvents.Add(newDividendEvent);
}
}
}
// --- 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<KeyExecutiveEntity>()
.Where(e => e.Entity.AssetDataIsin == cleanIsin)
.ToList())
{
entry.State = EntityState.Detached;
}
assetData.KeyExecutives = new List<KeyExecutiveEntity>();
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<KeyExecutiveEntity>())
.OrderBy(e => e.SortOrder > 0 ? e.SortOrder : GetExecutiveRank(e.Title))
.ThenBy(e => GetExecutiveRank(e.Title))
.ToList();
var eventsList = assetData.AssetEvents?.ToList() ?? new List<AssetEventEntity>();
return MapToDto(assetData, fundamentalData, executivesList, eventsList);
}
}
/// <inheritdoc />
public async Task<List<CorporateEventDto>> GetAllEventsAsync(CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
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();
}
/// <inheritdoc />
public async Task<List<CorporateEventDto>> GetEventsByMonthAsync(int year, int month,
CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
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<KeyExecutiveEntity> executives,
List<AssetEventEntity> events)
{
var tickerEntities = assetData.AvailableTickers != null && assetData.AvailableTickers.Count > 0
? assetData.AvailableTickers
: (assetData.PrimaryTicker != null ? new List<TickerEntity> { assetData.PrimaryTicker } : new List<TickerEntity>());
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
};
}
/// <summary>
/// Leitet den Anzeigenamen der Börse aus dem Ticker-Suffix ab.
/// </summary>
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;
}
}