812 lines
42 KiB
C#
812 lines
42 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 ConcurrentDictionary<string, SemaphoreSlim> IsinLocks = 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();
|
|
|
|
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>();
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 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);
|
|
}
|
|
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) && !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;
|
|
}
|
|
} |