refactor: save current workspace state including FinlyticAnalyzer fixes, FinlyticApp trade route alignment, and DTO audit documentation

This commit is contained in:
2026-08-12 18:30:42 +02:00
parent a9553e9fbf
commit 3d8af3940b
163 changed files with 3421 additions and 1751 deletions
@@ -37,6 +37,11 @@ public interface IFundamentalsDbService
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of corporate events sorted chronologically.</returns>
Task<List<CorporateEventDto>> GetAllEventsAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Gets corporate events for a specific month.
/// </summary>
Task<List<CorporateEventDto>> GetEventsByMonthAsync(int year, int month, CancellationToken cancellationToken = default);
}
public class FundamentalsDbService : IFundamentalsDbService
@@ -45,17 +50,20 @@ public class FundamentalsDbService : IFundamentalsDbService
private readonly IServiceScopeFactory _scopeFactory;
private readonly IYahooFinanceScraper _scraper;
private readonly IHtmlFallbackScraper _fallbackScraper;
private readonly YahooFinanceClient _yahooClient;
private readonly ILogger<FundamentalsDbService> _logger;
public FundamentalsDbService(
IServiceScopeFactory scopeFactory,
IYahooFinanceScraper scraper,
IHtmlFallbackScraper fallbackScraper,
YahooFinanceClient yahooClient,
ILogger<FundamentalsDbService> logger)
{
_scopeFactory = scopeFactory;
_scraper = scraper;
_fallbackScraper = fallbackScraper;
_yahooClient = yahooClient;
_logger = logger;
}
@@ -197,10 +205,49 @@ public class FundamentalsDbService : IFundamentalsDbService
}
}
if (tickers.Count == 0) return existingEntity;
if (tickers.Count == 0)
{
_logger.LogWarning("[YahooFallbackScraper] No tickers resolved for ISIN {Isin}. Fallback scraper cannot be invoked without a ticker.", isin);
return existingEntity;
}
var primaryTicker = tickers[0];
_logger.LogInformation("[YahooFallbackScraper] Primary ticker resolved: '{Ticker}' for ISIN {Isin}", primaryTicker, isin);
var scraped = await _scraper.ScrapeFundamentalsAsync(isin, primaryTicker, cancellationToken);
bool needsFallback = IsDataIncomplete(scraped, _logger);
_logger.LogInformation("[YahooFallbackScraper] Primary scrape completeness check for '{Ticker}': scrapedIsNull={ScrapedIsNull}, needsFallback={NeedsFallback}",
primaryTicker, scraped == null, needsFallback);
if (needsFallback)
{
_logger.LogInformation("[YahooFallbackScraper] Executing Playwright Fallback Scraper for ticker '{Ticker}' (ISIN: {Isin})...", primaryTicker, isin);
var fallbackData = await _fallbackScraper.ScrapeFallbackAsync(isin, primaryTicker, cancellationToken);
if (fallbackData != null)
{
_logger.LogInformation("[YahooFallbackScraper] Fallback scraper returned data for {Ticker}. MarketCap={MarketCap}, EV={EV}, Sector='{Sector}'",
primaryTicker, fallbackData.TickerData?.MarketCapitalization, fallbackData.TickerData?.EnterpriseValue, fallbackData.Fundamentals?.Sector);
if (scraped == null)
{
_logger.LogInformation("[YahooFallbackScraper] Primary scraped data was null. Using entirely Playwright fallback data for {Ticker}...", primaryTicker);
scraped = fallbackData;
}
else
{
_logger.LogInformation("[YahooFallbackScraper] Merging Playwright fallback data into primary scraped data for {Ticker}...", primaryTicker);
// Merge fallback into scraped
MergeFundamentals(scraped, fallbackData);
}
}
else
{
_logger.LogWarning("[YahooFallbackScraper] Fallback scraper returned NULL for {Ticker}!", primaryTicker);
}
}
// ------------------------------
if (scraped == null) return existingEntity;
var tickerEntities = new List<TickerFundamentalsEntity> { scraped.TickerData };
@@ -234,7 +281,93 @@ public class FundamentalsDbService : IFundamentalsDbService
return await LoadEntityGraphAsync(context, isin, cancellationToken);
}
private static void MergeFundamentals(ScrapedFundamentalsData target, ScrapedFundamentalsData source)
{
var t = target.TickerData;
var s = source.TickerData;
// Kennzahlen & Ratios
if (t.MarketCapitalization == 0 && s.MarketCapitalization > 0) t.MarketCapitalization = s.MarketCapitalization;
if ((t.EnterpriseValue == 0) && s.EnterpriseValue > 0) t.EnterpriseValue = s.EnterpriseValue;
t.PeRatioTrailing ??= s.PeRatioTrailing;
t.PeRatioForward ??= s.PeRatioForward;
t.PegRatio ??= s.PegRatio;
t.PbRatio ??= s.PbRatio;
t.PsRatio ??= s.PsRatio;
t.EvToEbitda ??= s.EvToEbitda;
t.EvToRevenue ??= s.EvToRevenue;
// Margen
t.GrossMargin ??= s.GrossMargin;
t.OperatingMargin ??= s.OperatingMargin;
t.NetProfitMargin ??= s.NetProfitMargin;
t.ReturnOnEquity ??= s.ReturnOnEquity;
t.ReturnOnAssets ??= s.ReturnOnAssets;
// Preise & Dividenden
if (t.FiftyTwoWeekHigh == 0 && s.FiftyTwoWeekHigh > 0) t.FiftyTwoWeekHigh = s.FiftyTwoWeekHigh;
if (t.FiftyTwoWeekLow == 0 && s.FiftyTwoWeekLow > 0) t.FiftyTwoWeekLow = s.FiftyTwoWeekLow;
if ((!t.DividendYield.HasValue || t.DividendYield == 0) && s.DividendYield > 0) t.DividendYield = s.DividendYield;
// Stammdaten
if (string.IsNullOrWhiteSpace(target.Fundamentals.Sector)) target.Fundamentals.Sector = source.Fundamentals.Sector;
if (string.IsNullOrWhiteSpace(target.Fundamentals.Industry)) target.Fundamentals.Industry = source.Fundamentals.Industry;
if (!target.Fundamentals.Employees.HasValue) target.Fundamentals.Employees = source.Fundamentals.Employees;
if (string.IsNullOrWhiteSpace(target.Fundamentals.BusinessSummary)) target.Fundamentals.BusinessSummary = source.Fundamentals.BusinessSummary;
}
private static bool IsDataIncomplete(ScrapedFundamentalsData? data, ILogger logger)
{
if (data == null || data.TickerData == null)
{
logger.LogWarning("[YahooFallbackScraper] IsDataIncomplete -> TRUE (scraped data or TickerData is NULL)");
return true;
}
var td = data.TickerData;
var f = data.Fundamentals;
int missingCriticalFields = 0;
// 1. Absolute Must-Haves (sofortiger Fallback wenn 0)
if (td.MarketCapitalization == 0)
{
logger.LogWarning("[YahooFallbackScraper] IsDataIncomplete -> TRUE (MarketCapitalization is 0)");
return true;
}
if (td.FiftyTwoWeekHigh == 0 || td.FiftyTwoWeekLow == 0)
{
logger.LogWarning("[YahooFallbackScraper] IsDataIncomplete -> TRUE (52WeekHigh={High} or 52WeekLow={Low} is 0)", td.FiftyTwoWeekHigh, td.FiftyTwoWeekLow);
return true;
}
// 2. Bewertung & Ratios (Zähle fehlende Metriken)
// KGV: Trailing ODER Forward muss vorhanden sein, sonst zählt die KGV-Bewertung als fehlend
if ((!td.PeRatioTrailing.HasValue || td.PeRatioTrailing == 0) && (!td.PeRatioForward.HasValue || td.PeRatioForward == 0))
missingCriticalFields++;
if (!td.PbRatio.HasValue || td.PbRatio == 0) missingCriticalFields++;
if (!td.PsRatio.HasValue || td.PsRatio == 0) missingCriticalFields++;
if (td.EnterpriseValue == 0) missingCriticalFields++;
// 3. Margen & Profitabilität
if (!td.GrossMargin.HasValue) missingCriticalFields++;
if (!td.OperatingMargin.HasValue) missingCriticalFields++;
if (!td.NetProfitMargin.HasValue) missingCriticalFields++;
// 4. Stammdaten
if (string.IsNullOrWhiteSpace(f.Sector)) missingCriticalFields++;
if (string.IsNullOrWhiteSpace(f.Industry)) missingCriticalFields++;
// Wenn 2 oder mehr der wichtigen Kennzahlen fehlen, gilt die Quelle als unvollständig
bool isIncomplete = missingCriticalFields >= 2;
logger.LogInformation("[YahooFallbackScraper] IsDataIncomplete total missingCriticalFields={Count} (threshold >= 2 -> isIncomplete={Result})", missingCriticalFields, isIncomplete);
return isIncomplete;
}
#endregion
#region Data Access & Mapping Helpers
@@ -546,9 +679,14 @@ public class FundamentalsDbService : IFundamentalsDbService
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
var now = DateTime.UtcNow;
var startOfToday = new DateTime(now.Year, now.Month, now.Day, 0, 0, 0, DateTimeKind.Utc);
var endOfYear = new DateTime(now.Year, 12, 31, 23, 59, 59, DateTimeKind.Utc);
var entities = await context.AssetFundamentals
.AsNoTracking()
.Where(f => f.NextEarningsDate.HasValue || f.ExDividendDate.HasValue)
.Where(f => (f.NextEarningsDate.HasValue && f.NextEarningsDate.Value >= startOfToday && f.NextEarningsDate.Value <= endOfYear)
|| (f.ExDividendDate.HasValue && f.ExDividendDate.Value >= startOfToday && f.ExDividendDate.Value <= endOfYear))
.ToListAsync(cancellationToken);
var events = new List<CorporateEventDto>();
@@ -585,5 +723,60 @@ public class FundamentalsDbService : IFundamentalsDbService
return events.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);
_logger.LogInformation("[FundamentalsDbService] Querying events between {Start} and {End}", startOfMonth, startOfNextMonth);
var entities = await context.AssetFundamentals
.AsNoTracking()
.Where(f => (f.NextEarningsDate != null && f.NextEarningsDate >= startOfMonth && f.NextEarningsDate < startOfNextMonth)
|| (f.ExDividendDate != null && f.ExDividendDate >= startOfMonth && f.ExDividendDate < startOfNextMonth))
.ToListAsync(cancellationToken);
_logger.LogInformation("[FundamentalsDbService] Found {Count} entities.", entities.Count);
var events = new List<CorporateEventDto>();
foreach (var entity in entities)
{
var companyName = string.IsNullOrWhiteSpace(entity.CompanyName) ? entity.PrimaryTicker : entity.CompanyName;
if (entity.NextEarningsDate != null && entity.NextEarningsDate >= startOfMonth && entity.NextEarningsDate < startOfNextMonth)
{
events.Add(new CorporateEventDto
{
Isin = entity.Isin,
Ticker = entity.PrimaryTicker,
CompanyName = companyName,
EventType = "Quartalsergebnis",
Date = entity.NextEarningsDate.Value
});
}
if (entity.ExDividendDate != null && entity.ExDividendDate >= startOfMonth && entity.ExDividendDate < startOfNextMonth)
{
events.Add(new CorporateEventDto
{
Isin = entity.Isin,
Ticker = entity.PrimaryTicker,
CompanyName = companyName,
EventType = "Ex-Dividendentag",
Date = entity.ExDividendDate.Value
});
}
}
_logger.LogInformation("[FundamentalsDbService] Returning {Count} total events.", events.Count);
return events.OrderBy(e => e.Date).ToList();
}
#endregion
}