feat(fundamentals): refactor entities, add Yahoo modules scraper, 52W metrics and migrations
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,305 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticFundamentals.Entities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace FinlyticFundamentals.Services;
|
||||
|
||||
public interface IHtmlFallbackScraper
|
||||
{
|
||||
Task<ScrapedFundamentalsData?> ScrapeFallbackAsync(string isin, string ticker, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback scraper using Playwright (headless Chromium) to scrape Yahoo Finance pages
|
||||
/// for alternative ticker symbols (e.g., APC.SG) whose data is not available via the API.
|
||||
/// Targets stable data-testid selectors from the rendered Yahoo Finance SPA.
|
||||
/// </summary>
|
||||
public class HtmlFallbackScraper : IHtmlFallbackScraper, IAsyncDisposable
|
||||
{
|
||||
private readonly ILogger<HtmlFallbackScraper> _logger;
|
||||
|
||||
private IPlaywright? _playwright;
|
||||
private IBrowser? _browser;
|
||||
private readonly SemaphoreSlim _browserLock = new(1, 1);
|
||||
|
||||
public HtmlFallbackScraper(ILogger<HtmlFallbackScraper> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ScrapedFundamentalsData?> ScrapeFallbackAsync(
|
||||
string isin,
|
||||
string ticker,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[YahooFallbackScraper] Executing Playwright Fallback Scrape for ticker '{Ticker}' (ISIN: {Isin})...",
|
||||
ticker, isin);
|
||||
|
||||
try
|
||||
{
|
||||
var browser = await GetOrInitBrowserAsync(cancellationToken);
|
||||
|
||||
var fundamentals = new AssetFundamentalsEntity
|
||||
{
|
||||
Isin = isin,
|
||||
PrimaryTicker = ticker,
|
||||
LastUpdatedAt = DateTime.UtcNow,
|
||||
LastStaticUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var tickerData = new TickerFundamentalsEntity
|
||||
{
|
||||
Ticker = ticker,
|
||||
Isin = isin,
|
||||
LastUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
fundamentals.CompanyName = ticker;
|
||||
// Share context for both pages so we only have to accept cookies once
|
||||
await using (var ctx = await browser.NewContextAsync(BuildContextOptions()))
|
||||
{
|
||||
// ── 1. Key Statistics Page ─────────────────────────────────────
|
||||
var statsUrl = $"https://finance.yahoo.com/quote/{Uri.EscapeDataString(ticker)}/key-statistics/";
|
||||
_logger.LogInformation("[YahooFallbackScraper] Navigating to stats page: {Url}", statsUrl);
|
||||
|
||||
var statsPage = await ctx.NewPageAsync();
|
||||
try
|
||||
{
|
||||
await statsPage.GotoAsync(statsUrl, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 45_000
|
||||
});
|
||||
|
||||
await HandleConsentAsync(statsPage);
|
||||
|
||||
// Wait for the statistics section to be visible
|
||||
await statsPage.WaitForSelectorAsync(
|
||||
"section[data-testid='qsp-statistics'], section[data-testid='stats-highlight']",
|
||||
new PageWaitForSelectorOptions { Timeout = 20_000 });
|
||||
|
||||
// --- Valuation Measures Table ---
|
||||
var valuationRows = await statsPage.QuerySelectorAllAsync(
|
||||
"section[data-testid='qsp-statistics'] table tbody tr");
|
||||
|
||||
foreach (var row in valuationRows)
|
||||
{
|
||||
var cells = await row.QuerySelectorAllAsync("td");
|
||||
if (cells.Count < 2) continue;
|
||||
|
||||
var label = (await cells[0].InnerTextAsync()).Trim();
|
||||
var value = (await cells[1].InnerTextAsync()).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "N/A" || value == "--") continue;
|
||||
|
||||
switch (NormalizeLabel(label))
|
||||
{
|
||||
case "market cap": tickerData.MarketCapitalization = ParseSuffixNumber(value) ?? 0m; break;
|
||||
case "enterprise value": tickerData.EnterpriseValue = ParseSuffixNumber(value) ?? 0m; break;
|
||||
case "trailing p/e": tickerData.PeRatioTrailing = ParseDecimal(value); break;
|
||||
case "forward p/e": tickerData.PeRatioForward = ParseDecimal(value); break;
|
||||
case "peg ratio (5yr expected)": tickerData.PegRatio = ParseDecimal(value); break;
|
||||
case "price/sales": tickerData.PsRatio = ParseDecimal(value); break;
|
||||
case "price/book": tickerData.PbRatio = ParseDecimal(value); break;
|
||||
case "enterprise value/revenue": tickerData.EvToRevenue = ParseDecimal(value); break;
|
||||
case "enterprise value/ebitda": tickerData.EvToEbitda = ParseDecimal(value); break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Financial Highlight Cards ---
|
||||
var highlightRows = await statsPage.QuerySelectorAllAsync(
|
||||
"div[data-testid='stats-highlight'] section[data-testid='card-container'] table tr");
|
||||
|
||||
foreach (var row in highlightRows)
|
||||
{
|
||||
var cells = await row.QuerySelectorAllAsync("td");
|
||||
if (cells.Count < 2) continue;
|
||||
|
||||
var label = (await cells[0].InnerTextAsync()).Trim();
|
||||
var value = (await cells[1].InnerTextAsync()).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value) || value == "N/A" || value == "--") continue;
|
||||
|
||||
switch (NormalizeLabel(label))
|
||||
{
|
||||
case "profit margin": tickerData.NetProfitMargin = ParsePercent(value); break;
|
||||
case "operating margin": tickerData.OperatingMargin = ParsePercent(value); break;
|
||||
case "return on assets": tickerData.ReturnOnAssets = ParsePercent(value); break;
|
||||
case "return on equity": tickerData.ReturnOnEquity = ParsePercent(value); break;
|
||||
case "current ratio": tickerData.CurrentRatio = ParseDecimal(value); break;
|
||||
case "quick ratio": tickerData.QuickRatio = ParseDecimal(value); break;
|
||||
case "total debt/equity": tickerData.DebtToEquity = ParseDecimal(value); break;
|
||||
case "52 week high": tickerData.FiftyTwoWeekHigh = ParseDecimal(value) ?? 0m; break;
|
||||
case "52 week low": tickerData.FiftyTwoWeekLow = ParseDecimal(value) ?? 0m; break;
|
||||
case "forward annual dividend yield":
|
||||
case "trailing annual dividend yield":
|
||||
tickerData.DividendYield ??= ParsePercent(value); break;
|
||||
case "payout ratio": tickerData.PayoutRatio = ParsePercent(value); break;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[YahooFallbackScraper] Stats parsed for '{Ticker}': MarketCap={MarketCap}, EV={EV}, TrailingPE={PE}, ForwardPE={FPE}",
|
||||
ticker, tickerData.MarketCapitalization, tickerData.EnterpriseValue,
|
||||
tickerData.PeRatioTrailing, tickerData.PeRatioForward);
|
||||
}
|
||||
catch (TimeoutException tex)
|
||||
{
|
||||
_logger.LogWarning(tex,
|
||||
"[YahooFallbackScraper] Timeout waiting for stats page selectors for ticker '{Ticker}'. Page may not have loaded.", ticker);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await statsPage.CloseAsync();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ── 2. Guard: no meaningful data ──────────────────────────────
|
||||
if (tickerData.MarketCapitalization == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"[YahooFallbackScraper] Playwright scrape for '{Ticker}' produced no meaningful data (MarketCap=0). Returning NULL.",
|
||||
ticker);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogInformation(
|
||||
"[YahooFallbackScraper] Playwright Fallback Scrape complete for '{Ticker}'. MarketCap={MarketCap}",
|
||||
ticker, tickerData.MarketCapitalization);
|
||||
|
||||
return new ScrapedFundamentalsData(
|
||||
fundamentals,
|
||||
tickerData,
|
||||
new List<CompanyExecutiveEntity>(),
|
||||
new List<FinancialStatementEntity>(),
|
||||
new List<ForwardEstimateEntity>()
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex,
|
||||
"[YahooFallbackScraper] Error during Playwright Fallback Scrape for ticker '{Ticker}' (ISIN: {Isin})",
|
||||
ticker, isin);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Browser Lifecycle ──────────────────────────────────────────────────
|
||||
|
||||
private async Task<IBrowser> GetOrInitBrowserAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_browser != null) return _browser;
|
||||
|
||||
await _browserLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_browser != null) return _browser;
|
||||
|
||||
_playwright = await Playwright.CreateAsync();
|
||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Args = new[] { "--no-sandbox", "--disable-dev-shm-usage" }
|
||||
});
|
||||
|
||||
|
||||
_logger.LogInformation("[YahooFallbackScraper] Playwright Chromium browser initialized.");
|
||||
return _browser;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_browserLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static BrowserNewContextOptions BuildContextOptions() => new()
|
||||
{
|
||||
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
ViewportSize = new ViewportSize { Width = 1280, Height = 900 },
|
||||
Locale = "en-US",
|
||||
ExtraHTTPHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Accept-Language"] = "en-US,en;q=0.9"
|
||||
}
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_browser != null) await _browser.CloseAsync();
|
||||
_playwright?.Dispose();
|
||||
}
|
||||
|
||||
// ── Parse Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private async Task HandleConsentAsync(IPage page)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (page.Url.Contains("consent.yahoo.com"))
|
||||
{
|
||||
_logger.LogInformation("[YahooFallbackScraper] Redirected to consent page. Attempting to accept cookies...");
|
||||
var agreeBtn = page.Locator("button[name='agree'], button.accept-all, button[value='agree']");
|
||||
if (await agreeBtn.CountAsync() > 0)
|
||||
{
|
||||
await agreeBtn.First.ClickAsync();
|
||||
await page.WaitForNavigationAsync(new PageWaitForNavigationOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 20_000 });
|
||||
_logger.LogInformation("[YahooFallbackScraper] Cookie consent accepted. Navigated back to: {Url}", page.Url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("[YahooFallbackScraper] On consent page but could not find the 'agree' button.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "[YahooFallbackScraper] Error while handling cookie consent.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lowercases, strips trailing digits, common Yahoo qualifiers like (ttm), (mrq), and extra spaces.</summary>
|
||||
private static string NormalizeLabel(string label)
|
||||
{
|
||||
label = label.ToLowerInvariant();
|
||||
label = Regex.Replace(label, @"\s*\d+\s*$", ""); // trailing superscripts
|
||||
label = label.Replace("(ttm)", "").Replace("(mrq)", "").Replace("(fye)", ""); // remove date qualifiers
|
||||
label = Regex.Replace(label, @"\s+", " "); // collapse spaces
|
||||
return label.Trim();
|
||||
}
|
||||
|
||||
private static decimal? ParseDecimal(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input) || input == "N/A" || input == "--" || input == "-") return null;
|
||||
input = Regex.Replace(input, @"[^\d.-]", "");
|
||||
return decimal.TryParse(input, NumberStyles.Any, CultureInfo.InvariantCulture, out var val) ? val : null;
|
||||
}
|
||||
|
||||
private static decimal? ParsePercent(string? input)
|
||||
{
|
||||
var val = ParseDecimal(input);
|
||||
if (!val.HasValue) return null;
|
||||
return val.Value > 1m ? val.Value / 100m : val.Value;
|
||||
}
|
||||
|
||||
private static decimal? ParseSuffixNumber(string? input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input) || input == "N/A" || input == "--" || input == "-") return null;
|
||||
input = input.Trim();
|
||||
decimal multiplier = input.EndsWith("T", StringComparison.OrdinalIgnoreCase) ? 1_000_000_000_000m
|
||||
: input.EndsWith("B", StringComparison.OrdinalIgnoreCase) ? 1_000_000_000m
|
||||
: input.EndsWith("M", StringComparison.OrdinalIgnoreCase) ? 1_000_000m
|
||||
: input.EndsWith("K", StringComparison.OrdinalIgnoreCase) ? 1_000m
|
||||
: 1m;
|
||||
var numPart = Regex.Replace(input, @"[^\d.-]", "");
|
||||
var val = ParseDecimal(numPart);
|
||||
return val.HasValue ? val.Value * multiplier : null;
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,15 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Clients;
|
||||
using FinlyticCore.Dtos.Fundamentals;
|
||||
using FinlyticCore.Dtos.Yahoo;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Services.Yahoo;
|
||||
using FinlyticFundamentals.Entities;
|
||||
using FinlyticFundamentals.Database;
|
||||
using FinlyticFundamentals.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticFundamentals.Services;
|
||||
@@ -15,477 +18,242 @@ namespace FinlyticFundamentals.Services;
|
||||
public interface IYahooFinanceScraper
|
||||
{
|
||||
/// <summary>
|
||||
/// Resolves ticker from ISIN.
|
||||
/// Ermittelt den primären Börsenticker zu einer ISIN anhand von Börsenplatz-Prioritäten.
|
||||
/// </summary>
|
||||
Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
|
||||
Task<TickerInfoDto?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Resolves all tickers from ISIN.
|
||||
/// Ermittelt alle gefundenen Börsenticker zu einer ISIN, sortiert nach Priorität.
|
||||
/// </summary>
|
||||
Task<List<string>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default);
|
||||
Task<List<TickerInfoDto>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Scrapes fundamentals.
|
||||
/// Ruft Fundamental- und Unternehmensdaten primär über die Yahoo Finance API ab
|
||||
/// und fällt automatisch auf den Playwright HTML Scraper zurück, falls keine Daten vorhanden sind.
|
||||
/// </summary>
|
||||
Task<ScrapedFundamentalsData?> ScrapeFundamentalsAsync(string isin, string ticker,
|
||||
/// <param name="symbolOrIsin">Das Tickersymbol (z. B. "MSFT") oder die ISIN.</param>
|
||||
/// <param name="forceHtmlScrape">Erzwingt sofortiges HTML-Scraping ohne API-Vorprüfung.</param>
|
||||
/// <param name="cancellationToken">Abbruch-Token.</param>
|
||||
/// <returns>Das aggregierte <see cref="YahooQuoteSummaryModulesDto"/> oder <c>null</c>.</returns>
|
||||
Task<YahooQuoteSummaryModulesDto?> GetQuoteSummaryModulesAsync(
|
||||
string symbolOrIsin,
|
||||
bool forceHtmlScrape = false,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public record ScrapedFundamentalsData(
|
||||
AssetFundamentalsEntity Fundamentals,
|
||||
TickerFundamentalsEntity TickerData,
|
||||
List<CompanyExecutiveEntity> Executives,
|
||||
List<FinancialStatementEntity> Statements,
|
||||
List<ForwardEstimateEntity> Estimates
|
||||
);
|
||||
|
||||
public class YahooFinanceScraper : IYahooFinanceScraper
|
||||
{
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly YahooFinanceClient _yahooClient;
|
||||
private readonly ILogger<YahooFinanceScraper> _logger;
|
||||
private readonly YahooFinanceClient _yahooApiClient;
|
||||
private readonly IYahooFinanceHtmlClient _htmlScraperClient;
|
||||
private readonly IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> _finlyticLogger;
|
||||
|
||||
public YahooFinanceScraper(HttpClient httpClient, YahooFinanceClient yahooClient,
|
||||
ILogger<YahooFinanceScraper> logger)
|
||||
public YahooFinanceScraper(
|
||||
YahooFinanceClient yahooApiClient,
|
||||
IYahooFinanceHtmlClient htmlScraperClient,
|
||||
IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> finlyticLogger)
|
||||
{
|
||||
_httpClient = httpClient;
|
||||
_yahooClient = yahooClient;
|
||||
_logger = logger;
|
||||
_yahooApiClient = yahooApiClient;
|
||||
_htmlScraperClient = htmlScraperClient;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
|
||||
public async Task<TickerInfoDto?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tickers = await ResolveAllTickersFromIsinAsync(isin, cancellationToken);
|
||||
return tickers.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<List<string>> ResolveAllTickersFromIsinAsync(string isin,
|
||||
CancellationToken cancellationToken = default)
|
||||
public async Task<List<TickerInfoDto>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isin)) return new();
|
||||
if (string.IsNullOrWhiteSpace(isin)) return new List<TickerInfoDto>();
|
||||
|
||||
var symbols = new List<(string symbol, int priority)>();
|
||||
|
||||
var primary = await _yahooClient.SearchAsync(isin, quotesCount: 20, cancellationToken: cancellationToken);
|
||||
var quotes = primary?.Quotes ?? new();
|
||||
|
||||
foreach (var q in quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)))
|
||||
{
|
||||
symbols.Add((q.Symbol, GetExchangePriority(q.Symbol, isin)));
|
||||
}
|
||||
|
||||
if (quotes.Count == 0) return [];
|
||||
|
||||
// 2. Namenssuche für deutsche/andere Handelsplätze
|
||||
var companyName = quotes[0].LongName!;
|
||||
|
||||
var secondary =
|
||||
await _yahooClient.SearchAsync(companyName, quotesCount: 20, cancellationToken: cancellationToken);
|
||||
|
||||
foreach (var q in secondary?.Quotes ?? new())
|
||||
{
|
||||
if (!string.IsNullOrEmpty(q.Symbol) &&
|
||||
!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
symbols.Add((q.Symbol, GetExchangePriority(q.Symbol, isin)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 3. Sortieren und zurückgeben
|
||||
return symbols
|
||||
.OrderBy(s => s.priority)
|
||||
.Select(s => s.symbol)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(20)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private int GetExchangePriority(string symbol, string isin)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(isin) && isin.StartsWith("US", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (!symbol.Contains('.')) return 1;
|
||||
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 2;
|
||||
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase)) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 1; // XETRA
|
||||
}
|
||||
else if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 2; // Frankfurt
|
||||
}
|
||||
else if (symbol.EndsWith(".TG", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 3; // Gettex
|
||||
}
|
||||
else if (symbol.EndsWith(".MU", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbol.EndsWith(".BE", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbol.EndsWith(".DU", StringComparison.OrdinalIgnoreCase) ||
|
||||
symbol.EndsWith(".HM", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 4; // Other German regional exchanges
|
||||
}
|
||||
else if (symbol.Contains('.') && !symbol.EndsWith(".OB", StringComparison.OrdinalIgnoreCase) &&
|
||||
!symbol.EndsWith(".PK", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return 5; // Domestic/home non-US exchanges
|
||||
}
|
||||
else
|
||||
{
|
||||
return 6; // Other
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ScrapedFundamentalsData?> ScrapeFundamentalsAsync(string isin, string ticker,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"[{Channel}] Fetching fundamental data for Ticker {Ticker} (ISIN: {Isin}) using YahooFinanceClient...",
|
||||
"FundamentalsChannel", ticker, isin);
|
||||
var cleanIsin = isin.Trim().ToUpperInvariant();
|
||||
var symbols = new List<(string symbol, string exchange, int priority)>();
|
||||
|
||||
try
|
||||
{
|
||||
var summaryResponse = await _yahooClient.GetFullQuoteSummaryAsync(ticker, cancellationToken);
|
||||
if (summaryResponse?.QuoteSummary?.Result == null || summaryResponse.QuoteSummary.Result.Count == 0)
|
||||
// 1. Suche via ISIN
|
||||
var primary = await _yahooApiClient.SearchAsync(cleanIsin, quotesCount: 20, cancellationToken: cancellationToken);
|
||||
var quotes = primary?.Quotes ?? new List<YahooSearchQuoteDto>();
|
||||
|
||||
foreach (var q in quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)))
|
||||
{
|
||||
_logger.LogWarning("[{Channel}] YahooFinanceClient returned no result for ticker {Ticker}",
|
||||
"FundamentalsChannel", ticker);
|
||||
return null;
|
||||
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin)));
|
||||
}
|
||||
|
||||
var root = summaryResponse.QuoteSummary.Result[0];
|
||||
|
||||
var assetProfile = root.AssetProfile;
|
||||
var financialData = root.FinancialData;
|
||||
var defaultKeyStatistics = root.DefaultKeyStatistics;
|
||||
var summaryDetail = root.SummaryDetail;
|
||||
var calendarEvents = root.CalendarEvents;
|
||||
|
||||
// Instantiate entities
|
||||
var fundamentals = new AssetFundamentalsEntity
|
||||
// 2. Falls Ticker gefunden, aber mit Unternehmensname noch mehr Exchangeticker auffindbar sind
|
||||
if (quotes.Count > 0)
|
||||
{
|
||||
Isin = isin,
|
||||
PrimaryTicker = ticker,
|
||||
LastUpdatedAt = DateTime.UtcNow,
|
||||
LastStaticUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
var tickerData = new TickerFundamentalsEntity
|
||||
{
|
||||
Ticker = ticker,
|
||||
Isin = isin,
|
||||
LastUpdatedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
// 1. Static Profile Data
|
||||
if (assetProfile != null)
|
||||
{
|
||||
fundamentals.BusinessSummary = assetProfile.LongBusinessSummary;
|
||||
fundamentals.Sector = assetProfile.Sector;
|
||||
fundamentals.Industry = assetProfile.Industry;
|
||||
fundamentals.Country = assetProfile.Country;
|
||||
fundamentals.Employees = assetProfile.FullTimeEmployees;
|
||||
}
|
||||
|
||||
// Company Name
|
||||
fundamentals.CompanyName = ticker;
|
||||
|
||||
// 2. Exchange & Trading Currency for Ticker
|
||||
if (financialData != null && !string.IsNullOrWhiteSpace(financialData.FinancialCurrency))
|
||||
{
|
||||
tickerData.TradingCurrency = financialData.FinancialCurrency;
|
||||
}
|
||||
|
||||
if (summaryDetail != null && !string.IsNullOrWhiteSpace(summaryDetail.Currency))
|
||||
{
|
||||
tickerData.TradingCurrency = summaryDetail.Currency;
|
||||
}
|
||||
|
||||
// 3. Dynamic Price & Valuation Data
|
||||
if (financialData != null)
|
||||
{
|
||||
tickerData.CurrentPrice = financialData.CurrentPrice?.DecimalValue ?? 0;
|
||||
tickerData.GrossMargin = financialData.GrossMargins?.DecimalValue;
|
||||
tickerData.OperatingMargin = financialData.OperatingMargins?.DecimalValue;
|
||||
tickerData.NetProfitMargin = financialData.ProfitMargins?.DecimalValue;
|
||||
tickerData.ReturnOnEquity = financialData.ReturnOnEquity?.DecimalValue;
|
||||
tickerData.ReturnOnAssets = financialData.ReturnOnAssets?.DecimalValue;
|
||||
tickerData.CurrentRatio = financialData.CurrentRatio?.DecimalValue;
|
||||
tickerData.QuickRatio = financialData.QuickRatio?.DecimalValue;
|
||||
tickerData.DebtToEquity = financialData.DebtToEquity?.DecimalValue;
|
||||
|
||||
// Targets on Company Level
|
||||
fundamentals.PriceTargetLow = financialData.TargetLowPrice?.DecimalValue;
|
||||
fundamentals.PriceTargetHigh = financialData.TargetHighPrice?.DecimalValue;
|
||||
fundamentals.PriceTargetMedian = financialData.TargetMedianPrice?.DecimalValue;
|
||||
fundamentals.PriceTargetMean = financialData.TargetMeanPrice?.DecimalValue;
|
||||
}
|
||||
|
||||
if (summaryDetail != null)
|
||||
{
|
||||
if (tickerData.CurrentPrice == 0)
|
||||
var companyName = quotes[0].LongName ?? quotes[0].ShortName;
|
||||
if (!string.IsNullOrWhiteSpace(companyName))
|
||||
{
|
||||
tickerData.CurrentPrice = summaryDetail.Open?.DecimalValue ??
|
||||
summaryDetail.PreviousClose?.DecimalValue ?? 0;
|
||||
}
|
||||
|
||||
tickerData.FiftyTwoWeekHigh = summaryDetail.FiftyTwoWeekHigh?.DecimalValue ?? 0;
|
||||
tickerData.FiftyTwoWeekLow = summaryDetail.FiftyTwoWeekLow?.DecimalValue ?? 0;
|
||||
}
|
||||
|
||||
var mCap = defaultKeyStatistics?.SharesOutstanding?.DecimalValue;
|
||||
mCap ??= summaryDetail?.MarketCap?.DecimalValue;
|
||||
tickerData.MarketCapitalization = mCap ?? 0;
|
||||
|
||||
var ev = defaultKeyStatistics?.EnterpriseValue?.DecimalValue;
|
||||
tickerData.EnterpriseValue = ev ?? 0;
|
||||
|
||||
tickerData.PeRatioTrailing = defaultKeyStatistics?.TrailingEps?.DecimalValue ??
|
||||
summaryDetail?.TrailingPE?.DecimalValue;
|
||||
tickerData.PeRatioForward =
|
||||
defaultKeyStatistics?.ForwardPE?.DecimalValue ?? summaryDetail?.ForwardPE?.DecimalValue;
|
||||
|
||||
if (defaultKeyStatistics != null)
|
||||
{
|
||||
tickerData.PegRatio = defaultKeyStatistics.PegRatio?.DecimalValue;
|
||||
tickerData.PbRatio = defaultKeyStatistics.PriceToBook?.DecimalValue;
|
||||
fundamentals.ShortRatio = defaultKeyStatistics.ShortRatio?.DecimalValue;
|
||||
fundamentals.ShortPercentOfFloat = defaultKeyStatistics.ShortPercentOfFloat?.DecimalValue;
|
||||
fundamentals.PercentHeldByInstitutions = defaultKeyStatistics.HeldPercentInstitutions?.DecimalValue;
|
||||
fundamentals.PercentHeldByInsiders = defaultKeyStatistics.HeldPercentInsiders?.DecimalValue;
|
||||
}
|
||||
|
||||
tickerData.PsRatio = defaultKeyStatistics?.PriceToSalesTrailing12Months?.DecimalValue ??
|
||||
summaryDetail?.PriceToSalesTrailing12Months?.DecimalValue;
|
||||
tickerData.EvToEbitda = defaultKeyStatistics?.EnterpriseToEbitda?.DecimalValue;
|
||||
tickerData.EvToRevenue = defaultKeyStatistics?.EnterpriseToRevenue?.DecimalValue;
|
||||
|
||||
tickerData.DividendYield = summaryDetail?.DividendYield?.DecimalValue;
|
||||
tickerData.PayoutRatio = summaryDetail?.PayoutRatio?.DecimalValue;
|
||||
|
||||
if (financialData != null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(financialData.RecommendationKey) &&
|
||||
!financialData.RecommendationKey.Equals("none", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
fundamentals.ConsensusRating = financialData.RecommendationKey;
|
||||
}
|
||||
else if (financialData.RecommendationMean != null && financialData.RecommendationMean.Raw.HasValue)
|
||||
{
|
||||
double mean = financialData.RecommendationMean.Raw.Value;
|
||||
fundamentals.ConsensusRating = mean <= 1.8
|
||||
? "strong_buy"
|
||||
: (mean <= 2.5 ? "buy" : (mean <= 3.5 ? "hold" : (mean <= 4.2 ? "sell" : "strong_sell")));
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Calendar Events Data
|
||||
if (calendarEvents != null)
|
||||
{
|
||||
if (calendarEvents.ExDividendDate?.Raw.HasValue == true)
|
||||
{
|
||||
long seconds = (long)calendarEvents.ExDividendDate.Raw.Value;
|
||||
if (seconds > 0)
|
||||
fundamentals.ExDividendDate = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
|
||||
}
|
||||
|
||||
if (calendarEvents.Earnings?.EarningsDate != null && calendarEvents.Earnings.EarningsDate.Count > 0)
|
||||
{
|
||||
var firstDate = calendarEvents.Earnings.EarningsDate[0];
|
||||
if (firstDate.Raw.HasValue && firstDate.Raw.Value > 0)
|
||||
var secondary = await _yahooApiClient.SearchAsync(companyName, quotesCount: 20, cancellationToken: cancellationToken);
|
||||
foreach (var q in secondary?.Quotes ?? new List<YahooSearchQuoteDto>())
|
||||
{
|
||||
fundamentals.NextEarningsDate =
|
||||
DateTimeOffset.FromUnixTimeSeconds((long)firstDate.Raw.Value).UtcDateTime;
|
||||
if (!string.IsNullOrEmpty(q.Symbol) &&
|
||||
!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fundamentals.ExDividendDate.HasValue && summaryDetail?.ExDividendDate?.Raw.HasValue == true)
|
||||
{
|
||||
long seconds = (long)summaryDetail.ExDividendDate.Raw.Value;
|
||||
if (seconds > 0) fundamentals.ExDividendDate = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
|
||||
}
|
||||
|
||||
tickerData.ExDividendDate = fundamentals.ExDividendDate;
|
||||
|
||||
// 5. Executives List
|
||||
var executives = new List<CompanyExecutiveEntity>();
|
||||
if (assetProfile?.CompanyOfficers != null)
|
||||
{
|
||||
foreach (var officer in assetProfile.CompanyOfficers)
|
||||
{
|
||||
var exec = new CompanyExecutiveEntity
|
||||
{
|
||||
Isin = isin,
|
||||
Name = !string.IsNullOrWhiteSpace(officer.Name) ? officer.Name : "Unknown",
|
||||
Title = !string.IsNullOrWhiteSpace(officer.Title) ? officer.Title : "Officer",
|
||||
Age = officer.Age,
|
||||
Compensation = officer.TotalPay?.DecimalValue
|
||||
};
|
||||
executives.Add(exec);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Financial Statements
|
||||
var statements = new List<FinancialStatementEntity>();
|
||||
|
||||
// A. Annual Statements
|
||||
if (root.IncomeStatementHistory?.IncomeStatementHistory != null)
|
||||
{
|
||||
foreach (var item in root.IncomeStatementHistory.IncomeStatementHistory)
|
||||
{
|
||||
MapIncomeStatement(item, isin, "Annual", statements);
|
||||
}
|
||||
}
|
||||
|
||||
if (root.BalanceSheetHistory?.BalanceSheetStatements != null)
|
||||
{
|
||||
foreach (var item in root.BalanceSheetHistory.BalanceSheetStatements)
|
||||
{
|
||||
MapBalanceSheet(item, isin, "Annual", statements);
|
||||
}
|
||||
}
|
||||
|
||||
if (root.CashflowStatementHistory?.CashflowStatements != null)
|
||||
{
|
||||
foreach (var item in root.CashflowStatementHistory.CashflowStatements)
|
||||
{
|
||||
MapCashflowStatement(item, isin, "Annual", statements);
|
||||
}
|
||||
}
|
||||
|
||||
// B. Quarterly Statements
|
||||
if (root.IncomeStatementHistoryQuarterly?.IncomeStatementHistory != null)
|
||||
{
|
||||
foreach (var item in root.IncomeStatementHistoryQuarterly.IncomeStatementHistory)
|
||||
{
|
||||
MapIncomeStatement(item, isin, "Quarterly", statements);
|
||||
}
|
||||
}
|
||||
|
||||
if (root.BalanceSheetHistoryQuarterly?.BalanceSheetStatements != null)
|
||||
{
|
||||
foreach (var item in root.BalanceSheetHistoryQuarterly.BalanceSheetStatements)
|
||||
{
|
||||
MapBalanceSheet(item, isin, "Quarterly", statements);
|
||||
}
|
||||
}
|
||||
|
||||
if (root.CashflowStatementHistoryQuarterly?.CashflowStatements != null)
|
||||
{
|
||||
foreach (var item in root.CashflowStatementHistoryQuarterly.CashflowStatements)
|
||||
{
|
||||
MapCashflowStatement(item, isin, "Quarterly", statements);
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Forward Estimates
|
||||
var estimates = new List<ForwardEstimateEntity>();
|
||||
|
||||
return new ScrapedFundamentalsData(fundamentals, tickerData, executives, statements, estimates);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Failed to scrape fundamentals for ISIN {Isin} (Ticker: {Ticker})",
|
||||
"FundamentalsChannel", isin, ticker);
|
||||
return null;
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel, ex,
|
||||
"[YahooFinanceScraper] Fehler beim Auflösen des Tickers für ISIN '{Isin}'", cleanIsin);
|
||||
}
|
||||
|
||||
return symbols
|
||||
.OrderBy(s => s.priority)
|
||||
.Select(s => new TickerInfoDto(){Ticker = s.symbol, Exchange = s.exchange})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void MapIncomeStatement(YahooIncomeStatementDto item, string isin, string periodType,
|
||||
List<FinancialStatementEntity> statements)
|
||||
/// <inheritdoc />
|
||||
public async Task<YahooQuoteSummaryModulesDto?> GetQuoteSummaryModulesAsync(
|
||||
string symbolOrIsin,
|
||||
bool forceHtmlScrape = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (item.EndDate?.Raw.HasValue != true) return;
|
||||
var endDate = DateTimeOffset.FromUnixTimeSeconds((long)item.EndDate.Raw.Value).UtcDateTime.Date;
|
||||
if (string.IsNullOrWhiteSpace(symbolOrIsin)) return null;
|
||||
|
||||
var statement = GetOrCreateStatement(statements, isin, periodType, endDate);
|
||||
var symbol = symbolOrIsin.Trim().ToUpperInvariant();
|
||||
|
||||
if (item.TotalRevenue?.Raw.HasValue == true) statement.TotalRevenue = item.TotalRevenue.DecimalValue;
|
||||
if (item.CostOfRevenue?.Raw.HasValue == true) statement.CostOfRevenue = item.CostOfRevenue.DecimalValue;
|
||||
if (item.GrossProfit?.Raw.HasValue == true) statement.GrossProfit = item.GrossProfit.DecimalValue;
|
||||
else if (statement.TotalRevenue.HasValue && statement.CostOfRevenue.HasValue)
|
||||
statement.GrossProfit = statement.TotalRevenue - statement.CostOfRevenue;
|
||||
|
||||
if (item.TotalOperatingExpenses?.Raw.HasValue == true)
|
||||
statement.OperatingExpenses = item.TotalOperatingExpenses.DecimalValue;
|
||||
if (item.OperatingIncome?.Raw.HasValue == true) statement.OperatingIncome = item.OperatingIncome.DecimalValue;
|
||||
else if (statement.GrossProfit.HasValue && statement.OperatingExpenses.HasValue)
|
||||
statement.OperatingIncome = statement.GrossProfit - statement.OperatingExpenses;
|
||||
|
||||
if (item.Ebit?.Raw.HasValue == true) statement.Ebitda = item.Ebit.DecimalValue;
|
||||
if (item.NetIncome?.Raw.HasValue == true) statement.NetIncome = item.NetIncome.DecimalValue;
|
||||
}
|
||||
|
||||
private static void MapBalanceSheet(YahooBalanceSheetStatementDto item, string isin, string periodType,
|
||||
List<FinancialStatementEntity> statements)
|
||||
{
|
||||
if (item.EndDate?.Raw.HasValue != true) return;
|
||||
var endDate = DateTimeOffset.FromUnixTimeSeconds((long)item.EndDate.Raw.Value).UtcDateTime.Date;
|
||||
|
||||
var statement = GetOrCreateStatement(statements, isin, periodType, endDate);
|
||||
|
||||
if (item.Cash?.Raw.HasValue == true) statement.CashAndCashEquivalents = item.Cash.DecimalValue;
|
||||
if (item.NetReceivables?.Raw.HasValue == true) statement.AccountsReceivable = item.NetReceivables.DecimalValue;
|
||||
if (item.Inventory?.Raw.HasValue == true) statement.Inventory = item.Inventory.DecimalValue;
|
||||
if (item.TotalCurrentAssets?.Raw.HasValue == true)
|
||||
statement.TotalCurrentAssets = item.TotalCurrentAssets.DecimalValue;
|
||||
if (item.TotalCurrentLiabilities?.Raw.HasValue == true)
|
||||
statement.CurrentLiabilities = item.TotalCurrentLiabilities.DecimalValue;
|
||||
if (item.LongTermDebt?.Raw.HasValue == true) statement.LongTermDebt = item.LongTermDebt.DecimalValue;
|
||||
if (item.TotalLiab?.Raw.HasValue == true) statement.TotalLiabilities = item.TotalLiab.DecimalValue;
|
||||
if (item.TotalStockholderEquity?.Raw.HasValue == true)
|
||||
statement.TotalStockholdersEquity = item.TotalStockholderEquity.DecimalValue;
|
||||
}
|
||||
|
||||
private static void MapCashflowStatement(YahooCashflowStatementDto item, string isin, string periodType,
|
||||
List<FinancialStatementEntity> statements)
|
||||
{
|
||||
if (item.EndDate?.Raw.HasValue != true) return;
|
||||
var endDate = DateTimeOffset.FromUnixTimeSeconds((long)item.EndDate.Raw.Value).UtcDateTime.Date;
|
||||
|
||||
var statement = GetOrCreateStatement(statements, isin, periodType, endDate);
|
||||
|
||||
if (item.TotalCashFromOperatingActivities?.Raw.HasValue == true)
|
||||
statement.OperatingCashFlow = item.TotalCashFromOperatingActivities.DecimalValue;
|
||||
if (item.TotalCashflowsFromInvestingActivities?.Raw.HasValue == true)
|
||||
statement.InvestingCashFlow = item.TotalCashflowsFromInvestingActivities.DecimalValue;
|
||||
if (item.CapitalExpenditures?.Raw.HasValue == true)
|
||||
statement.CapitalExpenditures = item.CapitalExpenditures.DecimalValue;
|
||||
if (item.TotalCashFromFinancingActivities?.Raw.HasValue == true)
|
||||
statement.FinancingCashFlow = item.TotalCashFromFinancingActivities.DecimalValue;
|
||||
|
||||
if (statement.OperatingCashFlow.HasValue)
|
||||
// Falls eine ISIN übergeben wurde, zuerst Ticker auflösen
|
||||
if (IsIsin(symbol))
|
||||
{
|
||||
var capex = statement.CapitalExpenditures ?? 0m;
|
||||
statement.FreeCashFlow = statement.OperatingCashFlow.Value - Math.Abs(capex);
|
||||
}
|
||||
}
|
||||
|
||||
private static FinancialStatementEntity GetOrCreateStatement(List<FinancialStatementEntity> statements, string isin,
|
||||
string periodType, DateTime endDate)
|
||||
{
|
||||
var existing = statements.FirstOrDefault(s => s.PeriodType == periodType && s.EndDate.Date == endDate.Date);
|
||||
if (existing == null)
|
||||
{
|
||||
existing = new FinancialStatementEntity
|
||||
var resolvedTicker = await ResolveTickerFromIsinAsync(symbol, cancellationToken);
|
||||
if (resolvedTicker != null)
|
||||
{
|
||||
Isin = isin,
|
||||
PeriodType = periodType,
|
||||
EndDate = endDate.Date
|
||||
};
|
||||
statements.Add(existing);
|
||||
symbol = resolvedTicker.Ticker;
|
||||
}
|
||||
}
|
||||
|
||||
return existing;
|
||||
YahooQuoteSummaryModulesDto? apiModules = null;
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 1. PRIMÄRE DATENQUELLE: Yahoo Finance API (Cookie/Crumb)
|
||||
// -------------------------------------------------------------
|
||||
if (!forceHtmlScrape)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel,
|
||||
"[YahooFinanceScraper] Starte primären API-Abruf für '{Symbol}'...", symbol);
|
||||
|
||||
var apiResponse = await _yahooApiClient.GetFullQuoteSummaryAsync(symbol, cancellationToken);
|
||||
apiModules = apiResponse?.QuoteSummary?.Result?.FirstOrDefault();
|
||||
|
||||
if (apiModules != null && HasSufficientData(apiModules))
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel,
|
||||
"[YahooFinanceScraper] Erfolgreich Daten über API bezogen für '{Symbol}'.", symbol);
|
||||
return apiModules;
|
||||
}
|
||||
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel,
|
||||
"[YahooFinanceScraper] API lieferte unvollständige Daten für '{Symbol}'. Initiiere Fallback...", symbol);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel, ex,
|
||||
"[YahooFinanceScraper] API-Abruf fehlgeschlagen für '{Symbol}'. Wechsle zu Scraper...", symbol);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 2. FALLBACK DATENQUELLE: Playwright HTML Scraper
|
||||
// -------------------------------------------------------------
|
||||
YahooQuoteSummaryModulesDto? htmlModules = null;
|
||||
try
|
||||
{
|
||||
await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel,
|
||||
"[YahooFinanceScraper] Starte HTML-Scraper Fallback für '{Symbol}'...", symbol);
|
||||
|
||||
htmlModules = await _htmlScraperClient.ScrapeQuoteSummaryModulesAsync(symbol, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogErrorAsync(SettingKeys.YahooClientChannel, ex,
|
||||
"[YahooFinanceScraper] HTML-Scraper Fallback ebenfalls fehlgeschlagen für '{Symbol}'.", symbol);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------
|
||||
// 3. Zusammenführen (Merge API & HTML Fallback)
|
||||
// -------------------------------------------------------------
|
||||
if (apiModules == null) return htmlModules;
|
||||
if (htmlModules == null) return apiModules;
|
||||
|
||||
return MergeModules(apiModules, htmlModules);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob das Modul-DTO die wesentlichen Fundamentalblöcke enthält.
|
||||
/// </summary>
|
||||
private static bool HasSufficientData(YahooQuoteSummaryModulesDto modules)
|
||||
{
|
||||
return modules.SummaryDetail != null ||
|
||||
modules.FinancialData != null ||
|
||||
modules.DefaultKeyStatistics != null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Führt API- und Scraper-Daten zusammen, damit Lücken in API-Responses geschlossen werden.
|
||||
/// </summary>
|
||||
private static YahooQuoteSummaryModulesDto MergeModules(
|
||||
YahooQuoteSummaryModulesDto primary,
|
||||
YahooQuoteSummaryModulesDto secondary)
|
||||
{
|
||||
return new YahooQuoteSummaryModulesDto(
|
||||
QuoteType: primary.QuoteType ?? secondary.QuoteType,
|
||||
AssetProfile: primary.AssetProfile ?? secondary.AssetProfile,
|
||||
FinancialData: primary.FinancialData ?? secondary.FinancialData,
|
||||
DefaultKeyStatistics: primary.DefaultKeyStatistics ?? secondary.DefaultKeyStatistics,
|
||||
SummaryDetail: primary.SummaryDetail ?? secondary.SummaryDetail,
|
||||
IncomeStatementHistory: primary.IncomeStatementHistory ?? secondary.IncomeStatementHistory,
|
||||
IncomeStatementHistoryQuarterly: primary.IncomeStatementHistoryQuarterly ?? secondary.IncomeStatementHistoryQuarterly,
|
||||
BalanceSheetHistory: primary.BalanceSheetHistory ?? secondary.BalanceSheetHistory,
|
||||
BalanceSheetHistoryQuarterly: primary.BalanceSheetHistoryQuarterly ?? secondary.BalanceSheetHistoryQuarterly,
|
||||
CashflowStatementHistory: primary.CashflowStatementHistory ?? secondary.CashflowStatementHistory,
|
||||
CashflowStatementHistoryQuarterly: primary.CashflowStatementHistoryQuarterly ?? secondary.CashflowStatementHistoryQuarterly,
|
||||
CalendarEvents: primary.CalendarEvents ?? secondary.CalendarEvents
|
||||
);
|
||||
}
|
||||
|
||||
private static bool IsIsin(string value)
|
||||
{
|
||||
return value.Length == 12 &&
|
||||
char.IsLetter(value[0]) &&
|
||||
char.IsLetter(value[1]) &&
|
||||
value.All(char.IsLetterOrDigit);
|
||||
}
|
||||
|
||||
private static int GetExchangePriority(string symbol, string isin)
|
||||
{
|
||||
bool isGermanIsin = isin.StartsWith("DE", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (isGermanIsin)
|
||||
{
|
||||
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 1; // Xetra
|
||||
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) return 2; // Frankfurt
|
||||
if (symbol.EndsWith(".STU", StringComparison.OrdinalIgnoreCase)) return 3; // Stuttgart
|
||||
if (symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase)) return 4; // Stuttgart (alt)
|
||||
if (symbol.EndsWith(".HM", StringComparison.OrdinalIgnoreCase)) return 5; // Hamburg
|
||||
if (!symbol.Contains('.')) return 6; // US Primary
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!symbol.Contains('.')) return 1; // US Primary (NASDAQ, NYSE)
|
||||
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 2; // Xetra
|
||||
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) return 3; // Frankfurt
|
||||
if (symbol.EndsWith(".L", StringComparison.OrdinalIgnoreCase)) return 4; // London
|
||||
if (symbol.EndsWith(".PA", StringComparison.OrdinalIgnoreCase)) return 5; // Paris
|
||||
}
|
||||
|
||||
return 10;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user