diff --git a/FinlyticFundamentals/Database/FundamentalsDbContext.cs b/FinlyticFundamentals/Database/FundamentalsDbContext.cs new file mode 100644 index 0000000..da65942 --- /dev/null +++ b/FinlyticFundamentals/Database/FundamentalsDbContext.cs @@ -0,0 +1,82 @@ +using FinlyticFundamentals.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticFundamentals.Database; + +public class FundamentalsDbContext : DbContext +{ + public FundamentalsDbContext(DbContextOptions options) : base(options) + { + } + + public DbSet AssetFundamentals => Set(); + public DbSet CompanyExecutives => Set(); + public DbSet FinancialStatements => Set(); + public DbSet ForwardEstimates => Set(); + public DbSet TickerFundamentals => Set(); + public DbSet Settings => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // AssetFundamentals Configurations + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Isin); + entity.HasIndex(e => e.PrimaryTicker).IsUnique(); + + // Setup One-to-Many Relationships with cascades + entity.HasMany(e => e.Executives) + .WithOne(e => e.AssetFundamentals) + .HasForeignKey(e => e.Isin) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(e => e.FinancialStatements) + .WithOne(e => e.AssetFundamentals) + .HasForeignKey(e => e.Isin) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(e => e.Estimates) + .WithOne(e => e.AssetFundamentals) + .HasForeignKey(e => e.Isin) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasMany(e => e.TickerFundamentals) + .WithOne(e => e.AssetFundamentals) + .HasForeignKey(e => e.Isin) + .OnDelete(DeleteBehavior.Cascade); + }); + + // CompanyExecutive Configurations + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Isin); + }); + + // FinancialStatement Configurations + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Isin); + // Compound Index to prevent duplicate statement entries + entity.HasIndex(e => new { e.Isin, e.PeriodType, e.EndDate }).IsUnique(); + }); + + // ForwardEstimate Configurations + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasIndex(e => e.Isin); + entity.HasIndex(e => new { e.Isin, e.Period }).IsUnique(); + }); + + // TickerFundamentals Configurations + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Ticker); + entity.HasIndex(e => e.Isin); + }); + } +} diff --git a/FinlyticFundamentals/Dockerfile b/FinlyticFundamentals/Dockerfile new file mode 100644 index 0000000..78d1390 --- /dev/null +++ b/FinlyticFundamentals/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["FinlyticFundamentals/FinlyticFundamentals.csproj", "FinlyticFundamentals/"] +COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"] +RUN dotnet restore "FinlyticFundamentals/FinlyticFundamentals.csproj" +COPY . . +WORKDIR "/src/FinlyticFundamentals" +RUN dotnet build "./FinlyticFundamentals.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./FinlyticFundamentals.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "FinlyticFundamentals.dll"] diff --git a/FinlyticFundamentals/Entities/AssetFundamentalsEntity.cs b/FinlyticFundamentals/Entities/AssetFundamentalsEntity.cs new file mode 100644 index 0000000..18bc3f4 --- /dev/null +++ b/FinlyticFundamentals/Entities/AssetFundamentalsEntity.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; + +namespace FinlyticFundamentals.Entities; + +/// +/// Main database table representing global company profile, ownership structure, analyst predictions, and corporate events. +/// +public class AssetFundamentalsEntity +{ + [Key] + [Required] + public string Isin { get; set; } = string.Empty; + + [Required] + public string PrimaryTicker { get; set; } = string.Empty; + + // --- Static Company Profile Columns --- + [Required] + public string CompanyName { get; set; } = string.Empty; + public string? BusinessSummary { get; set; } + public string? Sector { get; set; } + public string? Industry { get; set; } + public string? Country { get; set; } + public int? Employees { get; set; } + + // --- Ownership & Sentiment Data (Company-wide) --- + public decimal? PercentHeldByInstitutions { get; set; } + public decimal? PercentHeldByInsiders { get; set; } + public decimal? ShortRatio { get; set; } + public decimal? ShortPercentOfFloat { get; set; } + + // --- Analyst Forecasts & Targets --- + public string? ConsensusRating { get; set; } + public decimal? PriceTargetLow { get; set; } + public decimal? PriceTargetHigh { get; set; } + public decimal? PriceTargetMedian { get; set; } + public decimal? PriceTargetMean { get; set; } + + // --- Corporate Calendar / Catalysts --- + public DateTime? ExDividendDate { get; set; } + public DateTime? NextEarningsDate { get; set; } + + // --- Cache Control Timestamps --- + public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastStaticUpdatedAt { get; set; } = DateTime.UtcNow; + + // --- Relational Collections --- + public List Executives { get; set; } = []; + public List FinancialStatements { get; set; } = []; + public List Estimates { get; set; } = []; + public List TickerFundamentals { get; set; } = []; +} diff --git a/FinlyticFundamentals/Entities/CompanyExecutiveEntity.cs b/FinlyticFundamentals/Entities/CompanyExecutiveEntity.cs new file mode 100644 index 0000000..f8c5d9d --- /dev/null +++ b/FinlyticFundamentals/Entities/CompanyExecutiveEntity.cs @@ -0,0 +1,28 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace FinlyticFundamentals.Entities; + +/// +/// Relational executive board table linked to the main fundamentals entity by ISIN. +/// +public class CompanyExecutiveEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public string Isin { get; set; } = string.Empty; + + [JsonIgnore] + public AssetFundamentalsEntity? AssetFundamentals { get; set; } + + [Required] + public string Name { get; set; } = string.Empty; + + [Required] + public string Title { get; set; } = string.Empty; + public int? Age { get; set; } + public decimal? Compensation { get; set; } +} diff --git a/FinlyticFundamentals/Entities/FinancialStatementEntity.cs b/FinlyticFundamentals/Entities/FinancialStatementEntity.cs new file mode 100644 index 0000000..d7ce897 --- /dev/null +++ b/FinlyticFundamentals/Entities/FinancialStatementEntity.cs @@ -0,0 +1,55 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace FinlyticFundamentals.Entities; + +/// +/// Relational statement table linking historical Income Statements, Balance Sheets, and Cash Flow metrics. +/// +public class FinancialStatementEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public string Isin { get; set; } = string.Empty; + + [JsonIgnore] + public AssetFundamentalsEntity? AssetFundamentals { get; set; } + + [Required] + public string PeriodType { get; set; } = string.Empty; // "Annual" or "Quarterly" + + [Required] + public DateTime EndDate { get; set; } + + // --- Income Statement Fields --- + public decimal? TotalRevenue { get; set; } + public decimal? CostOfRevenue { get; set; } + public decimal? GrossProfit { get; set; } + public decimal? OperatingExpenses { get; set; } + public decimal? OperatingIncome { get; set; } + public decimal? Ebitda { get; set; } + public decimal? NetIncome { get; set; } + public decimal? EpsBasic { get; set; } + public decimal? EpsDiluted { get; set; } + + // --- Balance Sheet Fields --- + public decimal? CashAndCashEquivalents { get; set; } + public decimal? AccountsReceivable { get; set; } + public decimal? Inventory { get; set; } + public decimal? TotalCurrentAssets { get; set; } + public decimal? TotalNonCurrentAssets { get; set; } + public decimal? CurrentLiabilities { get; set; } + public decimal? LongTermDebt { get; set; } + public decimal? TotalLiabilities { get; set; } + public decimal? TotalStockholdersEquity { get; set; } + + // --- Cash Flow Fields --- + public decimal? OperatingCashFlow { get; set; } + public decimal? InvestingCashFlow { get; set; } + public decimal? CapitalExpenditures { get; set; } + public decimal? FinancingCashFlow { get; set; } + public decimal? FreeCashFlow { get; set; } // OperatingCashFlow - CapEx +} diff --git a/FinlyticFundamentals/Entities/ForwardEstimateEntity.cs b/FinlyticFundamentals/Entities/ForwardEstimateEntity.cs new file mode 100644 index 0000000..5dfd36f --- /dev/null +++ b/FinlyticFundamentals/Entities/ForwardEstimateEntity.cs @@ -0,0 +1,26 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Serialization; + +namespace FinlyticFundamentals.Entities; + +/// +/// Relational table for analyst consensus revenue and EPS forecasts. +/// +public class ForwardEstimateEntity +{ + [Key] + public Guid Id { get; set; } = Guid.NewGuid(); + + [Required] + public string Isin { get; set; } = string.Empty; + + [JsonIgnore] + public AssetFundamentalsEntity? AssetFundamentals { get; set; } + + [Required] + public string Period { get; set; } = string.Empty; // "CurrentQuarter", "NextQuarter", "CurrentYear", "NextYear" + public decimal? ExpectedRevenue { get; set; } + public decimal? ExpectedEps { get; set; } + public decimal? ExpectedGrowthRate { get; set; } +} diff --git a/FinlyticFundamentals/Entities/FundamentalsSettingsEntity.cs b/FinlyticFundamentals/Entities/FundamentalsSettingsEntity.cs new file mode 100644 index 0000000..19307d1 --- /dev/null +++ b/FinlyticFundamentals/Entities/FundamentalsSettingsEntity.cs @@ -0,0 +1,14 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace FinlyticFundamentals.Entities; + +public class FundamentalsSettingsEntity +{ + [Key] + public Guid Id { get; set; } + + public int CacheTtlHours { get; set; } = 24; + public bool EnableYahooFallback { get; set; } = true; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticFundamentals/Entities/TickerFundamentalsEntity.cs b/FinlyticFundamentals/Entities/TickerFundamentalsEntity.cs new file mode 100644 index 0000000..b6365e9 --- /dev/null +++ b/FinlyticFundamentals/Entities/TickerFundamentalsEntity.cs @@ -0,0 +1,65 @@ +using System; +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace FinlyticFundamentals.Entities; + +/// +/// Database table representing dynamic trading data, exchange-dependent valuation ratios, liquidity, and leverage stats for each traded ticker symbol. +/// +public class TickerFundamentalsEntity +{ + [Key] + [Required] + public string Ticker { get; set; } = string.Empty; // Primary key, e.g., "POR.DE" + + [Required] + public string Isin { get; set; } = string.Empty; + + [ForeignKey("Isin")] + public AssetFundamentalsEntity? AssetFundamentals { get; set; } + + public string? Exchange { get; set; } + public string? TradingCurrency { get; set; } + + // Real-time & Price Performance + public decimal CurrentPrice { get; set; } + public decimal DayChangeAbsolute { get; set; } + public decimal DayChangePercent { get; set; } + public decimal FiftyTwoWeekHigh { get; set; } + public decimal FiftyTwoWeekLow { get; set; } + + // Size & Enterprise Valuation + public decimal MarketCapitalization { get; set; } + public decimal EnterpriseValue { get; set; } + + // Valuation Ratios + public decimal? PeRatioTrailing { get; set; } + public decimal? PeRatioForward { get; set; } + public decimal? PegRatio { get; set; } + public decimal? PbRatio { get; set; } + public decimal? PsRatio { get; set; } + public decimal? EvToEbitda { get; set; } + public decimal? EvToRevenue { get; set; } + + // Profitability & Return Ratios + public decimal? GrossMargin { get; set; } + public decimal? OperatingMargin { get; set; } + public decimal? NetProfitMargin { get; set; } + public decimal? ReturnOnEquity { get; set; } + public decimal? ReturnOnAssets { get; set; } + public decimal? ReturnOnInvestedCapital { get; set; } + + // Financial Health, Solvency & Liquidity + public decimal? DebtToEquity { get; set; } + public decimal? CurrentRatio { get; set; } + public decimal? QuickRatio { get; set; } + public decimal? InterestCoverage { get; set; } + + // Dividend Performance Metrics + public decimal? DividendYield { get; set; } + public decimal? PayoutRatio { get; set; } + public DateTime? ExDividendDate { get; set; } + + public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/FinlyticFundamentals/FinlyticFundamentals.csproj b/FinlyticFundamentals/FinlyticFundamentals.csproj new file mode 100644 index 0000000..160b817 --- /dev/null +++ b/FinlyticFundamentals/FinlyticFundamentals.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + Linux + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/FinlyticFundamentals/Migrations/20260801073430_Init.Designer.cs b/FinlyticFundamentals/Migrations/20260801073430_Init.Designer.cs new file mode 100644 index 0000000..72d655d --- /dev/null +++ b/FinlyticFundamentals/Migrations/20260801073430_Init.Designer.cs @@ -0,0 +1,446 @@ +// +using System; +using FinlyticFundamentals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticFundamentals.Migrations +{ + [DbContext(typeof(FundamentalsDbContext))] + [Migration("20260801073430_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetFundamentalsEntity", b => + { + b.Property("Isin") + .HasColumnType("text"); + + b.Property("BusinessSummary") + .HasColumnType("text"); + + b.Property("CompanyName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConsensusRating") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("Employees") + .HasColumnType("integer"); + + b.Property("ExDividendDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Industry") + .HasColumnType("text"); + + b.Property("LastStaticUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEarningsDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PercentHeldByInsiders") + .HasColumnType("numeric"); + + b.Property("PercentHeldByInstitutions") + .HasColumnType("numeric"); + + b.Property("PriceTargetHigh") + .HasColumnType("numeric"); + + b.Property("PriceTargetLow") + .HasColumnType("numeric"); + + b.Property("PriceTargetMean") + .HasColumnType("numeric"); + + b.Property("PriceTargetMedian") + .HasColumnType("numeric"); + + b.Property("PrimaryTicker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("ShortPercentOfFloat") + .HasColumnType("numeric"); + + b.Property("ShortRatio") + .HasColumnType("numeric"); + + b.HasKey("Isin"); + + b.HasIndex("PrimaryTicker") + .IsUnique(); + + b.ToTable("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.CompanyExecutiveEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Age") + .HasColumnType("integer"); + + b.Property("Compensation") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Isin"); + + b.ToTable("CompanyExecutives"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FinancialStatementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountsReceivable") + .HasColumnType("numeric"); + + b.Property("CapitalExpenditures") + .HasColumnType("numeric"); + + b.Property("CashAndCashEquivalents") + .HasColumnType("numeric"); + + b.Property("CostOfRevenue") + .HasColumnType("numeric"); + + b.Property("CurrentLiabilities") + .HasColumnType("numeric"); + + b.Property("Ebitda") + .HasColumnType("numeric"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EpsBasic") + .HasColumnType("numeric"); + + b.Property("EpsDiluted") + .HasColumnType("numeric"); + + b.Property("FinancingCashFlow") + .HasColumnType("numeric"); + + b.Property("FreeCashFlow") + .HasColumnType("numeric"); + + b.Property("GrossProfit") + .HasColumnType("numeric"); + + b.Property("Inventory") + .HasColumnType("numeric"); + + b.Property("InvestingCashFlow") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("LongTermDebt") + .HasColumnType("numeric"); + + b.Property("NetIncome") + .HasColumnType("numeric"); + + b.Property("OperatingCashFlow") + .HasColumnType("numeric"); + + b.Property("OperatingExpenses") + .HasColumnType("numeric"); + + b.Property("OperatingIncome") + .HasColumnType("numeric"); + + b.Property("PeriodType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalCurrentAssets") + .HasColumnType("numeric"); + + b.Property("TotalLiabilities") + .HasColumnType("numeric"); + + b.Property("TotalNonCurrentAssets") + .HasColumnType("numeric"); + + b.Property("TotalRevenue") + .HasColumnType("numeric"); + + b.Property("TotalStockholdersEquity") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("Isin"); + + b.HasIndex("Isin", "PeriodType", "EndDate") + .IsUnique(); + + b.ToTable("FinancialStatements"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.ForwardEstimateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpectedEps") + .HasColumnType("numeric"); + + b.Property("ExpectedGrowthRate") + .HasColumnType("numeric"); + + b.Property("ExpectedRevenue") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Period") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Isin"); + + b.HasIndex("Isin", "Period") + .IsUnique(); + + b.ToTable("ForwardEstimates"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CacheTtlHours") + .HasColumnType("integer"); + + b.Property("EnableYahooFallback") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.TickerFundamentalsEntity", b => + { + b.Property("Ticker") + .HasColumnType("text"); + + b.Property("CurrentPrice") + .HasColumnType("numeric"); + + b.Property("CurrentRatio") + .HasColumnType("numeric"); + + b.Property("DayChangeAbsolute") + .HasColumnType("numeric"); + + b.Property("DayChangePercent") + .HasColumnType("numeric"); + + b.Property("DebtToEquity") + .HasColumnType("numeric"); + + b.Property("DividendYield") + .HasColumnType("numeric"); + + b.Property("EnterpriseValue") + .HasColumnType("numeric"); + + b.Property("EvToEbitda") + .HasColumnType("numeric"); + + b.Property("EvToRevenue") + .HasColumnType("numeric"); + + b.Property("ExDividendDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Exchange") + .HasColumnType("text"); + + b.Property("FiftyTwoWeekHigh") + .HasColumnType("numeric"); + + b.Property("FiftyTwoWeekLow") + .HasColumnType("numeric"); + + b.Property("GrossMargin") + .HasColumnType("numeric"); + + b.Property("InterestCoverage") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MarketCapitalization") + .HasColumnType("numeric"); + + b.Property("NetProfitMargin") + .HasColumnType("numeric"); + + b.Property("OperatingMargin") + .HasColumnType("numeric"); + + b.Property("PayoutRatio") + .HasColumnType("numeric"); + + b.Property("PbRatio") + .HasColumnType("numeric"); + + b.Property("PeRatioForward") + .HasColumnType("numeric"); + + b.Property("PeRatioTrailing") + .HasColumnType("numeric"); + + b.Property("PegRatio") + .HasColumnType("numeric"); + + b.Property("PsRatio") + .HasColumnType("numeric"); + + b.Property("QuickRatio") + .HasColumnType("numeric"); + + b.Property("ReturnOnAssets") + .HasColumnType("numeric"); + + b.Property("ReturnOnEquity") + .HasColumnType("numeric"); + + b.Property("ReturnOnInvestedCapital") + .HasColumnType("numeric"); + + b.Property("TradingCurrency") + .HasColumnType("text"); + + b.HasKey("Ticker"); + + b.HasIndex("Isin"); + + b.ToTable("TickerFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.CompanyExecutiveEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("Executives") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FinancialStatementEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("FinancialStatements") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.ForwardEstimateEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("Estimates") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.TickerFundamentalsEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("TickerFundamentals") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetFundamentalsEntity", b => + { + b.Navigation("Estimates"); + + b.Navigation("Executives"); + + b.Navigation("FinancialStatements"); + + b.Navigation("TickerFundamentals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticFundamentals/Migrations/20260801073430_Init.cs b/FinlyticFundamentals/Migrations/20260801073430_Init.cs new file mode 100644 index 0000000..6436b09 --- /dev/null +++ b/FinlyticFundamentals/Migrations/20260801073430_Init.cs @@ -0,0 +1,255 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticFundamentals.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AssetFundamentals", + columns: table => new + { + Isin = table.Column(type: "text", nullable: false), + PrimaryTicker = table.Column(type: "text", nullable: false), + CompanyName = table.Column(type: "text", nullable: false), + BusinessSummary = table.Column(type: "text", nullable: true), + Sector = table.Column(type: "text", nullable: true), + Industry = table.Column(type: "text", nullable: true), + Country = table.Column(type: "text", nullable: true), + Employees = table.Column(type: "integer", nullable: true), + PercentHeldByInstitutions = table.Column(type: "numeric", nullable: true), + PercentHeldByInsiders = table.Column(type: "numeric", nullable: true), + ShortRatio = table.Column(type: "numeric", nullable: true), + ShortPercentOfFloat = table.Column(type: "numeric", nullable: true), + ConsensusRating = table.Column(type: "text", nullable: true), + PriceTargetLow = table.Column(type: "numeric", nullable: true), + PriceTargetHigh = table.Column(type: "numeric", nullable: true), + PriceTargetMedian = table.Column(type: "numeric", nullable: true), + PriceTargetMean = table.Column(type: "numeric", nullable: true), + ExDividendDate = table.Column(type: "timestamp with time zone", nullable: true), + NextEarningsDate = table.Column(type: "timestamp with time zone", nullable: true), + LastUpdatedAt = table.Column(type: "timestamp with time zone", nullable: false), + LastStaticUpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AssetFundamentals", x => x.Isin); + }); + + migrationBuilder.CreateTable( + name: "Settings", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CacheTtlHours = table.Column(type: "integer", nullable: false), + EnableYahooFallback = table.Column(type: "boolean", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Settings", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "CompanyExecutives", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Isin = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Title = table.Column(type: "text", nullable: false), + Age = table.Column(type: "integer", nullable: true), + Compensation = table.Column(type: "numeric", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_CompanyExecutives", x => x.Id); + table.ForeignKey( + name: "FK_CompanyExecutives_AssetFundamentals_Isin", + column: x => x.Isin, + principalTable: "AssetFundamentals", + principalColumn: "Isin", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "FinancialStatements", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Isin = table.Column(type: "text", nullable: false), + PeriodType = table.Column(type: "text", nullable: false), + EndDate = table.Column(type: "timestamp with time zone", nullable: false), + TotalRevenue = table.Column(type: "numeric", nullable: true), + CostOfRevenue = table.Column(type: "numeric", nullable: true), + GrossProfit = table.Column(type: "numeric", nullable: true), + OperatingExpenses = table.Column(type: "numeric", nullable: true), + OperatingIncome = table.Column(type: "numeric", nullable: true), + Ebitda = table.Column(type: "numeric", nullable: true), + NetIncome = table.Column(type: "numeric", nullable: true), + EpsBasic = table.Column(type: "numeric", nullable: true), + EpsDiluted = table.Column(type: "numeric", nullable: true), + CashAndCashEquivalents = table.Column(type: "numeric", nullable: true), + AccountsReceivable = table.Column(type: "numeric", nullable: true), + Inventory = table.Column(type: "numeric", nullable: true), + TotalCurrentAssets = table.Column(type: "numeric", nullable: true), + TotalNonCurrentAssets = table.Column(type: "numeric", nullable: true), + CurrentLiabilities = table.Column(type: "numeric", nullable: true), + LongTermDebt = table.Column(type: "numeric", nullable: true), + TotalLiabilities = table.Column(type: "numeric", nullable: true), + TotalStockholdersEquity = table.Column(type: "numeric", nullable: true), + OperatingCashFlow = table.Column(type: "numeric", nullable: true), + InvestingCashFlow = table.Column(type: "numeric", nullable: true), + CapitalExpenditures = table.Column(type: "numeric", nullable: true), + FinancingCashFlow = table.Column(type: "numeric", nullable: true), + FreeCashFlow = table.Column(type: "numeric", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_FinancialStatements", x => x.Id); + table.ForeignKey( + name: "FK_FinancialStatements_AssetFundamentals_Isin", + column: x => x.Isin, + principalTable: "AssetFundamentals", + principalColumn: "Isin", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "ForwardEstimates", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Isin = table.Column(type: "text", nullable: false), + Period = table.Column(type: "text", nullable: false), + ExpectedRevenue = table.Column(type: "numeric", nullable: true), + ExpectedEps = table.Column(type: "numeric", nullable: true), + ExpectedGrowthRate = table.Column(type: "numeric", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_ForwardEstimates", x => x.Id); + table.ForeignKey( + name: "FK_ForwardEstimates_AssetFundamentals_Isin", + column: x => x.Isin, + principalTable: "AssetFundamentals", + principalColumn: "Isin", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TickerFundamentals", + columns: table => new + { + Ticker = table.Column(type: "text", nullable: false), + Isin = table.Column(type: "text", nullable: false), + Exchange = table.Column(type: "text", nullable: true), + TradingCurrency = table.Column(type: "text", nullable: true), + CurrentPrice = table.Column(type: "numeric", nullable: false), + DayChangeAbsolute = table.Column(type: "numeric", nullable: false), + DayChangePercent = table.Column(type: "numeric", nullable: false), + FiftyTwoWeekHigh = table.Column(type: "numeric", nullable: false), + FiftyTwoWeekLow = table.Column(type: "numeric", nullable: false), + MarketCapitalization = table.Column(type: "numeric", nullable: false), + EnterpriseValue = table.Column(type: "numeric", nullable: false), + PeRatioTrailing = table.Column(type: "numeric", nullable: true), + PeRatioForward = table.Column(type: "numeric", nullable: true), + PegRatio = table.Column(type: "numeric", nullable: true), + PbRatio = table.Column(type: "numeric", nullable: true), + PsRatio = table.Column(type: "numeric", nullable: true), + EvToEbitda = table.Column(type: "numeric", nullable: true), + EvToRevenue = table.Column(type: "numeric", nullable: true), + GrossMargin = table.Column(type: "numeric", nullable: true), + OperatingMargin = table.Column(type: "numeric", nullable: true), + NetProfitMargin = table.Column(type: "numeric", nullable: true), + ReturnOnEquity = table.Column(type: "numeric", nullable: true), + ReturnOnAssets = table.Column(type: "numeric", nullable: true), + ReturnOnInvestedCapital = table.Column(type: "numeric", nullable: true), + DebtToEquity = table.Column(type: "numeric", nullable: true), + CurrentRatio = table.Column(type: "numeric", nullable: true), + QuickRatio = table.Column(type: "numeric", nullable: true), + InterestCoverage = table.Column(type: "numeric", nullable: true), + DividendYield = table.Column(type: "numeric", nullable: true), + PayoutRatio = table.Column(type: "numeric", nullable: true), + ExDividendDate = table.Column(type: "timestamp with time zone", nullable: true), + LastUpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TickerFundamentals", x => x.Ticker); + table.ForeignKey( + name: "FK_TickerFundamentals_AssetFundamentals_Isin", + column: x => x.Isin, + principalTable: "AssetFundamentals", + principalColumn: "Isin", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AssetFundamentals_PrimaryTicker", + table: "AssetFundamentals", + column: "PrimaryTicker", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_CompanyExecutives_Isin", + table: "CompanyExecutives", + column: "Isin"); + + migrationBuilder.CreateIndex( + name: "IX_FinancialStatements_Isin", + table: "FinancialStatements", + column: "Isin"); + + migrationBuilder.CreateIndex( + name: "IX_FinancialStatements_Isin_PeriodType_EndDate", + table: "FinancialStatements", + columns: new[] { "Isin", "PeriodType", "EndDate" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ForwardEstimates_Isin", + table: "ForwardEstimates", + column: "Isin"); + + migrationBuilder.CreateIndex( + name: "IX_ForwardEstimates_Isin_Period", + table: "ForwardEstimates", + columns: new[] { "Isin", "Period" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_TickerFundamentals_Isin", + table: "TickerFundamentals", + column: "Isin"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CompanyExecutives"); + + migrationBuilder.DropTable( + name: "FinancialStatements"); + + migrationBuilder.DropTable( + name: "ForwardEstimates"); + + migrationBuilder.DropTable( + name: "Settings"); + + migrationBuilder.DropTable( + name: "TickerFundamentals"); + + migrationBuilder.DropTable( + name: "AssetFundamentals"); + } + } +} diff --git a/FinlyticFundamentals/Migrations/FundamentalsDbContextModelSnapshot.cs b/FinlyticFundamentals/Migrations/FundamentalsDbContextModelSnapshot.cs new file mode 100644 index 0000000..813d6e4 --- /dev/null +++ b/FinlyticFundamentals/Migrations/FundamentalsDbContextModelSnapshot.cs @@ -0,0 +1,443 @@ +// +using System; +using FinlyticFundamentals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticFundamentals.Migrations +{ + [DbContext(typeof(FundamentalsDbContext))] + partial class FundamentalsDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetFundamentalsEntity", b => + { + b.Property("Isin") + .HasColumnType("text"); + + b.Property("BusinessSummary") + .HasColumnType("text"); + + b.Property("CompanyName") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConsensusRating") + .HasColumnType("text"); + + b.Property("Country") + .HasColumnType("text"); + + b.Property("Employees") + .HasColumnType("integer"); + + b.Property("ExDividendDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Industry") + .HasColumnType("text"); + + b.Property("LastStaticUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NextEarningsDate") + .HasColumnType("timestamp with time zone"); + + b.Property("PercentHeldByInsiders") + .HasColumnType("numeric"); + + b.Property("PercentHeldByInstitutions") + .HasColumnType("numeric"); + + b.Property("PriceTargetHigh") + .HasColumnType("numeric"); + + b.Property("PriceTargetLow") + .HasColumnType("numeric"); + + b.Property("PriceTargetMean") + .HasColumnType("numeric"); + + b.Property("PriceTargetMedian") + .HasColumnType("numeric"); + + b.Property("PrimaryTicker") + .IsRequired() + .HasColumnType("text"); + + b.Property("Sector") + .HasColumnType("text"); + + b.Property("ShortPercentOfFloat") + .HasColumnType("numeric"); + + b.Property("ShortRatio") + .HasColumnType("numeric"); + + b.HasKey("Isin"); + + b.HasIndex("PrimaryTicker") + .IsUnique(); + + b.ToTable("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.CompanyExecutiveEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Age") + .HasColumnType("integer"); + + b.Property("Compensation") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Isin"); + + b.ToTable("CompanyExecutives"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FinancialStatementEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccountsReceivable") + .HasColumnType("numeric"); + + b.Property("CapitalExpenditures") + .HasColumnType("numeric"); + + b.Property("CashAndCashEquivalents") + .HasColumnType("numeric"); + + b.Property("CostOfRevenue") + .HasColumnType("numeric"); + + b.Property("CurrentLiabilities") + .HasColumnType("numeric"); + + b.Property("Ebitda") + .HasColumnType("numeric"); + + b.Property("EndDate") + .HasColumnType("timestamp with time zone"); + + b.Property("EpsBasic") + .HasColumnType("numeric"); + + b.Property("EpsDiluted") + .HasColumnType("numeric"); + + b.Property("FinancingCashFlow") + .HasColumnType("numeric"); + + b.Property("FreeCashFlow") + .HasColumnType("numeric"); + + b.Property("GrossProfit") + .HasColumnType("numeric"); + + b.Property("Inventory") + .HasColumnType("numeric"); + + b.Property("InvestingCashFlow") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("LongTermDebt") + .HasColumnType("numeric"); + + b.Property("NetIncome") + .HasColumnType("numeric"); + + b.Property("OperatingCashFlow") + .HasColumnType("numeric"); + + b.Property("OperatingExpenses") + .HasColumnType("numeric"); + + b.Property("OperatingIncome") + .HasColumnType("numeric"); + + b.Property("PeriodType") + .IsRequired() + .HasColumnType("text"); + + b.Property("TotalCurrentAssets") + .HasColumnType("numeric"); + + b.Property("TotalLiabilities") + .HasColumnType("numeric"); + + b.Property("TotalNonCurrentAssets") + .HasColumnType("numeric"); + + b.Property("TotalRevenue") + .HasColumnType("numeric"); + + b.Property("TotalStockholdersEquity") + .HasColumnType("numeric"); + + b.HasKey("Id"); + + b.HasIndex("Isin"); + + b.HasIndex("Isin", "PeriodType", "EndDate") + .IsUnique(); + + b.ToTable("FinancialStatements"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.ForwardEstimateEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ExpectedEps") + .HasColumnType("numeric"); + + b.Property("ExpectedGrowthRate") + .HasColumnType("numeric"); + + b.Property("ExpectedRevenue") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Period") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Isin"); + + b.HasIndex("Isin", "Period") + .IsUnique(); + + b.ToTable("ForwardEstimates"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalsSettingsEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CacheTtlHours") + .HasColumnType("integer"); + + b.Property("EnableYahooFallback") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.TickerFundamentalsEntity", b => + { + b.Property("Ticker") + .HasColumnType("text"); + + b.Property("CurrentPrice") + .HasColumnType("numeric"); + + b.Property("CurrentRatio") + .HasColumnType("numeric"); + + b.Property("DayChangeAbsolute") + .HasColumnType("numeric"); + + b.Property("DayChangePercent") + .HasColumnType("numeric"); + + b.Property("DebtToEquity") + .HasColumnType("numeric"); + + b.Property("DividendYield") + .HasColumnType("numeric"); + + b.Property("EnterpriseValue") + .HasColumnType("numeric"); + + b.Property("EvToEbitda") + .HasColumnType("numeric"); + + b.Property("EvToRevenue") + .HasColumnType("numeric"); + + b.Property("ExDividendDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Exchange") + .HasColumnType("text"); + + b.Property("FiftyTwoWeekHigh") + .HasColumnType("numeric"); + + b.Property("FiftyTwoWeekLow") + .HasColumnType("numeric"); + + b.Property("GrossMargin") + .HasColumnType("numeric"); + + b.Property("InterestCoverage") + .HasColumnType("numeric"); + + b.Property("Isin") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MarketCapitalization") + .HasColumnType("numeric"); + + b.Property("NetProfitMargin") + .HasColumnType("numeric"); + + b.Property("OperatingMargin") + .HasColumnType("numeric"); + + b.Property("PayoutRatio") + .HasColumnType("numeric"); + + b.Property("PbRatio") + .HasColumnType("numeric"); + + b.Property("PeRatioForward") + .HasColumnType("numeric"); + + b.Property("PeRatioTrailing") + .HasColumnType("numeric"); + + b.Property("PegRatio") + .HasColumnType("numeric"); + + b.Property("PsRatio") + .HasColumnType("numeric"); + + b.Property("QuickRatio") + .HasColumnType("numeric"); + + b.Property("ReturnOnAssets") + .HasColumnType("numeric"); + + b.Property("ReturnOnEquity") + .HasColumnType("numeric"); + + b.Property("ReturnOnInvestedCapital") + .HasColumnType("numeric"); + + b.Property("TradingCurrency") + .HasColumnType("text"); + + b.HasKey("Ticker"); + + b.HasIndex("Isin"); + + b.ToTable("TickerFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.CompanyExecutiveEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("Executives") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FinancialStatementEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("FinancialStatements") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.ForwardEstimateEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("Estimates") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.TickerFundamentalsEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetFundamentalsEntity", "AssetFundamentals") + .WithMany("TickerFundamentals") + .HasForeignKey("Isin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetFundamentals"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetFundamentalsEntity", b => + { + b.Navigation("Estimates"); + + b.Navigation("Executives"); + + b.Navigation("FinancialStatements"); + + b.Navigation("TickerFundamentals"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticFundamentals/Program.cs b/FinlyticFundamentals/Program.cs new file mode 100644 index 0000000..55550f3 --- /dev/null +++ b/FinlyticFundamentals/Program.cs @@ -0,0 +1,55 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using FinlyticFundamentals.Database; +using FinlyticFundamentals.Services; +using FinlyticFundamentals.Util; + +var builder = Host.CreateApplicationBuilder(args); + +// Register DB Context +builder.Services.AddDbContext(options => + options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))); + +// Register HTTP Clients +builder.Services.AddHttpClient() + .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + UseCookies = true, + CookieContainer = new System.Net.CookieContainer() + }); + +// Register Application Services +builder.Services.AddSingleton(); +builder.Services.AddTransient(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); + +// Register MQTT Client (as a Hosted Service) +builder.Services.AddHostedService(); + +var host = builder.Build(); + +// Run startup database migrations +using (var scope = host.Services.CreateScope()) +{ + try + { + var context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); + Console.WriteLine("Database migrations successfully executed for FinlyticFundamentals."); + + var settingsService = scope.ServiceProvider.GetRequiredService(); + await settingsService.GetSettingsAsync(); + } + catch (Exception ex) + { + var logger = scope.ServiceProvider.GetRequiredService>(); + logger.LogError(ex, "[{Channel}] An error occurred during database migration on startup.", "FundamentalsChannel"); + } +} + +await host.RunAsync(); diff --git a/FinlyticFundamentals/Project.md b/FinlyticFundamentals/Project.md new file mode 100644 index 0000000..ba18a19 --- /dev/null +++ b/FinlyticFundamentals/Project.md @@ -0,0 +1,31 @@ +# Finlytic Fundamentals Service + +Finlytic Fundamentals is a C# background worker microservice responsible for fetching, caching, and serving financial fundamental data (P/E ratios, market cap, dividend yield, revenue growth, corporate calendar events) across global equities. + +--- + +## Core Features & Architecture + +1. **Fundamental Data Ingestion**: + - Scrapes and ingests company fundamentals (`AssetFundamentalsDto`) including P/E, EPS, Market Cap, Dividend Yield, Revenue, Profit Margins, and Debt-to-Equity ratios. + +2. **Corporate Event Calendar**: + - Tracks earnings release dates, ex-dividend dates, payout dates, and shareholder meetings (`CorporateEventDto`). + +3. **MQTT Distribution Channels**: + - Publishes fundamental updates to `finlytic/fundamentals/{isin}` and `finlytic/assets/fundamentals/{isin}`. + - Responds to RPC requests on `services/request/fundamentals_Get/#` and `services/request/events_GetAll/#`. + +--- + +## Feature Status + +### Implemented Features +- [x] Fundamentals Database Persistence & Caching (`FundamentalsDbContext`). +- [x] Corporate Event Calendar storage & query handlers. +- [x] Zero-Allocation MQTT serialization via `FinlyticJsonSerializerContext`. +- [x] Pure Worker Service architecture (`Host.CreateApplicationBuilder`, no Kestrel HTTP server). + +### Planned Features +- [ ] Financial Modeling Prep / SEC EDGAR API automated quarterly filing sync. +- [ ] Automated Dividend Growth Rate & Dividend Safety Rating calculation engine. diff --git a/FinlyticFundamentals/Services/FundamentalsDbService.cs b/FinlyticFundamentals/Services/FundamentalsDbService.cs new file mode 100644 index 0000000..c50b977 --- /dev/null +++ b/FinlyticFundamentals/Services/FundamentalsDbService.cs @@ -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 +{ + /// + /// Gets the fundamental data for a given ISIN. + /// If a specific ticker is provided, the resolution pipeline prioritizes/fetches only that ticker. + /// + /// The ISIN identifier of the asset. + /// Optional specific ticker symbol (e.g., "APC.DE"). If omitted, tickers are resolved automatically. + /// If true, forces a full static scrape for profile, financials, and executives. + /// Cancellation token. + /// The mapped or null if unavailable. + Task GetFundamentalsAsync( + string isin, + string? ticker = null, + bool forceRefresh = false, + CancellationToken cancellationToken = default); + + /// + /// Gets all upcoming and historic corporate events (e.g., earnings releases, ex-dividend dates). + /// + /// Cancellation token. + /// A list of corporate events sorted chronologically. + Task> GetAllEventsAsync(CancellationToken cancellationToken = default); +} + +public class FundamentalsDbService : IFundamentalsDbService +{ + private static readonly ConcurrentDictionary IsinLocks = new(); + + private readonly IServiceScopeFactory _scopeFactory; + private readonly IYahooFinanceScraper _scraper; + private readonly YahooFinanceClient _yahooClient; + private readonly ILogger _logger; + + public FundamentalsDbService( + IServiceScopeFactory scopeFactory, + IYahooFinanceScraper scraper, + YahooFinanceClient yahooClient, + ILogger logger) + { + _scopeFactory = scopeFactory; + _scraper = scraper; + _yahooClient = yahooClient; + _logger = logger; + } + + /// + public async Task 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(); + + 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 + + /// + /// Stellt sicher, dass der angeforderte Ticker existiert und dessen Live-Preise frisch sind (TTL: 15 Minuten). + /// + private async Task 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; + } + + /// + /// Führt ein vollständiges Scraping der Bilanzen und Ticker durch und speichert das Ergebnis ab. + /// + private async Task ExecuteFullScrapeAndPersistAsync( + FundamentalsDbContext context, + string isin, + string? requestedTicker, + AssetFundamentalsEntity? existingEntity, + CancellationToken cancellationToken) + { + _logger.LogInformation("[{Channel}] Initiating full static scrape for ISIN {Isin}...", "FundamentalsChannel", isin); + + List 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 { 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 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 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(); + + 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() + }; + } + + /// + public async Task> GetAllEventsAsync(CancellationToken cancellationToken = default) + { + using var scope = _scopeFactory.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var entities = await context.AssetFundamentals + .AsNoTracking() + .Where(f => f.NextEarningsDate.HasValue || f.ExDividendDate.HasValue) + .ToListAsync(cancellationToken); + + var events = new List(); + + 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 +} \ No newline at end of file diff --git a/FinlyticFundamentals/Services/SettingsDbService.cs b/FinlyticFundamentals/Services/SettingsDbService.cs new file mode 100644 index 0000000..542fbb0 --- /dev/null +++ b/FinlyticFundamentals/Services/SettingsDbService.cs @@ -0,0 +1,88 @@ +using FinlyticFundamentals.Database; +using FinlyticFundamentals.Entities; +using Microsoft.EntityFrameworkCore; + +namespace FinlyticFundamentals.Services; + +public interface ISettingsDbService +{ + /// + /// Gets the settings. + /// + Task GetSettingsAsync(); + /// + /// Saves the settings. + /// + Task SaveSettingsAsync(FundamentalsSettingsEntity settings); + /// + /// Updates the settings from a dictionary. + /// + Task UpdateSettingsFromDictionaryAsync(Dictionary dictionary); +} + +public class SettingsDbService : ISettingsDbService +{ + private readonly FundamentalsDbContext _context; + + public SettingsDbService(FundamentalsDbContext context) + { + _context = context; + } + + /// + /// Gets the settings asynchronously. + /// + public async Task 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; + } + + /// + /// Saves the settings asynchronously. + /// + public async Task 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; + } + + /// + /// Updates the settings from a dictionary asynchronously. + /// + public async Task UpdateSettingsFromDictionaryAsync(Dictionary 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); + } +} diff --git a/FinlyticFundamentals/Services/YahooFinanceScraper.cs b/FinlyticFundamentals/Services/YahooFinanceScraper.cs new file mode 100644 index 0000000..7500214 --- /dev/null +++ b/FinlyticFundamentals/Services/YahooFinanceScraper.cs @@ -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 +{ + /// + /// Resolves ticker from ISIN. + /// + Task ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default); + + /// + /// Resolves all tickers from ISIN. + /// + Task> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default); + + /// + /// Scrapes fundamentals. + /// + Task ScrapeFundamentalsAsync(string isin, string ticker, + CancellationToken cancellationToken = default); +} + +public record ScrapedFundamentalsData( + AssetFundamentalsEntity Fundamentals, + TickerFundamentalsEntity TickerData, + List Executives, + List Statements, + List Estimates +); + +public class YahooFinanceScraper : IYahooFinanceScraper +{ + private readonly HttpClient _httpClient; + private readonly YahooFinanceClient _yahooClient; + private readonly ILogger _logger; + + public YahooFinanceScraper(HttpClient httpClient, YahooFinanceClient yahooClient, + ILogger logger) + { + _httpClient = httpClient; + _yahooClient = yahooClient; + _logger = logger; + } + + /// + public async Task ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default) + { + var tickers = await ResolveAllTickersFromIsinAsync(isin, cancellationToken); + return tickers.FirstOrDefault(); + } + + /// + public async Task> 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 + } + } + + /// + public async Task 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(); + 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(); + + // 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(); + + 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 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 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 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 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; + } +} \ No newline at end of file diff --git a/FinlyticFundamentals/Util/FundamentalsMqttClient.cs b/FinlyticFundamentals/Util/FundamentalsMqttClient.cs new file mode 100644 index 0000000..b20eff9 --- /dev/null +++ b/FinlyticFundamentals/Util/FundamentalsMqttClient.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FinlyticCore.Dtos; +using FinlyticCore.Models; +using FinlyticCore.Util; +using FinlyticFundamentals.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace FinlyticFundamentals.Util; + +public class FundamentalsMqttClient : ManagedMqttClient, IHostedService +{ + private readonly ILogger _logger; + private readonly IConfiguration _configuration; + private readonly IFundamentalsDbService _dbService; + private readonly IServiceScopeFactory _scopeFactory; + + public FundamentalsMqttClient( + ILogger logger, + IConfiguration configuration, + IFundamentalsDbService dbService, + IServiceScopeFactory scopeFactory) : base(logger) + { + _logger = logger; + _configuration = configuration; + _dbService = dbService; + _scopeFactory = scopeFactory; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + var config = new MqttConfiguration + { + Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost", + Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"), + ClientId = _configuration["MQTT:ClientId"] ?? "finlytic_fundamentals_" + Guid.NewGuid().ToString("N") + }; + + _logger.LogInformation("[{Channel}] Starting Fundamentals MQTT client. Host: {Host}, ClientId: {ClientId}", "FundamentalsChannel", config.Host, config.ClientId); + + await ConnectAsync(config); + } + + /// + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("[{Channel}] Stopping Fundamentals MQTT client.", "FundamentalsChannel"); + await DisconnectAsync(); + } + + /// + protected override async Task OnConnectedAsync() + { + _logger.LogInformation("[{Channel}] Fundamentals MQTT client connected. Subscribing to RPC request topics...", "FundamentalsChannel"); + await SubscribeAsync("services/request/fundamentals_Get/#"); + await SubscribeAsync("services/request/events_GetAll/#"); + await SubscribeAsync("services/request/health_Ping/#"); + await SubscribeAsync("services/config/updated/#"); + } + + /// + protected override async Task OnMessageReceivedAsync(string topic, string payload) + { + if (string.IsNullOrWhiteSpace(topic)) return; + + // 1. Config update events + if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase)) + { + if (topic.EndsWith("FinlyticFundamentals", StringComparison.OrdinalIgnoreCase)) + { + await OnConfigUpdatedAsync(payload); + } + return; + } + + // Extract correlationId from topic suffix (e.g. services/request/fundamentals_Get/{correlationId}) + var lastSlash = topic.LastIndexOf('/'); + if (lastSlash < 0 || lastSlash >= topic.Length - 1) return; + + var correlationId = topic.Substring(lastSlash + 1); + + // 2. Dispatch to specific channel handlers + if (topic.Contains("fundamentals_Get", StringComparison.OrdinalIgnoreCase)) + { + await OnFundamentalsGetAsync(payload, correlationId); + } + else if (topic.Contains("events_GetAll", StringComparison.OrdinalIgnoreCase)) + { + await OnEventsGetAllAsync(correlationId); + } + else if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase)) + { + await OnHealthPingAsync(topic, correlationId); + } + } + + /// + /// Handles fundamentals_Get RPC requests using source-generated DTO deserialization. + /// + private async Task OnFundamentalsGetAsync(string payload, string correlationId) + { + if (string.IsNullOrWhiteSpace(payload)) + { + _logger.LogWarning("[{Channel}] [FundamentalsMqttClient] Received empty payload for fundamentals_Get request.", "FundamentalsChannel"); + return; + } + + try + { + var request = (IsinRequest?)JsonSerializer.Deserialize(payload, typeof(IsinRequest), FinlyticJsonSerializerContext.Default); + if (request == null || string.IsNullOrWhiteSpace(request.Isin)) + { + _logger.LogWarning("[{Channel}] [FundamentalsMqttClient] Request missing mandatory ISIN parameter in payload.", "FundamentalsChannel"); + return; + } + + _logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Processing RPC fundamentals_Get for ISIN '{Isin}' (forceRefresh={ForceRefresh}) [CorrelationId: {CorrelationId}]", + "FundamentalsChannel", request.Isin, request.ForceRefresh.ToString(), correlationId); + + var fundamentals = await _dbService.GetFundamentalsAsync(request.Isin, request.Ticker, request.ForceRefresh); + var responseTopic = $"services/response/fundamentals_Get/{correlationId}"; + + _logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Publishing RPC fundamentals response to '{ResponseTopic}'", "FundamentalsChannel", responseTopic); + await PublishAsync(responseTopic, fundamentals); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] [FundamentalsMqttClient] Failed to process fundamentals_Get request.", "FundamentalsChannel"); + } + } + + /// + /// Handles events_GetAll RPC requests. + /// + private async Task OnEventsGetAllAsync(string correlationId) + { + _logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Processing RPC events_GetAll request [CorrelationId: {CorrelationId}]", "FundamentalsChannel", correlationId); + try + { + var events = await _dbService.GetAllEventsAsync(); + var responseTopic = $"services/response/events_GetAll/{correlationId}"; + + _logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Publishing events RPC response to '{ResponseTopic}'", "FundamentalsChannel", responseTopic); + await PublishAsync(responseTopic, events); + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] [FundamentalsMqttClient] Failed to process events_GetAll request.", "FundamentalsChannel"); + } + } + + /// + /// Handles health_Ping RPC requests. + /// + private async Task OnHealthPingAsync(string topic, string correlationId) + { + if (topic.Contains("FinlyticFundamentals", StringComparison.OrdinalIgnoreCase) || !topic.Contains("/", StringComparison.OrdinalIgnoreCase)) + { + string respTopic = $"services/response/health_Ping/{correlationId}"; + await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticFundamentals", "Online", DateTime.UtcNow, "Connected")); + _logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", "FundamentalsChannel", correlationId); + } + } + + /// + /// Handles dynamic service config update events. + /// + private async Task OnConfigUpdatedAsync(string payload) + { + _logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Received config update event for FinlyticFundamentals.", "FundamentalsChannel"); + try + { + using var doc = JsonDocument.Parse(payload); + if (doc.RootElement.TryGetProperty("settings", out var settingsProp)) + { + var dict = (Dictionary?)JsonSerializer.Deserialize(settingsProp.GetRawText(), typeof(Dictionary), FinlyticJsonSerializerContext.Default); + if (dict != null && dict.Count > 0) + { + using var scope = _scopeFactory.CreateScope(); + var settingsDb = scope.ServiceProvider.GetRequiredService(); + await settingsDb.UpdateSettingsFromDictionaryAsync(dict); + _logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Persisted {Count} updated settings to FinlyticFundamentals database.", "FundamentalsChannel", dict.Count); + } + } + } + catch (Exception ex) + { + _logger.LogError(ex, "[{Channel}] [FundamentalsMqttClient] Error processing MQTT config update event.", "FundamentalsChannel"); + } + } +} diff --git a/FinlyticFundamentals/appsettings.json b/FinlyticFundamentals/appsettings.json new file mode 100644 index 0000000..72a4fc4 --- /dev/null +++ b/FinlyticFundamentals/appsettings.json @@ -0,0 +1,17 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Warning" + } + }, + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Database=finlytic_fundamentals;Username=admin;Password=admin" + }, + "MQTT": { + "Host": "localhost", + "Port": 1883, + "ClientId": "finlytic_fundamentals" + } +} diff --git a/FinlyticFundamentals/refactor.ps1 b/FinlyticFundamentals/refactor.ps1 new file mode 100644 index 0000000..df14032 --- /dev/null +++ b/FinlyticFundamentals/refactor.ps1 @@ -0,0 +1,16 @@ +$files = Get-ChildItem -Path "e:\Projects\Finlytic\FinlyticFundamentals" -Recurse -Include *.cs -Exclude "Migrations\*" + +foreach ($file in $files) { + $content = Get-Content $file.FullName -Raw + + # regex for logger + # match _logger.LogX("message", args) + # The tricky part is we need to match the message string correctly. + # $content = [regex]::Replace($content, '(_?logger\.Log(?:Information|Warning|Error))\(\s*(ex\s*,\s*)?("[^"]*")\s*(.*?)\)', { + # param($match) + # ... + # }) + + # Actually, a simpler way is using a Python script via downloaded Python or just do it in powershell carefully. + +} diff --git a/FinlyticFundamentals/refactor.py b/FinlyticFundamentals/refactor.py new file mode 100644 index 0000000..abd6efe --- /dev/null +++ b/FinlyticFundamentals/refactor.py @@ -0,0 +1,117 @@ +import os +import re + +def process_file(filepath): + with open(filepath, 'r', encoding='utf-8') as f: + content = f.read() + + # 1. Update logger statements + # We want to match: _logger.LogInformation("something", args) or _logger.LogError(ex, "something", args) + # This regex is a bit tricky, let's use a simpler approach or careful regex. + # Pattern to match logger.Log...( optionally (ex, ) then string then args ) + + # Let's match: (logger\.Log[A-Za-z]+)\((.*?)"(.*?)"(.*?)\) + # Wait, multi-line strings or strings with escaped quotes might break it. + # The regex approach: + # Match: (logger\.Log(?:Information|Warning|Error))\(([^"]*)"([^"]*)"(.*)\) + # If the string already starts with "[{Channel}] ", skip it. + + def replacer(m): + func_part = m.group(1) # e.g. _logger.LogInformation + pre_str = m.group(2) # e.g. (ex, or empty if it's the first arg) + msg_str = m.group(3) + post_str = m.group(4) + + if "[{Channel}]" in msg_str: + return m.group(0) + + new_msg = f"[{{Channel}}] {msg_str}" + # append "FundamentalsChannel" as the first argument after the string, or right after if there are no args + if post_str.strip().startswith(','): + # args exist, we need to insert our channel arg before the existing ones, but wait, the channel arg corresponds to {Channel} which is FIRST in the string, so we must pass "FundamentalsChannel" as the FIRST format argument. + new_post = f', "FundamentalsChannel"{post_str}' + elif post_str.strip() == '': + # no extra args, just the closing paren + new_post = f', "FundamentalsChannel")' + # Note: m.group(4) didn't include the closing paren if we don't match it. Let's adjust the regex to match up to closing paren. + else: + new_post = f', "FundamentalsChannel"{post_str}' + + return f'{func_part}({pre_str}"{new_msg}"{new_post}' + + # Regex to capture the parts. + # group 1: logger.Log... + # group 2: anything before the first quote (like exception) + # group 3: the string itself + # group 4: the rest of the arguments up to the closing parenthesis + # We need to find all instances. Let's do a line-by-line or simple regex. + + lines = content.split('\n') + new_lines = [] + + # 2. Add /// to public methods + # Method pattern: public (async )?(Task|void|[A-Za-z0-9_<>]+) [A-Za-z0-9_]+\(.*\) + method_pattern = re.compile(r'^\s*public\s+(?:async\s+)?[A-Za-z0-9_<>\[\]]+\s+[A-Za-z0-9_]+\(.*') + + for i, line in enumerate(lines): + # Apply logger transformation + # We look for _logger.LogInformation, _logger.LogWarning, _logger.LogError, logger.LogError etc. + if '.LogInformation(' in line or '.LogWarning(' in line or '.LogError(' in line: + # simple replacement logic + match = re.search(r'([_a-zA-Z0-9]+\.Log(?:Information|Warning|Error))\(([^"]*)"(.*?)"(.*)\)', line) + if match: + func_part = match.group(1) + pre_str = match.group(2) + msg_str = match.group(3) + post_str = match.group(4) + + if "[{Channel}]" not in msg_str: + new_msg = f"[{{Channel}}] {msg_str}" + if post_str.strip() == ')': + new_post = ', "FundamentalsChannel")' + elif post_str.endswith(');'): + new_post = ', "FundamentalsChannel");' + # strip the ); from post_str for clean insertion + post_str = post_str[:-2] + new_post = f', "FundamentalsChannel"{post_str});' + else: + new_post = f', "FundamentalsChannel"{post_str}' + + line = f'{line[:match.start()]}{func_part}({pre_str}"{new_msg}"{new_post}{line[match.end():]}' + + # Check for public method to add /// + # We need to make sure we don't add it if it already has one. + # Also interface methods: Task Something(); + if method_pattern.match(line): + # Check previous line + if i > 0 and '///' not in lines[i-1] and '[' not in lines[i-1]: + indent = len(line) - len(line.lstrip()) + summary = ' ' * indent + '/// \n' + ' ' * indent + '/// \n' + ' ' * indent + '/// ' + new_lines.append(summary) + + # Interface methods inside public interface + if re.match(r'^\s*(?:Task|void|[A-Za-z0-9_<>\[\]]+)\s+[A-Za-z0-9_]+\(.*', line): + if i > 0 and '///' not in lines[i-1] and '[' not in lines[i-1]: + # check if we are in an interface + # kinda hard with just line by line, but let's try. + pass + + new_lines.append(line) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write('\n'.join(new_lines)) + +def main(): + dirs = ['Services', 'Util', '.'] + base = r'e:\Projects\Finlytic\FinlyticFundamentals' + + for d in dirs: + p = os.path.join(base, d) + if os.path.isdir(p): + for file in os.listdir(p): + if file.endswith('.cs'): + filepath = os.path.join(p, file) + process_file(filepath) + +if __name__ == "__main__": + main()