589 lines
29 KiB
C#
589 lines
29 KiB
C#
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
|
|
} |