feat(fundamentals): multi-ticker DB caching, parallel html scraper, and frontend mappings
This commit is contained in:
@@ -79,44 +79,53 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
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} | EnableHtmlFallback: {Html}",
|
||||
cleanIsin, requestedTicker ?? "NULL", forceRefresh, enableHtmlFallback);
|
||||
"[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);
|
||||
|
||||
var fundamentalData = await context.FundamentalData
|
||||
.FirstOrDefaultAsync(f => f.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 fundamentalsExpired = fundamentalData == null ||
|
||||
(DateTime.UtcNow - fundamentalData.LastUpdatedUtc).TotalDays > validityDays;
|
||||
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));
|
||||
|
||||
// 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 shouldUpdate = assetDataMissing || executivesMissing || fundamentalsMissingOrExpired || tickersCorruptOrMissing || effectiveForceRefresh || forceHtmlFallback;
|
||||
|
||||
bool shouldUpdateAssetData = assetDataMissing || effectiveForceRefresh || tickerChanged;
|
||||
bool shouldUpdateExecutives = executivesMissing || effectiveForceRefresh;
|
||||
bool shouldUpdateFundamentals = fundamentalsExpired || effectiveForceRefresh || tickerChanged;
|
||||
|
||||
if (shouldUpdateAssetData || shouldUpdateExecutives || shouldUpdateFundamentals)
|
||||
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;
|
||||
@@ -150,8 +159,10 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
TickerInfoDto activeQueryTicker;
|
||||
if (!string.IsNullOrWhiteSpace(requestedTicker))
|
||||
{
|
||||
var matchDto = resolvedTickers.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
|
||||
var matchEntity = assetData?.AvailableTickers?.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
|
||||
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)
|
||||
{
|
||||
@@ -197,12 +208,18 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
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: false,
|
||||
forceHtmlScrape: forceHtmlFallback,
|
||||
includeProfile: needProfile,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
else
|
||||
@@ -211,51 +228,96 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
"[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", activeQueryTicker.Ticker);
|
||||
}
|
||||
|
||||
// --- Update AssetDataEntity ---
|
||||
if (shouldUpdateAssetData)
|
||||
// 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)
|
||||
{
|
||||
if (assetData == null)
|
||||
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
|
||||
{
|
||||
assetData = new AssetDataEntity
|
||||
Isin = cleanIsin,
|
||||
PrimaryTicker = new TickerEntity
|
||||
{
|
||||
Isin = cleanIsin,
|
||||
PrimaryTicker = new TickerEntity
|
||||
{
|
||||
Ticker = yahooPrimaryTicker.Ticker,
|
||||
Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
|
||||
},
|
||||
KeyExecutives = new List<KeyExecutiveEntity>(),
|
||||
AssetEvents = new List<AssetEventEntity>()
|
||||
};
|
||||
context.AssetData.Add(assetData);
|
||||
}
|
||||
Ticker = yahooPrimaryTicker.Ticker,
|
||||
Exchange = yahooPrimaryTicker.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 trName = trDetails?.Company?.Name?.Trim() ?? string.Empty;
|
||||
string trDescription = trDetails?.Company?.Description?.Trim() ?? string.Empty;
|
||||
|
||||
string fallbackName = modulesDto?.QuoteType?.ShortName
|
||||
?? modulesDto?.QuoteType?.LongName
|
||||
?? activeQueryTicker.Ticker;
|
||||
string yahooName = modulesDto?.QuoteType?.LongName?.Trim()
|
||||
?? modulesDto?.QuoteType?.ShortName?.Trim()
|
||||
?? string.Empty;
|
||||
string yahooDesc = modulesDto?.AssetProfile?.LongBusinessSummary?.Trim() ?? string.Empty;
|
||||
|
||||
assetData.Name = !string.IsNullOrWhiteSpace(trName) ? trName : fallbackName;
|
||||
assetData.Description = !string.IsNullOrWhiteSpace(trDescription)
|
||||
? trDescription
|
||||
: (modulesDto?.AssetProfile?.LongBusinessSummary ?? 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;
|
||||
}
|
||||
|
||||
// PrimaryTicker ist FEST der erste von Yahoo Finance
|
||||
// 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"
|
||||
};
|
||||
}
|
||||
|
||||
if (!resolvedTickers.Any(t => string.Equals(t.Ticker, yahooPrimaryTicker.Ticker, StringComparison.OrdinalIgnoreCase)))
|
||||
// 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)))
|
||||
{
|
||||
resolvedTickers.Insert(0, yahooPrimaryTicker);
|
||||
validTickers.Insert(0, yahooPrimaryTicker);
|
||||
}
|
||||
|
||||
assetData.AvailableTickers.Clear();
|
||||
foreach (var a in resolvedTickers)
|
||||
foreach (var a in validTickers)
|
||||
{
|
||||
assetData.AvailableTickers.Add(new TickerEntity
|
||||
{
|
||||
@@ -263,21 +325,19 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
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);
|
||||
}
|
||||
|
||||
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 && (shouldUpdateAssetData || effectiveForceRefresh) && assetData != null)
|
||||
if (trDetails != null && assetData != null)
|
||||
{
|
||||
// 1. Alte Events direkt in der DB löschen (bypasses Change Tracker)
|
||||
await context.AssetEvents
|
||||
.Where(e => e.AssetDataIsin == cleanIsin)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
// 2. ALLE tracked AssetEventEntity-Einträge aus dem Change Tracker entfernen
|
||||
foreach (var entry in context.ChangeTracker.Entries<AssetEventEntity>()
|
||||
.Where(e => e.Entity.AssetDataIsin == cleanIsin)
|
||||
.ToList())
|
||||
@@ -285,7 +345,6 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
|
||||
// 3. Navigation-Collection zurücksetzen
|
||||
assetData.AssetEvents = new List<AssetEventEntity>();
|
||||
|
||||
var trEventList = new List<TradeRepublicEventDto>();
|
||||
@@ -324,18 +383,18 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
}
|
||||
|
||||
// --- Process Modules DTO (Executives & Fundamental Data) ---
|
||||
if (modulesDto != null)
|
||||
if (modulesDto != null || trDetails?.Company != null)
|
||||
{
|
||||
// Update KeyExecutives
|
||||
if (shouldUpdateExecutives && assetData != 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)
|
||||
{
|
||||
// 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())
|
||||
@@ -343,14 +402,12 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
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)
|
||||
if (yahooOfficers != null && yahooOfficers.Count > 0)
|
||||
{
|
||||
int sortIdx = 0;
|
||||
foreach (var officer in modulesDto.AssetProfile.CompanyOfficers)
|
||||
foreach (var officer in yahooOfficers)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(officer.Name))
|
||||
{
|
||||
@@ -368,6 +425,49 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
}
|
||||
}
|
||||
}
|
||||
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.",
|
||||
@@ -375,16 +475,23 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
}
|
||||
|
||||
// Update FundamentalDataEntity
|
||||
if (shouldUpdateFundamentals)
|
||||
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
|
||||
{
|
||||
Isin = cleanIsin,
|
||||
Id = Guid.NewGuid(),
|
||||
AssetDataIsin = cleanIsin
|
||||
};
|
||||
context.FundamentalData.Add(fundamentalData);
|
||||
assetData?.FundamentalData.Add(fundamentalData);
|
||||
}
|
||||
|
||||
fundamentalData.Ticker = new TickerEntity
|
||||
@@ -542,7 +649,7 @@ public class FundamentalsDbService : IFundamentalsDbService
|
||||
: (assetData.PrimaryTicker != null ? new List<TickerEntity> { assetData.PrimaryTicker } : new List<TickerEntity>());
|
||||
|
||||
var tickerDtos = tickerEntities
|
||||
.Where(t => t != null && !string.IsNullOrWhiteSpace(t.Ticker))
|
||||
.Where(t => t != null && !string.IsNullOrWhiteSpace(t.Ticker) && !t.Ticker.Contains(assetData.Isin, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(a => new TickerInfoDto
|
||||
{
|
||||
Ticker = a.Ticker,
|
||||
|
||||
Reference in New Issue
Block a user