feat(fundamentals): preserve ticker selection and add deterministic key executives sorting

This commit is contained in:
2026-08-15 19:30:04 +02:00
parent 960a4bdbd6
commit 7e18257e3e
11 changed files with 942 additions and 235 deletions
@@ -129,92 +129,85 @@ public class FundamentalsDbService : IFundamentalsDbService
"[DEBUG-TR-ERROR] Could not fetch Trade Republic details for {Isin}", cleanIsin);
}
// --- STEP 2: Ticker auflösen (Null-safe) ---
TickerInfoDto primaryTicker;
// --- 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 match = assetData?.AvailableTickers?
.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase));
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));
if (match != null)
if (matchDto != null)
{
primaryTicker = new TickerInfoDto
activeQueryTicker = new TickerInfoDto
{
Ticker = match.Ticker,
Exchange = !string.IsNullOrWhiteSpace(match.Exchange)
? match.Exchange
: GetExchangeDisplayName(match.Ticker)
Ticker = matchDto.Ticker,
Exchange = !string.IsNullOrWhiteSpace(matchDto.Exchange) ? matchDto.Exchange : GetExchangeDisplayName(matchDto.Ticker)
};
}
else if (assetData?.PrimaryTicker != null &&
string.Equals(assetData.PrimaryTicker.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase))
else if (matchEntity != null)
{
primaryTicker = new TickerInfoDto
activeQueryTicker = new TickerInfoDto
{
Ticker = assetData.PrimaryTicker.Ticker,
Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange)
? assetData.PrimaryTicker.Exchange
: GetExchangeDisplayName(assetData.PrimaryTicker.Ticker)
Ticker = matchEntity.Ticker,
Exchange = !string.IsNullOrWhiteSpace(matchEntity.Exchange) ? matchEntity.Exchange : GetExchangeDisplayName(matchEntity.Ticker)
};
}
else
{
primaryTicker = new TickerInfoDto
activeQueryTicker = 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"
};
activeQueryTicker = yahooPrimaryTicker;
}
if (string.IsNullOrWhiteSpace(primaryTicker.Exchange))
if (string.IsNullOrWhiteSpace(activeQueryTicker.Exchange))
{
primaryTicker = new TickerInfoDto
activeQueryTicker = new TickerInfoDto
{
Ticker = primaryTicker.Ticker,
Exchange = GetExchangeDisplayName(primaryTicker.Ticker)
Ticker = activeQueryTicker.Ticker,
Exchange = GetExchangeDisplayName(activeQueryTicker.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);
"[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 ---
YahooQuoteSummaryModulesDto? modulesDto = null;
if (!string.IsNullOrWhiteSpace(primaryTicker.Ticker) && primaryTicker.Ticker != cleanIsin)
if (!string.IsNullOrWhiteSpace(activeQueryTicker.Ticker) && activeQueryTicker.Ticker != cleanIsin)
{
modulesDto = await _scraper.GetQuoteSummaryModulesAsync(
primaryTicker.Ticker,
activeQueryTicker.Ticker,
forceHtmlScrape: false,
cancellationToken: cancellationToken);
}
else
{
await _finlyticLogger.LogWarningAsync(SettingKeys.FundamentalsChannel,
"[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", primaryTicker.Ticker);
"[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", activeQueryTicker.Ticker);
}
// --- Update AssetDataEntity ---
@@ -227,8 +220,8 @@ public class FundamentalsDbService : IFundamentalsDbService
Isin = cleanIsin,
PrimaryTicker = new TickerEntity
{
Ticker = primaryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown"
Ticker = yahooPrimaryTicker.Ticker,
Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
},
KeyExecutives = new List<KeyExecutiveEntity>(),
AssetEvents = new List<AssetEventEntity>()
@@ -241,27 +234,27 @@ public class FundamentalsDbService : IFundamentalsDbService
string fallbackName = modulesDto?.QuoteType?.ShortName
?? modulesDto?.QuoteType?.LongName
?? primaryTicker.Ticker;
?? activeQueryTicker.Ticker;
assetData.Name = !string.IsNullOrWhiteSpace(trName) ? trName : fallbackName;
assetData.Description = !string.IsNullOrWhiteSpace(trDescription)
? trDescription
: (modulesDto?.AssetProfile?.LongBusinessSummary ?? string.Empty);
// PrimaryTicker ist FEST der erste von Yahoo Finance
assetData.PrimaryTicker = new TickerEntity
{
Ticker = primaryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown"
Ticker = yahooPrimaryTicker.Ticker,
Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
};
var tickers = await _scraper.ResolveAllTickersFromIsinAsync(cleanIsin, cancellationToken);
if (!tickers.Any(t => string.Equals(t.Ticker, primaryTicker.Ticker, StringComparison.OrdinalIgnoreCase)))
if (!resolvedTickers.Any(t => string.Equals(t.Ticker, yahooPrimaryTicker.Ticker, StringComparison.OrdinalIgnoreCase)))
{
tickers.Insert(0, primaryTicker);
resolvedTickers.Insert(0, yahooPrimaryTicker);
}
assetData.AvailableTickers.Clear();
foreach (var a in tickers)
foreach (var a in resolvedTickers)
{
assetData.AvailableTickers.Add(new TickerEntity
{
@@ -278,7 +271,21 @@ public class FundamentalsDbService : IFundamentalsDbService
// --- Process Trade Republic Corporate Events ---
if (trDetails != null && (shouldUpdateAssetData || effectiveForceRefresh) && assetData != null)
{
assetData.AssetEvents ??= new List<AssetEventEntity>();
// 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())
{
entry.State = EntityState.Detached;
}
// 3. Navigation-Collection zurücksetzen
assetData.AssetEvents = new List<AssetEventEntity>();
var trEventList = new List<TradeRepublicEventDto>();
if (trDetails.Events != null) trEventList.AddRange(trDetails.Events);
@@ -298,17 +305,19 @@ public class FundamentalsDbService : IFundamentalsDbService
if (!isDuplicate)
{
assetData.AssetEvents.Add(new AssetEventEntity
var newEvent = new AssetEventEntity
{
AssetDataIsin = cleanIsin,
Ticker = new TickerEntity
{
Ticker = primaryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown"
Ticker = yahooPrimaryTicker.Ticker,
Exchange = yahooPrimaryTicker.Exchange ?? "Unknown"
},
Type = evtType,
Date = evtDate
});
};
context.AssetEvents.Add(newEvent);
assetData.AssetEvents.Add(newEvent);
}
}
}
@@ -339,6 +348,7 @@ public class FundamentalsDbService : IFundamentalsDbService
// 4. Neue Executives aufbauen und direkt über den DbSet hinzufügen
if (modulesDto.AssetProfile?.CompanyOfficers != null)
{
int sortIdx = 0;
foreach (var officer in modulesDto.AssetProfile.CompanyOfficers)
{
if (!string.IsNullOrWhiteSpace(officer.Name))
@@ -349,7 +359,8 @@ public class FundamentalsDbService : IFundamentalsDbService
Name = officer.Name,
Title = officer.Title ?? string.Empty,
Payment = officer.TotalPay?.Fmt ??
(officer.TotalPay?.Raw?.ToString() ?? string.Empty)
(officer.TotalPay?.Raw?.ToString() ?? string.Empty),
SortOrder = sortIdx++
};
context.KeyExecutives.Add(newExec);
assetData.KeyExecutives.Add(newExec);
@@ -377,8 +388,8 @@ public class FundamentalsDbService : IFundamentalsDbService
fundamentalData.Ticker = new TickerEntity
{
Ticker = primaryTicker.Ticker,
Exchange = primaryTicker.Exchange ?? "Unknown"
Ticker = activeQueryTicker.Ticker,
Exchange = activeQueryTicker.Exchange ?? "Unknown"
};
fundamentalData.MarketCap = (decimal?)modulesDto.SummaryDetail?.MarketCap?.Raw;
fundamentalData.EnterpriseValue =
@@ -452,7 +463,10 @@ public class FundamentalsDbService : IFundamentalsDbService
if (assetData == null) return null;
var executivesList = assetData.KeyExecutives?.ToList() ?? new List<KeyExecutiveEntity>();
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);
@@ -610,12 +624,16 @@ public class FundamentalsDbService : IFundamentalsDbService
LastUpdatedUtc = fundData.LastUpdatedUtc
}
: null,
Executives = executives.Select(e => new KeyExecutiveDto
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
Payment = e.Payment,
SortOrder = e.SortOrder
}).ToList(),
Events = events.Select(e => new CorporateEventDto
{
@@ -666,4 +684,21 @@ public class FundamentalsDbService : IFundamentalsDbService
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;
}
}
@@ -130,6 +130,7 @@ public class YahooFinanceScraper : IYahooFinanceScraper
foreach (var q in validQuotes.Skip(1))
{
if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
{
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin))));