refactor(assets): update asset entity mappings, database migrations, and MQTT RPC handlers

This commit is contained in:
2026-08-24 21:37:21 +02:00
parent 7060f0f7b1
commit 6974b2075b
26 changed files with 645 additions and 2237 deletions
+17 -17
View File
@@ -17,9 +17,9 @@ public class AssetsDbContext : DbContext, ISettingsDbContext
} }
public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>(); public DbSet<SettingEntity> DynamicSettings => Set<SettingEntity>();
public DbSet<Settings> Settings { get; set; } public DbSet<AssetEntity> TradeRepublicAssets => Set<AssetEntity>();
public DbSet<AssetEntity> TradeRepublicAssets { get; set; } public DbSet<DerivativeEntity> Derivatives => Set<DerivativeEntity>();
public DbSet<TagEntity> TradeRepublicTags { get; set; } public DbSet<TagEntity> TradeRepublicTags => Set<TagEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
{ {
@@ -30,20 +30,29 @@ public class AssetsDbContext : DbContext, ISettingsDbContext
entity.HasKey(e => e.Id); entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key).IsUnique(); entity.HasIndex(e => e.Key).IsUnique();
}); });
modelBuilder.Entity<AssetEntity>(entity => modelBuilder.Entity<AssetEntity>(entity =>
{ {
entity.HasKey(e => new {e.Isin, e.InstrumentCategory}); entity.HasKey(e => new { e.Isin, e.InstrumentCategory });
entity.HasIndex(e => e.LastUpdatedAt); entity.HasIndex(e => e.LastUpdatedAt);
entity.HasDiscriminator<string>("AssetType") entity.HasDiscriminator<string>("AssetType")
.HasValue<StockEntity>("Stock") .HasValue<StockEntity>("Stock")
.HasValue<CryptoEntity>("Crypto")
.HasValue<EtfEntity>("Etf") .HasValue<EtfEntity>("Etf")
.HasValue<SyntheticEntity>("Synthetic") .HasValue<SyntheticEntity>("Synthetic");
.HasValue<BondEntity>("Bond")
.HasValue<DerivativeEntity>("Derivative");
}); });
modelBuilder.Entity<DerivativeEntity>(entity =>
{
entity.HasKey(e => e.Isin);
entity.HasIndex(e => e.UnderlyingIsin);
entity.HasIndex(e => e.OptionType);
entity.HasIndex(e => e.Leverage);
entity.HasIndex(e => e.LastUpdatedAt);
entity.HasIndex(e => new { e.UnderlyingIsin, e.OptionType, e.Leverage, e.Barrier });
});
var stringListConverter = var stringListConverter =
new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>( new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null), v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
@@ -75,15 +84,6 @@ public class AssetsDbContext : DbContext, ISettingsDbContext
modelBuilder.Entity<TagEntity>(entity => { entity.HasKey(e => e.Id); }); modelBuilder.Entity<TagEntity>(entity => { entity.HasKey(e => e.Id); });
modelBuilder.Entity<Settings>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.CurrentScanningType)
.HasConversion<string>()
.HasMaxLength(50);
});
modelBuilder.Entity<AssetEntity>() modelBuilder.Entity<AssetEntity>()
.HasMany(a => a.Tags) .HasMany(a => a.Tags)
.WithMany(t => t.Assets); .WithMany(t => t.Assets);
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release ARG BUILD_CONFIGURATION=Release
WORKDIR /src WORKDIR /src
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
COPY ["FinlyticAssets/FinlyticAssets.csproj", "FinlyticAssets/"] COPY ["FinlyticAssets/FinlyticAssets.csproj", "FinlyticAssets/"]
RUN dotnet restore "FinlyticAssets/FinlyticAssets.csproj" RUN dotnet restore "FinlyticAssets/FinlyticAssets.csproj"
COPY . . COPY . .
-2
View File
@@ -7,8 +7,6 @@ public abstract class AssetEntity
public string Type { get; set; } = string.Empty; public string Type { get; set; } = string.Empty;
public string InstrumentCategory { get; set; } = string.Empty; public string InstrumentCategory { get; set; } = string.Empty;
public bool HasCfd { get; set; } public bool HasCfd { get; set; }
public string? ImageId { get; set; }
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow; public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
public List<TagEntity> Tags { get; set; } = new(); public List<TagEntity> Tags { get; set; } = new();
+10 -3
View File
@@ -11,11 +11,17 @@ public enum OptionType
Short Short
} }
public class DerivativeEntity : AssetEntity public class DerivativeEntity
{ {
[Key]
[StringLength(12, MinimumLength = 12)]
public string Isin { get; set; } = string.Empty;
// --- Verknüpfung zum Basiswert (Underlying) --- // --- Verknüpfung zum Basiswert (Underlying) ---
[StringLength(12, MinimumLength = 12)] [StringLength(12, MinimumLength = 12)]
public string? UnderlyingIsin { get; set; } public string UnderlyingIsin { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
// --- Derivat-Spezifikationen --- // --- Derivat-Spezifikationen ---
public OptionType OptionType { get; set; } // Long / Short public OptionType OptionType { get; set; } // Long / Short
@@ -54,7 +60,8 @@ public class DerivativeEntity : AssetEntity
[MaxLength(150)] [MaxLength(150)]
public string IssuerDisplayName { get; set; } = string.Empty; public string IssuerDisplayName { get; set; } = string.Empty;
public string? IssuerImageId { get; set; } public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// PostgreSQL Mapped Array für TR-Kategorien // PostgreSQL Mapped Array für TR-Kategorien
public List<string> DerivativeProductCategories { get; set; } = new(); public List<string> DerivativeProductCategories { get; set; } = new();
-68
View File
@@ -1,68 +0,0 @@
using System;
using System.ComponentModel.DataAnnotations;
using FinlyticAssets.Models;
using FinlyticCore.Models.Assets;
namespace FinlyticAssets.Entities;
/// <summary>
/// Represents the global synchronization and timing configurations for the Trade Republic asset scanner.
/// </summary>
public class Settings
{
/// <summary>
/// Gets or sets the unique identifier for the settings record.
/// </summary>
[Key]
public Guid Id { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the very first full scan of all assets has been completed.
/// Used to switch from fast initial discovery delays to stealthy incremental update delays.
/// </summary>
public bool FinishedInitialScan { get; set; }
/// <summary>
/// Gets or sets the maximum number of assets requested per single API pagination call.
/// Values around 100 look like standard dynamic-scrolling payloads from a real client device.
/// </summary>
public int TradeRepublicMaxRequestPageSize { get; set; } = 100;
/// <summary>
/// Gets or sets the idle delay in seconds between switching from one full asset category to another (e.g., from Stocks to Crypto)
/// during standard maintenance mode. (Default 12000s = ~3.3 hours).
/// </summary>
public int AssetUpdateTypeDelay { get; set; } = 12000;
/// <summary>
/// Gets or sets the idle delay in seconds between switching asset categories during the initial setup scan.
/// Set to 0 to move immediately to the next type after finishing the current one. (Default 0s).
/// </summary>
public int InitAssetUpdateTypeDelay { get; set; } = 0;
/// <summary>
/// Gets or sets the baseline delay in seconds between sequential page requests of the same asset type during the initial setup scan.
/// (Default 120s = 2 minutes).
/// </summary>
public int InitBatchAssetUpdateDelay { get; set; } = 120;
/// <summary>
/// Gets or sets the standard baseline delay in seconds between sequential page requests of the same asset type during recurring incremental updates.
/// Spreads pagination widely over time to blend into regular human traffic profiles. (Default 600s = 10 minutes).
/// </summary>
public int BatchAssetUpdateDelay { get; set; } = 600;
/// <summary>
/// Gets or sets the asset type that is currently being processed by the full scan.
/// Acts as a live pointer for recovery after a service interruption.
/// </summary>
public AssetType CurrentScanningType { get; set; } = AssetType.Stock;
/// <summary>
/// Gets or sets the page number of the <see cref="CurrentScanningType"/> that is currently being fetched or was just processed.
/// Trade Republic uses 1-based pagination. A value of 0 means the scan is currently idle or between types.
/// </summary>
public int CurrentScanningPage { get; set; } = 0;
}
-282
View File
@@ -1,282 +0,0 @@
// <auto-generated />
using System;
using FinlyticAssets.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 FinlyticAssets.Migrations
{
[DbContext(typeof(AssetsDbContext))]
[Migration("20260801073314_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("AssetEntityTagEntity", b =>
{
b.Property<string>("TagsId")
.HasColumnType("text");
b.Property<string>("AssetsIsin")
.HasColumnType("text");
b.Property<string>("AssetsInstrumentCategory")
.HasColumnType("text");
b.HasKey("TagsId", "AssetsIsin", "AssetsInstrumentCategory");
b.HasIndex("AssetsIsin", "AssetsInstrumentCategory");
b.ToTable("AssetEntityTagEntity");
});
modelBuilder.Entity("FinlyticAssets.Entities.Settings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<int>("BatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<int>("CurrentScanningPage")
.HasColumnType("integer");
b.Property<string>("CurrentScanningType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("FinishedInitialScan")
.HasColumnType("boolean");
b.Property<int>("InitAssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<int>("InitBatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<int>("TradeRepublicMaxRequestPageSize")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.AssetEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("InstrumentCategory")
.HasColumnType("text");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("character varying(13)");
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Isin", "InstrumentCategory");
b.HasIndex("LastUpdatedAt");
b.ToTable("TradeRepublicAssets");
b.HasDiscriminator<string>("AssetType").HasValue("AssetEntity");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.TagEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("TradeRepublicTags");
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.BondEntity", b =>
{
b.HasBaseType("FinlyticCore.Entities.Assets.AssetEntity");
b.Property<string>("BondIssuerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue("Bond");
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.CryptoEntity", b =>
{
b.HasBaseType("FinlyticCore.Entities.Assets.AssetEntity");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("SearchSubtitle")
.HasColumnName("CryptoEntity_SearchSubtitle");
});
b.HasDiscriminator().HasValue("Crypto");
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.DerivativeEntity", b =>
{
b.HasBaseType("FinlyticCore.Entities.Assets.AssetEntity");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<string>("UnderlyingIsin")
.HasColumnType("text");
b.HasDiscriminator().HasValue("Derivative");
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.EtfEntity", b =>
{
b.HasBaseType("FinlyticCore.Entities.Assets.AssetEntity");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EtfDescription")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MappedEtfIndexName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("EtfEntity_DerivativeProductCategories");
t.Property("SearchSubtitle")
.HasColumnName("EtfEntity_SearchSubtitle");
t.Property("Subtitle")
.HasColumnName("EtfEntity_Subtitle");
});
b.HasDiscriminator().HasValue("Etf");
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.StockEntity", b =>
{
b.HasBaseType("FinlyticCore.Entities.Assets.AssetEntity");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("StockEntity_DerivativeProductCategories");
});
b.HasDiscriminator().HasValue("Stock");
});
modelBuilder.Entity("FinlyticCore.Entities.Assets.SyntheticEntity", b =>
{
b.HasBaseType("FinlyticCore.Entities.Assets.AssetEntity");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("SyntheticEntity_DerivativeProductCategories");
});
b.HasDiscriminator().HasValue("Synthetic");
});
modelBuilder.Entity("AssetEntityTagEntity", b =>
{
b.HasOne("FinlyticCore.Entities.Assets.TagEntity", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FinlyticCore.Entities.Assets.AssetEntity", null)
.WithMany()
.HasForeignKey("AssetsIsin", "AssetsInstrumentCategory")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -1,184 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticAssets.Migrations
{
/// <inheritdoc />
public partial class UpdateDerivativeAssets : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "UnderlyingIsin",
table: "TradeRepublicAssets",
type: "character varying(12)",
maxLength: 12,
nullable: true,
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Barrier",
table: "TradeRepublicAssets",
type: "numeric(18,6)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Currency",
table: "TradeRepublicAssets",
type: "character varying(10)",
maxLength: 10,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Delta",
table: "TradeRepublicAssets",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<DateTime>(
name: "Expiry",
table: "TradeRepublicAssets",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Factor",
table: "TradeRepublicAssets",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Issuer",
table: "TradeRepublicAssets",
type: "character varying(150)",
maxLength: 150,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "IssuerDisplayName",
table: "TradeRepublicAssets",
type: "character varying(150)",
maxLength: 150,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "IssuerImageId",
table: "TradeRepublicAssets",
type: "text",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Leverage",
table: "TradeRepublicAssets",
type: "numeric(10,4)",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NextGenProductCategoryName",
table: "TradeRepublicAssets",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<int>(
name: "OptionType",
table: "TradeRepublicAssets",
type: "integer",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "ProductCategoryName",
table: "TradeRepublicAssets",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Size",
table: "TradeRepublicAssets",
type: "numeric",
nullable: true);
migrationBuilder.AddColumn<decimal>(
name: "Strike",
table: "TradeRepublicAssets",
type: "numeric(18,6)",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Barrier",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Currency",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Delta",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Expiry",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Factor",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Issuer",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "IssuerDisplayName",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "IssuerImageId",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Leverage",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "NextGenProductCategoryName",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "OptionType",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "ProductCategoryName",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Size",
table: "TradeRepublicAssets");
migrationBuilder.DropColumn(
name: "Strike",
table: "TradeRepublicAssets");
migrationBuilder.AlterColumn<string>(
name: "UnderlyingIsin",
table: "TradeRepublicAssets",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "character varying(12)",
oldMaxLength: 12,
oldNullable: true);
}
}
}
@@ -1,365 +0,0 @@
// <auto-generated />
using System;
using FinlyticAssets.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 FinlyticAssets.Migrations
{
[DbContext(typeof(AssetsDbContext))]
[Migration("20260813205059_AddDynamicSettings")]
partial class AddDynamicSettings
{
/// <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("AssetEntityTagEntity", b =>
{
b.Property<string>("TagsId")
.HasColumnType("text");
b.Property<string>("AssetsIsin")
.HasColumnType("text");
b.Property<string>("AssetsInstrumentCategory")
.HasColumnType("text");
b.HasKey("TagsId", "AssetsIsin", "AssetsInstrumentCategory");
b.HasIndex("AssetsIsin", "AssetsInstrumentCategory");
b.ToTable("AssetEntityTagEntity");
});
modelBuilder.Entity("FinlyticAssets.Entities.AssetEntity", b =>
{
b.Property<string>("Isin")
.HasColumnType("text");
b.Property<string>("InstrumentCategory")
.HasColumnType("text");
b.Property<string>("AssetType")
.IsRequired()
.HasMaxLength(13)
.HasColumnType("character varying(13)");
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Isin", "InstrumentCategory");
b.HasIndex("LastUpdatedAt");
b.ToTable("TradeRepublicAssets");
b.HasDiscriminator<string>("AssetType").HasValue("AssetEntity");
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FinlyticAssets.Entities.Settings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<int>("BatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<int>("CurrentScanningPage")
.HasColumnType("integer");
b.Property<string>("CurrentScanningType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("FinishedInitialScan")
.HasColumnType("boolean");
b.Property<int>("InitAssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<int>("InitBatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<int>("TradeRepublicMaxRequestPageSize")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("FinlyticAssets.Entities.TagEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("TradeRepublicTags");
});
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("FinlyticAssets.Entities.BondEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("BondIssuerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue("Bond");
});
modelBuilder.Entity("FinlyticAssets.Entities.CryptoEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("SearchSubtitle")
.HasColumnName("CryptoEntity_SearchSubtitle");
});
b.HasDiscriminator().HasValue("Crypto");
});
modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<decimal>("Barrier")
.HasColumnType("numeric(18,6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<decimal?>("Delta")
.HasColumnType("numeric");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("Factor")
.HasColumnType("numeric");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerDisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerImageId")
.HasColumnType("text");
b.Property<decimal>("Leverage")
.HasColumnType("numeric(10,4)");
b.Property<string>("NextGenProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("OptionType")
.HasColumnType("integer");
b.Property<string>("ProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal?>("Size")
.HasColumnType("numeric");
b.Property<decimal>("Strike")
.HasColumnType("numeric(18,6)");
b.Property<string>("UnderlyingIsin")
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.HasDiscriminator().HasValue("Derivative");
});
modelBuilder.Entity("FinlyticAssets.Entities.EtfEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<string>("EtfDescription")
.IsRequired()
.HasColumnType("text");
b.Property<string>("MappedEtfIndexName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("EtfEntity_DerivativeProductCategories");
t.Property("SearchSubtitle")
.HasColumnName("EtfEntity_SearchSubtitle");
t.Property("Subtitle")
.HasColumnName("EtfEntity_Subtitle");
});
b.HasDiscriminator().HasValue("Etf");
});
modelBuilder.Entity("FinlyticAssets.Entities.StockEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("StockEntity_DerivativeProductCategories");
});
b.HasDiscriminator().HasValue("Stock");
});
modelBuilder.Entity("FinlyticAssets.Entities.SyntheticEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("SyntheticEntity_DerivativeProductCategories");
});
b.HasDiscriminator().HasValue("Synthetic");
});
modelBuilder.Entity("AssetEntityTagEntity", b =>
{
b.HasOne("FinlyticAssets.Entities.TagEntity", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FinlyticAssets.Entities.AssetEntity", null)
.WithMany()
.HasForeignKey("AssetsIsin", "AssetsInstrumentCategory")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
#pragma warning restore 612, 618
}
}
}
@@ -1,42 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticAssets.Migrations
{
/// <inheritdoc />
public partial class AddDynamicSettings : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
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.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DynamicSettings");
}
}
}
@@ -1,42 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticAssets.Migrations
{
/// <inheritdoc />
public partial class UpdateDynamicSettingsUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql(@"
DELETE FROM ""DynamicSettings"" a USING ""DynamicSettings"" b
WHERE a.""Key"" = b.""Key"" AND a.""Id"" < b.""Id"";
");
migrationBuilder.DropIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings");
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings");
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key");
}
}
}
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace FinlyticAssets.Migrations namespace FinlyticAssets.Migrations
{ {
[DbContext(typeof(AssetsDbContext))] [DbContext(typeof(AssetsDbContext))]
[Migration("20260815184053_UpdateDynamicSettingsUniqueIndex")] [Migration("20260818185753_Init")]
partial class UpdateDynamicSettingsUniqueIndex partial class Init
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -59,9 +59,6 @@ namespace FinlyticAssets.Migrations
b.Property<bool>("HasCfd") b.Property<bool>("HasCfd")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt") b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -84,41 +81,91 @@ namespace FinlyticAssets.Migrations
b.UseTphMappingStrategy(); b.UseTphMappingStrategy();
}); });
modelBuilder.Entity("FinlyticAssets.Entities.Settings", b => modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<string>("Isin")
.ValueGeneratedOnAdd() .HasMaxLength(12)
.HasColumnType("uuid"); .HasColumnType("character varying(12)");
b.Property<int>("AssetUpdateTypeDelay") b.Property<decimal>("Barrier")
.HasColumnType("integer"); .HasColumnType("numeric(18,6)");
b.Property<int>("BatchAssetUpdateDelay") b.Property<DateTime>("CreatedAt")
.HasColumnType("integer"); .HasColumnType("timestamp with time zone");
b.Property<int>("CurrentScanningPage") b.Property<string>("Currency")
.HasColumnType("integer");
b.Property<string>("CurrentScanningType")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(10)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(10)");
b.Property<bool>("FinishedInitialScan") b.Property<decimal?>("Delta")
.HasColumnType("boolean"); .HasColumnType("numeric");
b.Property<int>("InitAssetUpdateTypeDelay") b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("Factor")
.HasColumnType("numeric");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerDisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("Leverage")
.HasColumnType("numeric(10,4)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("NextGenProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("OptionType")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int>("InitBatchAssetUpdateDelay") b.Property<string>("ProductCategoryName")
.HasColumnType("integer"); .IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("TradeRepublicMaxRequestPageSize") b.Property<decimal?>("Size")
.HasColumnType("integer"); .HasColumnType("numeric");
b.HasKey("Id"); b.Property<decimal>("Strike")
.HasColumnType("numeric(18,6)");
b.ToTable("Settings"); b.Property<string>("UnderlyingIsin")
.IsRequired()
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.HasKey("Isin");
b.HasIndex("LastUpdatedAt");
b.HasIndex("Leverage");
b.HasIndex("OptionType");
b.HasIndex("UnderlyingIsin");
b.ToTable("Derivatives");
}); });
modelBuilder.Entity("FinlyticAssets.Entities.TagEntity", b => modelBuilder.Entity("FinlyticAssets.Entities.TagEntity", b =>
@@ -170,109 +217,6 @@ namespace FinlyticAssets.Migrations
b.ToTable("DynamicSettings"); b.ToTable("DynamicSettings");
}); });
modelBuilder.Entity("FinlyticAssets.Entities.BondEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("BondIssuerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue("Bond");
});
modelBuilder.Entity("FinlyticAssets.Entities.CryptoEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("SearchSubtitle")
.HasColumnName("CryptoEntity_SearchSubtitle");
});
b.HasDiscriminator().HasValue("Crypto");
});
modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<decimal>("Barrier")
.HasColumnType("numeric(18,6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<decimal?>("Delta")
.HasColumnType("numeric");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("Factor")
.HasColumnType("numeric");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerDisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerImageId")
.HasColumnType("text");
b.Property<decimal>("Leverage")
.HasColumnType("numeric(10,4)");
b.Property<string>("NextGenProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("OptionType")
.HasColumnType("integer");
b.Property<string>("ProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal?>("Size")
.HasColumnType("numeric");
b.Property<decimal>("Strike")
.HasColumnType("numeric(18,6)");
b.Property<string>("UnderlyingIsin")
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.HasDiscriminator().HasValue("Derivative");
});
modelBuilder.Entity("FinlyticAssets.Entities.EtfEntity", b => modelBuilder.Entity("FinlyticAssets.Entities.EtfEntity", b =>
{ {
b.HasBaseType("FinlyticAssets.Entities.AssetEntity"); b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
@@ -297,18 +241,6 @@ namespace FinlyticAssets.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("EtfEntity_DerivativeProductCategories");
t.Property("SearchSubtitle")
.HasColumnName("EtfEntity_SearchSubtitle");
t.Property("Subtitle")
.HasColumnName("EtfEntity_Subtitle");
});
b.HasDiscriminator().HasValue("Etf"); b.HasDiscriminator().HasValue("Etf");
}); });
@@ -1,4 +1,4 @@
using System; using System;
using Microsoft.EntityFrameworkCore.Migrations; using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable #nullable disable
@@ -12,22 +12,47 @@ namespace FinlyticAssets.Migrations
protected override void Up(MigrationBuilder migrationBuilder) protected override void Up(MigrationBuilder migrationBuilder)
{ {
migrationBuilder.CreateTable( migrationBuilder.CreateTable(
name: "Settings", name: "Derivatives",
columns: table => new columns: table => new
{ {
Id = table.Column<Guid>(type: "uuid", nullable: false), Isin = table.Column<string>(type: "character varying(12)", maxLength: 12, nullable: false),
FinishedInitialScan = table.Column<bool>(type: "boolean", nullable: false), UnderlyingIsin = table.Column<string>(type: "character varying(12)", maxLength: 12, nullable: false),
TradeRepublicMaxRequestPageSize = table.Column<int>(type: "integer", nullable: false), Name = table.Column<string>(type: "text", nullable: false),
AssetUpdateTypeDelay = table.Column<int>(type: "integer", nullable: false), OptionType = table.Column<int>(type: "integer", nullable: false),
InitAssetUpdateTypeDelay = table.Column<int>(type: "integer", nullable: false), ProductCategoryName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
InitBatchAssetUpdateDelay = table.Column<int>(type: "integer", nullable: false), NextGenProductCategoryName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
BatchAssetUpdateDelay = table.Column<int>(type: "integer", nullable: false), Strike = table.Column<decimal>(type: "numeric(18,6)", nullable: false),
CurrentScanningType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false), Barrier = table.Column<decimal>(type: "numeric(18,6)", nullable: false),
CurrentScanningPage = table.Column<int>(type: "integer", nullable: false) Leverage = table.Column<decimal>(type: "numeric(10,4)", nullable: false),
Size = table.Column<decimal>(type: "numeric", nullable: true),
Factor = table.Column<decimal>(type: "numeric", nullable: true),
Delta = table.Column<decimal>(type: "numeric", nullable: true),
Currency = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
Expiry = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
Issuer = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
IssuerDisplayName = table.Column<string>(type: "character varying(150)", maxLength: 150, nullable: false),
LastUpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
DerivativeProductCategories = table.Column<string>(type: "text", nullable: false)
}, },
constraints: table => constraints: table =>
{ {
table.PrimaryKey("PK_Settings", x => x.Id); table.PrimaryKey("PK_Derivatives", 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( migrationBuilder.CreateTable(
@@ -39,20 +64,13 @@ namespace FinlyticAssets.Migrations
Name = table.Column<string>(type: "text", nullable: false), Name = table.Column<string>(type: "text", nullable: false),
Type = table.Column<string>(type: "text", nullable: false), Type = table.Column<string>(type: "text", nullable: false),
HasCfd = table.Column<bool>(type: "boolean", nullable: false), HasCfd = table.Column<bool>(type: "boolean", nullable: false),
ImageId = table.Column<string>(type: "text", nullable: true),
LastUpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false), LastUpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
AssetType = table.Column<string>(type: "character varying(13)", maxLength: 13, nullable: false), AssetType = table.Column<string>(type: "character varying(13)", maxLength: 13, nullable: false),
BondIssuerName = table.Column<string>(type: "text", nullable: true),
SearchSubtitle = table.Column<string>(type: "text", nullable: true),
Subtitle = table.Column<string>(type: "text", nullable: true),
CryptoEntity_SearchSubtitle = table.Column<string>(type: "text", nullable: true),
DerivativeProductCategories = table.Column<string>(type: "text", nullable: true), DerivativeProductCategories = table.Column<string>(type: "text", nullable: true),
UnderlyingIsin = table.Column<string>(type: "text", nullable: true),
EtfEntity_DerivativeProductCategories = table.Column<string>(type: "text", nullable: true),
EtfDescription = table.Column<string>(type: "text", nullable: true), EtfDescription = table.Column<string>(type: "text", nullable: true),
MappedEtfIndexName = table.Column<string>(type: "text", nullable: true), MappedEtfIndexName = table.Column<string>(type: "text", nullable: true),
EtfEntity_Subtitle = table.Column<string>(type: "text", nullable: true), Subtitle = table.Column<string>(type: "text", nullable: true),
EtfEntity_SearchSubtitle = table.Column<string>(type: "text", nullable: true), SearchSubtitle = table.Column<string>(type: "text", nullable: true),
StockEntity_DerivativeProductCategories = table.Column<string>(type: "text", nullable: true), StockEntity_DerivativeProductCategories = table.Column<string>(type: "text", nullable: true),
SyntheticEntity_DerivativeProductCategories = table.Column<string>(type: "text", nullable: true) SyntheticEntity_DerivativeProductCategories = table.Column<string>(type: "text", nullable: true)
}, },
@@ -104,6 +122,32 @@ namespace FinlyticAssets.Migrations
table: "AssetEntityTagEntity", table: "AssetEntityTagEntity",
columns: new[] { "AssetsIsin", "AssetsInstrumentCategory" }); columns: new[] { "AssetsIsin", "AssetsInstrumentCategory" });
migrationBuilder.CreateIndex(
name: "IX_Derivatives_LastUpdatedAt",
table: "Derivatives",
column: "LastUpdatedAt");
migrationBuilder.CreateIndex(
name: "IX_Derivatives_Leverage",
table: "Derivatives",
column: "Leverage");
migrationBuilder.CreateIndex(
name: "IX_Derivatives_OptionType",
table: "Derivatives",
column: "OptionType");
migrationBuilder.CreateIndex(
name: "IX_Derivatives_UnderlyingIsin",
table: "Derivatives",
column: "UnderlyingIsin");
migrationBuilder.CreateIndex(
name: "IX_DynamicSettings_Key",
table: "DynamicSettings",
column: "Key",
unique: true);
migrationBuilder.CreateIndex( migrationBuilder.CreateIndex(
name: "IX_TradeRepublicAssets_LastUpdatedAt", name: "IX_TradeRepublicAssets_LastUpdatedAt",
table: "TradeRepublicAssets", table: "TradeRepublicAssets",
@@ -117,7 +161,10 @@ namespace FinlyticAssets.Migrations
name: "AssetEntityTagEntity"); name: "AssetEntityTagEntity");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "Settings"); name: "Derivatives");
migrationBuilder.DropTable(
name: "DynamicSettings");
migrationBuilder.DropTable( migrationBuilder.DropTable(
name: "TradeRepublicAssets"); name: "TradeRepublicAssets");
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace FinlyticAssets.Migrations namespace FinlyticAssets.Migrations
{ {
[DbContext(typeof(AssetsDbContext))] [DbContext(typeof(AssetsDbContext))]
[Migration("20260813202348_UpdateDerivativeAssets")] [Migration("20260821153552_SyncAssetModelDrift")]
partial class UpdateDerivativeAssets partial class SyncAssetModelDrift
{ {
/// <inheritdoc /> /// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder) protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -59,9 +59,6 @@ namespace FinlyticAssets.Migrations
b.Property<bool>("HasCfd") b.Property<bool>("HasCfd")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt") b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -84,104 +81,18 @@ namespace FinlyticAssets.Migrations
b.UseTphMappingStrategy(); b.UseTphMappingStrategy();
}); });
modelBuilder.Entity("FinlyticAssets.Entities.Settings", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<int>("BatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<int>("CurrentScanningPage")
.HasColumnType("integer");
b.Property<string>("CurrentScanningType")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<bool>("FinishedInitialScan")
.HasColumnType("boolean");
b.Property<int>("InitAssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<int>("InitBatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<int>("TradeRepublicMaxRequestPageSize")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("Settings");
});
modelBuilder.Entity("FinlyticAssets.Entities.TagEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("TradeRepublicTags");
});
modelBuilder.Entity("FinlyticAssets.Entities.BondEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("BondIssuerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue("Bond");
});
modelBuilder.Entity("FinlyticAssets.Entities.CryptoEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("SearchSubtitle")
.HasColumnName("CryptoEntity_SearchSubtitle");
});
b.HasDiscriminator().HasValue("Crypto");
});
modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b => modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{ {
b.HasBaseType("FinlyticAssets.Entities.AssetEntity"); b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.Property<decimal>("Barrier") b.Property<decimal>("Barrier")
.HasColumnType("numeric(18,6)"); .HasColumnType("numeric(18,6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency") b.Property<string>("Currency")
.IsRequired() .IsRequired()
.HasMaxLength(10) .HasMaxLength(10)
@@ -210,12 +121,16 @@ namespace FinlyticAssets.Migrations
.HasMaxLength(150) .HasMaxLength(150)
.HasColumnType("character varying(150)"); .HasColumnType("character varying(150)");
b.Property<string>("IssuerImageId") b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("text"); .HasColumnType("timestamp with time zone");
b.Property<decimal>("Leverage") b.Property<decimal>("Leverage")
.HasColumnType("numeric(10,4)"); .HasColumnType("numeric(10,4)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("NextGenProductCategoryName") b.Property<string>("NextGenProductCategoryName")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@@ -236,10 +151,72 @@ namespace FinlyticAssets.Migrations
.HasColumnType("numeric(18,6)"); .HasColumnType("numeric(18,6)");
b.Property<string>("UnderlyingIsin") b.Property<string>("UnderlyingIsin")
.IsRequired()
.HasMaxLength(12) .HasMaxLength(12)
.HasColumnType("character varying(12)"); .HasColumnType("character varying(12)");
b.HasDiscriminator().HasValue("Derivative"); b.HasKey("Isin");
b.HasIndex("LastUpdatedAt");
b.HasIndex("Leverage");
b.HasIndex("OptionType");
b.HasIndex("UnderlyingIsin");
b.HasIndex("UnderlyingIsin", "OptionType", "Leverage", "Barrier");
b.ToTable("Derivatives");
});
modelBuilder.Entity("FinlyticAssets.Entities.TagEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("TradeRepublicTags");
});
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")
.IsUnique();
b.ToTable("DynamicSettings");
}); });
modelBuilder.Entity("FinlyticAssets.Entities.EtfEntity", b => modelBuilder.Entity("FinlyticAssets.Entities.EtfEntity", b =>
@@ -266,18 +243,6 @@ namespace FinlyticAssets.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("EtfEntity_DerivativeProductCategories");
t.Property("SearchSubtitle")
.HasColumnName("EtfEntity_SearchSubtitle");
t.Property("Subtitle")
.HasColumnName("EtfEntity_Subtitle");
});
b.HasDiscriminator().HasValue("Etf"); b.HasDiscriminator().HasValue("Etf");
}); });
@@ -0,0 +1,27 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FinlyticAssets.Migrations
{
/// <inheritdoc />
public partial class SyncAssetModelDrift : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Derivatives_UnderlyingIsin_OptionType_Leverage_Barrier",
table: "Derivatives",
columns: new[] { "UnderlyingIsin", "OptionType", "Leverage", "Barrier" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Derivatives_UnderlyingIsin_OptionType_Leverage_Barrier",
table: "Derivatives");
}
}
}
@@ -56,9 +56,6 @@ namespace FinlyticAssets.Migrations
b.Property<bool>("HasCfd") b.Property<bool>("HasCfd")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt") b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
@@ -81,41 +78,93 @@ namespace FinlyticAssets.Migrations
b.UseTphMappingStrategy(); b.UseTphMappingStrategy();
}); });
modelBuilder.Entity("FinlyticAssets.Entities.Settings", b => modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<string>("Isin")
.ValueGeneratedOnAdd() .HasMaxLength(12)
.HasColumnType("uuid"); .HasColumnType("character varying(12)");
b.Property<int>("AssetUpdateTypeDelay") b.Property<decimal>("Barrier")
.HasColumnType("integer"); .HasColumnType("numeric(18,6)");
b.Property<int>("BatchAssetUpdateDelay") b.Property<DateTime>("CreatedAt")
.HasColumnType("integer"); .HasColumnType("timestamp with time zone");
b.Property<int>("CurrentScanningPage") b.Property<string>("Currency")
.HasColumnType("integer");
b.Property<string>("CurrentScanningType")
.IsRequired() .IsRequired()
.HasMaxLength(50) .HasMaxLength(10)
.HasColumnType("character varying(50)"); .HasColumnType("character varying(10)");
b.Property<bool>("FinishedInitialScan") b.Property<decimal?>("Delta")
.HasColumnType("boolean"); .HasColumnType("numeric");
b.Property<int>("InitAssetUpdateTypeDelay") b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("Factor")
.HasColumnType("numeric");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerDisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<decimal>("Leverage")
.HasColumnType("numeric(10,4)");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("NextGenProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("OptionType")
.HasColumnType("integer"); .HasColumnType("integer");
b.Property<int>("InitBatchAssetUpdateDelay") b.Property<string>("ProductCategoryName")
.HasColumnType("integer"); .IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("TradeRepublicMaxRequestPageSize") b.Property<decimal?>("Size")
.HasColumnType("integer"); .HasColumnType("numeric");
b.HasKey("Id"); b.Property<decimal>("Strike")
.HasColumnType("numeric(18,6)");
b.ToTable("Settings"); b.Property<string>("UnderlyingIsin")
.IsRequired()
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.HasKey("Isin");
b.HasIndex("LastUpdatedAt");
b.HasIndex("Leverage");
b.HasIndex("OptionType");
b.HasIndex("UnderlyingIsin");
b.HasIndex("UnderlyingIsin", "OptionType", "Leverage", "Barrier");
b.ToTable("Derivatives");
}); });
modelBuilder.Entity("FinlyticAssets.Entities.TagEntity", b => modelBuilder.Entity("FinlyticAssets.Entities.TagEntity", b =>
@@ -167,109 +216,6 @@ namespace FinlyticAssets.Migrations
b.ToTable("DynamicSettings"); b.ToTable("DynamicSettings");
}); });
modelBuilder.Entity("FinlyticAssets.Entities.BondEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("BondIssuerName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.HasDiscriminator().HasValue("Bond");
});
modelBuilder.Entity("FinlyticAssets.Entities.CryptoEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("SearchSubtitle")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Subtitle")
.IsRequired()
.HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("SearchSubtitle")
.HasColumnName("CryptoEntity_SearchSubtitle");
});
b.HasDiscriminator().HasValue("Crypto");
});
modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<decimal>("Barrier")
.HasColumnType("numeric(18,6)");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<decimal?>("Delta")
.HasColumnType("numeric");
b.Property<string>("DerivativeProductCategories")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime?>("Expiry")
.HasColumnType("timestamp with time zone");
b.Property<decimal?>("Factor")
.HasColumnType("numeric");
b.Property<string>("Issuer")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerDisplayName")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerImageId")
.HasColumnType("text");
b.Property<decimal>("Leverage")
.HasColumnType("numeric(10,4)");
b.Property<string>("NextGenProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("OptionType")
.HasColumnType("integer");
b.Property<string>("ProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal?>("Size")
.HasColumnType("numeric");
b.Property<decimal>("Strike")
.HasColumnType("numeric(18,6)");
b.Property<string>("UnderlyingIsin")
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.HasDiscriminator().HasValue("Derivative");
});
modelBuilder.Entity("FinlyticAssets.Entities.EtfEntity", b => modelBuilder.Entity("FinlyticAssets.Entities.EtfEntity", b =>
{ {
b.HasBaseType("FinlyticAssets.Entities.AssetEntity"); b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
@@ -294,18 +240,6 @@ namespace FinlyticAssets.Migrations
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.ToTable("TradeRepublicAssets", t =>
{
t.Property("DerivativeProductCategories")
.HasColumnName("EtfEntity_DerivativeProductCategories");
t.Property("SearchSubtitle")
.HasColumnName("EtfEntity_SearchSubtitle");
t.Property("Subtitle")
.HasColumnName("EtfEntity_Subtitle");
});
b.HasDiscriminator().HasValue("Etf"); b.HasDiscriminator().HasValue("Etf");
}); });
+3 -13
View File
@@ -1,4 +1,3 @@
using System;
using FinlyticAssets.Database; using FinlyticAssets.Database;
using FinlyticAssets.Services; using FinlyticAssets.Services;
using FinlyticAssets.Util; using FinlyticAssets.Util;
@@ -24,14 +23,12 @@ builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>
builder.Services.AddScoped<TradeRepublicClient>(); builder.Services.AddScoped<TradeRepublicClient>();
builder.Services.AddScoped<ITradeRepublicService, TradeRepublicService>(); builder.Services.AddScoped<ITradeRepublicService, TradeRepublicService>();
builder.Services.AddScoped<IAssetsDbService, AssetsDbService>(); builder.Services.AddScoped<IAssetsDbService, AssetsDbService>();
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
builder.Services.AddScoped<IAssetsIndexService, AssetsIndexService>(); builder.Services.AddScoped<IAssetsIndexService, AssetsIndexService>();
builder.Services.AddSingleton<AssetsMqttClient>(); builder.Services.AddSingleton<AssetsMqttClient>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AssetsMqttClient>()); builder.Services.AddHostedService(sp => sp.GetRequiredService<AssetsMqttClient>());
builder.Services.AddHostedService<AssetScannerBackgroundService>(); builder.Services.AddHostedService<AssetScannerBackgroundService>();
builder.Services.AddHostedService<LogoFetcherBackgroundService>();
var host = builder.Build(); var host = builder.Build();
@@ -40,21 +37,14 @@ using (var scope = host.Services.CreateScope())
try try
{ {
var context = scope.ServiceProvider.GetRequiredService<AssetsDbContext>(); var context = scope.ServiceProvider.GetRequiredService<AssetsDbContext>();
await context.Database.MigrateAsync(); var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
await context.MigrateWithBootstrapAsync(connStr);
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
var settings = await settingsService.GetSettings();
if (settings.InitAssetUpdateTypeDelay == 3600)
{
settings.InitAssetUpdateTypeDelay = 0;
await settingsService.SaveSettings(settings);
Console.WriteLine("[Startup] InitAssetUpdateTypeDelay reset from 3600 to 0.");
}
} }
catch (Exception ex) catch (Exception ex)
{ {
Console.WriteLine($"Critical error during database migration for FinlyticAssets: {ex.Message}"); Console.WriteLine($"Critical error during database migration for FinlyticAssets: {ex.Message}");
} }
} }
host.Run(); host.Run();
-67
View File
@@ -1,67 +0,0 @@
# Finlytic Assets Service
Finlytic Assets is a scalable C# microservice designed for asset discovery, metadata ingestion, and quick querying. It interfaces directly with the Trade Republic API via WebSockets and exposes a high-performance RPC interface over MQTT to other services in the Finlytic ecosystem.
---
## Architecture Overview
```mermaid
graph TD
TR[Trade Republic WebSocket API] <-->|WS Protocol| TRS[TradeRepublicService]
TRS <-->|Ingest| ADS[AssetsFullScanService]
ADS <-->|Save / Update| DB[PostgreSQL Database]
DB -->|Trigger Index Update| AIS[AssetsIndexService]
AIS -->|Write Cache| Index[assets/index/index.json]
MS[Other Finlytic Services] <-->|MQTT Request-Reply| MqttClient[AssetsMqttClient]
MqttClient <-->|Query Cache| DB
```
---
## Core Features & Workflows
### 1. Automated WebSocket scraping
- **Continuous Scan**: The background worker (`AssetsFullScanService`) iterates through all supported asset types:
- **Stocks** (`StockEntity`)
- **Crypto** (`CryptoEntity`)
- **Derivatives** (`DerivativeEntity`)
- **Bonds** (`BondEntity`)
- **Funds/ETFs** (`EtfEntity`)
- **Resilient Reconnection**: Uses a robust custom socket wrapper (`TradeRepublicClient`) that closes automatically after 5 minutes of inactivity to mimic human interaction profiles and reconnects dynamically when a request is made.
### 2. Scanner State Persistence & Crash Recovery
- **Resilient State**: The scan loop continuously persists progress to the database (`Settings` table), recording `CurrentScanningType` and `CurrentScanningPage`.
- **Exit Recovery**: Resumes exactly from the last saved page and asset type upon restart.
### 3. Stealth Scheduling with Randomized Jitter
- **Dynamic Delays**: Scanner wait times are read dynamically from database configuration (`Settings`).
- **Jitter Offset**: Randomized offsets between batches and asset types to prevent rate limiting.
### 4. Active vs. Dead Asset Tracking
- **Recency Filter**: Assets updated within 14 days are active. Inactive assets are flagged.
### 5. MQTT RPC Interface
- **Get Asset by ISIN**: Subscribes to `services/request/assets_Get/#`.
- **Omnibox Search**: Subscribes to `services/request/assets_Search/#`.
- **JIT Fallback**: Performs JIT-lookup directly against the Trade Republic API for unknown ISINs.
### 6. Local Indexing File (`index.json`)
- **Automatic Regeneration**: Serializes active assets list (`ISIN`, `Name`) to shared volume `assets/index/index.json`.
---
## Feature Status
### Implemented Features
- [x] Trade Republic WebSocket scanner for Stocks, Crypto, Derivatives, Bonds, ETFs.
- [x] State persistence & crash recovery in PostgreSQL.
- [x] JIT Trade Republic lookup fallback for new ISINs.
- [x] Shared local index file generation (`index.json`).
- [x] Zero-Allocation MQTT RPC handlers.
- [x] Pure Worker Service architecture (No Kestrel HTTP server).
### Planned Features
- [ ] Real-time WebSocket price tick streaming over MQTT topic `finlytic/assets/ticks/{isin}`.
- [ ] Derivative option-chain Greeks calculator integration.
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticAssets.Models;
using FinlyticAssets.Util; using FinlyticAssets.Util;
using FinlyticCore.Dtos.TradeRepublic; using FinlyticCore.Dtos.TradeRepublic;
using FinlyticCore.Models.Assets; using FinlyticCore.Models.Assets;
@@ -15,14 +15,16 @@ using Microsoft.Extensions.Hosting;
namespace FinlyticAssets.Services; namespace FinlyticAssets.Services;
/// <summary> /// <summary>
/// A background service that runs a continuous asset synchronization loop, /// A background service that runs a continuous asset synchronization loop for Stocks and ETFs,
/// scanning Trade Republic to retrieve, update, and index all supported asset types. /// retrieving, updating, and indexing assets while downloading logos inline.
/// </summary> /// </summary>
public class AssetScannerBackgroundService : BackgroundService public class AssetScannerBackgroundService : BackgroundService
{ {
private readonly IServiceScopeFactory _serviceScopeFactory; private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IFinlyticLogger<AssetScannerBackgroundService> _finlyticLogger; private readonly IFinlyticLogger<AssetScannerBackgroundService> _finlyticLogger;
private static readonly AssetType[] ScannedAssetTypes = [AssetType.Stock, AssetType.Fund];
private AssetsCount? _assetsCount; private AssetsCount? _assetsCount;
private AssetsCount? _currAssetsCount; private AssetsCount? _currAssetsCount;
@@ -54,24 +56,35 @@ public class AssetScannerBackgroundService : BackgroundService
{ {
using var scope = _serviceScopeFactory.CreateScope(); using var scope = _serviceScopeFactory.CreateScope();
var tradeRepublicService = scope.ServiceProvider.GetRequiredService<ITradeRepublicService>(); var tradeRepublicService = scope.ServiceProvider.GetRequiredService<ITradeRepublicService>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>(); var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
var assetsDbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>(); var assetsDbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>(); var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
var isAutoScanEnabled = await settingsService.GetSettingAsync(SettingKeys.ScannerEnableAutoScan, stoppingToken);
if (!isAutoScanEnabled)
{
await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Auto scan is disabled in dynamic settings. Waiting 1 minute...");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue;
}
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Requesting total asset counts from Trade Republic..."); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Requesting total asset counts from Trade Republic...");
_assetsCount = await tradeRepublicService.GetAssetsCount(stoppingToken); _assetsCount = await tradeRepublicService.GetAssetsCount(stoppingToken);
_currAssetsCount = new AssetsCount(); _currAssetsCount = new AssetsCount();
var initSettings = await settingsService.GetSettings(); var initCurrentTypeStr = await settingsService.GetSettingAsync(SettingKeys.ScannerCurrentScanningType, stoppingToken);
var isRecoveryMode = initSettings.CurrentScanningPage > 0; var initCurrentPage = await settingsService.GetSettingAsync(SettingKeys.ScannerCurrentScanningPage, stoppingToken);
var isRecoveryMode = initCurrentPage > 0;
foreach (var type in Enum.GetValues<AssetType>()) Enum.TryParse<AssetType>(initCurrentTypeStr, true, out var initCurrentType);
foreach (var type in ScannedAssetTypes)
{ {
if (stoppingToken.IsCancellationRequested) break; if (stoppingToken.IsCancellationRequested) break;
if (isRecoveryMode) if (isRecoveryMode)
{ {
if (type != initSettings.CurrentScanningType) if (type != initCurrentType)
{ {
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Recovery: {AssetType} was already processed. Skipping.", type); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Recovery: {AssetType} was already processed. Skipping.", type);
continue; continue;
@@ -80,19 +93,17 @@ public class AssetScannerBackgroundService : BackgroundService
} }
else else
{ {
var settings = await settingsService.GetSettings(); await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningType, type.ToString(), stoppingToken);
settings.CurrentScanningType = type; await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
settings.CurrentScanningPage = 0;
await settingsService.SaveSettings(settings);
} }
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Processing asset type: {AssetType}...", type); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Processing asset type: {AssetType}...", type);
await HandleAssetType(type, tradeRepublicService, settingsService, assetsDbService, indexService, stoppingToken); await HandleAssetType(type, tradeRepublicService, settingsService, assetsDbService, indexService, stoppingToken);
var currentSettings = await settingsService.GetSettings(); var finishedInitial = await settingsService.GetSettingAsync(SettingKeys.ScannerFinishedInitialScan, stoppingToken);
var delaySeconds = currentSettings.FinishedInitialScan var delaySeconds = finishedInitial
? currentSettings.AssetUpdateTypeDelay ? await settingsService.GetSettingAsync(SettingKeys.ScannerTypeDelay, stoppingToken)
: currentSettings.InitAssetUpdateTypeDelay; : await settingsService.GetSettingAsync(SettingKeys.ScannerInitTypeDelay, stoppingToken);
if (delaySeconds > 0) if (delaySeconds > 0)
{ {
@@ -102,19 +113,18 @@ public class AssetScannerBackgroundService : BackgroundService
} }
} }
var finalSettings = await settingsService.GetSettings(); await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
finalSettings.CurrentScanningPage = 0;
if (!finalSettings.FinishedInitialScan && !stoppingToken.IsCancellationRequested) var finishedScan = await settingsService.GetSettingAsync(SettingKeys.ScannerFinishedInitialScan, stoppingToken);
if (!finishedScan && !stoppingToken.IsCancellationRequested)
{ {
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Initial scan successfully completed. Switching FinishedInitialScan to true."); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Initial scan successfully completed. Switching FinishedInitialScan to true.");
finalSettings.FinishedInitialScan = true; await settingsService.SetSettingAsync(SettingKeys.ScannerFinishedInitialScan, true, stoppingToken);
} }
await settingsService.SaveSettings(finalSettings); var cycleDelay = await settingsService.GetSettingAsync(SettingKeys.ScannerCycleDelayMinutes, stoppingToken);
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Full scan cycle completed. Waiting {Minutes} minutes before starting the next cycle.", cycleDelay);
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Full scan cycle completed. Waiting 1 minute before starting the next cycle."); await Task.Delay(TimeSpan.FromMinutes(cycleDelay), stoppingToken);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
} }
catch (Exception e) when (!stoppingToken.IsCancellationRequested) catch (Exception e) when (!stoppingToken.IsCancellationRequested)
{ {
@@ -129,7 +139,7 @@ public class AssetScannerBackgroundService : BackgroundService
private async Task HandleAssetType( private async Task HandleAssetType(
AssetType type, AssetType type,
ITradeRepublicService tradeRepublicService, ITradeRepublicService tradeRepublicService,
ISettingsDbService settingsDbService, ISettingsService settingsService,
IAssetsDbService assetsDbService, IAssetsDbService assetsDbService,
IAssetsIndexService indexService, IAssetsIndexService indexService,
CancellationToken stoppingToken) CancellationToken stoppingToken)
@@ -144,26 +154,28 @@ public class AssetScannerBackgroundService : BackgroundService
_currAssetsCount ??= new AssetsCount(); _currAssetsCount ??= new AssetsCount();
var currentItemOffset = 0; var currentItemOffset = 0;
var settings = await settingsDbService.GetSettings(); var configuredPageSize = await settingsService.GetSettingAsync(SettingKeys.ScannerMaxPageSize, stoppingToken);
var pageSize = Math.Clamp(settings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : settings.TradeRepublicMaxRequestPageSize, 1, 100); var pageSize = Math.Clamp(configuredPageSize <= 0 ? 50 : configuredPageSize, 1, 100);
if (settings.CurrentScanningType == type && settings.CurrentScanningPage > 0) var currentScanningTypeStr = await settingsService.GetSettingAsync(SettingKeys.ScannerCurrentScanningType, stoppingToken);
var currentScanningPage = await settingsService.GetSettingAsync(SettingKeys.ScannerCurrentScanningPage, stoppingToken);
if (string.Equals(currentScanningTypeStr, type.ToString(), StringComparison.OrdinalIgnoreCase) && currentScanningPage > 0)
{ {
currentItemOffset = (settings.CurrentScanningPage - 1) * pageSize; currentItemOffset = (currentScanningPage - 1) * pageSize;
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Resuming full scan for {AssetType} from Page {Page} (Calculated Offset: {Offset}).", await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Resuming full scan for {AssetType} from Page {Page} (Calculated Offset: {Offset}).",
type, settings.CurrentScanningPage, currentItemOffset); type, currentScanningPage, currentItemOffset);
} }
while (currentItemOffset < totalCount && !stoppingToken.IsCancellationRequested) while (currentItemOffset < totalCount && !stoppingToken.IsCancellationRequested)
{ {
var currentSettings = await settingsDbService.GetSettings(); configuredPageSize = await settingsService.GetSettingAsync(SettingKeys.ScannerMaxPageSize, stoppingToken);
pageSize = Math.Clamp(currentSettings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : currentSettings.TradeRepublicMaxRequestPageSize, 1, 100); pageSize = Math.Clamp(configuredPageSize <= 0 ? 50 : configuredPageSize, 1, 100);
var currentPage = (currentItemOffset / pageSize) + 1; var currentPage = (currentItemOffset / pageSize) + 1;
currentSettings.CurrentScanningType = type; await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningType, type.ToString(), stoppingToken);
currentSettings.CurrentScanningPage = currentPage; await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, currentPage, stoppingToken);
await settingsDbService.SaveSettings(currentSettings);
await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Fetching {AssetType} - Page {Page}. Numerical Offset: {Offset}/{Total}", await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Fetching {AssetType} - Page {Page}. Numerical Offset: {Offset}/{Total}",
type, currentPage, currentItemOffset, totalCount); type, currentPage, currentItemOffset, totalCount);
@@ -173,8 +185,7 @@ public class AssetScannerBackgroundService : BackgroundService
if (assets?.Results == null || assets.Results.Count == 0) if (assets?.Results == null || assets.Results.Count == 0)
{ {
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Fetch for {AssetType} (Page {Page}) returned no results. Reached end of available assets.", type, currentPage); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Fetch for {AssetType} (Page {Page}) returned no results. Reached end of available assets.", type, currentPage);
currentSettings.CurrentScanningPage = 0; await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
await settingsDbService.SaveSettings(currentSettings);
break; break;
} }
@@ -186,14 +197,14 @@ public class AssetScannerBackgroundService : BackgroundService
if (assets.Results.Count < pageSize) if (assets.Results.Count < pageSize)
{ {
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Reached the last page for {AssetType}.", type); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Reached the last page for {AssetType}.", type);
currentSettings.CurrentScanningPage = 0; await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
await settingsDbService.SaveSettings(currentSettings);
break; break;
} }
var delaySeconds = currentSettings.FinishedInitialScan var finishedInitial = await settingsService.GetSettingAsync(SettingKeys.ScannerFinishedInitialScan, stoppingToken);
? currentSettings.BatchAssetUpdateDelay var delaySeconds = finishedInitial
: currentSettings.InitBatchAssetUpdateDelay; ? await settingsService.GetSettingAsync(SettingKeys.ScannerBatchDelay, stoppingToken)
: await settingsService.GetSettingAsync(SettingKeys.ScannerInitBatchDelay, stoppingToken);
if (delaySeconds > 0) if (delaySeconds > 0)
{ {
@@ -219,6 +230,26 @@ public class AssetScannerBackgroundService : BackgroundService
var changedRows = await assetsDbService.AddOrUpdateAssetsAsync(assets); var changedRows = await assetsDbService.AddOrUpdateAssetsAsync(assets);
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] [Scan] {Count} assets passed to the DB service. {Changed} modifications/inserts executed.", assets.Count, changedRows); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] [Scan] {Count} assets passed to the DB service. {Changed} modifications/inserts executed.", assets.Count, changedRows);
// Inline logo fetching for each asset in the batch if missing on disk
string directoryPath = Volumes.LogosRelativePath;
foreach (var a in assets)
{
if (stoppingToken.IsCancellationRequested) break;
if (string.IsNullOrWhiteSpace(a.Isin)) continue;
string cleanIsin = a.Isin.Trim().ToUpperInvariant();
string logoPath = Path.Combine(directoryPath, $"{cleanIsin}.svg");
if (!File.Exists(logoPath))
{
try
{
await indexService.DownloadAndSaveLogoAsync(cleanIsin, stoppingToken);
}
catch { /* Ignore non-fatal logo fetch failures */ }
}
}
if (changedRows > 0) if (changedRows > 0)
{ {
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Database modifications detected. Recreating the asset index file..."); await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Database modifications detected. Recreating the asset index file...");
+34 -83
View File
@@ -14,7 +14,7 @@ using Microsoft.EntityFrameworkCore;
namespace FinlyticAssets.Services; namespace FinlyticAssets.Services;
/// <summary> /// <summary>
/// Defines database operations for managing Trade Republic asset entities. /// Defines database operations for managing Trade Republic asset entities and on-demand derivatives.
/// </summary> /// </summary>
public interface IAssetsDbService public interface IAssetsDbService
{ {
@@ -24,7 +24,6 @@ public interface IAssetsDbService
public Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin); public Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin);
public Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin); public Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin);
public Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery); public Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery);
public Task UpdateAssetImageIdAsync(string isin, string imageId);
public Task<bool> DeleteAssetAsync(string isin); public Task<bool> DeleteAssetAsync(string isin);
public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15); public Task<List<AssetEntity>> GetDiscoveryAssetsAsync(int limit = 15);
public Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default); public Task<List<DerivativeEntity>> GetDerivativesByUnderlyingAsync(string underlyingIsin, string optionType = "long", decimal? targetLeverage = null, string? after = null, int? page = null, bool forceRefresh = false, CancellationToken cancellationToken = default);
@@ -63,7 +62,6 @@ public class AssetsDbService : IAssetsDbService
Asset = a, Asset = a,
Score = (a.Tags?.Count ?? 0) * 10 Score = (a.Tags?.Count ?? 0) * 10
+ (a.HasCfd ? 5 : 0) + (a.HasCfd ? 5 : 0)
+ (string.IsNullOrEmpty(a.ImageId) ? 0 : 15)
+ (a.Name.Length > 3 ? 5 : 0) + (a.Name.Length > 3 ? 5 : 0)
}) })
.OrderByDescending(x => x.Score) .OrderByDescending(x => x.Score)
@@ -147,7 +145,6 @@ public class AssetsDbService : IAssetsDbService
existingEntity.Type = dtoAsset.Type; existingEntity.Type = dtoAsset.Type;
existingEntity.InstrumentCategory = dtoAsset.InstrumentCategory; existingEntity.InstrumentCategory = dtoAsset.InstrumentCategory;
existingEntity.HasCfd = dtoAsset.HasCfd; existingEntity.HasCfd = dtoAsset.HasCfd;
existingEntity.ImageId = dtoAsset.ImageId;
existingEntity.LastUpdatedAt = now; existingEntity.LastUpdatedAt = now;
UpdateSubtypeProperties(existingEntity, dtoAsset); UpdateSubtypeProperties(existingEntity, dtoAsset);
@@ -220,14 +217,13 @@ public class AssetsDbService : IAssetsDbService
existingEntity.Type != dto.Type || existingEntity.Type != dto.Type ||
existingEntity.InstrumentCategory != dto.InstrumentCategory || existingEntity.InstrumentCategory != dto.InstrumentCategory ||
existingEntity.HasCfd != dto.HasCfd || existingEntity.HasCfd != dto.HasCfd ||
existingEntity.ImageId != dto.ImageId || !existingEntity.Tags.Select(t => t.Id).Order().SequenceEqual(mappedTags.Select(t => t.Id).Order()) ||
!existingEntity.Tags.SequenceEqual(mappedTags)) HasSubtypeChanges(existingEntity, dto))
{ {
existingEntity.Name = dto.Name; existingEntity.Name = dto.Name;
existingEntity.Type = dto.Type; existingEntity.Type = dto.Type;
existingEntity.InstrumentCategory = dto.InstrumentCategory; existingEntity.InstrumentCategory = dto.InstrumentCategory;
existingEntity.HasCfd = dto.HasCfd; existingEntity.HasCfd = dto.HasCfd;
existingEntity.ImageId = dto.ImageId;
existingEntity.LastUpdatedAt = now; existingEntity.LastUpdatedAt = now;
existingEntity.Tags = mappedTags; existingEntity.Tags = mappedTags;
@@ -255,18 +251,8 @@ public class AssetsDbService : IAssetsDbService
Type = stock.Type, Type = stock.Type,
InstrumentCategory = stock.InstrumentCategory, InstrumentCategory = stock.InstrumentCategory,
HasCfd = stock.HasCfd, HasCfd = stock.HasCfd,
ImageId = stock.ImageId,
DerivativeProductCategories = stock.DerivativeProductCategories?.ToList() ?? new List<string>() DerivativeProductCategories = stock.DerivativeProductCategories?.ToList() ?? new List<string>()
}, },
TradeRepublicCrypto crypto => new CryptoEntity
{
Isin = crypto.Isin,
Name = crypto.Name,
Type = crypto.Type,
InstrumentCategory = crypto.InstrumentCategory,
HasCfd = crypto.HasCfd,
ImageId = crypto.ImageId
},
TradeRepublicEtf etf => new EtfEntity TradeRepublicEtf etf => new EtfEntity
{ {
Isin = etf.Isin, Isin = etf.Isin,
@@ -274,7 +260,6 @@ public class AssetsDbService : IAssetsDbService
Type = etf.Type, Type = etf.Type,
InstrumentCategory = etf.InstrumentCategory, InstrumentCategory = etf.InstrumentCategory,
HasCfd = etf.HasCfd, HasCfd = etf.HasCfd,
ImageId = etf.ImageId,
DerivativeProductCategories = etf.DerivativeProductCategories?.ToList() ?? new List<string>() DerivativeProductCategories = etf.DerivativeProductCategories?.ToList() ?? new List<string>()
}, },
TradeRepublicSynthetic syn => new SyntheticEntity TradeRepublicSynthetic syn => new SyntheticEntity
@@ -284,39 +269,15 @@ public class AssetsDbService : IAssetsDbService
Type = syn.Type, Type = syn.Type,
InstrumentCategory = syn.InstrumentCategory, InstrumentCategory = syn.InstrumentCategory,
HasCfd = syn.HasCfd, HasCfd = syn.HasCfd,
ImageId = syn.ImageId,
DerivativeProductCategories = syn.DerivativeProductCategories?.ToList() ?? new List<string>() DerivativeProductCategories = syn.DerivativeProductCategories?.ToList() ?? new List<string>()
}, },
TradeRepublicBond bond => new BondEntity
{
Isin = bond.Isin,
Name = bond.Name,
Type = bond.Type,
InstrumentCategory = bond.InstrumentCategory,
HasCfd = bond.HasCfd,
ImageId = bond.ImageId,
BondIssuerName = bond.BondIssuerName,
SearchSubtitle = bond.SearchSubtitle
},
TradeRepublicDerivative deriv => new DerivativeEntity
{
Isin = deriv.Isin,
Name = deriv.Name,
Type = deriv.Type,
InstrumentCategory = deriv.InstrumentCategory,
HasCfd = deriv.HasCfd,
ImageId = deriv.ImageId,
UnderlyingIsin = deriv.UnderlyingIsin,
DerivativeProductCategories = deriv.DerivativeProductCategories?.ToList() ?? new List<string>()
},
_ => new StockEntity _ => new StockEntity
{ {
Isin = dto.Isin, Isin = dto.Isin,
Name = dto.Name, Name = dto.Name,
Type = dto.Type, Type = dto.Type,
InstrumentCategory = dto.InstrumentCategory, InstrumentCategory = dto.InstrumentCategory,
HasCfd = dto.HasCfd, HasCfd = dto.HasCfd
ImageId = dto.ImageId
} }
}; };
} }
@@ -334,14 +295,21 @@ public class AssetsDbService : IAssetsDbService
case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto: case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto:
syn.DerivativeProductCategories = synDto.DerivativeProductCategories?.ToList() ?? new List<string>(); syn.DerivativeProductCategories = synDto.DerivativeProductCategories?.ToList() ?? new List<string>();
break; break;
case BondEntity bond when dto is TradeRepublicBond b: }
bond.BondIssuerName = b.BondIssuerName; }
bond.SearchSubtitle = b.SearchSubtitle;
break; private static bool HasSubtypeChanges(AssetEntity entity, TradeRepublicAsset dto)
case DerivativeEntity deriv when dto is TradeRepublicDerivative d: {
deriv.UnderlyingIsin = d.UnderlyingIsin; switch (entity)
deriv.DerivativeProductCategories = d.DerivativeProductCategories?.ToList() ?? new List<string>(); {
break; case StockEntity stock when dto is TradeRepublicStock s:
return !(stock.DerivativeProductCategories?.SequenceEqual(s.DerivativeProductCategories ?? Array.Empty<string>()) ?? (s.DerivativeProductCategories == null || !s.DerivativeProductCategories.Any()));
case EtfEntity etf when dto is TradeRepublicEtf e:
return !(etf.DerivativeProductCategories?.SequenceEqual(e.DerivativeProductCategories ?? Array.Empty<string>()) ?? (e.DerivativeProductCategories == null || !e.DerivativeProductCategories.Any()));
case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto:
return !(syn.DerivativeProductCategories?.SequenceEqual(synDto.DerivativeProductCategories ?? Array.Empty<string>()) ?? (synDto.DerivativeProductCategories == null || !synDto.DerivativeProductCategories.Any()));
default:
return false;
} }
} }
@@ -362,24 +330,6 @@ public class AssetsDbService : IAssetsDbService
.ToListAsync(); .ToListAsync();
} }
/// <summary>Inherits documentation from interface.</summary>
public async Task UpdateAssetImageIdAsync(string isin, string imageId)
{
var existingAssets = await _context.TradeRepublicAssets
.Where(a => a.Isin == isin)
.ToListAsync();
if (existingAssets.Count > 0)
{
foreach (var asset in existingAssets)
{
asset.ImageId = imageId;
}
await _context.SaveChangesAsync();
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetsDbService] Updated ImageId for ISIN {Isin} in database to '{ImageId}'", isin, imageId);
}
}
/// <summary>Inherits documentation from interface.</summary> /// <summary>Inherits documentation from interface.</summary>
public async Task<bool> DeleteAssetAsync(string isin) public async Task<bool> DeleteAssetAsync(string isin)
{ {
@@ -412,7 +362,6 @@ public class AssetsDbService : IAssetsDbService
int pageIndex = Math.Max(0, page ?? 0); int pageIndex = Math.Max(0, page ?? 0);
decimal levQuery = targetLeverage.HasValue && targetLeverage.Value > 0 ? targetLeverage.Value : 0m; decimal levQuery = targetLeverage.HasValue && targetLeverage.Value > 0 ? targetLeverage.Value : 0m;
string trAfter = !string.IsNullOrEmpty(after) ? after : (pageIndex > 0 ? pageIndex.ToString() : "0"); string trAfter = !string.IsNullOrEmpty(after) ? after : (pageIndex > 0 ? pageIndex.ToString() : "0");
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetsDbService] Fetching derivatives for {Isin} (OptionType: {Option}, Leverage: {Lev}, Page: {Page}, TR-After: {After})", await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetsDbService] Fetching derivatives for {Isin} (OptionType: {Option}, Leverage: {Lev}, Page: {Page}, TR-After: {After})",
@@ -438,8 +387,7 @@ public class AssetsDbService : IAssetsDbService
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var isins = fetchedItems.Select(r => r.Isin).ToList(); var isins = fetchedItems.Select(r => r.Isin).ToList();
var existingDerivatives = await _context.TradeRepublicAssets var existingDerivatives = await _context.Derivatives
.OfType<DerivativeEntity>()
.Where(d => isins.Contains(d.Isin)) .Where(d => isins.Contains(d.Isin))
.ToDictionaryAsync(d => d.Isin, cancellationToken); .ToDictionaryAsync(d => d.Isin, cancellationToken);
@@ -466,14 +414,13 @@ public class AssetsDbService : IAssetsDbService
existing.NextGenProductCategoryName = item.NextGenProductCategoryName; existing.NextGenProductCategoryName = item.NextGenProductCategoryName;
existing.Issuer = item.Issuer; existing.Issuer = item.Issuer;
existing.IssuerDisplayName = item.IssuerDisplayName; existing.IssuerDisplayName = item.IssuerDisplayName;
existing.IssuerImageId = item.IssuerImageId;
existing.Size = item.Size; existing.Size = item.Size;
existing.Factor = item.Factor; existing.Factor = item.Factor;
existing.Delta = item.Delta; existing.Delta = item.Delta;
existing.Currency = item.Currency; existing.Currency = item.Currency;
existing.LastUpdatedAt = now; existing.LastUpdatedAt = now;
_context.TradeRepublicAssets.Update(existing); _context.Derivatives.Update(existing);
resultEntities.Add(existing); resultEntities.Add(existing);
} }
else else
@@ -482,8 +429,6 @@ public class AssetsDbService : IAssetsDbService
{ {
Isin = item.Isin, Isin = item.Isin,
Name = !string.IsNullOrWhiteSpace(item.ProductCategoryName) ? item.ProductCategoryName : item.Isin, Name = !string.IsNullOrWhiteSpace(item.ProductCategoryName) ? item.ProductCategoryName : item.Isin,
Type = "derivative",
InstrumentCategory = "derivative",
UnderlyingIsin = underlyingIsin, UnderlyingIsin = underlyingIsin,
Strike = item.Strike ?? 0m, Strike = item.Strike ?? 0m,
Barrier = item.Barrier ?? 0m, Barrier = item.Barrier ?? 0m,
@@ -494,27 +439,33 @@ public class AssetsDbService : IAssetsDbService
NextGenProductCategoryName = item.NextGenProductCategoryName, NextGenProductCategoryName = item.NextGenProductCategoryName,
Issuer = item.Issuer, Issuer = item.Issuer,
IssuerDisplayName = item.IssuerDisplayName, IssuerDisplayName = item.IssuerDisplayName,
IssuerImageId = item.IssuerImageId,
Size = item.Size, Size = item.Size,
Factor = item.Factor, Factor = item.Factor,
Delta = item.Delta, Delta = item.Delta,
Currency = item.Currency, Currency = item.Currency,
LastUpdatedAt = now LastUpdatedAt = now,
CreatedAt = now
}; };
await _context.TradeRepublicAssets.AddAsync(newDeriv, cancellationToken); await _context.Derivatives.AddAsync(newDeriv, cancellationToken);
resultEntities.Add(newDeriv); resultEntities.Add(newDeriv);
} }
} }
await _context.SaveChangesAsync(cancellationToken); await _context.SaveChangesAsync(cancellationToken);
return resultEntities; return resultEntities.Where(d => d.Barrier > 0 && d.Leverage > 0).ToList();
} }
return await _context.TradeRepublicAssets var dbQuery = _context.Derivatives
.OfType<DerivativeEntity>()
.AsNoTracking() .AsNoTracking()
.Where(d => d.UnderlyingIsin == underlyingIsin) .Where(d => d.UnderlyingIsin == underlyingIsin && d.OptionType == targetOptionType && d.Barrier > 0 && d.Leverage > 0);
if (targetLeverage.HasValue && targetLeverage.Value > 0)
{
dbQuery = dbQuery.OrderBy(d => Math.Abs(d.Leverage - targetLeverage.Value));
}
return await dbQuery
.Take(pageSize) .Take(pageSize)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
} }
@@ -5,7 +5,7 @@ using System.Net.Http;
using System.Text.Json; using System.Text.Json;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticAssets.Models; using FinlyticCore.Models.Assets;
using FinlyticAssets.Util; using FinlyticAssets.Util;
using FinlyticCore.Services; using FinlyticCore.Services;
@@ -1,169 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using FinlyticAssets.Util;
using FinlyticCore.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
namespace FinlyticAssets.Services;
/// <summary>
/// Dedicated background service that periodically scans for missing asset logos in the local storage directory
/// and fetches them in batches from Trade Republic CDN.
/// Swaps missing/404 logos with a clean SVG placeholder image and triggers ReCreateIndexFileAsync.
/// </summary>
public class LogoFetcherBackgroundService : BackgroundService
{
private readonly IFinlyticLogger<LogoFetcherBackgroundService> _finlyticLogger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly HttpClient _httpClient;
private const string PlaceholderSvg = """
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
<rect width="100" height="100" rx="30" fill="#1E293B"/>
<path d="M 30 65 L 45 45 L 60 55 L 75 35" fill="none" stroke="#10B981" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="75" cy="35" r="5" fill="#06B6D4"/>
</svg>
""";
public LogoFetcherBackgroundService(
IFinlyticLogger<LogoFetcherBackgroundService> finlyticLogger,
IServiceScopeFactory scopeFactory)
{
_finlyticLogger = finlyticLogger;
_scopeFactory = scopeFactory;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept", "image/svg+xml,image/*,*/*");
_httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Referer", "https://traderepublic.com/");
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[LogoFetcherBackgroundService] LogoFetcherBackgroundService started. Will fetch missing logos periodically.");
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ProcessMissingLogosBatchAsync(stoppingToken);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
await _finlyticLogger.LogErrorAsync(SettingKeys.AssetsChannel, ex, "[LogoFetcherBackgroundService] Error occurred while executing logo batch fetch.");
}
try
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
}
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[LogoFetcherBackgroundService] LogoFetcherBackgroundService stopped.");
}
private async Task ProcessMissingLogosBatchAsync(CancellationToken stoppingToken)
{
using var scope = _scopeFactory.CreateScope();
var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
var validAssets = await dbService.GetAllValidAssetsAsync();
if (validAssets == null || !validAssets.Any()) return;
string directoryPath = Volumes.LogosRelativePath;
if (!Directory.Exists(directoryPath))
{
Directory.CreateDirectory(directoryPath);
}
var missingIsins = validAssets
.Select(a => a.Isin?.Trim().ToUpperInvariant())
.Where(isin => !string.IsNullOrEmpty(isin))
.Distinct()
.Where(isin => !File.Exists(Path.Combine(directoryPath, $"{isin}.svg")))
.ToList();
if (missingIsins.Count == 0)
{
await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "[LogoFetcherBackgroundService] All asset logos are downloaded and up to date.");
return;
}
var batchToFetch = missingIsins.Take(60).ToList();
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[LogoFetcherBackgroundService] Found {Count} missing logos on disk. Fetching bulk batch of {BatchSize} logos...", missingIsins.Count, batchToFetch.Count);
int successCount = 0;
foreach (var isin in batchToFetch)
{
if (stoppingToken.IsCancellationRequested) break;
string targetUrl = $"https://assets.traderepublic.com/img/logos/{isin}/v2/dark.min.svg";
string filePath = Path.Combine(directoryPath, $"{isin}.svg");
string dbImageEndpoint = $"/api/v1/logo/{isin}";
try
{
using var response = await _httpClient.GetAsync(targetUrl, stoppingToken);
if (response.IsSuccessStatusCode)
{
byte[] data = await response.Content.ReadAsByteArrayAsync(stoppingToken);
await File.WriteAllBytesAsync(filePath, data, stoppingToken);
successCount++;
}
else
{
await _finlyticLogger.LogWarningAsync(SettingKeys.AssetsChannel, "[LogoFetcherBackgroundService] Logo not found on CDN for ISIN {Isin} (HTTP {StatusCode}). Saving SVG placeholder.", isin, response.StatusCode);
byte[] placeholderData = Encoding.UTF8.GetBytes(PlaceholderSvg);
await File.WriteAllBytesAsync(filePath, placeholderData, stoppingToken);
successCount++;
}
await dbService.UpdateAssetImageIdAsync(isin, dbImageEndpoint);
}
catch (Exception ex) when (!stoppingToken.IsCancellationRequested)
{
await _finlyticLogger.LogWarningAsync(SettingKeys.AssetsChannel, ex, "[LogoFetcherBackgroundService] Exception while downloading logo for ISIN {Isin} from {Url}. Saving SVG placeholder.", isin, targetUrl);
try
{
byte[] placeholderData = Encoding.UTF8.GetBytes(PlaceholderSvg);
await File.WriteAllBytesAsync(filePath, placeholderData, stoppingToken);
successCount++;
await dbService.UpdateAssetImageIdAsync(isin, dbImageEndpoint);
}
catch { }
}
await Task.Delay(50, stoppingToken);
}
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[LogoFetcherBackgroundService] Batch fetch complete. Successfully processed {SuccessCount}/{BatchSize} logos. Remaining missing: {Remaining}",
successCount, batchToFetch.Count, missingIsins.Count - batchToFetch.Count);
if (successCount > 0)
{
try
{
await indexService.ReCreateIndexFileAsync(stoppingToken);
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[LogoFetcherBackgroundService] Successfully updated index.json after logo batch fetch.");
}
catch (Exception ex)
{
await _finlyticLogger.LogWarningAsync(SettingKeys.AssetsChannel, ex, "[LogoFetcherBackgroundService] Failed to update index.json after logo batch fetch.");
}
}
}
}
@@ -1,125 +0,0 @@
using FinlyticAssets.Database;
using FinlyticAssets.Entities;
using Microsoft.EntityFrameworkCore;
namespace FinlyticAssets.Services;
/// <summary>
/// Defines the business logic for managing global application settings.
/// Supports retrieving and updating (upserting) the central single-row configuration record.
/// </summary>
public interface ISettingsDbService
{
/// <summary>
/// Retrieves the current global settings from the database.
/// </summary>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the current <see cref="Settings"/>.
/// If no settings exist in the database yet, a new instance initialized with default values is returned.
/// </returns>
public Task<Settings> GetSettings();
/// <summary>
/// Persists the provided settings by updating the existing record or inserting the first one if the table is empty.
/// </summary>
/// <param name="settings">The new configuration values to be persisted.</param>
/// <returns>
/// A task that represents the asynchronous operation. The task result contains the freshly saved
/// and tracked <see cref="Settings"/> instance.
/// </returns>
public Task<Settings> SaveSettings(Settings settings);
/// <summary>
/// Updates settings from a key-value dictionary received via Admin Panel MQTT events.
/// </summary>
public Task UpdateSettingsFromDictionary(Dictionary<string, string> dictionary);
}
/// <summary>
/// Implements the <see cref="ISettingsDbService"/> utilizing Entity Framework Core.
/// This service is designed for a single-row table architecture to maintain stateful global configurations.
/// </summary>
public class SettingsDbService : ISettingsDbService
{
private readonly AssetsDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="SettingsDbService"/> class with the required database context.
/// </summary>
/// <param name="context">The EF Core context used to access the assets database.</param>
public SettingsDbService(AssetsDbContext context)
{
_context = context;
}
/// <summary>Inherits documentation from interface.</summary>
public async Task<Settings> GetSettings()
{
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
if (settings == null)
{
settings = new Settings { Id = Guid.NewGuid() };
_context.Settings.Add(settings);
await _context.SaveChangesAsync();
_context.ChangeTracker.Clear();
}
return settings;
}
/// <summary>Inherits documentation from interface.</summary>
public async Task<Settings> SaveSettings(Settings settings)
{
var existing = await _context.Settings.FirstOrDefaultAsync();
if (existing == null)
{
if (settings.Id == Guid.Empty)
{
settings.Id = Guid.NewGuid();
}
_context.Settings.Add(settings);
await _context.SaveChangesAsync();
return settings;
}
else
{
existing.FinishedInitialScan = settings.FinishedInitialScan;
existing.TradeRepublicMaxRequestPageSize = settings.TradeRepublicMaxRequestPageSize;
existing.AssetUpdateTypeDelay = settings.AssetUpdateTypeDelay;
existing.InitAssetUpdateTypeDelay = settings.InitAssetUpdateTypeDelay;
existing.InitBatchAssetUpdateDelay = settings.InitBatchAssetUpdateDelay;
existing.BatchAssetUpdateDelay = settings.BatchAssetUpdateDelay;
existing.CurrentScanningType = settings.CurrentScanningType;
existing.CurrentScanningPage = settings.CurrentScanningPage;
_context.Settings.Update(existing);
await _context.SaveChangesAsync();
return existing;
}
}
/// <summary>Inherits documentation from interface.</summary>
public async Task UpdateSettingsFromDictionary(Dictionary<string, string> dictionary)
{
var settings = await GetSettings();
foreach (var (key, value) in dictionary)
{
if (string.Equals(key, "TradeRepublicMaxRequestPageSize", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var ps))
settings.TradeRepublicMaxRequestPageSize = ps;
else if (string.Equals(key, "AssetUpdateTypeDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var autd))
settings.AssetUpdateTypeDelay = autd;
else if (string.Equals(key, "InitAssetUpdateTypeDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var iautd))
settings.InitAssetUpdateTypeDelay = iautd;
else if (string.Equals(key, "BatchAssetUpdateDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var baud))
settings.BatchAssetUpdateDelay = baud;
else if (string.Equals(key, "InitBatchAssetUpdateDelay", StringComparison.OrdinalIgnoreCase) && int.TryParse(value, out var ibaud))
settings.InitBatchAssetUpdateDelay = ibaud;
else if (string.Equals(key, "FinishedInitialScan", StringComparison.OrdinalIgnoreCase) && bool.TryParse(value, out var fis))
settings.FinishedInitialScan = fis;
}
await SaveSettings(settings);
}
}
+43 -45
View File
@@ -1,3 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using FinlyticAssets.Entities; using FinlyticAssets.Entities;
using FinlyticCore.Dtos.Assets; using FinlyticCore.Dtos.Assets;
@@ -14,6 +17,8 @@ public static class AssetMapper
Type = t.Type Type = t.Type
}).ToList(); }).ToList();
var dynamicLogoUrl = $"/api/v1/logo/{entity.Isin}";
return entity switch return entity switch
{ {
StockEntity stock => new StockDto StockEntity stock => new StockDto
@@ -23,7 +28,7 @@ public static class AssetMapper
Type = stock.Type, Type = stock.Type,
InstrumentCategory = stock.InstrumentCategory, InstrumentCategory = stock.InstrumentCategory,
HasCfd = stock.HasCfd, HasCfd = stock.HasCfd,
ImageId = stock.ImageId, ImageId = dynamicLogoUrl,
LastUpdatedAt = stock.LastUpdatedAt, LastUpdatedAt = stock.LastUpdatedAt,
Tags = dtoTags, Tags = dtoTags,
DerivativeProductCategories = stock.DerivativeProductCategories DerivativeProductCategories = stock.DerivativeProductCategories
@@ -35,7 +40,7 @@ public static class AssetMapper
Type = etf.Type, Type = etf.Type,
InstrumentCategory = etf.InstrumentCategory, InstrumentCategory = etf.InstrumentCategory,
HasCfd = etf.HasCfd, HasCfd = etf.HasCfd,
ImageId = etf.ImageId, ImageId = dynamicLogoUrl,
LastUpdatedAt = etf.LastUpdatedAt, LastUpdatedAt = etf.LastUpdatedAt,
Tags = dtoTags, Tags = dtoTags,
DerivativeProductCategories = etf.DerivativeProductCategories, DerivativeProductCategories = etf.DerivativeProductCategories,
@@ -44,42 +49,44 @@ public static class AssetMapper
Subtitle = etf.Subtitle, Subtitle = etf.Subtitle,
SearchSubtitle = etf.SearchSubtitle SearchSubtitle = etf.SearchSubtitle
}, },
CryptoEntity crypto => new CryptoDto SyntheticEntity synth => new SyntheticDto
{ {
Isin = crypto.Isin, Isin = synth.Isin,
Name = crypto.Name, Name = synth.Name,
Type = crypto.Type, Type = synth.Type,
InstrumentCategory = crypto.InstrumentCategory, InstrumentCategory = synth.InstrumentCategory,
HasCfd = crypto.HasCfd, HasCfd = synth.HasCfd,
ImageId = crypto.ImageId, ImageId = dynamicLogoUrl,
LastUpdatedAt = crypto.LastUpdatedAt, LastUpdatedAt = synth.LastUpdatedAt,
Tags = dtoTags, Tags = dtoTags,
Subtitle = crypto.Subtitle, DerivativeProductCategories = synth.DerivativeProductCategories
SearchSubtitle = crypto.SearchSubtitle
}, },
BondEntity bond => new BondDto _ => new StockDto
{ {
Isin = bond.Isin, Isin = entity.Isin,
Name = bond.Name, Name = entity.Name,
Type = bond.Type, Type = entity.Type,
InstrumentCategory = bond.InstrumentCategory, InstrumentCategory = entity.InstrumentCategory,
HasCfd = bond.HasCfd, HasCfd = entity.HasCfd,
ImageId = bond.ImageId, ImageId = dynamicLogoUrl,
LastUpdatedAt = bond.LastUpdatedAt, LastUpdatedAt = entity.LastUpdatedAt,
Tags = dtoTags, Tags = dtoTags
BondIssuerName = bond.BondIssuerName, }
SearchSubtitle = bond.SearchSubtitle };
}, }
DerivativeEntity deriv => new DerivativeDto
public static DerivativeDto ToDto(this DerivativeEntity deriv)
{
return new DerivativeDto
{ {
Isin = deriv.Isin, Isin = deriv.Isin,
Name = deriv.Name, Name = deriv.Name,
Type = deriv.Type, Type = "derivative",
InstrumentCategory = deriv.InstrumentCategory, InstrumentCategory = "derivative",
HasCfd = deriv.HasCfd, HasCfd = false,
ImageId = deriv.ImageId, ImageId = $"/api/v1/logo/{deriv.UnderlyingIsin}",
LastUpdatedAt = deriv.LastUpdatedAt, LastUpdatedAt = deriv.LastUpdatedAt,
Tags = dtoTags, Tags = new List<TagDto>(),
DerivativeProductCategories = deriv.DerivativeProductCategories, DerivativeProductCategories = deriv.DerivativeProductCategories,
UnderlyingIsin = deriv.UnderlyingIsin, UnderlyingIsin = deriv.UnderlyingIsin,
OptionType = deriv.OptionType.ToString(), OptionType = deriv.OptionType.ToString(),
@@ -95,21 +102,7 @@ public static class AssetMapper
Expiry = deriv.Expiry, Expiry = deriv.Expiry,
Issuer = deriv.Issuer, Issuer = deriv.Issuer,
IssuerDisplayName = deriv.IssuerDisplayName, IssuerDisplayName = deriv.IssuerDisplayName,
IssuerImageId = deriv.IssuerImageId IssuerImageId = null
},
SyntheticEntity synth => new SyntheticDto
{
Isin = synth.Isin,
Name = synth.Name,
Type = synth.Type,
InstrumentCategory = synth.InstrumentCategory,
HasCfd = synth.HasCfd,
ImageId = synth.ImageId,
LastUpdatedAt = synth.LastUpdatedAt,
Tags = dtoTags,
DerivativeProductCategories = synth.DerivativeProductCategories
},
_ => throw new NotSupportedException($"Mapping for type {entity.GetType().Name} is not supported.")
}; };
} }
@@ -117,4 +110,9 @@ public static class AssetMapper
{ {
return entities.Select(e => e.ToDto()).ToList(); return entities.Select(e => e.ToDto()).ToList();
} }
public static List<DerivativeDto> ToDtoList(this IEnumerable<DerivativeEntity> derivatives)
{
return derivatives.Select(d => d.ToDto()).ToList();
}
} }
+133 -251
View File
@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
@@ -6,9 +7,14 @@ using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using FinlyticAssets.Entities; using FinlyticAssets.Entities;
using FinlyticAssets.Services; using FinlyticAssets.Services;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Assets;
using FinlyticCore.Dtos.Settings; using FinlyticCore.Dtos.Settings;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models; using FinlyticCore.Models;
using FinlyticCore.Models.Assets;
using FinlyticCore.Services; using FinlyticCore.Services;
using FinlyticCore.Services.TradeRepublic;
using FinlyticCore.Util; using FinlyticCore.Util;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -18,13 +24,14 @@ using Microsoft.Extensions.Logging;
namespace FinlyticAssets.Util; namespace FinlyticAssets.Util;
/// <summary> /// <summary>
/// Represents a managed MQTT client acting as a server-side RPC provider within the asset microservice. /// Managed MQTT client acting as an RPC provider for asset lookups, discovery, and on-demand derivatives.
/// </summary> /// </summary>
public class AssetsMqttClient : ManagedMqttClient, IHostedService public class AssetsMqttClient : ManagedMqttClient, IHostedService
{ {
private readonly ILogger<AssetsMqttClient> _logger; private readonly ILogger<AssetsMqttClient> _logger;
private readonly IServiceScopeFactory _scopeFactory; private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly ConcurrentDictionary<string, (LivePriceDto Price, DateTime CachedAt)> _priceCache = new(StringComparer.OrdinalIgnoreCase);
public AssetsMqttClient( public AssetsMqttClient(
ILogger<AssetsMqttClient> logger, ILogger<AssetsMqttClient> logger,
@@ -41,12 +48,7 @@ public class AssetsMqttClient : ManagedMqttClient, IHostedService
/// </summary> /// </summary>
public async Task StartAsync(CancellationToken cancellationToken) public async Task StartAsync(CancellationToken cancellationToken)
{ {
var config = new MqttConfiguration() var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticAssets");
{
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticAssets")}_{Guid.NewGuid()}"
};
_logger.LogInformation("Starting Assets MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId); _logger.LogInformation("Starting Assets MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config); await ConnectAsync(config);
@@ -66,278 +68,158 @@ public class AssetsMqttClient : ManagedMqttClient, IHostedService
/// </summary> /// </summary>
protected override async Task OnConnectedAsync() protected override async Task OnConnectedAsync()
{ {
_logger.LogInformation("Assets MQTT Client connected. Subscribing to topics..."); _logger.LogInformation("Assets MQTT Client connected. Registering topic subscriptions...");
await SubscribeAsync("services/request/assets_Get/#");
await SubscribeAsync("services/request/assets_Search/#");
await SubscribeAsync("services/request/assets_GetDiscovery/#");
await SubscribeAsync("services/request/assets_GetDerivatives/#");
await SubscribeAsync("services/request/assets_FetchLogo/#");
await SubscribeAsync("services/request/assets_settings_GetAll/#");
await SubscribeAsync("services/request/assets_settings_Update/#");
await SubscribeAsync("services/request/health_Ping/#");
await SubscribeAsync("services/config/updated/#");
FinlyticCore.Services.FinlyticLogBroadcaster.OnLogPublished = async (logDto) => await SubscribeAsync(MqttTopics.ResponseWildcard);
await SubscribeRpcAsync<GetValidAssetRequest, List<AssetDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.AssetsGet), HandleAssetsGetRpcAsync);
await SubscribeRpcAsync<GetDiscoveryAssetsRequest, List<AssetDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.AssetsGetDiscovery), HandleAssetsGetDiscoveryRpcAsync);
await SubscribeRpcAsync<GetDerivativesRequest, List<DerivativeDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.AssetsGetDerivatives), HandleAssetsGetDerivativesRpcAsync);
await SubscribeRpcAsync<IsinRequest, LivePriceDto?>(MqttTopics.RequestFilter(MqttTopics.Channels.TrGetLivePrice), HandleGetLivePriceRpcAsync);
await SubscribeRpcAsync<object, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.AssetsSettingsGetAll), HandleSettingsGetAllRpcAsync);
await SubscribeRpcAsync<Dictionary<string, object?>, List<DynamicSettingDto>>(MqttTopics.RequestFilter(MqttTopics.Channels.AssetsSettingsUpdate), HandleSettingsUpdateRpcAsync);
await SubscribeAsync<object>(MqttTopics.RequestFilter(MqttTopics.Channels.HealthPing), HandleHealthPingRpcAsync);
FinlyticLogBroadcaster.OnLogPublished = async (logDto) =>
{ {
if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticAssets", StringComparison.OrdinalIgnoreCase)) if (IsConnected && string.Equals(logDto.ServiceName, "FinlyticAssets", StringComparison.OrdinalIgnoreCase))
{ {
await PublishAsync("finlytic/logs/FinlyticAssets", logDto); await PublishAsync(MqttTopics.Logs("FinlyticAssets"), logDto);
} }
}; };
} }
/// <summary> private async Task<List<AssetDto>> HandleAssetsGetRpcAsync(GetValidAssetRequest? req, string correlationId)
/// Processes incoming messages on the subscribed topics.
/// </summary>
protected override async Task OnMessageReceivedAsync(string topic, string payload)
{ {
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase)) if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return [];
{
await HandleConfigUpdatedAsync(topic, payload); using var scope = _scopeFactory.CreateScope();
return; var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
var assets = await dbService.GetValidAssetsByIsinAsync(req.Isin.Trim().ToUpperInvariant());
return assets.ToDtoList();
} }
var segments = topic.Split('/'); private async Task<List<AssetDto>> HandleAssetsGetDiscoveryRpcAsync(GetDiscoveryAssetsRequest? req, string correlationId)
if (segments.Length < 4) return;
var channel = segments[2];
var correlationId = segments[segments.Length - 1];
if (topic.Contains("health_Ping", StringComparison.OrdinalIgnoreCase))
{
await HandleHealthPingAsync(topic, segments, correlationId);
return;
}
if (topic.StartsWith("services/request/assets_settings_GetAll", StringComparison.OrdinalIgnoreCase))
{
await HandleSettingsGetAllAsync(correlationId);
return;
}
if (topic.StartsWith("services/request/assets_settings_Update", StringComparison.OrdinalIgnoreCase))
{
await HandleSettingsUpdateAsync(payload, correlationId);
return;
}
try
{ {
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>(); var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>(); int limit = req?.Limit > 0 ? req.Limit : 15;
var discoveryAssets = await dbService.GetDiscoveryAssetsAsync(limit);
return discoveryAssets.ToDtoList();
}
if (channel == "assets_FetchLogo") private async Task<List<DerivativeDto>> HandleAssetsGetDerivativesRpcAsync(GetDerivativesRequest? req, string correlationId)
{ {
await HandleFetchLogoAsync(payload, correlationId, indexService); if (req == null || string.IsNullOrWhiteSpace(req.UnderlyingIsin)) return [];
return;
}
List<AssetEntity> responseData = [];
switch (channel)
{
case "assets_Get":
responseData = await HandleAssetsGetAsync(payload, dbService);
break;
case "assets_Search":
responseData = await HandleAssetsSearchAsync(payload, dbService);
break;
case "assets_GetDiscovery":
responseData = await HandleAssetsGetDiscoveryAsync(payload, dbService);
break;
case "assets_GetDerivatives":
responseData = (await HandleAssetsGetDerivativesAsync(payload, dbService)).Cast<AssetEntity>().ToList();
break;
}
string defaultResponseTopic = $"services/response/{channel}/{correlationId}";
await PublishAsync(defaultResponseTopic, responseData.ToDtoList());
}
catch (Exception ex)
{
OnError(ex);
}
}
private async Task HandleSettingsGetAllAsync(string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAssets] [Settings_GetAll] Retrieving all dynamic settings via reflection [CorrelationId: {CorrelationId}]", correlationId);
try
{
var settings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
var responseTopic = $"services/response/assets_settings_GetAll/{correlationId}";
await PublishAsync(responseTopic, settings);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAssets] [Settings_GetAll] Published {Count} settings to '{ResponseTopic}'", settings.Count, responseTopic);
}
catch (Exception ex)
{
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticAssets] [Settings_GetAll] Failed to retrieve settings.");
}
}
private async Task HandleSettingsUpdateAsync(string payload, string correlationId)
{
if (string.IsNullOrWhiteSpace(payload)) return;
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>(); var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>(); var derivatives = await dbService.GetDerivativesByUnderlyingAsync(
req.UnderlyingIsin.Trim().ToUpperInvariant(),
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAssets] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
try
{
Dictionary<string, object?>? updates = null;
try
{
updates = JsonSerializer.Deserialize<Dictionary<string, object?>>(payload);
}
catch
{
var list = JsonSerializer.Deserialize<List<DynamicSettingDto>>(payload);
if (list != null)
{
updates = new Dictionary<string, object?>();
foreach (var item in list) updates[item.Key] = item.Value;
}
}
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAssets] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
}
var currentSettings = await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
var responseTopic = $"services/response/assets_settings_Update/{correlationId}";
await PublishAsync(responseTopic, currentSettings);
}
catch (Exception ex)
{
await finlyticLogger.LogErrorAsync(SettingKeys.MqttChannel, ex, "[FinlyticAssets] [Settings_Update] Failed to update settings.");
}
}
private async Task HandleConfigUpdatedAsync(string topic, string payload)
{
if (!topic.EndsWith("FinlyticAssets", StringComparison.OrdinalIgnoreCase))
return;
try
{
var updatePayload = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.ServiceConfigUpdatePayload);
if (updatePayload?.Settings != null && updatePayload.Settings.Count > 0)
{
using var scope = _scopeFactory.CreateScope();
var settings = scope.ServiceProvider.GetRequiredService<ISettingsService>();
var dict = updatePayload.Settings.ToDictionary(k => k.Key, v => (object?)v.Value);
await settings.UpdateSettingsAsync(dict);
}
}
catch (Exception ex)
{
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
await finlyticLogger.LogErrorAsync(SettingKeys.AssetsChannel, ex, "[AssetsMqttClient] Error processing MQTT config update event.");
}
}
private async Task HandleHealthPingAsync(string topic, string[] segments, string correlationId)
{
bool isForMe = segments.Length >= 5
? segments[3].Equals("FinlyticAssets", StringComparison.OrdinalIgnoreCase)
: topic.Contains("FinlyticAssets", StringComparison.OrdinalIgnoreCase);
if (isForMe)
{
string respTopic = $"services/response/health_Ping/{correlationId}";
await PublishAsync(respTopic, new FinlyticCore.Dtos.ServiceHealthResponse("FinlyticAssets", "Online", DateTime.UtcNow, "Connected"));
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[AssetsMqttClient] Responded to live health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
}
}
private async Task HandleFetchLogoAsync(string payload, string correlationId, IAssetsIndexService indexService)
{
var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.IsinRequest);
string? isin = req?.Isin;
string? savedPath = null;
if (!string.IsNullOrEmpty(isin))
{
savedPath = await indexService.DownloadAndSaveLogoAsync(isin);
}
string responseTopic = $"services/response/assets_FetchLogo/{correlationId}";
await PublishAsync(responseTopic, new FinlyticCore.Dtos.FetchLogoResponse(isin, savedPath, savedPath != null));
}
private async Task<List<AssetEntity>> HandleAssetsGetAsync(string payload, IAssetsDbService dbService)
{
var validReq = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetValidAssetRequest);
if (validReq != null)
{
return await dbService.GetValidAssetsByIsinAsync(validReq.Isin);
}
return [];
}
private async Task<List<AssetEntity>> HandleAssetsSearchAsync(string payload, IAssetsDbService dbService)
{
var searchReq = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.SearchAssetsRequest);
if (searchReq != null)
{
return await dbService.FindAffectedActiveAssetsAsync(searchReq.SearchQuery);
}
return [];
}
private async Task<List<AssetEntity>> HandleAssetsGetDiscoveryAsync(string payload, IAssetsDbService dbService)
{
int limit = 15;
if (!string.IsNullOrWhiteSpace(payload))
{
try
{
var discReq = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetDiscoveryAssetsRequest);
if (discReq != null && discReq.Limit > 0) limit = discReq.Limit;
}
catch { }
}
return await dbService.GetDiscoveryAssetsAsync(limit);
}
private async Task<List<DerivativeEntity>> HandleAssetsGetDerivativesAsync(string payload, IAssetsDbService dbService)
{
if (string.IsNullOrWhiteSpace(payload)) return [];
try
{
var req = JsonSerializer.Deserialize(payload, FinlyticJsonSerializerContext.Default.GetDerivativesRequest);
if (req != null && !string.IsNullOrEmpty(req.UnderlyingIsin))
{
return await dbService.GetDerivativesByUnderlyingAsync(
req.UnderlyingIsin,
req.OptionType, req.OptionType,
req.TargetLeverage, req.TargetLeverage,
req.After, req.After,
req.Page, req.Page,
req.ShouldForceRefresh); req.ShouldForceRefresh);
return derivatives.ToDtoList();
} }
private async Task<LivePriceDto?> HandleGetLivePriceRpcAsync(IsinRequest? req, string correlationId)
{
if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return null;
var cleanIsin = req.Isin.Trim().ToUpperInvariant();
// Return fresh price from cache if less than 3 seconds old
if (_priceCache.TryGetValue(cleanIsin, out var cached) && (DateTime.UtcNow - cached.CachedAt).TotalSeconds < 3)
{
return cached.Price;
}
using var scope = _scopeFactory.CreateScope();
var trService = scope.ServiceProvider.GetRequiredService<ITradeRepublicService>();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
var tcs = new TaskCompletionSource<LivePriceDto?>(TaskCreationOptions.RunContinuationsAsynchronously);
int? subId = null;
try
{
subId = await trService.SubscribeRealtimeTickerAsync(cleanIsin, tick =>
{
decimal currentPrice = tick.Last?.PriceValue > 0 ? tick.Last.PriceValue :
(tick.Bid?.PriceValue > 0 && tick.Ask?.PriceValue > 0 ? (tick.Bid.PriceValue + tick.Ask.PriceValue) / 2m :
(tick.Ask?.PriceValue ?? tick.Bid?.PriceValue ?? 0m));
decimal preClose = tick.Pre?.PriceValue > 0 ? tick.Pre.PriceValue : (tick.Open?.PriceValue ?? 0m);
decimal dailyChange = preClose > 0m ? ((currentPrice - preClose) / preClose) * 100m : 0m;
var livePrice = new LivePriceDto(
Isin: cleanIsin,
CurrentPrice: currentPrice,
DailyChangePercent: dailyChange,
Bid: tick.Bid?.PriceValue,
Ask: tick.Ask?.PriceValue
);
_priceCache[cleanIsin] = (livePrice, DateTime.UtcNow);
tcs.TrySetResult(livePrice);
});
var result = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(3));
return result;
} }
catch (Exception ex) catch (Exception ex)
{ {
using var scope = _scopeFactory.CreateScope(); await finlyticLogger.LogWarningAsync(SettingKeys.AssetsChannel, "[AssetsMqttClient] Failed or timed out fetching live price for {Isin}: {Message}", cleanIsin, ex.Message);
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>(); if (_priceCache.TryGetValue(cleanIsin, out var stale))
await finlyticLogger.LogErrorAsync(SettingKeys.AssetsChannel, ex, "[AssetsMqttClient] Error parsing GetDerivativesRequest payload."); {
return stale.Price;
}
return null;
}
finally
{
if (subId.HasValue)
{
_ = trService.UnsubscribeRealtimeTickerAsync(subId.Value);
}
}
} }
return []; private async Task<List<DynamicSettingDto>> HandleSettingsGetAllRpcAsync(object? _, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAssets] [Settings_GetAll] Retrieving service dynamic settings [CorrelationId: {CorrelationId}]", correlationId);
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
}
private async Task<List<DynamicSettingDto>> HandleSettingsUpdateRpcAsync(Dictionary<string, object?>? updates, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsService>();
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAssets] [Settings_Update] Processing settings update RPC [CorrelationId: {CorrelationId}]", correlationId);
if (updates != null && updates.Count > 0)
{
await settingsService.UpdateSettingsAsync(updates);
await finlyticLogger.LogInfoAsync(SettingKeys.MqttChannel, "[FinlyticAssets] [Settings_Update] Successfully updated {Count} settings in database and cache.", updates.Count);
}
return await settingsService.GetAllRegisteredSettingsAsync(new[] { typeof(SettingKeys) });
}
private async Task HandleHealthPingRpcAsync(object? _, string topic, string correlationId)
{
if (topic.Contains("FinlyticAssets", StringComparison.OrdinalIgnoreCase))
{
string respTopic = MqttTopics.ResponseTopic(MqttTopics.Channels.HealthPing, correlationId);
await PublishAsync(respTopic, new FinlyticCore.Dtos.ServiceHealthResponse("FinlyticAssets", "Online", DateTime.UtcNow, "Connected"));
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
await finlyticLogger.LogInfoAsync(SettingKeys.HealthPingChannel, "[AssetsMqttClient] Responded to health_Ping RPC request [CorrelationId: {CorrelationId}].", correlationId);
}
} }
} }
+15 -9
View File
@@ -8,15 +8,21 @@ public static class SettingKeys
public static readonly SettingKey<bool> AssetsChannel = new("Logging.Channel.Assets", true); public static readonly SettingKey<bool> AssetsChannel = new("Logging.Channel.Assets", true);
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true); public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true); public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
public static readonly SettingKey<bool> TradeRepublicChannel = new("Logging.Channel.TradeRepublic", true);
// --- Asset Scanning --- // --- Trade Republic Connection ---
public static readonly SettingKey<bool> EnableAutoScan = new("Scanner.EnableAutoScan", true); public static readonly SettingKey<int> TradeRepublicWsReconnectInterval = new("TradeRepublic.WsReconnectIntervalSeconds", 5);
public static readonly SettingKey<int> ScanIntervalHours = new("Scanner.ScanIntervalHours", 12); public static readonly SettingKey<int> TradeRepublicWsTimeout = new("TradeRepublic.WsTimeoutSeconds", 15);
public static readonly SettingKey<int> MaxConcurrentScans = new("Scanner.MaxConcurrentScans", 5);
public static readonly SettingKey<bool> EnableDerivativeScanning = new("Scanner.EnableDerivativeScanning", true);
// --- Logos & Media --- // --- Asset Scanning Config ---
public static readonly SettingKey<bool> AutoFetchLogos = new("Media.AutoFetchLogos", true); public static readonly SettingKey<bool> ScannerEnableAutoScan = new("Scanner.EnableAutoScan", true);
public static readonly SettingKey<int> LogoFetchBatchSize = new("Media.LogoFetchBatchSize", 25); public static readonly SettingKey<string> ScannerCurrentScanningType = new("Scanner.CurrentScanningType", "Stock");
public static readonly SettingKey<string> LogoStorageDirectory = new("Media.LogoStorageDirectory", "data/logos"); public static readonly SettingKey<int> ScannerCurrentScanningPage = new("Scanner.CurrentScanningPage", 0);
public static readonly SettingKey<bool> ScannerFinishedInitialScan = new("Scanner.FinishedInitialScan", false);
public static readonly SettingKey<int> ScannerBatchDelay = new("Scanner.BatchAssetUpdateDelay", 0);
public static readonly SettingKey<int> ScannerTypeDelay = new("Scanner.AssetUpdateTypeDelay", 0);
public static readonly SettingKey<int> ScannerInitBatchDelay = new("Scanner.InitBatchAssetUpdateDelay", 0);
public static readonly SettingKey<int> ScannerInitTypeDelay = new("Scanner.InitAssetUpdateTypeDelay", 0);
public static readonly SettingKey<int> ScannerMaxPageSize = new("Scanner.TradeRepublicMaxRequestPageSize", 50);
public static readonly SettingKey<int> ScannerCycleDelayMinutes = new("Scanner.CycleDelayMinutes", 1440);
} }
@@ -1,17 +0,0 @@
namespace FinlyticAssets.Util;
public class StringCodeGenerator
{
/// <summary>
/// Generates a W3C traceparent string for telemetry tracking.
/// </summary>
public static string GenerateTraceparent()
{
var traceId = Guid.NewGuid().ToString("N");
var spanId = Guid.NewGuid().ToString("N").Substring(0, 16);
return $"00-{traceId}-{spanId}-01";
}
}