Files
Finlytic/FinlyticFundamentals/Services/FundamentalsDbService.cs
T

669 lines
34 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 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 ConcurrentDictionary<string, SemaphoreSlim> IsinLocks = new();
private readonly IServiceScopeFactory _scopeFactory;
private readonly IYahooFinanceScraper _scraper;
private readonly ITradeRepublicService _tradeRepublicService;
private readonly IFinlyticLogger<FundamentalsDbService, FundamentalsDbContext> _finlyticLogger;
public FundamentalsDbService(
IServiceScopeFactory scopeFactory,
IYahooFinanceScraper scraper,
ITradeRepublicService tradeRepublicService,
IFinlyticLogger<FundamentalsDbService, FundamentalsDbContext> 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();
var isinLock = IsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1));
await isinLock.WaitAsync(cancellationToken);
try
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService<FundamentalsDbContext>>();
// 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<KeyExecutiveEntity>(),
AssetEvents = new List<AssetEventEntity>()
};
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<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)
{
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<KeyExecutiveEntity>()
.Where(e => e.Entity.AssetDataIsin == cleanIsin)
.ToList())
{
entry.State = EntityState.Detached;
}
// 3. Navigation-Collection zurücksetzen
assetData.KeyExecutives = new List<KeyExecutiveEntity>();
// 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<KeyExecutiveEntity>();
var eventsList = assetData.AssetEvents?.ToList() ?? new List<AssetEventEntity>();
return MapToDto(assetData, fundamentalData, executivesList, eventsList);
}
finally
{
isinLock.Release();
}
}
/// <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))
.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
};
}
/// <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";
}
}