feat(fundamentals): refactor entities, add Yahoo modules scraper, 52W metrics and migrations

This commit is contained in:
2026-08-14 23:55:15 +02:00
parent 4f733bf8c3
commit 1447f0aa4c
18 changed files with 2565 additions and 1562 deletions
@@ -1,3 +1,4 @@
using FinlyticCore.Entities.Settings;
using FinlyticFundamentals.Entities;
using Microsoft.EntityFrameworkCore;
@@ -9,74 +10,84 @@ public class FundamentalsDbContext : DbContext
{
}
public DbSet<AssetFundamentalsEntity> AssetFundamentals => Set<AssetFundamentalsEntity>();
public DbSet<CompanyExecutiveEntity> CompanyExecutives => Set<CompanyExecutiveEntity>();
public DbSet<FinancialStatementEntity> FinancialStatements => Set<FinancialStatementEntity>();
public DbSet<ForwardEstimateEntity> ForwardEstimates => Set<ForwardEstimateEntity>();
public DbSet<TickerFundamentalsEntity> TickerFundamentals => Set<TickerFundamentalsEntity>();
public DbSet<FundamentalsSettingsEntity> Settings => Set<FundamentalsSettingsEntity>();
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
public DbSet<AssetDataEntity> AssetData => Set<AssetDataEntity>();
public DbSet<FundamentalDataEntity> FundamentalData => Set<FundamentalDataEntity>();
public DbSet<KeyExecutiveEntity> KeyExecutives => Set<KeyExecutiveEntity>();
public DbSet<AssetEventEntity> AssetEvents => Set<AssetEventEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// AssetFundamentals Configurations
modelBuilder.Entity<AssetFundamentalsEntity>(entity =>
modelBuilder.Entity<SettingEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key);
});
modelBuilder.Entity<AssetDataEntity>(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)
entity.OwnsOne(e => e.PrimaryTicker, t =>
{
t.Property(p => p.Ticker).HasColumnName("PrimaryTicker").HasDefaultValue(string.Empty);
t.Property(p => p.Exchange).HasColumnName("PrimaryTickerExchange").HasDefaultValue(string.Empty);
});
entity.OwnsMany(e => e.AvailableTickers, t =>
{
t.ToTable("Tickers");
t.WithOwner().HasForeignKey("AssetDataIsin");
t.Property<Guid>("Id");
t.HasKey("Id");
t.Property(p => p.Ticker).HasColumnName("Ticker");
t.Property(p => p.Exchange).HasColumnName("Exchange");
t.HasIndex(p => p.Ticker);
});
entity.HasMany(e => e.FundamentalData)
.WithOne(e => e.AssetData)
.HasForeignKey(e => e.AssetDataIsin)
.OnDelete(DeleteBehavior.Cascade);
entity.HasMany(e => e.FinancialStatements)
.WithOne(e => e.AssetFundamentals)
.HasForeignKey(e => e.Isin)
entity.HasMany(e => e.KeyExecutives)
.WithOne(e => e.AssetData)
.HasForeignKey(e => e.AssetDataIsin)
.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)
entity.HasMany(e => e.AssetEvents)
.WithOne(e => e.AssetData)
.HasForeignKey(e => e.AssetDataIsin)
.OnDelete(DeleteBehavior.Cascade);
});
// CompanyExecutive Configurations
modelBuilder.Entity<CompanyExecutiveEntity>(entity =>
modelBuilder.Entity<FundamentalDataEntity>(entity =>
{
entity.HasKey(e => e.Isin);
entity.OwnsOne(e => e.Ticker, t =>
{
t.Property(p => p.Ticker).HasColumnName("Ticker").HasDefaultValue(string.Empty);
t.Property(p => p.Exchange).HasColumnName("TickerExchange").HasDefaultValue(string.Empty);
});
});
modelBuilder.Entity<KeyExecutiveEntity>(entity =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Isin);
});
// FinancialStatement Configurations
modelBuilder.Entity<FinancialStatementEntity>(entity =>
modelBuilder.Entity<AssetEventEntity>(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<ForwardEstimateEntity>(entity =>
entity.OwnsOne(e => e.Ticker, t =>
{
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Isin);
entity.HasIndex(e => new { e.Isin, e.Period }).IsUnique();
t.Property(p => p.Ticker).HasColumnName("Ticker").HasDefaultValue(string.Empty);
t.Property(p => p.Exchange).HasColumnName("TickerExchange").HasDefaultValue(string.Empty);
});
// TickerFundamentals Configurations
modelBuilder.Entity<TickerFundamentalsEntity>(entity =>
{
entity.HasKey(e => e.Ticker);
entity.HasIndex(e => e.Isin);
});
}
}
@@ -1,15 +1,19 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
namespace FinlyticFundamentals.Entities;
public class AssetDataEntity
{
[Key] public string Isin { get; set; } = string.Empty;
[Key] public string Isin { get; set; } = "";
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Description { get; set; } = "";
public TickerEntity PrimaryTicker { get; set; } = new();
public List<TickerEntity> AvailableTickers { get; set; } = new();
public ICollection<KeyExecutiveEntity> KeyExecutives { get; set; }
public ICollection<AssetEventEntity> AssetEvents { get; set; }
public ICollection<KeyExecutiveEntity> KeyExecutives { get; set; } = new List<KeyExecutiveEntity>();
public ICollection<AssetEventEntity> AssetEvents { get; set; } = new List<AssetEventEntity>();
public ICollection<FundamentalDataEntity> FundamentalData { get; set; } = new List<FundamentalDataEntity>();
}
@@ -1,6 +1,23 @@
namespace FinlyticFundamentals.Entities;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticFundamentals.Entities;
public class AssetEventEntity
{
[Key]
public Guid Id { get; set; } = Guid.NewGuid();
public TickerEntity Ticker { get; set; } = new();
public string Type { get; set; } = string.Empty;
public DateTime Date { get; set; }
[Required]
public string AssetDataIsin { get; set; } = string.Empty;
[ForeignKey(nameof(AssetDataIsin))]
public AssetDataEntity AssetData { get; set; } = null!;
}
@@ -1,6 +1,69 @@
namespace FinlyticFundamentals.Entities;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticFundamentals.Entities;
public class FundamentalDataEntity
{
[Key]
public string Isin { get; set; } = string.Empty;
public TickerEntity Ticker { get; set; } = new();
// --- Valuation & Multiples ---
public decimal? MarketCap { get; set; }
public decimal? EnterpriseValue { get; set; }
public decimal? TrailingPe { get; set; }
public decimal? ForwardPe { get; set; }
public decimal? PegRatio { get; set; }
public decimal? PriceToSales { get; set; }
public decimal? PriceToBook { get; set; }
public decimal? EvToEbitda { get; set; }
// --- Income Statement (TTM) ---
public decimal? TotalRevenue { get; set; }
public decimal? RevenueGrowthYoY { get; set; }
public decimal? GrossProfit { get; set; }
public decimal? OperatingIncome { get; set; } // EBIT
public decimal? Ebitda { get; set; }
public decimal? NetIncome { get; set; }
public decimal? DilutedEps { get; set; }
// --- Balance Sheet & Cash Flow (MRQ / TTM) ---
public decimal? TotalCash { get; set; }
public decimal? TotalDebt { get; set; }
public decimal? DebtToEquity { get; set; }
public decimal? CurrentRatio { get; set; }
public decimal? OperatingCashFlow { get; set; }
public decimal? FreeCashFlow { get; set; }
// --- Dividenden & Profitabilität ---
public decimal? ReturnOnEquity { get; set; }
public decimal? ReturnOnAssets { get; set; }
public decimal? ForwardDividendYield { get; set; }
public decimal? PayoutRatio { get; set; }
// --- 52-Wochen-Spannbreite ---
public decimal? FiftyTwoWeekHigh { get; set; }
public decimal? FiftyTwoWeekLow { get; set; }
// --- Analysten-Ratings & Kursziele ---
public string? ConsensusRating { get; set; }
public decimal? PriceTargetLow { get; set; }
public decimal? PriceTargetMean { get; set; }
public decimal? PriceTargetHigh { get; set; }
// --- Aktionärsstruktur & Short-Interesse ---
public decimal? PercentHeldByInstitutions { get; set; }
public decimal? PercentHeldByInsiders { get; set; }
public decimal? ShortPercentOfFloat { get; set; }
public decimal? ShortRatio { get; set; }
public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
[Required]
public string AssetDataIsin { get; set; } = string.Empty;
[ForeignKey(nameof(AssetDataIsin))]
public AssetDataEntity AssetData { get; set; } = null!;
}
@@ -1,6 +1,17 @@
namespace FinlyticFundamentals.Entities;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace FinlyticFundamentals.Entities;
public class KeyExecutiveEntity
{
[Key] public Guid Id { get; set; } = Guid.NewGuid();
public string Name { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Payment { get; set; } = string.Empty;
[Required] public string AssetDataIsin { get; set; } = string.Empty;
[ForeignKey(nameof(AssetDataIsin))] public AssetDataEntity AssetData { get; set; } = null!;
}
@@ -1,6 +1,10 @@
namespace FinlyticFundamentals.Entities;
using Microsoft.EntityFrameworkCore;
namespace FinlyticFundamentals.Entities;
[Owned]
public class TickerEntity
{
public string Ticker { get; set; } = string.Empty;
public string Exchange { get; set; } = string.Empty;
}
@@ -29,4 +29,8 @@
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>
@@ -0,0 +1,394 @@
// <auto-generated />
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("20260814211010_Init")]
partial class Init
{
/// <inheritdoc />
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("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key");
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Isin");
b.ToTable("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin");
b.ToTable("AssetEvents");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<decimal?>("CurrentRatio")
.HasColumnType("numeric");
b.Property<decimal?>("DebtToEquity")
.HasColumnType("numeric");
b.Property<decimal?>("DilutedEps")
.HasColumnType("numeric");
b.Property<decimal?>("Ebitda")
.HasColumnType("numeric");
b.Property<decimal?>("EnterpriseValue")
.HasColumnType("numeric");
b.Property<decimal?>("EvToEbitda")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardDividendYield")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardPe")
.HasColumnType("numeric");
b.Property<decimal?>("FreeCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("GrossProfit")
.HasColumnType("numeric");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("MarketCap")
.HasColumnType("numeric");
b.Property<decimal?>("NetIncome")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingIncome")
.HasColumnType("numeric");
b.Property<decimal?>("PayoutRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PegRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToBook")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToSales")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnAssets")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnEquity")
.HasColumnType("numeric");
b.Property<decimal?>("RevenueGrowthYoY")
.HasColumnType("numeric");
b.Property<decimal?>("TotalCash")
.HasColumnType("numeric");
b.Property<decimal?>("TotalDebt")
.HasColumnType("numeric");
b.Property<decimal?>("TotalRevenue")
.HasColumnType("numeric");
b.Property<decimal?>("TrailingPe")
.HasColumnType("numeric");
b.HasKey("Isin");
b.HasIndex("AssetDataIsin");
b.ToTable("FundamentalData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Payment")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin");
b.ToTable("KeyExecutives");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "PrimaryTicker", b1 =>
{
b1.Property<string>("AssetDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTicker");
b1.HasKey("AssetDataEntityIsin");
b1.ToTable("AssetData");
b1.WithOwner()
.HasForeignKey("AssetDataEntityIsin");
});
b.OwnsMany("FinlyticFundamentals.Entities.TickerEntity", "AvailableTickers", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Exchange");
b1.Property<string>("Ticker")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Ticker");
b1.HasKey("Id");
b1.HasIndex("AssetDataIsin");
b1.HasIndex("Ticker");
b1.ToTable("Tickers", (string)null);
b1.WithOwner()
.HasForeignKey("AssetDataIsin");
});
b.Navigation("AvailableTickers");
b.Navigation("PrimaryTicker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("AssetEvents")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<Guid>("AssetEventEntityId")
.HasColumnType("uuid");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("AssetEventEntityId");
b1.ToTable("AssetEvents");
b1.WithOwner()
.HasForeignKey("AssetEventEntityId");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("FundamentalData")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<string>("FundamentalDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("FundamentalDataEntityIsin");
b1.ToTable("FundamentalData");
b1.WithOwner()
.HasForeignKey("FundamentalDataEntityIsin");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("KeyExecutives")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Navigation("AssetEvents");
b.Navigation("FundamentalData");
b.Navigation("KeyExecutives");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,206 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticFundamentals.Migrations
{
/// <inheritdoc />
public partial class Init : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "AssetData",
columns: table => new
{
Isin = table.Column<string>(type: "text", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: false),
PrimaryTicker = table.Column<string>(type: "text", nullable: false, defaultValue: ""),
PrimaryTickerExchange = table.Column<string>(type: "text", nullable: false, defaultValue: "")
},
constraints: table =>
{
table.PrimaryKey("PK_AssetData", x => x.Isin);
});
migrationBuilder.CreateTable(
name: "DynamicSettings",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Key = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
ValueJson = table.Column<string>(type: "text", nullable: false),
ServiceIdentifier = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_DynamicSettings", x => x.Id);
});
migrationBuilder.CreateTable(
name: "AssetEvents",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Ticker = table.Column<string>(type: "text", nullable: false, defaultValue: ""),
TickerExchange = table.Column<string>(type: "text", nullable: false, defaultValue: ""),
Type = table.Column<string>(type: "text", nullable: false),
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
AssetDataIsin = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_AssetEvents", x => x.Id);
table.ForeignKey(
name: "FK_AssetEvents_AssetData_AssetDataIsin",
column: x => x.AssetDataIsin,
principalTable: "AssetData",
principalColumn: "Isin",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "FundamentalData",
columns: table => new
{
Isin = table.Column<string>(type: "text", nullable: false),
Ticker = table.Column<string>(type: "text", nullable: false, defaultValue: ""),
TickerExchange = table.Column<string>(type: "text", nullable: false, defaultValue: ""),
MarketCap = table.Column<decimal>(type: "numeric", nullable: true),
EnterpriseValue = table.Column<decimal>(type: "numeric", nullable: true),
TrailingPe = table.Column<decimal>(type: "numeric", nullable: true),
ForwardPe = table.Column<decimal>(type: "numeric", nullable: true),
PegRatio = table.Column<decimal>(type: "numeric", nullable: true),
PriceToSales = table.Column<decimal>(type: "numeric", nullable: true),
PriceToBook = table.Column<decimal>(type: "numeric", nullable: true),
EvToEbitda = table.Column<decimal>(type: "numeric", nullable: true),
TotalRevenue = table.Column<decimal>(type: "numeric", nullable: true),
RevenueGrowthYoY = table.Column<decimal>(type: "numeric", nullable: true),
GrossProfit = table.Column<decimal>(type: "numeric", nullable: true),
OperatingIncome = table.Column<decimal>(type: "numeric", nullable: true),
Ebitda = table.Column<decimal>(type: "numeric", nullable: true),
NetIncome = table.Column<decimal>(type: "numeric", nullable: true),
DilutedEps = table.Column<decimal>(type: "numeric", nullable: true),
TotalCash = table.Column<decimal>(type: "numeric", nullable: true),
TotalDebt = table.Column<decimal>(type: "numeric", nullable: true),
DebtToEquity = table.Column<decimal>(type: "numeric", nullable: true),
CurrentRatio = table.Column<decimal>(type: "numeric", nullable: true),
OperatingCashFlow = table.Column<decimal>(type: "numeric", nullable: true),
FreeCashFlow = table.Column<decimal>(type: "numeric", nullable: true),
ReturnOnEquity = table.Column<decimal>(type: "numeric", nullable: true),
ReturnOnAssets = table.Column<decimal>(type: "numeric", nullable: true),
ForwardDividendYield = table.Column<decimal>(type: "numeric", nullable: true),
PayoutRatio = table.Column<decimal>(type: "numeric", nullable: true),
LastUpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
AssetDataIsin = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FundamentalData", x => x.Isin);
table.ForeignKey(
name: "FK_FundamentalData_AssetData_AssetDataIsin",
column: x => x.AssetDataIsin,
principalTable: "AssetData",
principalColumn: "Isin",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "KeyExecutives",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Title = table.Column<string>(type: "text", nullable: false),
Payment = table.Column<string>(type: "text", nullable: false),
AssetDataIsin = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_KeyExecutives", x => x.Id);
table.ForeignKey(
name: "FK_KeyExecutives_AssetData_AssetDataIsin",
column: x => x.AssetDataIsin,
principalTable: "AssetData",
principalColumn: "Isin",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Tickers",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
Ticker = table.Column<string>(type: "text", nullable: false),
Exchange = table.Column<string>(type: "text", nullable: false),
AssetDataIsin = table.Column<string>(type: "text", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tickers", x => x.Id);
table.ForeignKey(
name: "FK_Tickers_AssetData_AssetDataIsin",
column: x => x.AssetDataIsin,
principalTable: "AssetData",
principalColumn: "Isin",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_AssetEvents_AssetDataIsin",
table: "AssetEvents",
column: "AssetDataIsin");
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key");
migrationBuilder.CreateIndex(
name: "IX_FundamentalData_AssetDataIsin",
table: "FundamentalData",
column: "AssetDataIsin");
migrationBuilder.CreateIndex(
name: "IX_KeyExecutives_AssetDataIsin",
table: "KeyExecutives",
column: "AssetDataIsin");
migrationBuilder.CreateIndex(
name: "IX_Tickers_AssetDataIsin",
table: "Tickers",
column: "AssetDataIsin");
migrationBuilder.CreateIndex(
name: "IX_Tickers_Ticker",
table: "Tickers",
column: "Ticker");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "AssetEvents");
migrationBuilder.DropTable(
name: "DynamicSettings");
migrationBuilder.DropTable(
name: "FundamentalData");
migrationBuilder.DropTable(
name: "KeyExecutives");
migrationBuilder.DropTable(
name: "Tickers");
migrationBuilder.DropTable(
name: "AssetData");
}
}
}
@@ -0,0 +1,424 @@
// <auto-generated />
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("20260814214749_AddMoreFundamentalMetrics")]
partial class AddMoreFundamentalMetrics
{
/// <inheritdoc />
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("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key");
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Isin");
b.ToTable("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin");
b.ToTable("AssetEvents");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ConsensusRating")
.HasColumnType("text");
b.Property<decimal?>("CurrentRatio")
.HasColumnType("numeric");
b.Property<decimal?>("DebtToEquity")
.HasColumnType("numeric");
b.Property<decimal?>("DilutedEps")
.HasColumnType("numeric");
b.Property<decimal?>("Ebitda")
.HasColumnType("numeric");
b.Property<decimal?>("EnterpriseValue")
.HasColumnType("numeric");
b.Property<decimal?>("EvToEbitda")
.HasColumnType("numeric");
b.Property<decimal?>("FiftyTwoWeekHigh")
.HasColumnType("numeric");
b.Property<decimal?>("FiftyTwoWeekLow")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardDividendYield")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardPe")
.HasColumnType("numeric");
b.Property<decimal?>("FreeCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("GrossProfit")
.HasColumnType("numeric");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("MarketCap")
.HasColumnType("numeric");
b.Property<decimal?>("NetIncome")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingIncome")
.HasColumnType("numeric");
b.Property<decimal?>("PayoutRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PegRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PercentHeldByInsiders")
.HasColumnType("numeric");
b.Property<decimal?>("PercentHeldByInstitutions")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetHigh")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetLow")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetMean")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToBook")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToSales")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnAssets")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnEquity")
.HasColumnType("numeric");
b.Property<decimal?>("RevenueGrowthYoY")
.HasColumnType("numeric");
b.Property<decimal?>("ShortPercentOfFloat")
.HasColumnType("numeric");
b.Property<decimal?>("ShortRatio")
.HasColumnType("numeric");
b.Property<decimal?>("TotalCash")
.HasColumnType("numeric");
b.Property<decimal?>("TotalDebt")
.HasColumnType("numeric");
b.Property<decimal?>("TotalRevenue")
.HasColumnType("numeric");
b.Property<decimal?>("TrailingPe")
.HasColumnType("numeric");
b.HasKey("Isin");
b.HasIndex("AssetDataIsin");
b.ToTable("FundamentalData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Payment")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin");
b.ToTable("KeyExecutives");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "PrimaryTicker", b1 =>
{
b1.Property<string>("AssetDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTicker");
b1.HasKey("AssetDataEntityIsin");
b1.ToTable("AssetData");
b1.WithOwner()
.HasForeignKey("AssetDataEntityIsin");
});
b.OwnsMany("FinlyticFundamentals.Entities.TickerEntity", "AvailableTickers", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Exchange");
b1.Property<string>("Ticker")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Ticker");
b1.HasKey("Id");
b1.HasIndex("AssetDataIsin");
b1.HasIndex("Ticker");
b1.ToTable("Tickers", (string)null);
b1.WithOwner()
.HasForeignKey("AssetDataIsin");
});
b.Navigation("AvailableTickers");
b.Navigation("PrimaryTicker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("AssetEvents")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<Guid>("AssetEventEntityId")
.HasColumnType("uuid");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("AssetEventEntityId");
b1.ToTable("AssetEvents");
b1.WithOwner()
.HasForeignKey("AssetEventEntityId");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("FundamentalData")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<string>("FundamentalDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("FundamentalDataEntityIsin");
b1.ToTable("FundamentalData");
b1.WithOwner()
.HasForeignKey("FundamentalDataEntityIsin");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("KeyExecutives")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Navigation("AssetEvents");
b.Navigation("FundamentalData");
b.Navigation("KeyExecutives");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,118 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticFundamentals.Migrations
{
/// <inheritdoc />
public partial class AddMoreFundamentalMetrics : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ConsensusRating",
table: "FundamentalData",
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "FiftyTwoWeekHigh",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "FiftyTwoWeekLow",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PercentHeldByInsiders",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PercentHeldByInstitutions",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PriceTargetHigh",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PriceTargetLow",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "PriceTargetMean",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ShortPercentOfFloat",
table: "FundamentalData",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "ShortRatio",
table: "FundamentalData",
type: "numeric",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ConsensusRating",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "FiftyTwoWeekHigh",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "FiftyTwoWeekLow",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "PercentHeldByInsiders",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "PercentHeldByInstitutions",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "PriceTargetHigh",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "PriceTargetLow",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "PriceTargetMean",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "ShortPercentOfFloat",
table: "FundamentalData");
migrationBuilder.DropColumn(
name: "ShortRatio",
table: "FundamentalData");
}
}
}
@@ -0,0 +1,421 @@
// <auto-generated />
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("FinlyticCore.Entities.Settings.SettingEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ServiceIdentifier")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("ValueJson")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Key");
b.ToTable("DynamicSettings");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.HasKey("Isin");
b.ToTable("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("Date")
.HasColumnType("timestamp with time zone");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin");
b.ToTable("AssetEvents");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ConsensusRating")
.HasColumnType("text");
b.Property<decimal?>("CurrentRatio")
.HasColumnType("numeric");
b.Property<decimal?>("DebtToEquity")
.HasColumnType("numeric");
b.Property<decimal?>("DilutedEps")
.HasColumnType("numeric");
b.Property<decimal?>("Ebitda")
.HasColumnType("numeric");
b.Property<decimal?>("EnterpriseValue")
.HasColumnType("numeric");
b.Property<decimal?>("EvToEbitda")
.HasColumnType("numeric");
b.Property<decimal?>("FiftyTwoWeekHigh")
.HasColumnType("numeric");
b.Property<decimal?>("FiftyTwoWeekLow")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardDividendYield")
.HasColumnType("numeric");
b.Property<decimal?>("ForwardPe")
.HasColumnType("numeric");
b.Property<decimal?>("FreeCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("GrossProfit")
.HasColumnType("numeric");
b.Property<DateTime>("LastUpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("MarketCap")
.HasColumnType("numeric");
b.Property<decimal?>("NetIncome")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingCashFlow")
.HasColumnType("numeric");
b.Property<decimal?>("OperatingIncome")
.HasColumnType("numeric");
b.Property<decimal?>("PayoutRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PegRatio")
.HasColumnType("numeric");
b.Property<decimal?>("PercentHeldByInsiders")
.HasColumnType("numeric");
b.Property<decimal?>("PercentHeldByInstitutions")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetHigh")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetLow")
.HasColumnType("numeric");
b.Property<decimal?>("PriceTargetMean")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToBook")
.HasColumnType("numeric");
b.Property<decimal?>("PriceToSales")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnAssets")
.HasColumnType("numeric");
b.Property<decimal?>("ReturnOnEquity")
.HasColumnType("numeric");
b.Property<decimal?>("RevenueGrowthYoY")
.HasColumnType("numeric");
b.Property<decimal?>("ShortPercentOfFloat")
.HasColumnType("numeric");
b.Property<decimal?>("ShortRatio")
.HasColumnType("numeric");
b.Property<decimal?>("TotalCash")
.HasColumnType("numeric");
b.Property<decimal?>("TotalDebt")
.HasColumnType("numeric");
b.Property<decimal?>("TotalRevenue")
.HasColumnType("numeric");
b.Property<decimal?>("TrailingPe")
.HasColumnType("numeric");
b.HasKey("Isin");
b.HasIndex("AssetDataIsin");
b.ToTable("FundamentalData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Payment")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AssetDataIsin");
b.ToTable("KeyExecutives");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "PrimaryTicker", b1 =>
{
b1.Property<string>("AssetDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("PrimaryTicker");
b1.HasKey("AssetDataEntityIsin");
b1.ToTable("AssetData");
b1.WithOwner()
.HasForeignKey("AssetDataEntityIsin");
});
b.OwnsMany("FinlyticFundamentals.Entities.TickerEntity", "AvailableTickers", b1 =>
{
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("AssetDataIsin")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Exchange");
b1.Property<string>("Ticker")
.IsRequired()
.HasColumnType("text")
.HasColumnName("Ticker");
b1.HasKey("Id");
b1.HasIndex("AssetDataIsin");
b1.HasIndex("Ticker");
b1.ToTable("Tickers", (string)null);
b1.WithOwner()
.HasForeignKey("AssetDataIsin");
});
b.Navigation("AvailableTickers");
b.Navigation("PrimaryTicker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("AssetEvents")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<Guid>("AssetEventEntityId")
.HasColumnType("uuid");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("AssetEventEntityId");
b1.ToTable("AssetEvents");
b1.WithOwner()
.HasForeignKey("AssetEventEntityId");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("FundamentalData")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 =>
{
b1.Property<string>("FundamentalDataEntityIsin")
.HasColumnType("text");
b1.Property<string>("Exchange")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("TickerExchange");
b1.Property<string>("Ticker")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("text")
.HasDefaultValue("")
.HasColumnName("Ticker");
b1.HasKey("FundamentalDataEntityIsin");
b1.ToTable("FundamentalData");
b1.WithOwner()
.HasForeignKey("FundamentalDataEntityIsin");
});
b.Navigation("AssetData");
b.Navigation("Ticker")
.IsRequired();
});
modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b =>
{
b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData")
.WithMany("KeyExecutives")
.HasForeignKey("AssetDataIsin")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("AssetData");
});
modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b =>
{
b.Navigation("AssetEvents");
b.Navigation("FundamentalData");
b.Navigation("KeyExecutives");
});
#pragma warning restore 612, 618
}
}
}
+18 -11
View File
@@ -1,18 +1,20 @@
using System;
using FinlyticCore.Clients;
using FinlyticCore.Services;
using FinlyticCore.Services.PlaywrightScrapper;
using FinlyticCore.Services.TradeRepublic;
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;
using Microsoft.EntityFrameworkCore.Diagnostics;
var builder = Host.CreateApplicationBuilder(args);
// Register DB Context
builder.Services.AddDbContext<FundamentalsDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"))
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning)));
// Register HTTP Clients
builder.Services.AddHttpClient<IYahooFinanceScraper, YahooFinanceScraper>()
@@ -23,12 +25,19 @@ builder.Services.AddHttpClient<IYahooFinanceScraper, YahooFinanceScraper>()
AllowAutoRedirect = true
});
builder.Services.AddSingleton<IHtmlFallbackScraper, HtmlFallbackScraper>();
builder.Services.AddSingleton<IPlaywrightBrowserFactory, PlaywrightBrowserFactory>();
builder.Services.AddSingleton<IPlaywrightExecutionService, PlaywrightExecutionService>();
builder.Services.AddTransient<IYahooFinanceHtmlClient, YahooFinanceHtmlClient<FundamentalsDbService, FundamentalsDbContext>>();
// Register Application Services
builder.Services.AddSingleton<TradeRepublicClient>();
builder.Services.AddSingleton<ITradeRepublicService, TradeRepublicService>();
builder.Services.AddSingleton<FinlyticCore.Services.Yahoo.YahooFinanceClient>();
builder.Services.AddSingleton<IFundamentalsDbService, FundamentalsDbService>();
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
builder.Services.AddTransient<IFundamentalsDbService, FundamentalsDbService>();
builder.Services.AddScoped<IYahooFinanceScraper, YahooFinanceScraper>();
builder.Services.AddScoped(typeof(ISettingsService<>), typeof(SettingsService<>));
builder.Services.AddScoped(typeof(IFinlyticLogger<,>), typeof(FinlyticLogger<,>));
// Register MQTT Client (as a Hosted Service)
builder.Services.AddHostedService<FundamentalsMqttClient>();
@@ -44,8 +53,6 @@ using (var scope = host.Services.CreateScope())
await context.Database.MigrateAsync();
Console.WriteLine("Database migrations successfully executed for FinlyticFundamentals.");
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
await settingsService.GetSettingsAsync();
}
catch (Exception ex)
{
File diff suppressed because it is too large Load Diff
@@ -1,305 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using FinlyticFundamentals.Entities;
using Microsoft.Extensions.Logging;
using Microsoft.Playwright;
namespace FinlyticFundamentals.Services;
public interface IHtmlFallbackScraper
{
Task<ScrapedFundamentalsData?> ScrapeFallbackAsync(string isin, string ticker, CancellationToken cancellationToken = default);
}
/// <summary>
/// Fallback scraper using Playwright (headless Chromium) to scrape Yahoo Finance pages
/// for alternative ticker symbols (e.g., APC.SG) whose data is not available via the API.
/// Targets stable data-testid selectors from the rendered Yahoo Finance SPA.
/// </summary>
public class HtmlFallbackScraper : IHtmlFallbackScraper, IAsyncDisposable
{
private readonly ILogger<HtmlFallbackScraper> _logger;
private IPlaywright? _playwright;
private IBrowser? _browser;
private readonly SemaphoreSlim _browserLock = new(1, 1);
public HtmlFallbackScraper(ILogger<HtmlFallbackScraper> logger)
{
_logger = logger;
}
public async Task<ScrapedFundamentalsData?> ScrapeFallbackAsync(
string isin,
string ticker,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"[YahooFallbackScraper] Executing Playwright Fallback Scrape for ticker '{Ticker}' (ISIN: {Isin})...",
ticker, isin);
try
{
var browser = await GetOrInitBrowserAsync(cancellationToken);
var fundamentals = new AssetFundamentalsEntity
{
Isin = isin,
PrimaryTicker = ticker,
LastUpdatedAt = DateTime.UtcNow,
LastStaticUpdatedAt = DateTime.UtcNow
};
var tickerData = new TickerFundamentalsEntity
{
Ticker = ticker,
Isin = isin,
LastUpdatedAt = DateTime.UtcNow
};
fundamentals.CompanyName = ticker;
// Share context for both pages so we only have to accept cookies once
await using (var ctx = await browser.NewContextAsync(BuildContextOptions()))
{
// ── 1. Key Statistics Page ─────────────────────────────────────
var statsUrl = $"https://finance.yahoo.com/quote/{Uri.EscapeDataString(ticker)}/key-statistics/";
_logger.LogInformation("[YahooFallbackScraper] Navigating to stats page: {Url}", statsUrl);
var statsPage = await ctx.NewPageAsync();
try
{
await statsPage.GotoAsync(statsUrl, new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
Timeout = 45_000
});
await HandleConsentAsync(statsPage);
// Wait for the statistics section to be visible
await statsPage.WaitForSelectorAsync(
"section[data-testid='qsp-statistics'], section[data-testid='stats-highlight']",
new PageWaitForSelectorOptions { Timeout = 20_000 });
// --- Valuation Measures Table ---
var valuationRows = await statsPage.QuerySelectorAllAsync(
"section[data-testid='qsp-statistics'] table tbody tr");
foreach (var row in valuationRows)
{
var cells = await row.QuerySelectorAllAsync("td");
if (cells.Count < 2) continue;
var label = (await cells[0].InnerTextAsync()).Trim();
var value = (await cells[1].InnerTextAsync()).Trim();
if (string.IsNullOrWhiteSpace(value) || value == "N/A" || value == "--") continue;
switch (NormalizeLabel(label))
{
case "market cap": tickerData.MarketCapitalization = ParseSuffixNumber(value) ?? 0m; break;
case "enterprise value": tickerData.EnterpriseValue = ParseSuffixNumber(value) ?? 0m; break;
case "trailing p/e": tickerData.PeRatioTrailing = ParseDecimal(value); break;
case "forward p/e": tickerData.PeRatioForward = ParseDecimal(value); break;
case "peg ratio (5yr expected)": tickerData.PegRatio = ParseDecimal(value); break;
case "price/sales": tickerData.PsRatio = ParseDecimal(value); break;
case "price/book": tickerData.PbRatio = ParseDecimal(value); break;
case "enterprise value/revenue": tickerData.EvToRevenue = ParseDecimal(value); break;
case "enterprise value/ebitda": tickerData.EvToEbitda = ParseDecimal(value); break;
}
}
// --- Financial Highlight Cards ---
var highlightRows = await statsPage.QuerySelectorAllAsync(
"div[data-testid='stats-highlight'] section[data-testid='card-container'] table tr");
foreach (var row in highlightRows)
{
var cells = await row.QuerySelectorAllAsync("td");
if (cells.Count < 2) continue;
var label = (await cells[0].InnerTextAsync()).Trim();
var value = (await cells[1].InnerTextAsync()).Trim();
if (string.IsNullOrWhiteSpace(value) || value == "N/A" || value == "--") continue;
switch (NormalizeLabel(label))
{
case "profit margin": tickerData.NetProfitMargin = ParsePercent(value); break;
case "operating margin": tickerData.OperatingMargin = ParsePercent(value); break;
case "return on assets": tickerData.ReturnOnAssets = ParsePercent(value); break;
case "return on equity": tickerData.ReturnOnEquity = ParsePercent(value); break;
case "current ratio": tickerData.CurrentRatio = ParseDecimal(value); break;
case "quick ratio": tickerData.QuickRatio = ParseDecimal(value); break;
case "total debt/equity": tickerData.DebtToEquity = ParseDecimal(value); break;
case "52 week high": tickerData.FiftyTwoWeekHigh = ParseDecimal(value) ?? 0m; break;
case "52 week low": tickerData.FiftyTwoWeekLow = ParseDecimal(value) ?? 0m; break;
case "forward annual dividend yield":
case "trailing annual dividend yield":
tickerData.DividendYield ??= ParsePercent(value); break;
case "payout ratio": tickerData.PayoutRatio = ParsePercent(value); break;
}
}
_logger.LogInformation(
"[YahooFallbackScraper] Stats parsed for '{Ticker}': MarketCap={MarketCap}, EV={EV}, TrailingPE={PE}, ForwardPE={FPE}",
ticker, tickerData.MarketCapitalization, tickerData.EnterpriseValue,
tickerData.PeRatioTrailing, tickerData.PeRatioForward);
}
catch (TimeoutException tex)
{
_logger.LogWarning(tex,
"[YahooFallbackScraper] Timeout waiting for stats page selectors for ticker '{Ticker}'. Page may not have loaded.", ticker);
}
finally
{
await statsPage.CloseAsync();
}
}
// ── 2. Guard: no meaningful data ──────────────────────────────
if (tickerData.MarketCapitalization == 0)
{
_logger.LogWarning(
"[YahooFallbackScraper] Playwright scrape for '{Ticker}' produced no meaningful data (MarketCap=0). Returning NULL.",
ticker);
return null;
}
_logger.LogInformation(
"[YahooFallbackScraper] Playwright Fallback Scrape complete for '{Ticker}'. MarketCap={MarketCap}",
ticker, tickerData.MarketCapitalization);
return new ScrapedFundamentalsData(
fundamentals,
tickerData,
new List<CompanyExecutiveEntity>(),
new List<FinancialStatementEntity>(),
new List<ForwardEstimateEntity>()
);
}
catch (Exception ex)
{
_logger.LogError(ex,
"[YahooFallbackScraper] Error during Playwright Fallback Scrape for ticker '{Ticker}' (ISIN: {Isin})",
ticker, isin);
return null;
}
}
// ── Browser Lifecycle ──────────────────────────────────────────────────
private async Task<IBrowser> GetOrInitBrowserAsync(CancellationToken cancellationToken = default)
{
if (_browser != null) return _browser;
await _browserLock.WaitAsync(cancellationToken);
try
{
if (_browser != null) return _browser;
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
{
Headless = true,
Args = new[] { "--no-sandbox", "--disable-dev-shm-usage" }
});
_logger.LogInformation("[YahooFallbackScraper] Playwright Chromium browser initialized.");
return _browser;
}
finally
{
_browserLock.Release();
}
}
private static BrowserNewContextOptions BuildContextOptions() => new()
{
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
ViewportSize = new ViewportSize { Width = 1280, Height = 900 },
Locale = "en-US",
ExtraHTTPHeaders = new Dictionary<string, string>
{
["Accept-Language"] = "en-US,en;q=0.9"
}
};
public async ValueTask DisposeAsync()
{
if (_browser != null) await _browser.CloseAsync();
_playwright?.Dispose();
}
// ── Parse Helpers ─────────────────────────────────────────────────────
private async Task HandleConsentAsync(IPage page)
{
try
{
if (page.Url.Contains("consent.yahoo.com"))
{
_logger.LogInformation("[YahooFallbackScraper] Redirected to consent page. Attempting to accept cookies...");
var agreeBtn = page.Locator("button[name='agree'], button.accept-all, button[value='agree']");
if (await agreeBtn.CountAsync() > 0)
{
await agreeBtn.First.ClickAsync();
await page.WaitForNavigationAsync(new PageWaitForNavigationOptions { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 20_000 });
_logger.LogInformation("[YahooFallbackScraper] Cookie consent accepted. Navigated back to: {Url}", page.Url);
}
else
{
_logger.LogWarning("[YahooFallbackScraper] On consent page but could not find the 'agree' button.");
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "[YahooFallbackScraper] Error while handling cookie consent.");
}
}
/// <summary>Lowercases, strips trailing digits, common Yahoo qualifiers like (ttm), (mrq), and extra spaces.</summary>
private static string NormalizeLabel(string label)
{
label = label.ToLowerInvariant();
label = Regex.Replace(label, @"\s*\d+\s*$", ""); // trailing superscripts
label = label.Replace("(ttm)", "").Replace("(mrq)", "").Replace("(fye)", ""); // remove date qualifiers
label = Regex.Replace(label, @"\s+", " "); // collapse spaces
return label.Trim();
}
private static decimal? ParseDecimal(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input == "N/A" || input == "--" || input == "-") return null;
input = Regex.Replace(input, @"[^\d.-]", "");
return decimal.TryParse(input, NumberStyles.Any, CultureInfo.InvariantCulture, out var val) ? val : null;
}
private static decimal? ParsePercent(string? input)
{
var val = ParseDecimal(input);
if (!val.HasValue) return null;
return val.Value > 1m ? val.Value / 100m : val.Value;
}
private static decimal? ParseSuffixNumber(string? input)
{
if (string.IsNullOrWhiteSpace(input) || input == "N/A" || input == "--" || input == "-") return null;
input = input.Trim();
decimal multiplier = input.EndsWith("T", StringComparison.OrdinalIgnoreCase) ? 1_000_000_000_000m
: input.EndsWith("B", StringComparison.OrdinalIgnoreCase) ? 1_000_000_000m
: input.EndsWith("M", StringComparison.OrdinalIgnoreCase) ? 1_000_000m
: input.EndsWith("K", StringComparison.OrdinalIgnoreCase) ? 1_000m
: 1m;
var numPart = Regex.Replace(input, @"[^\d.-]", "");
var val = ParseDecimal(numPart);
return val.HasValue ? val.Value * multiplier : null;
}
}
@@ -2,12 +2,15 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticCore.Clients;
using FinlyticCore.Dtos.Fundamentals;
using FinlyticCore.Dtos.Yahoo;
using FinlyticCore.Services;
using FinlyticCore.Services.Yahoo;
using FinlyticFundamentals.Entities;
using FinlyticFundamentals.Database;
using FinlyticFundamentals.Util;
using Microsoft.Extensions.Logging;
namespace FinlyticFundamentals.Services;
@@ -15,477 +18,242 @@ namespace FinlyticFundamentals.Services;
public interface IYahooFinanceScraper
{
/// <summary>
/// Resolves ticker from ISIN.
/// Ermittelt den primären Börsenticker zu einer ISIN anhand von Börsenplatz-Prioritäten.
/// </summary>
Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
Task<TickerInfoDto?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default);
/// <summary>
/// Resolves all tickers from ISIN.
/// Ermittelt alle gefundenen Börsenticker zu einer ISIN, sortiert nach Priorität.
/// </summary>
Task<List<string>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default);
Task<List<TickerInfoDto>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default);
/// <summary>
/// Scrapes fundamentals.
/// Ruft Fundamental- und Unternehmensdaten primär über die Yahoo Finance API ab
/// und fällt automatisch auf den Playwright HTML Scraper zurück, falls keine Daten vorhanden sind.
/// </summary>
Task<ScrapedFundamentalsData?> ScrapeFundamentalsAsync(string isin, string ticker,
/// <param name="symbolOrIsin">Das Tickersymbol (z. B. "MSFT") oder die ISIN.</param>
/// <param name="forceHtmlScrape">Erzwingt sofortiges HTML-Scraping ohne API-Vorprüfung.</param>
/// <param name="cancellationToken">Abbruch-Token.</param>
/// <returns>Das aggregierte <see cref="YahooQuoteSummaryModulesDto"/> oder <c>null</c>.</returns>
Task<YahooQuoteSummaryModulesDto?> GetQuoteSummaryModulesAsync(
string symbolOrIsin,
bool forceHtmlScrape = false,
CancellationToken cancellationToken = default);
}
public record ScrapedFundamentalsData(
AssetFundamentalsEntity Fundamentals,
TickerFundamentalsEntity TickerData,
List<CompanyExecutiveEntity> Executives,
List<FinancialStatementEntity> Statements,
List<ForwardEstimateEntity> Estimates
);
public class YahooFinanceScraper : IYahooFinanceScraper
{
private readonly HttpClient _httpClient;
private readonly YahooFinanceClient _yahooClient;
private readonly ILogger<YahooFinanceScraper> _logger;
private readonly YahooFinanceClient _yahooApiClient;
private readonly IYahooFinanceHtmlClient _htmlScraperClient;
private readonly IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> _finlyticLogger;
public YahooFinanceScraper(HttpClient httpClient, YahooFinanceClient yahooClient,
ILogger<YahooFinanceScraper> logger)
public YahooFinanceScraper(
YahooFinanceClient yahooApiClient,
IYahooFinanceHtmlClient htmlScraperClient,
IFinlyticLogger<YahooFinanceScraper, FundamentalsDbContext> finlyticLogger)
{
_httpClient = httpClient;
_yahooClient = yahooClient;
_logger = logger;
_yahooApiClient = yahooApiClient;
_htmlScraperClient = htmlScraperClient;
_finlyticLogger = finlyticLogger;
}
/// <inheritdoc />
public async Task<string?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
public async Task<TickerInfoDto?> ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default)
{
var tickers = await ResolveAllTickersFromIsinAsync(isin, cancellationToken);
return tickers.FirstOrDefault();
}
/// <inheritdoc />
public async Task<List<string>> ResolveAllTickersFromIsinAsync(string isin,
CancellationToken cancellationToken = default)
public async Task<List<TickerInfoDto>> ResolveAllTickersFromIsinAsync(string isin, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return new();
if (string.IsNullOrWhiteSpace(isin)) return new List<TickerInfoDto>();
var symbols = new List<(string symbol, int priority)>();
var cleanIsin = isin.Trim().ToUpperInvariant();
var symbols = new List<(string symbol, string exchange, int priority)>();
var primary = await _yahooClient.SearchAsync(isin, quotesCount: 20, cancellationToken: cancellationToken);
var quotes = primary?.Quotes ?? new();
try
{
// 1. Suche via ISIN
var primary = await _yahooApiClient.SearchAsync(cleanIsin, quotesCount: 20, cancellationToken: cancellationToken);
var quotes = primary?.Quotes ?? new List<YahooSearchQuoteDto>();
foreach (var q in quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)))
{
symbols.Add((q.Symbol, GetExchangePriority(q.Symbol, isin)));
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin)));
}
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())
// 2. Falls Ticker gefunden, aber mit Unternehmensname noch mehr Exchangeticker auffindbar sind
if (quotes.Count > 0)
{
var companyName = quotes[0].LongName ?? quotes[0].ShortName;
if (!string.IsNullOrWhiteSpace(companyName))
{
var secondary = await _yahooApiClient.SearchAsync(companyName, quotesCount: 20, cancellationToken: cancellationToken);
foreach (var q in secondary?.Quotes ?? new List<YahooSearchQuoteDto>())
{
if (!string.IsNullOrEmpty(q.Symbol) &&
!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase)))
{
symbols.Add((q.Symbol, GetExchangePriority(q.Symbol, isin)));
}
}
// 3. Sortieren und zurückgeben
return symbols
.OrderBy(s => s.priority)
.Select(s => s.symbol)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(20)
.ToList();
}
private int GetExchangePriority(string symbol, string isin)
{
if (!string.IsNullOrEmpty(isin) && isin.StartsWith("US", StringComparison.OrdinalIgnoreCase))
{
if (!symbol.Contains('.')) return 1;
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 2;
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase) ||
symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase)) return 3;
return 4;
}
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase))
{
return 1; // XETRA
}
else if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase))
{
return 2; // Frankfurt
}
else if (symbol.EndsWith(".TG", StringComparison.OrdinalIgnoreCase))
{
return 3; // Gettex
}
else if (symbol.EndsWith(".MU", StringComparison.OrdinalIgnoreCase) ||
symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase) ||
symbol.EndsWith(".BE", StringComparison.OrdinalIgnoreCase) ||
symbol.EndsWith(".DU", StringComparison.OrdinalIgnoreCase) ||
symbol.EndsWith(".HM", StringComparison.OrdinalIgnoreCase))
{
return 4; // Other German regional exchanges
}
else if (symbol.Contains('.') && !symbol.EndsWith(".OB", StringComparison.OrdinalIgnoreCase) &&
!symbol.EndsWith(".PK", StringComparison.OrdinalIgnoreCase))
{
return 5; // Domestic/home non-US exchanges
}
else
{
return 6; // Other
}
}
/// <inheritdoc />
public async Task<ScrapedFundamentalsData?> ScrapeFundamentalsAsync(string isin, string ticker,
CancellationToken cancellationToken = default)
{
_logger.LogInformation(
"[{Channel}] Fetching fundamental data for Ticker {Ticker} (ISIN: {Isin}) using YahooFinanceClient...",
"FundamentalsChannel", ticker, isin);
try
{
var summaryResponse = await _yahooClient.GetFullQuoteSummaryAsync(ticker, cancellationToken);
if (summaryResponse?.QuoteSummary?.Result == null || summaryResponse.QuoteSummary.Result.Count == 0)
{
_logger.LogWarning("[{Channel}] YahooFinanceClient returned no result for ticker {Ticker}",
"FundamentalsChannel", ticker);
return null;
}
var root = summaryResponse.QuoteSummary.Result[0];
var assetProfile = root.AssetProfile;
var financialData = root.FinancialData;
var defaultKeyStatistics = root.DefaultKeyStatistics;
var summaryDetail = root.SummaryDetail;
var calendarEvents = root.CalendarEvents;
// Instantiate entities
var fundamentals = new AssetFundamentalsEntity
{
Isin = isin,
PrimaryTicker = ticker,
LastUpdatedAt = DateTime.UtcNow,
LastStaticUpdatedAt = DateTime.UtcNow
};
var tickerData = new TickerFundamentalsEntity
{
Ticker = ticker,
Isin = isin,
LastUpdatedAt = DateTime.UtcNow
};
// 1. Static Profile Data
if (assetProfile != null)
{
fundamentals.BusinessSummary = assetProfile.LongBusinessSummary;
fundamentals.Sector = assetProfile.Sector;
fundamentals.Industry = assetProfile.Industry;
fundamentals.Country = assetProfile.Country;
fundamentals.Employees = assetProfile.FullTimeEmployees;
}
// Company Name
fundamentals.CompanyName = ticker;
// 2. Exchange & Trading Currency for Ticker
if (financialData != null && !string.IsNullOrWhiteSpace(financialData.FinancialCurrency))
{
tickerData.TradingCurrency = financialData.FinancialCurrency;
}
if (summaryDetail != null && !string.IsNullOrWhiteSpace(summaryDetail.Currency))
{
tickerData.TradingCurrency = summaryDetail.Currency;
}
// 3. Dynamic Price & Valuation Data
if (financialData != null)
{
tickerData.CurrentPrice = financialData.CurrentPrice?.DecimalValue ?? 0;
tickerData.GrossMargin = financialData.GrossMargins?.DecimalValue;
tickerData.OperatingMargin = financialData.OperatingMargins?.DecimalValue;
tickerData.NetProfitMargin = financialData.ProfitMargins?.DecimalValue;
tickerData.ReturnOnEquity = financialData.ReturnOnEquity?.DecimalValue;
tickerData.ReturnOnAssets = financialData.ReturnOnAssets?.DecimalValue;
tickerData.CurrentRatio = financialData.CurrentRatio?.DecimalValue;
tickerData.QuickRatio = financialData.QuickRatio?.DecimalValue;
tickerData.DebtToEquity = financialData.DebtToEquity?.DecimalValue;
// Targets on Company Level
fundamentals.PriceTargetLow = financialData.TargetLowPrice?.DecimalValue;
fundamentals.PriceTargetHigh = financialData.TargetHighPrice?.DecimalValue;
fundamentals.PriceTargetMedian = financialData.TargetMedianPrice?.DecimalValue;
fundamentals.PriceTargetMean = financialData.TargetMeanPrice?.DecimalValue;
}
if (summaryDetail != null)
{
if (tickerData.CurrentPrice == 0)
{
tickerData.CurrentPrice = summaryDetail.Open?.DecimalValue ??
summaryDetail.PreviousClose?.DecimalValue ?? 0;
}
tickerData.FiftyTwoWeekHigh = summaryDetail.FiftyTwoWeekHigh?.DecimalValue ?? 0;
tickerData.FiftyTwoWeekLow = summaryDetail.FiftyTwoWeekLow?.DecimalValue ?? 0;
}
var mCap = defaultKeyStatistics?.SharesOutstanding?.DecimalValue;
mCap ??= summaryDetail?.MarketCap?.DecimalValue;
tickerData.MarketCapitalization = mCap ?? 0;
var ev = defaultKeyStatistics?.EnterpriseValue?.DecimalValue;
tickerData.EnterpriseValue = ev ?? 0;
tickerData.PeRatioTrailing = defaultKeyStatistics?.TrailingEps?.DecimalValue ??
summaryDetail?.TrailingPE?.DecimalValue;
tickerData.PeRatioForward =
defaultKeyStatistics?.ForwardPE?.DecimalValue ?? summaryDetail?.ForwardPE?.DecimalValue;
if (defaultKeyStatistics != null)
{
tickerData.PegRatio = defaultKeyStatistics.PegRatio?.DecimalValue;
tickerData.PbRatio = defaultKeyStatistics.PriceToBook?.DecimalValue;
fundamentals.ShortRatio = defaultKeyStatistics.ShortRatio?.DecimalValue;
fundamentals.ShortPercentOfFloat = defaultKeyStatistics.ShortPercentOfFloat?.DecimalValue;
fundamentals.PercentHeldByInstitutions = defaultKeyStatistics.HeldPercentInstitutions?.DecimalValue;
fundamentals.PercentHeldByInsiders = defaultKeyStatistics.HeldPercentInsiders?.DecimalValue;
}
tickerData.PsRatio = defaultKeyStatistics?.PriceToSalesTrailing12Months?.DecimalValue ??
summaryDetail?.PriceToSalesTrailing12Months?.DecimalValue;
tickerData.EvToEbitda = defaultKeyStatistics?.EnterpriseToEbitda?.DecimalValue;
tickerData.EvToRevenue = defaultKeyStatistics?.EnterpriseToRevenue?.DecimalValue;
tickerData.DividendYield = summaryDetail?.DividendYield?.DecimalValue;
tickerData.PayoutRatio = summaryDetail?.PayoutRatio?.DecimalValue;
if (financialData != null)
{
if (!string.IsNullOrWhiteSpace(financialData.RecommendationKey) &&
!financialData.RecommendationKey.Equals("none", StringComparison.OrdinalIgnoreCase))
{
fundamentals.ConsensusRating = financialData.RecommendationKey;
}
else if (financialData.RecommendationMean != null && financialData.RecommendationMean.Raw.HasValue)
{
double mean = financialData.RecommendationMean.Raw.Value;
fundamentals.ConsensusRating = mean <= 1.8
? "strong_buy"
: (mean <= 2.5 ? "buy" : (mean <= 3.5 ? "hold" : (mean <= 4.2 ? "sell" : "strong_sell")));
}
}
// 4. Calendar Events Data
if (calendarEvents != null)
{
if (calendarEvents.ExDividendDate?.Raw.HasValue == true)
{
long seconds = (long)calendarEvents.ExDividendDate.Raw.Value;
if (seconds > 0)
fundamentals.ExDividendDate = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
}
if (calendarEvents.Earnings?.EarningsDate != null && calendarEvents.Earnings.EarningsDate.Count > 0)
{
var firstDate = calendarEvents.Earnings.EarningsDate[0];
if (firstDate.Raw.HasValue && firstDate.Raw.Value > 0)
{
fundamentals.NextEarningsDate =
DateTimeOffset.FromUnixTimeSeconds((long)firstDate.Raw.Value).UtcDateTime;
symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin)));
}
}
}
if (!fundamentals.ExDividendDate.HasValue && summaryDetail?.ExDividendDate?.Raw.HasValue == true)
{
long seconds = (long)summaryDetail.ExDividendDate.Raw.Value;
if (seconds > 0) fundamentals.ExDividendDate = DateTimeOffset.FromUnixTimeSeconds(seconds).UtcDateTime;
}
tickerData.ExDividendDate = fundamentals.ExDividendDate;
// 5. Executives List
var executives = new List<CompanyExecutiveEntity>();
if (assetProfile?.CompanyOfficers != null)
{
foreach (var officer in assetProfile.CompanyOfficers)
{
var exec = new CompanyExecutiveEntity
{
Isin = isin,
Name = !string.IsNullOrWhiteSpace(officer.Name) ? officer.Name : "Unknown",
Title = !string.IsNullOrWhiteSpace(officer.Title) ? officer.Title : "Officer",
Age = officer.Age,
Compensation = officer.TotalPay?.DecimalValue
};
executives.Add(exec);
}
}
// 6. Financial Statements
var statements = new List<FinancialStatementEntity>();
// A. Annual Statements
if (root.IncomeStatementHistory?.IncomeStatementHistory != null)
{
foreach (var item in root.IncomeStatementHistory.IncomeStatementHistory)
{
MapIncomeStatement(item, isin, "Annual", statements);
}
}
if (root.BalanceSheetHistory?.BalanceSheetStatements != null)
{
foreach (var item in root.BalanceSheetHistory.BalanceSheetStatements)
{
MapBalanceSheet(item, isin, "Annual", statements);
}
}
if (root.CashflowStatementHistory?.CashflowStatements != null)
{
foreach (var item in root.CashflowStatementHistory.CashflowStatements)
{
MapCashflowStatement(item, isin, "Annual", statements);
}
}
// B. Quarterly Statements
if (root.IncomeStatementHistoryQuarterly?.IncomeStatementHistory != null)
{
foreach (var item in root.IncomeStatementHistoryQuarterly.IncomeStatementHistory)
{
MapIncomeStatement(item, isin, "Quarterly", statements);
}
}
if (root.BalanceSheetHistoryQuarterly?.BalanceSheetStatements != null)
{
foreach (var item in root.BalanceSheetHistoryQuarterly.BalanceSheetStatements)
{
MapBalanceSheet(item, isin, "Quarterly", statements);
}
}
if (root.CashflowStatementHistoryQuarterly?.CashflowStatements != null)
{
foreach (var item in root.CashflowStatementHistoryQuarterly.CashflowStatements)
{
MapCashflowStatement(item, isin, "Quarterly", statements);
}
}
// 7. Forward Estimates
var estimates = new List<ForwardEstimateEntity>();
return new ScrapedFundamentalsData(fundamentals, tickerData, executives, statements, estimates);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] Failed to scrape fundamentals for ISIN {Isin} (Ticker: {Ticker})",
"FundamentalsChannel", isin, ticker);
return null;
await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel, ex,
"[YahooFinanceScraper] Fehler beim Auflösen des Tickers für ISIN '{Isin}'", cleanIsin);
}
return symbols
.OrderBy(s => s.priority)
.Select(s => new TickerInfoDto(){Ticker = s.symbol, Exchange = s.exchange})
.ToList();
}
/// <inheritdoc />
public async Task<YahooQuoteSummaryModulesDto?> GetQuoteSummaryModulesAsync(
string symbolOrIsin,
bool forceHtmlScrape = false,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(symbolOrIsin)) return null;
var symbol = symbolOrIsin.Trim().ToUpperInvariant();
// Falls eine ISIN übergeben wurde, zuerst Ticker auflösen
if (IsIsin(symbol))
{
var resolvedTicker = await ResolveTickerFromIsinAsync(symbol, cancellationToken);
if (resolvedTicker != null)
{
symbol = resolvedTicker.Ticker;
}
}
private static void MapIncomeStatement(YahooIncomeStatementDto item, string isin, string periodType,
List<FinancialStatementEntity> statements)
YahooQuoteSummaryModulesDto? apiModules = null;
// -------------------------------------------------------------
// 1. PRIMÄRE DATENQUELLE: Yahoo Finance API (Cookie/Crumb)
// -------------------------------------------------------------
if (!forceHtmlScrape)
{
if (item.EndDate?.Raw.HasValue != true) return;
var endDate = DateTimeOffset.FromUnixTimeSeconds((long)item.EndDate.Raw.Value).UtcDateTime.Date;
try
{
await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel,
"[YahooFinanceScraper] Starte primären API-Abruf für '{Symbol}'...", symbol);
var statement = GetOrCreateStatement(statements, isin, periodType, endDate);
var apiResponse = await _yahooApiClient.GetFullQuoteSummaryAsync(symbol, cancellationToken);
apiModules = apiResponse?.QuoteSummary?.Result?.FirstOrDefault();
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;
if (apiModules != null && HasSufficientData(apiModules))
{
await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel,
"[YahooFinanceScraper] Erfolgreich Daten über API bezogen für '{Symbol}'.", symbol);
return apiModules;
}
private static void MapBalanceSheet(YahooBalanceSheetStatementDto item, string isin, string periodType,
List<FinancialStatementEntity> statements)
{
if (item.EndDate?.Raw.HasValue != true) return;
var endDate = DateTimeOffset.FromUnixTimeSeconds((long)item.EndDate.Raw.Value).UtcDateTime.Date;
var statement = GetOrCreateStatement(statements, isin, periodType, endDate);
if (item.Cash?.Raw.HasValue == true) statement.CashAndCashEquivalents = item.Cash.DecimalValue;
if (item.NetReceivables?.Raw.HasValue == true) statement.AccountsReceivable = item.NetReceivables.DecimalValue;
if (item.Inventory?.Raw.HasValue == true) statement.Inventory = item.Inventory.DecimalValue;
if (item.TotalCurrentAssets?.Raw.HasValue == true)
statement.TotalCurrentAssets = item.TotalCurrentAssets.DecimalValue;
if (item.TotalCurrentLiabilities?.Raw.HasValue == true)
statement.CurrentLiabilities = item.TotalCurrentLiabilities.DecimalValue;
if (item.LongTermDebt?.Raw.HasValue == true) statement.LongTermDebt = item.LongTermDebt.DecimalValue;
if (item.TotalLiab?.Raw.HasValue == true) statement.TotalLiabilities = item.TotalLiab.DecimalValue;
if (item.TotalStockholderEquity?.Raw.HasValue == true)
statement.TotalStockholdersEquity = item.TotalStockholderEquity.DecimalValue;
await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel,
"[YahooFinanceScraper] API lieferte unvollständige Daten für '{Symbol}'. Initiiere Fallback...", symbol);
}
private static void MapCashflowStatement(YahooCashflowStatementDto item, string isin, string periodType,
List<FinancialStatementEntity> statements)
catch (Exception ex)
{
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);
await _finlyticLogger.LogWarningAsync(SettingKeys.YahooClientChannel, ex,
"[YahooFinanceScraper] API-Abruf fehlgeschlagen für '{Symbol}'. Wechsle zu Scraper...", symbol);
}
}
private static FinancialStatementEntity GetOrCreateStatement(List<FinancialStatementEntity> statements, string isin,
string periodType, DateTime endDate)
// -------------------------------------------------------------
// 2. FALLBACK DATENQUELLE: Playwright HTML Scraper
// -------------------------------------------------------------
YahooQuoteSummaryModulesDto? htmlModules = null;
try
{
var existing = statements.FirstOrDefault(s => s.PeriodType == periodType && s.EndDate.Date == endDate.Date);
if (existing == null)
await _finlyticLogger.LogInfoAsync(SettingKeys.YahooClientChannel,
"[YahooFinanceScraper] Starte HTML-Scraper Fallback für '{Symbol}'...", symbol);
htmlModules = await _htmlScraperClient.ScrapeQuoteSummaryModulesAsync(symbol, cancellationToken);
}
catch (Exception ex)
{
existing = new FinancialStatementEntity
{
Isin = isin,
PeriodType = periodType,
EndDate = endDate.Date
};
statements.Add(existing);
await _finlyticLogger.LogErrorAsync(SettingKeys.YahooClientChannel, ex,
"[YahooFinanceScraper] HTML-Scraper Fallback ebenfalls fehlgeschlagen für '{Symbol}'.", symbol);
}
return existing;
// -------------------------------------------------------------
// 3. Zusammenführen (Merge API & HTML Fallback)
// -------------------------------------------------------------
if (apiModules == null) return htmlModules;
if (htmlModules == null) return apiModules;
return MergeModules(apiModules, htmlModules);
}
/// <summary>
/// Prüft, ob das Modul-DTO die wesentlichen Fundamentalblöcke enthält.
/// </summary>
private static bool HasSufficientData(YahooQuoteSummaryModulesDto modules)
{
return modules.SummaryDetail != null ||
modules.FinancialData != null ||
modules.DefaultKeyStatistics != null;
}
/// <summary>
/// Führt API- und Scraper-Daten zusammen, damit Lücken in API-Responses geschlossen werden.
/// </summary>
private static YahooQuoteSummaryModulesDto MergeModules(
YahooQuoteSummaryModulesDto primary,
YahooQuoteSummaryModulesDto secondary)
{
return new YahooQuoteSummaryModulesDto(
QuoteType: primary.QuoteType ?? secondary.QuoteType,
AssetProfile: primary.AssetProfile ?? secondary.AssetProfile,
FinancialData: primary.FinancialData ?? secondary.FinancialData,
DefaultKeyStatistics: primary.DefaultKeyStatistics ?? secondary.DefaultKeyStatistics,
SummaryDetail: primary.SummaryDetail ?? secondary.SummaryDetail,
IncomeStatementHistory: primary.IncomeStatementHistory ?? secondary.IncomeStatementHistory,
IncomeStatementHistoryQuarterly: primary.IncomeStatementHistoryQuarterly ?? secondary.IncomeStatementHistoryQuarterly,
BalanceSheetHistory: primary.BalanceSheetHistory ?? secondary.BalanceSheetHistory,
BalanceSheetHistoryQuarterly: primary.BalanceSheetHistoryQuarterly ?? secondary.BalanceSheetHistoryQuarterly,
CashflowStatementHistory: primary.CashflowStatementHistory ?? secondary.CashflowStatementHistory,
CashflowStatementHistoryQuarterly: primary.CashflowStatementHistoryQuarterly ?? secondary.CashflowStatementHistoryQuarterly,
CalendarEvents: primary.CalendarEvents ?? secondary.CalendarEvents
);
}
private static bool IsIsin(string value)
{
return value.Length == 12 &&
char.IsLetter(value[0]) &&
char.IsLetter(value[1]) &&
value.All(char.IsLetterOrDigit);
}
private static int GetExchangePriority(string symbol, string isin)
{
bool isGermanIsin = isin.StartsWith("DE", StringComparison.OrdinalIgnoreCase);
if (isGermanIsin)
{
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 1; // Xetra
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) return 2; // Frankfurt
if (symbol.EndsWith(".STU", StringComparison.OrdinalIgnoreCase)) return 3; // Stuttgart
if (symbol.EndsWith(".SG", StringComparison.OrdinalIgnoreCase)) return 4; // Stuttgart (alt)
if (symbol.EndsWith(".HM", StringComparison.OrdinalIgnoreCase)) return 5; // Hamburg
if (!symbol.Contains('.')) return 6; // US Primary
}
else
{
if (!symbol.Contains('.')) return 1; // US Primary (NASDAQ, NYSE)
if (symbol.EndsWith(".DE", StringComparison.OrdinalIgnoreCase)) return 2; // Xetra
if (symbol.EndsWith(".F", StringComparison.OrdinalIgnoreCase)) return 3; // Frankfurt
if (symbol.EndsWith(".L", StringComparison.OrdinalIgnoreCase)) return 4; // London
if (symbol.EndsWith(".PA", StringComparison.OrdinalIgnoreCase)) return 5; // Paris
}
return 10;
}
}
@@ -1,11 +1,12 @@
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.Services;
using FinlyticCore.Util;
using FinlyticFundamentals.Database;
using FinlyticFundamentals.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -18,18 +19,15 @@ public class FundamentalsMqttClient : ManagedMqttClient, IHostedService
{
private readonly ILogger<FundamentalsMqttClient> _logger;
private readonly IConfiguration _configuration;
private readonly IFundamentalsDbService _dbService;
private readonly IServiceScopeFactory _scopeFactory;
public FundamentalsMqttClient(
ILogger<FundamentalsMqttClient> logger,
IConfiguration configuration,
IFundamentalsDbService dbService,
IServiceScopeFactory scopeFactory) : base(logger)
{
_logger = logger;
_configuration = configuration;
_dbService = dbService;
_scopeFactory = scopeFactory;
}
@@ -43,7 +41,7 @@ public class FundamentalsMqttClient : ManagedMqttClient, IHostedService
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);
_logger.LogInformation("[{Channel}] [MQTT_Client] Starting Fundamentals MQTT client. Host: {Host}, ClientId: {ClientId}", "MqttChannel", config.Host, config.ClientId);
await ConnectAsync(config);
}
@@ -51,19 +49,18 @@ public class FundamentalsMqttClient : ManagedMqttClient, IHostedService
/// <inheritdoc />
public async Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("[{Channel}] Stopping Fundamentals MQTT client.", "FundamentalsChannel");
_logger.LogInformation("[{Channel}] [MQTT_Client] Stopping Fundamentals MQTT client.", "MqttChannel");
await DisconnectAsync();
}
/// <inheritdoc />
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("[{Channel}] Fundamentals MQTT client connected. Subscribing to RPC request topics...", "FundamentalsChannel");
_logger.LogInformation("[{Channel}] [MQTT_Client] Connected. Subscribing to RPC request topics...", "MqttChannel");
await SubscribeAsync("services/request/fundamentals_Get/#");
await SubscribeAsync("services/request/events_GetAll/#");
await SubscribeAsync("services/request/events_GetByMonth/#");
await SubscribeAsync("services/request/health_Ping/#");
await SubscribeAsync("services/config/updated/#");
}
/// <inheritdoc />
@@ -71,23 +68,11 @@ public class FundamentalsMqttClient : ManagedMqttClient, IHostedService
{
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.StartsWith("services/request/fundamentals_Get", StringComparison.OrdinalIgnoreCase))
{
await OnFundamentalsGetAsync(payload, correlationId);
@@ -106,14 +91,15 @@ public class FundamentalsMqttClient : ManagedMqttClient, IHostedService
}
}
/// <summary>
/// Handles fundamentals_Get RPC requests using source-generated DTO deserialization.
/// </summary>
private async Task OnFundamentalsGetAsync(string payload, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<FundamentalsMqttClient, FundamentalsDbContext>>();
var dbService = scope.ServiceProvider.GetRequiredService<IFundamentalsDbService>();
if (string.IsNullOrWhiteSpace(payload))
{
_logger.LogWarning("[{Channel}] [FundamentalsMqttClient] Received empty payload for fundamentals_Get request.", "FundamentalsChannel");
await finlyticLogger.LogWarningAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Received empty payload for fundamentals_Get request.");
return;
}
@@ -122,108 +108,83 @@ public class FundamentalsMqttClient : ManagedMqttClient, IHostedService
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");
await finlyticLogger.LogWarningAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Request missing mandatory ISIN parameter in payload.");
return;
}
_logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Processing RPC fundamentals_Get for ISIN '{Isin}' (forceRefresh={ForceRefresh}) [CorrelationId: {CorrelationId}]",
"FundamentalsChannel", request.Isin, request.ForceRefresh.ToString(), correlationId);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Processing RPC fundamentals_Get for ISIN '{Isin}' (forceRefresh={ForceRefresh}) [CorrelationId: {CorrelationId}]",
request.Isin, request.ForceRefresh.ToString(), correlationId);
var fundamentals = await _dbService.GetFundamentalsAsync(request.Isin, request.Ticker, request.ForceRefresh);
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 finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Publishing RPC fundamentals response to '{ResponseTopic}'", responseTopic);
await PublishAsync(responseTopic, fundamentals);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] [FundamentalsMqttClient] Failed to process fundamentals_Get request.", "FundamentalsChannel");
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticFundamentals] [MQTT_Client] Failed to process fundamentals_Get request.");
}
}
/// <summary>
/// Handles events_GetAll RPC requests.
/// </summary>
private async Task OnEventsGetAllAsync(string correlationId)
{
_logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Processing RPC events_GetAll request [CorrelationId: {CorrelationId}]", "FundamentalsChannel", correlationId);
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<FundamentalsMqttClient, FundamentalsDbContext>>();
var dbService = scope.ServiceProvider.GetRequiredService<IFundamentalsDbService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Processing RPC events_GetAll request [CorrelationId: {CorrelationId}]", correlationId);
try
{
var events = await _dbService.GetAllEventsAsync();
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 finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Publishing events RPC response to '{ResponseTopic}'", responseTopic);
await PublishAsync(responseTopic, events);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] [FundamentalsMqttClient] Failed to process events_GetAll request.", "FundamentalsChannel");
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticFundamentals] [MQTT_Client] Failed to process events_GetAll request.");
}
}
/// <summary>
/// Handles events_GetByMonth RPC requests.
/// </summary>
private async Task OnEventsGetByMonthAsync(string payload, string correlationId)
{
if (string.IsNullOrWhiteSpace(payload)) return;
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<FundamentalsMqttClient, FundamentalsDbContext>>();
var dbService = scope.ServiceProvider.GetRequiredService<IFundamentalsDbService>();
try
{
var request = (GetEventsByMonthRequest?)JsonSerializer.Deserialize(payload, typeof(GetEventsByMonthRequest), FinlyticJsonSerializerContext.Default);
if (request == null) return;
_logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Processing RPC events_GetByMonth request for {Year}/{Month} [CorrelationId: {CorrelationId}]", "FundamentalsChannel", request.Year, request.Month, correlationId);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Processing RPC events_GetByMonth request for {Year}/{Month} [CorrelationId: {CorrelationId}]", request.Year, request.Month, correlationId);
var events = await _dbService.GetEventsByMonthAsync(request.Year, request.Month);
var events = await dbService.GetEventsByMonthAsync(request.Year, request.Month);
var responseTopic = $"services/response/events_GetByMonth/{correlationId}";
_logger.LogInformation("[{Channel}] [FundamentalsMqttClient] Publishing monthly events RPC response to '{ResponseTopic}'", "FundamentalsChannel", responseTopic);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticFundamentals] [MQTT_Client] Publishing monthly events RPC response to '{ResponseTopic}'", responseTopic);
await PublishAsync(responseTopic, events);
}
catch (Exception ex)
{
_logger.LogError(ex, "[{Channel}] [FundamentalsMqttClient] Failed to process events_GetByMonth request.", "FundamentalsChannel");
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticFundamentals] [MQTT_Client] Failed to process events_GetByMonth request.");
}
}
/// <summary>
/// Handles health_Ping RPC requests.
/// </summary>
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);
}
}
/// <summary>
/// Handles dynamic service config update events.
/// </summary>
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<string, string>?)JsonSerializer.Deserialize(settingsProp.GetRawText(), typeof(Dictionary<string, string>), FinlyticJsonSerializerContext.Default);
if (dict != null && dict.Count > 0)
{
using var scope = _scopeFactory.CreateScope();
var settingsDb = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
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");
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<FundamentalsMqttClient, FundamentalsDbContext>>();
var respTopic = $"services/response/health_Ping/{correlationId}";
await PublishAsync(respTopic, new ServiceHealthResponse("FinlyticFundamentals", "Online", DateTime.UtcNow, "Connected"));
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[FinlyticFundamentals] [Health_Ping] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
}
}
}
+13 -1
View File
@@ -1,6 +1,18 @@
namespace FinlyticFundamentals.Util;
using FinlyticCore.Models.Settings;
namespace FinlyticFundamentals.Util;
public class SettingKeys
{
// --- Logging Channels ---
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> FundamentalsChannel = new("Logging.Channel.Fundamentals", true);
public static readonly SettingKey<bool> HtmlScrapperChannel = new("Logging.Channel.HtmlScrapper", true);
public static readonly SettingKey<bool> YahooClientChannel = new("Logging.Channel.YahooClient", true);
// --- Features & Toggles ---
public static readonly SettingKey<bool> EnableHtmlFallback = new("Feature.EnableHtmlFallback", true);
public static readonly SettingKey<bool> AllowForceRefresh = new("Feature.AllowForceRefresh", true);
public static readonly SettingKey<int> FundamentalDataValidityDays = new("Cache.FundamentalDataValidityDays", 30);
}