665 lines
34 KiB
C#
665 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,
|
|
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();
|
|
}
|
|
|
|
/// <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,
|
|
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";
|
|
}
|
|
} |