feat(Fundamentals): add fundamentals service

This commit is contained in:
2026-08-09 21:01:39 +02:00
parent e0778b88ea
commit 605e41cfeb
21 changed files with 3122 additions and 0 deletions
@@ -0,0 +1,589 @@
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.Services.Yahoo;
using FinlyticFundamentals.Database;
using FinlyticFundamentals.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace FinlyticFundamentals.Services;
public interface IFundamentalsDbService
{
/// <summary>
/// Gets the fundamental data for a given ISIN.
/// If a specific ticker is provided, the resolution pipeline prioritizes/fetches only that ticker.
/// </summary>
/// <param name="isin">The ISIN identifier of the asset.</param>
/// <param name="ticker">Optional specific ticker symbol (e.g., "APC.DE"). If omitted, tickers are resolved automatically.</param>
/// <param name="forceRefresh">If true, forces a full static scrape for profile, financials, and executives.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The mapped <see cref="AssetFundamentalsDto"/> or null if unavailable.</returns>
Task<AssetFundamentalsDto?> GetFundamentalsAsync(
string isin,
string? ticker = null,
bool forceRefresh = false,
CancellationToken cancellationToken = default);
/// <summary>
/// Gets all upcoming and historic corporate events (e.g., earnings releases, ex-dividend dates).
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A list of corporate events sorted chronologically.</returns>
Task<List<CorporateEventDto>> GetAllEventsAsync(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 YahooFinanceClient _yahooClient;
private readonly ILogger<FundamentalsDbService> _logger;
public FundamentalsDbService(
IServiceScopeFactory scopeFactory,
IYahooFinanceScraper scraper,
YahooFinanceClient yahooClient,
ILogger<FundamentalsDbService> logger)
{
_scopeFactory = scopeFactory;
_scraper = scraper;
_yahooClient = yahooClient;
_logger = logger;
}
/// <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();
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
var isinLock = IsinLocks.GetOrAdd(cleanIsin, _ => new SemaphoreSlim(1, 1));
await isinLock.WaitAsync(cancellationToken);
try
{
// 1. Aus DB laden
var entity = await LoadEntityGraphAsync(context, cleanIsin, cancellationToken);
// Statische Daten älter als 30 Tage oder forced?
bool needsStaticScrape = entity == null || forceRefresh || (DateTime.UtcNow - entity.LastStaticUpdatedAt).TotalDays > 30;
if (needsStaticScrape)
{
entity = await ExecuteFullScrapeAndPersistAsync(context, cleanIsin, requestedTicker, entity, cancellationToken);
}
else
{
// Statik ist frisch -> Prüfen ob requested Ticker existiert oder neu nachgeladen werden muss
entity = await EnsureTickerDataUpToDateAsync(context, cleanIsin, requestedTicker, entity!, cancellationToken);
}
return entity != null ? MapToDto(entity, requestedTicker) : null;
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Failed to process fundamentals for ISIN {Isin}", "FundamentalsChannel", cleanIsin);
// Fallback auf Datenbankstand, falls vorhanden
var fallback = await LoadEntityGraphAsync(context, cleanIsin, cancellationToken);
return fallback != null ? MapToDto(fallback, requestedTicker) : null;
}
finally
{
isinLock.Release();
}
}
#region Internal Logic Pipelines
/// <summary>
/// Stellt sicher, dass der angeforderte Ticker existiert und dessen Live-Preise frisch sind (TTL: 15 Minuten).
/// </summary>
private async Task<AssetFundamentalsEntity> EnsureTickerDataUpToDateAsync(
FundamentalsDbContext context,
string isin,
string? requestedTicker,
AssetFundamentalsEntity entity,
CancellationToken cancellationToken)
{
var targetTickerSymbol = requestedTicker
?? (entity.TickerFundamentals.FirstOrDefault(t => t.Ticker == entity.PrimaryTicker)?.Ticker
?? entity.TickerFundamentals.FirstOrDefault()?.Ticker);
// Fall A: Ticker noch gar nicht in DB -> Einzel-Scrape für diesen Ticker durchführen
if (!string.IsNullOrEmpty(targetTickerSymbol) && !entity.TickerFundamentals.Any(t => t.Ticker.Equals(targetTickerSymbol, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("[{Channel}] Targeted ticker '{Ticker}' missing in DB for ISIN {Isin}. Fetching on-demand...", "FundamentalsChannel", targetTickerSymbol, isin);
var scraped = await _scraper.ScrapeFundamentalsAsync(isin, targetTickerSymbol, cancellationToken);
if (scraped?.TickerData != null)
{
entity.TickerFundamentals.Add(scraped.TickerData);
await context.SaveChangesAsync(cancellationToken);
}
return entity;
}
// Fall B: Ticker existiert -> Prüfen ob Live-Kurs älter als 15 Minuten ist
var targetTickerEntity = entity.TickerFundamentals.FirstOrDefault(t => t.Ticker.Equals(targetTickerSymbol, StringComparison.OrdinalIgnoreCase));
if (targetTickerEntity != null && (DateTime.UtcNow - targetTickerEntity.LastUpdatedAt).TotalMinutes > 15)
{
_logger.LogInformation("[{Channel}] Quote expired for ticker '{Ticker}'. Refreshing live price...", "FundamentalsChannel", targetTickerSymbol);
var quotesResponse = await _yahooClient.GetQuotesAsync(new[] { targetTickerEntity.Ticker }, cancellationToken);
var liveQuote = quotesResponse?.QuoteResponse?.Result?.FirstOrDefault();
if (liveQuote != null)
{
targetTickerEntity.CurrentPrice = (decimal?)liveQuote.RegularMarketPrice ?? targetTickerEntity.CurrentPrice;
targetTickerEntity.DayChangeAbsolute = (decimal?)liveQuote.RegularMarketChange ?? targetTickerEntity.DayChangeAbsolute;
targetTickerEntity.DayChangePercent = (decimal?)liveQuote.RegularMarketChangePercent ?? targetTickerEntity.DayChangePercent;
targetTickerEntity.FiftyTwoWeekHigh = (decimal?)liveQuote.FiftyTwoWeekHigh ?? targetTickerEntity.FiftyTwoWeekHigh;
targetTickerEntity.FiftyTwoWeekLow = (decimal?)liveQuote.FiftyTwoWeekLow ?? targetTickerEntity.FiftyTwoWeekLow;
targetTickerEntity.MarketCapitalization = (decimal?)liveQuote.MarketCap ?? targetTickerEntity.MarketCapitalization;
targetTickerEntity.LastUpdatedAt = DateTime.UtcNow;
entity.LastUpdatedAt = DateTime.UtcNow;
await context.SaveChangesAsync(cancellationToken);
}
}
return entity;
}
/// <summary>
/// Führt ein vollständiges Scraping der Bilanzen und Ticker durch und speichert das Ergebnis ab.
/// </summary>
private async Task<AssetFundamentalsEntity?> ExecuteFullScrapeAndPersistAsync(
FundamentalsDbContext context,
string isin,
string? requestedTicker,
AssetFundamentalsEntity? existingEntity,
CancellationToken cancellationToken)
{
_logger.LogInformation("[{Channel}] Initiating full static scrape for ISIN {Isin}...", "FundamentalsChannel", isin);
List<string> tickers = new();
if (!string.IsNullOrWhiteSpace(requestedTicker))
{
tickers.Add(requestedTicker);
}
else
{
tickers = await _scraper.ResolveAllTickersFromIsinAsync(isin, cancellationToken);
if (existingEntity?.TickerFundamentals != null)
{
foreach (var tf in existingEntity.TickerFundamentals)
{
if (!tickers.Contains(tf.Ticker, StringComparer.OrdinalIgnoreCase))
tickers.Add(tf.Ticker);
}
}
}
if (tickers.Count == 0) return existingEntity;
var primaryTicker = tickers[0];
var scraped = await _scraper.ScrapeFundamentalsAsync(isin, primaryTicker, cancellationToken);
if (scraped == null) return existingEntity;
var tickerEntities = new List<TickerFundamentalsEntity> { scraped.TickerData };
// Sekundär-Ticker parallel laden (nur wenn kein spezifischer Ticker verlangt war)
if (string.IsNullOrWhiteSpace(requestedTicker) && tickers.Count > 1)
{
var altTasks = tickers.Skip(1).Take(4).Select(async alt =>
{
try { return await _scraper.ScrapeFundamentalsAsync(isin, alt, cancellationToken); }
catch { return null; }
});
var altResults = await Task.WhenAll(altTasks);
foreach (var alt in altResults)
{
if (alt?.TickerData != null) tickerEntities.Add(alt.TickerData);
}
}
// DB Upsert
try
{
await SaveOrUpdateFundamentalsAsync(context, isin, primaryTicker, scraped, tickerEntities, cancellationToken);
}
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.NpgsqlException npgEx && npgEx.SqlState == "23505")
{
context.ChangeTracker.Clear();
await SaveOrUpdateFundamentalsAsync(context, isin, primaryTicker, scraped, tickerEntities, cancellationToken);
}
return await LoadEntityGraphAsync(context, isin, cancellationToken);
}
#endregion
#region Data Access & Mapping Helpers
private static Task<AssetFundamentalsEntity?> LoadEntityGraphAsync(FundamentalsDbContext context, string isin, CancellationToken ct)
{
return context.AssetFundamentals
.AsNoTracking()
.Include(f => f.Executives)
.Include(f => f.FinancialStatements)
.Include(f => f.Estimates)
.Include(f => f.TickerFundamentals)
.FirstOrDefaultAsync(f => f.Isin == isin, ct);
}
private async Task SaveOrUpdateFundamentalsAsync(
FundamentalsDbContext context,
string isin,
string primaryTicker,
ScrapedFundamentalsData scraped,
List<TickerFundamentalsEntity> tickerEntities,
CancellationToken cancellationToken)
{
var entity = await context.AssetFundamentals.FirstOrDefaultAsync(f => f.Isin == isin, cancellationToken);
if (entity == null)
{
entity = scraped.Fundamentals;
entity.Isin = isin;
entity.PrimaryTicker = primaryTicker;
entity.Executives = scraped.Executives;
entity.FinancialStatements = scraped.Statements;
entity.Estimates = scraped.Estimates;
entity.TickerFundamentals = new List<TickerFundamentalsEntity>();
foreach (var ex in entity.Executives) { ex.Isin = isin; if (ex.Id == Guid.Empty) ex.Id = Guid.NewGuid(); }
foreach (var stmt in entity.FinancialStatements) { stmt.Isin = isin; if (stmt.Id == Guid.Empty) stmt.Id = Guid.NewGuid(); }
context.AssetFundamentals.Add(entity);
}
else
{
entity.PrimaryTicker = primaryTicker;
entity.CompanyName = !string.IsNullOrWhiteSpace(scraped.Fundamentals.CompanyName) ? scraped.Fundamentals.CompanyName : entity.CompanyName;
entity.BusinessSummary = !string.IsNullOrWhiteSpace(scraped.Fundamentals.BusinessSummary) ? scraped.Fundamentals.BusinessSummary : entity.BusinessSummary;
entity.Sector = !string.IsNullOrWhiteSpace(scraped.Fundamentals.Sector) ? scraped.Fundamentals.Sector : entity.Sector;
entity.Industry = !string.IsNullOrWhiteSpace(scraped.Fundamentals.Industry) ? scraped.Fundamentals.Industry : entity.Industry;
entity.Country = !string.IsNullOrWhiteSpace(scraped.Fundamentals.Country) ? scraped.Fundamentals.Country : entity.Country;
entity.Employees = scraped.Fundamentals.Employees ?? entity.Employees;
entity.PercentHeldByInstitutions = scraped.Fundamentals.PercentHeldByInstitutions ?? entity.PercentHeldByInstitutions;
entity.PercentHeldByInsiders = scraped.Fundamentals.PercentHeldByInsiders ?? entity.PercentHeldByInsiders;
entity.ShortRatio = scraped.Fundamentals.ShortRatio ?? entity.ShortRatio;
entity.ShortPercentOfFloat = scraped.Fundamentals.ShortPercentOfFloat ?? entity.ShortPercentOfFloat;
if (!string.IsNullOrWhiteSpace(scraped.Fundamentals.ConsensusRating) && !scraped.Fundamentals.ConsensusRating.Equals("none", StringComparison.OrdinalIgnoreCase))
entity.ConsensusRating = scraped.Fundamentals.ConsensusRating;
entity.PriceTargetLow = scraped.Fundamentals.PriceTargetLow ?? entity.PriceTargetLow;
entity.PriceTargetHigh = scraped.Fundamentals.PriceTargetHigh ?? entity.PriceTargetHigh;
entity.PriceTargetMedian = scraped.Fundamentals.PriceTargetMedian ?? entity.PriceTargetMedian;
entity.PriceTargetMean = scraped.Fundamentals.PriceTargetMean ?? entity.PriceTargetMean;
entity.ExDividendDate = scraped.Fundamentals.ExDividendDate ?? entity.ExDividendDate;
entity.NextEarningsDate = scraped.Fundamentals.NextEarningsDate ?? entity.NextEarningsDate;
entity.LastStaticUpdatedAt = DateTime.UtcNow;
entity.LastUpdatedAt = DateTime.UtcNow;
// Executives & Statements aktualisieren
if (scraped.Executives.Count > 0)
{
await context.CompanyExecutives.Where(e => e.Isin == isin).ExecuteDeleteAsync(cancellationToken);
foreach (var exec in scraped.Executives)
{
exec.Isin = isin;
if (exec.Id == Guid.Empty) exec.Id = Guid.NewGuid();
context.CompanyExecutives.Add(exec);
}
}
if (scraped.Statements.Count > 0)
{
var existingStmts = await context.FinancialStatements.Where(s => s.Isin == isin).ToListAsync(cancellationToken);
foreach (var stmt in scraped.Statements)
{
var existingStmt = existingStmts.FirstOrDefault(s => s.PeriodType == stmt.PeriodType && s.EndDate.Date == stmt.EndDate.Date);
if (existingStmt == null)
{
stmt.Isin = isin;
if (stmt.Id == Guid.Empty) stmt.Id = Guid.NewGuid();
context.FinancialStatements.Add(stmt);
}
else
{
existingStmt.TotalRevenue = stmt.TotalRevenue ?? existingStmt.TotalRevenue;
existingStmt.CostOfRevenue = stmt.CostOfRevenue ?? existingStmt.CostOfRevenue;
existingStmt.GrossProfit = stmt.GrossProfit ?? existingStmt.GrossProfit;
existingStmt.OperatingExpenses = stmt.OperatingExpenses ?? existingStmt.OperatingExpenses;
existingStmt.OperatingIncome = stmt.OperatingIncome ?? existingStmt.OperatingIncome;
existingStmt.Ebitda = stmt.Ebitda ?? existingStmt.Ebitda;
existingStmt.NetIncome = stmt.NetIncome ?? existingStmt.NetIncome;
existingStmt.CashAndCashEquivalents = stmt.CashAndCashEquivalents ?? existingStmt.CashAndCashEquivalents;
existingStmt.TotalCurrentAssets = stmt.TotalCurrentAssets ?? existingStmt.TotalCurrentAssets;
existingStmt.CurrentLiabilities = stmt.CurrentLiabilities ?? existingStmt.CurrentLiabilities;
existingStmt.LongTermDebt = stmt.LongTermDebt ?? existingStmt.LongTermDebt;
existingStmt.TotalLiabilities = stmt.TotalLiabilities ?? existingStmt.TotalLiabilities;
existingStmt.TotalStockholdersEquity = stmt.TotalStockholdersEquity ?? existingStmt.TotalStockholdersEquity;
existingStmt.OperatingCashFlow = stmt.OperatingCashFlow ?? existingStmt.OperatingCashFlow;
existingStmt.InvestingCashFlow = stmt.InvestingCashFlow ?? existingStmt.InvestingCashFlow;
existingStmt.CapitalExpenditures = stmt.CapitalExpenditures ?? existingStmt.CapitalExpenditures;
existingStmt.FinancingCashFlow = stmt.FinancingCashFlow ?? existingStmt.FinancingCashFlow;
existingStmt.FreeCashFlow = stmt.FreeCashFlow ?? existingStmt.FreeCashFlow;
}
}
}
}
// Ticker-Fundamentaldaten aktualisieren
foreach (var t in tickerEntities)
{
t.Isin = isin;
var existingTicker = await context.TickerFundamentals.FirstOrDefaultAsync(tf => tf.Ticker == t.Ticker, cancellationToken);
if (existingTicker == null)
{
context.TickerFundamentals.Add(t);
}
else
{
existingTicker.Exchange = !string.IsNullOrEmpty(t.Exchange) ? t.Exchange : existingTicker.Exchange;
existingTicker.TradingCurrency = !string.IsNullOrEmpty(t.TradingCurrency) ? t.TradingCurrency : existingTicker.TradingCurrency;
existingTicker.CurrentPrice = t.CurrentPrice > 0 ? t.CurrentPrice : existingTicker.CurrentPrice;
existingTicker.DayChangeAbsolute = t.DayChangeAbsolute != 0 ? t.DayChangeAbsolute : existingTicker.DayChangeAbsolute;
existingTicker.DayChangePercent = t.DayChangePercent != 0 ? t.DayChangePercent : existingTicker.DayChangePercent;
existingTicker.FiftyTwoWeekHigh = t.FiftyTwoWeekHigh > 0 ? t.FiftyTwoWeekHigh : existingTicker.FiftyTwoWeekHigh;
existingTicker.FiftyTwoWeekLow = t.FiftyTwoWeekLow > 0 ? t.FiftyTwoWeekLow : existingTicker.FiftyTwoWeekLow;
existingTicker.MarketCapitalization = t.MarketCapitalization > 0 ? t.MarketCapitalization : existingTicker.MarketCapitalization;
existingTicker.EnterpriseValue = t.EnterpriseValue > 0 ? t.EnterpriseValue : existingTicker.EnterpriseValue;
existingTicker.PeRatioTrailing = t.PeRatioTrailing ?? existingTicker.PeRatioTrailing;
existingTicker.PeRatioForward = t.PeRatioForward ?? existingTicker.PeRatioForward;
existingTicker.PegRatio = t.PegRatio ?? existingTicker.PegRatio;
existingTicker.PbRatio = t.PbRatio ?? existingTicker.PbRatio;
existingTicker.PsRatio = t.PsRatio ?? existingTicker.PsRatio;
existingTicker.EvToEbitda = t.EvToEbitda ?? existingTicker.EvToEbitda;
existingTicker.EvToRevenue = t.EvToRevenue ?? existingTicker.EvToRevenue;
existingTicker.GrossMargin = t.GrossMargin ?? existingTicker.GrossMargin;
existingTicker.OperatingMargin = t.OperatingMargin ?? existingTicker.OperatingMargin;
existingTicker.NetProfitMargin = t.NetProfitMargin ?? existingTicker.NetProfitMargin;
existingTicker.ReturnOnEquity = t.ReturnOnEquity ?? existingTicker.ReturnOnEquity;
existingTicker.ReturnOnAssets = t.ReturnOnAssets ?? existingTicker.ReturnOnAssets;
existingTicker.DebtToEquity = t.DebtToEquity ?? existingTicker.DebtToEquity;
existingTicker.CurrentRatio = t.CurrentRatio ?? existingTicker.CurrentRatio;
existingTicker.QuickRatio = t.QuickRatio ?? existingTicker.QuickRatio;
existingTicker.DividendYield = t.DividendYield ?? existingTicker.DividendYield;
existingTicker.PayoutRatio = t.PayoutRatio ?? existingTicker.PayoutRatio;
existingTicker.ExDividendDate = t.ExDividendDate ?? existingTicker.ExDividendDate;
existingTicker.LastUpdatedAt = DateTime.UtcNow;
}
}
await context.SaveChangesAsync(cancellationToken);
}
private static AssetFundamentalsDto MapToDto(AssetFundamentalsEntity entity, string? requestedTicker)
{
var targetTicker = entity.TickerFundamentals?.FirstOrDefault(t => t.Ticker.Equals(requestedTicker, StringComparison.OrdinalIgnoreCase))
?? entity.TickerFundamentals?.FirstOrDefault(t => t.Ticker.Equals(entity.PrimaryTicker, StringComparison.OrdinalIgnoreCase))
?? entity.TickerFundamentals?.FirstOrDefault();
var selectedTickerSymbol = targetTicker?.Ticker ?? requestedTicker ?? entity.PrimaryTicker;
return new AssetFundamentalsDto
{
Isin = entity.Isin,
PrimaryTicker = entity.PrimaryTicker,
Ticker = selectedTickerSymbol,
CompanyName = entity.CompanyName,
Exchange = targetTicker?.Exchange,
TradingCurrency = targetTicker?.TradingCurrency,
BusinessSummary = entity.BusinessSummary,
Sector = entity.Sector,
Industry = entity.Industry,
Country = entity.Country,
Employees = entity.Employees,
CurrentPrice = targetTicker?.CurrentPrice ?? 0,
DayChangeAbsolute = targetTicker?.DayChangeAbsolute ?? 0,
DayChangePercent = targetTicker?.DayChangePercent ?? 0,
FiftyTwoWeekHigh = targetTicker?.FiftyTwoWeekHigh ?? 0,
FiftyTwoWeekLow = targetTicker?.FiftyTwoWeekLow ?? 0,
MarketCapitalization = targetTicker?.MarketCapitalization ?? 0,
EnterpriseValue = targetTicker?.EnterpriseValue ?? 0,
PeRatioTrailing = targetTicker?.PeRatioTrailing,
PeRatioForward = targetTicker?.PeRatioForward,
PegRatio = targetTicker?.PegRatio,
PbRatio = targetTicker?.PbRatio,
PsRatio = targetTicker?.PsRatio,
EvToEbitda = targetTicker?.EvToEbitda,
EvToRevenue = targetTicker?.EvToRevenue,
GrossMargin = targetTicker?.GrossMargin,
OperatingMargin = targetTicker?.OperatingMargin,
NetProfitMargin = targetTicker?.NetProfitMargin,
ReturnOnEquity = targetTicker?.ReturnOnEquity,
ReturnOnAssets = targetTicker?.ReturnOnAssets,
ReturnOnInvestedCapital = targetTicker?.ReturnOnInvestedCapital,
DebtToEquity = targetTicker?.DebtToEquity,
CurrentRatio = targetTicker?.CurrentRatio,
QuickRatio = targetTicker?.QuickRatio,
InterestCoverage = targetTicker?.InterestCoverage,
DividendYield = targetTicker?.DividendYield,
PayoutRatio = targetTicker?.PayoutRatio,
ExDividendDate = entity.ExDividendDate ?? targetTicker?.ExDividendDate,
NextEarningsDate = entity.NextEarningsDate,
PercentHeldByInstitutions = entity.PercentHeldByInstitutions,
PercentHeldByInsiders = entity.PercentHeldByInsiders,
ShortRatio = entity.ShortRatio,
ShortPercentOfFloat = entity.ShortPercentOfFloat,
ConsensusRating = entity.ConsensusRating,
PriceTargetLow = entity.PriceTargetLow,
PriceTargetHigh = entity.PriceTargetHigh,
PriceTargetMedian = entity.PriceTargetMedian,
PriceTargetMean = entity.PriceTargetMean,
LastUpdatedAt = entity.LastUpdatedAt,
Executives = entity.Executives.Select(e => new CompanyExecutiveDto
{
Name = e.Name,
Title = e.Title,
Age = e.Age,
Compensation = e.Compensation
}).ToList(),
FinancialStatements = entity.FinancialStatements.Select(s => new FinancialStatementDto
{
PeriodType = s.PeriodType,
EndDate = s.EndDate,
TotalRevenue = s.TotalRevenue,
CostOfRevenue = s.CostOfRevenue,
GrossProfit = s.GrossProfit,
OperatingExpenses = s.OperatingExpenses,
OperatingIncome = s.OperatingIncome,
Ebitda = s.Ebitda,
NetIncome = s.NetIncome,
EpsBasic = s.EpsBasic,
EpsDiluted = s.EpsDiluted,
CashAndCashEquivalents = s.CashAndCashEquivalents,
AccountsReceivable = s.AccountsReceivable,
Inventory = s.Inventory,
TotalCurrentAssets = s.TotalCurrentAssets,
TotalNonCurrentAssets = s.TotalNonCurrentAssets,
CurrentLiabilities = s.CurrentLiabilities,
LongTermDebt = s.LongTermDebt,
TotalLiabilities = s.TotalLiabilities,
TotalStockholdersEquity = s.TotalStockholdersEquity,
OperatingCashFlow = s.OperatingCashFlow,
InvestingCashFlow = s.InvestingCashFlow,
CapitalExpenditures = s.CapitalExpenditures,
FinancingCashFlow = s.FinancingCashFlow,
FreeCashFlow = s.FreeCashFlow
}).OrderByDescending(s => s.EndDate).ToList(),
Estimates = entity.Estimates.Select(e => new ForwardEstimateDto
{
Period = e.Period,
ExpectedRevenue = e.ExpectedRevenue,
ExpectedEps = e.ExpectedEps,
ExpectedGrowthRate = e.ExpectedGrowthRate
}).ToList(),
AvailableTickers = entity.TickerFundamentals.Select(t => new TickerDto
{
Ticker = t.Ticker,
Exchange = t.Exchange,
TradingCurrency = t.TradingCurrency,
CurrentPrice = t.CurrentPrice,
DayChangeAbsolute = t.DayChangeAbsolute,
DayChangePercent = t.DayChangePercent,
FiftyTwoWeekHigh = t.FiftyTwoWeekHigh,
FiftyTwoWeekLow = t.FiftyTwoWeekLow,
MarketCapitalization = t.MarketCapitalization,
EnterpriseValue = t.EnterpriseValue,
PeRatioTrailing = t.PeRatioTrailing,
PeRatioForward = t.PeRatioForward,
PegRatio = t.PegRatio,
PbRatio = t.PbRatio,
PsRatio = t.PsRatio,
EvToEbitda = t.EvToEbitda,
EvToRevenue = t.EvToRevenue,
GrossMargin = t.GrossMargin,
OperatingMargin = t.OperatingMargin,
NetProfitMargin = t.NetProfitMargin,
ReturnOnEquity = t.ReturnOnEquity,
ReturnOnAssets = t.ReturnOnAssets,
ReturnOnInvestedCapital = t.ReturnOnInvestedCapital,
DebtToEquity = t.DebtToEquity,
CurrentRatio = t.CurrentRatio,
QuickRatio = t.QuickRatio,
InterestCoverage = t.InterestCoverage,
DividendYield = t.DividendYield,
PayoutRatio = t.PayoutRatio,
ExDividendDate = t.ExDividendDate ?? entity.ExDividendDate
}).ToList()
};
}
/// <inheritdoc />
public async Task<List<CorporateEventDto>> GetAllEventsAsync(CancellationToken cancellationToken = default)
{
using var scope = _scopeFactory.CreateScope();
var context = scope.ServiceProvider.GetRequiredService<FundamentalsDbContext>();
var entities = await context.AssetFundamentals
.AsNoTracking()
.Where(f => f.NextEarningsDate.HasValue || f.ExDividendDate.HasValue)
.ToListAsync(cancellationToken);
var events = new List<CorporateEventDto>();
foreach (var entity in entities)
{
var companyName = string.IsNullOrWhiteSpace(entity.CompanyName) ? entity.PrimaryTicker : entity.CompanyName;
if (entity.NextEarningsDate.HasValue)
{
events.Add(new CorporateEventDto
{
Isin = entity.Isin,
Ticker = entity.PrimaryTicker,
CompanyName = companyName,
EventType = "Quartalsergebnis",
Date = entity.NextEarningsDate.Value
});
}
if (entity.ExDividendDate.HasValue)
{
events.Add(new CorporateEventDto
{
Isin = entity.Isin,
Ticker = entity.PrimaryTicker,
CompanyName = companyName,
EventType = "Ex-Dividendentag",
Date = entity.ExDividendDate.Value
});
}
}
return events.OrderBy(e => e.Date).ToList();
}
#endregion
}
@@ -0,0 +1,88 @@
using FinlyticFundamentals.Database;
using FinlyticFundamentals.Entities;
using Microsoft.EntityFrameworkCore;
namespace FinlyticFundamentals.Services;
public interface ISettingsDbService
{
/// <summary>
/// Gets the settings.
/// </summary>
Task<FundamentalsSettingsEntity> GetSettingsAsync();
/// <summary>
/// Saves the settings.
/// </summary>
Task<FundamentalsSettingsEntity> SaveSettingsAsync(FundamentalsSettingsEntity settings);
/// <summary>
/// Updates the settings from a dictionary.
/// </summary>
Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary);
}
public class SettingsDbService : ISettingsDbService
{
private readonly FundamentalsDbContext _context;
public SettingsDbService(FundamentalsDbContext context)
{
_context = context;
}
/// <summary>
/// Gets the settings asynchronously.
/// </summary>
public async Task<FundamentalsSettingsEntity> GetSettingsAsync()
{
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
if (settings == null)
{
settings = new FundamentalsSettingsEntity { Id = Guid.NewGuid() };
_context.Settings.Add(settings);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
}
return settings;
}
/// <summary>
/// Saves the settings asynchronously.
/// </summary>
public async Task<FundamentalsSettingsEntity> SaveSettingsAsync(FundamentalsSettingsEntity settings)
{
var existing = await _context.Settings.FirstOrDefaultAsync();
if (existing == null)
{
if (settings.Id == Guid.Empty) settings.Id = Guid.NewGuid();
_context.Settings.Add(settings);
}
else
{
existing.CacheTtlHours = settings.CacheTtlHours;
existing.EnableYahooFallback = settings.EnableYahooFallback;
existing.UpdatedAt = settings.UpdatedAt;
_context.Settings.Update(existing);
}
await _context.SaveChangesAsync();
return settings;
}
/// <summary>
/// Updates the settings from a dictionary asynchronously.
/// </summary>
public async Task UpdateSettingsFromDictionaryAsync(Dictionary<string, string> dictionary)
{
var settings = await GetSettingsAsync();
foreach (var (key, value) in dictionary)
{
if (string.Equals(key, "CacheTtlHours", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var ttl))
settings.CacheTtlHours = ttl;
else if (string.Equals(key, "EnableYahooFallback", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var fallback))
settings.EnableYahooFallback = fallback;
}
settings.UpdatedAt = DateTime.UtcNow;
await SaveSettingsAsync(settings);
}
}
@@ -0,0 +1,491 @@
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.Dtos.Yahoo;
using FinlyticCore.Services.Yahoo;
using FinlyticFundamentals.Entities;
using Microsoft.Extensions.Logging;
namespace FinlyticFundamentals.Services;
public interface IYahooFinanceScraper
{
/// <summary>
/// Resolves ticker from ISIN.
/// </summary>
Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
/// <summary>
/// Resolves all tickers from ISIN.
/// </summary>
Task<List<string>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default);
/// <summary>
/// Scrapes fundamentals.
/// </summary>
Task<ScrapedFundamentalsData?> ScrapeFundamentalsAsync(string isin, string ticker,
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;
public YahooFinanceScraper(HttpClient httpClient, YahooFinanceClient yahooClient,
ILogger<YahooFinanceScraper> logger)
{
_httpClient = httpClient;
_yahooClient = yahooClient;
_logger = logger;
}
/// <inheritdoc />
public async Task<string?> 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)
{
if (string.IsNullOrWhiteSpace(isin)) return new();
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);
try
{
var summaryResponse = await _yahooClient.GetFullQuoteSummaryAsync(ticker, cancellationToken);
if (summaryResponse?.QuoteSummary?.Result == null || summaryResponse.QuoteSummary.Result.Count == 0)
{
_logger.LogWarning("[{Channel}] YahooFinanceClient returned no result for ticker {Ticker}",
"FundamentalsChannel", ticker);
return null;
}
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
{
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)
{
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)
{
fundamentals.NextEarningsDate =
DateTimeOffset.FromUnixTimeSeconds((long)firstDate.Raw.Value).UtcDateTime;
}
}
}
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;
}
}
private static void MapIncomeStatement(YahooIncomeStatementDto 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.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)
{
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
{
Isin = isin,
PeriodType = periodType,
EndDate = endDate.Date
};
statements.Add(existing);
}
return existing;
}
}