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<Settings> Settings { get; set; }
public DbSet<AssetEntity> TradeRepublicAssets { get; set; }
public DbSet<TagEntity> TradeRepublicTags { get; set; }
public DbSet<AssetEntity> TradeRepublicAssets => Set<AssetEntity>();
public DbSet<DerivativeEntity> Derivatives => Set<DerivativeEntity>();
public DbSet<TagEntity> TradeRepublicTags => Set<TagEntity>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -30,20 +30,29 @@ public class AssetsDbContext : DbContext, ISettingsDbContext
entity.HasKey(e => e.Id);
entity.HasIndex(e => e.Key).IsUnique();
});
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.HasDiscriminator<string>("AssetType")
.HasValue<StockEntity>("Stock")
.HasValue<CryptoEntity>("Crypto")
.HasValue<EtfEntity>("Etf")
.HasValue<SyntheticEntity>("Synthetic")
.HasValue<BondEntity>("Bond")
.HasValue<DerivativeEntity>("Derivative");
.HasValue<SyntheticEntity>("Synthetic");
});
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 =
new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
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<Settings>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.CurrentScanningType)
.HasConversion<string>()
.HasMaxLength(50);
});
modelBuilder.Entity<AssetEntity>()
.HasMany(a => a.Tags)
.WithMany(t => t.Assets);
+1
View File
@@ -5,6 +5,7 @@ WORKDIR /app
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FinlyticCore/FinlyticCore.csproj", "FinlyticCore/"]
COPY ["FinlyticAssets/FinlyticAssets.csproj", "FinlyticAssets/"]
RUN dotnet restore "FinlyticAssets/FinlyticAssets.csproj"
COPY . .
-2
View File
@@ -7,8 +7,6 @@ public abstract class AssetEntity
public string Type { get; set; } = string.Empty;
public string InstrumentCategory { get; set; } = string.Empty;
public bool HasCfd { get; set; }
public string? ImageId { get; set; }
public DateTime LastUpdatedAt { get; set; } = DateTime.UtcNow;
public List<TagEntity> Tags { get; set; } = new();
+10 -3
View File
@@ -11,11 +11,17 @@ public enum OptionType
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) ---
[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 ---
public OptionType OptionType { get; set; } // Long / Short
@@ -54,7 +60,8 @@ public class DerivativeEntity : AssetEntity
[MaxLength(150)]
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
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
{
[DbContext(typeof(AssetsDbContext))]
[Migration("20260815184053_UpdateDynamicSettingsUniqueIndex")]
partial class UpdateDynamicSettingsUniqueIndex
[Migration("20260818185753_Init")]
partial class Init
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -59,9 +59,6 @@ namespace FinlyticAssets.Migrations
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -84,41 +81,91 @@ namespace FinlyticAssets.Migrations
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FinlyticAssets.Entities.Settings", b =>
modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.Property<int>("AssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<decimal>("Barrier")
.HasColumnType("numeric(18,6)");
b.Property<int>("BatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentScanningPage")
.HasColumnType("integer");
b.Property<string>("CurrentScanningType")
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("FinishedInitialScan")
.HasColumnType("boolean");
b.Property<decimal?>("Delta")
.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");
b.Property<int>("InitBatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<string>("ProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("TradeRepublicMaxRequestPageSize")
.HasColumnType("integer");
b.Property<decimal?>("Size")
.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 =>
@@ -170,109 +217,6 @@ namespace FinlyticAssets.Migrations
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");
@@ -297,18 +241,6 @@ namespace FinlyticAssets.Migrations
.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");
});
@@ -1,4 +1,4 @@
using System;
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -12,22 +12,47 @@ namespace FinlyticAssets.Migrations
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Settings",
name: "Derivatives",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
FinishedInitialScan = table.Column<bool>(type: "boolean", nullable: false),
TradeRepublicMaxRequestPageSize = table.Column<int>(type: "integer", nullable: false),
AssetUpdateTypeDelay = table.Column<int>(type: "integer", nullable: false),
InitAssetUpdateTypeDelay = table.Column<int>(type: "integer", nullable: false),
InitBatchAssetUpdateDelay = table.Column<int>(type: "integer", nullable: false),
BatchAssetUpdateDelay = table.Column<int>(type: "integer", nullable: false),
CurrentScanningType = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
CurrentScanningPage = table.Column<int>(type: "integer", nullable: false)
Isin = table.Column<string>(type: "character varying(12)", maxLength: 12, nullable: false),
UnderlyingIsin = table.Column<string>(type: "character varying(12)", maxLength: 12, nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
OptionType = table.Column<int>(type: "integer", nullable: false),
ProductCategoryName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
NextGenProductCategoryName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: false),
Strike = table.Column<decimal>(type: "numeric(18,6)", nullable: false),
Barrier = table.Column<decimal>(type: "numeric(18,6)", 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 =>
{
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(
@@ -39,20 +64,13 @@ namespace FinlyticAssets.Migrations
Name = table.Column<string>(type: "text", nullable: false),
Type = table.Column<string>(type: "text", 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),
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),
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),
MappedEtfIndexName = table.Column<string>(type: "text", nullable: true),
EtfEntity_Subtitle = table.Column<string>(type: "text", nullable: true),
EtfEntity_SearchSubtitle = table.Column<string>(type: "text", nullable: true),
Subtitle = table.Column<string>(type: "text", nullable: true),
SearchSubtitle = 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)
},
@@ -104,6 +122,32 @@ namespace FinlyticAssets.Migrations
table: "AssetEntityTagEntity",
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(
name: "IX_TradeRepublicAssets_LastUpdatedAt",
table: "TradeRepublicAssets",
@@ -117,7 +161,10 @@ namespace FinlyticAssets.Migrations
name: "AssetEntityTagEntity");
migrationBuilder.DropTable(
name: "Settings");
name: "Derivatives");
migrationBuilder.DropTable(
name: "DynamicSettings");
migrationBuilder.DropTable(
name: "TradeRepublicAssets");
@@ -12,8 +12,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace FinlyticAssets.Migrations
{
[DbContext(typeof(AssetsDbContext))]
[Migration("20260813202348_UpdateDerivativeAssets")]
partial class UpdateDerivativeAssets
[Migration("20260821153552_SyncAssetModelDrift")]
partial class SyncAssetModelDrift
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -59,9 +59,6 @@ namespace FinlyticAssets.Migrations
b.Property<bool>("HasCfd")
.HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -84,104 +81,18 @@ namespace FinlyticAssets.Migrations
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 =>
{
b.HasBaseType("FinlyticAssets.Entities.AssetEntity");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.Property<decimal>("Barrier")
.HasColumnType("numeric(18,6)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(10)
@@ -210,12 +121,16 @@ namespace FinlyticAssets.Migrations
.HasMaxLength(150)
.HasColumnType("character varying(150)");
b.Property<string>("IssuerImageId")
.HasColumnType("text");
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)
@@ -236,10 +151,72 @@ namespace FinlyticAssets.Migrations
.HasColumnType("numeric(18,6)");
b.Property<string>("UnderlyingIsin")
.IsRequired()
.HasMaxLength(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 =>
@@ -266,18 +243,6 @@ namespace FinlyticAssets.Migrations
.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");
});
@@ -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")
.HasColumnType("boolean");
b.Property<string>("ImageId")
.HasColumnType("text");
b.Property<DateTime>("LastUpdatedAt")
.HasColumnType("timestamp with time zone");
@@ -81,41 +78,93 @@ namespace FinlyticAssets.Migrations
b.UseTphMappingStrategy();
});
modelBuilder.Entity("FinlyticAssets.Entities.Settings", b =>
modelBuilder.Entity("FinlyticAssets.Entities.DerivativeEntity", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Isin")
.HasMaxLength(12)
.HasColumnType("character varying(12)");
b.Property<int>("AssetUpdateTypeDelay")
.HasColumnType("integer");
b.Property<decimal>("Barrier")
.HasColumnType("numeric(18,6)");
b.Property<int>("BatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CurrentScanningPage")
.HasColumnType("integer");
b.Property<string>("CurrentScanningType")
b.Property<string>("Currency")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("character varying(50)");
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<bool>("FinishedInitialScan")
.HasColumnType("boolean");
b.Property<decimal?>("Delta")
.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");
b.Property<int>("InitBatchAssetUpdateDelay")
.HasColumnType("integer");
b.Property<string>("ProductCategoryName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("TradeRepublicMaxRequestPageSize")
.HasColumnType("integer");
b.Property<decimal?>("Size")
.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 =>
@@ -167,109 +216,6 @@ namespace FinlyticAssets.Migrations
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");
@@ -294,18 +240,6 @@ namespace FinlyticAssets.Migrations
.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");
});
+3 -13
View File
@@ -1,4 +1,3 @@
using System;
using FinlyticAssets.Database;
using FinlyticAssets.Services;
using FinlyticAssets.Util;
@@ -24,14 +23,12 @@ builder.Services.AddSingleton(typeof(IFinlyticLogger<>), typeof(FinlyticLogger<>
builder.Services.AddScoped<TradeRepublicClient>();
builder.Services.AddScoped<ITradeRepublicService, TradeRepublicService>();
builder.Services.AddScoped<IAssetsDbService, AssetsDbService>();
builder.Services.AddScoped<ISettingsDbService, SettingsDbService>();
builder.Services.AddScoped<IAssetsIndexService, AssetsIndexService>();
builder.Services.AddSingleton<AssetsMqttClient>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<AssetsMqttClient>());
builder.Services.AddHostedService<AssetScannerBackgroundService>();
builder.Services.AddHostedService<LogoFetcherBackgroundService>();
var host = builder.Build();
@@ -40,21 +37,14 @@ using (var scope = host.Services.CreateScope())
try
{
var context = scope.ServiceProvider.GetRequiredService<AssetsDbContext>();
await context.Database.MigrateAsync();
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.");
}
var connStr = builder.Configuration.GetConnectionString("DefaultConnection") ?? "";
await context.MigrateWithBootstrapAsync(connStr);
}
catch (Exception ex)
{
Console.WriteLine($"Critical error during database migration for FinlyticAssets: {ex.Message}");
}
}
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.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using FinlyticAssets.Models;
using FinlyticAssets.Util;
using FinlyticCore.Dtos.TradeRepublic;
using FinlyticCore.Models.Assets;
@@ -15,14 +15,16 @@ using Microsoft.Extensions.Hosting;
namespace FinlyticAssets.Services;
/// <summary>
/// A background service that runs a continuous asset synchronization loop,
/// scanning Trade Republic to retrieve, update, and index all supported asset types.
/// A background service that runs a continuous asset synchronization loop for Stocks and ETFs,
/// retrieving, updating, and indexing assets while downloading logos inline.
/// </summary>
public class AssetScannerBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
private readonly IFinlyticLogger<AssetScannerBackgroundService> _finlyticLogger;
private static readonly AssetType[] ScannedAssetTypes = [AssetType.Stock, AssetType.Fund];
private AssetsCount? _assetsCount;
private AssetsCount? _currAssetsCount;
@@ -54,24 +56,35 @@ public class AssetScannerBackgroundService : BackgroundService
{
using var scope = _serviceScopeFactory.CreateScope();
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 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...");
_assetsCount = await tradeRepublicService.GetAssetsCount(stoppingToken);
_currAssetsCount = new AssetsCount();
var initSettings = await settingsService.GetSettings();
var isRecoveryMode = initSettings.CurrentScanningPage > 0;
var initCurrentTypeStr = await settingsService.GetSettingAsync(SettingKeys.ScannerCurrentScanningType, stoppingToken);
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 (isRecoveryMode)
{
if (type != initSettings.CurrentScanningType)
if (type != initCurrentType)
{
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Recovery: {AssetType} was already processed. Skipping.", type);
continue;
@@ -80,19 +93,17 @@ public class AssetScannerBackgroundService : BackgroundService
}
else
{
var settings = await settingsService.GetSettings();
settings.CurrentScanningType = type;
settings.CurrentScanningPage = 0;
await settingsService.SaveSettings(settings);
await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningType, type.ToString(), stoppingToken);
await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
}
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Processing asset type: {AssetType}...", type);
await HandleAssetType(type, tradeRepublicService, settingsService, assetsDbService, indexService, stoppingToken);
var currentSettings = await settingsService.GetSettings();
var delaySeconds = currentSettings.FinishedInitialScan
? currentSettings.AssetUpdateTypeDelay
: currentSettings.InitAssetUpdateTypeDelay;
var finishedInitial = await settingsService.GetSettingAsync(SettingKeys.ScannerFinishedInitialScan, stoppingToken);
var delaySeconds = finishedInitial
? await settingsService.GetSettingAsync(SettingKeys.ScannerTypeDelay, stoppingToken)
: await settingsService.GetSettingAsync(SettingKeys.ScannerInitTypeDelay, stoppingToken);
if (delaySeconds > 0)
{
@@ -102,19 +113,18 @@ public class AssetScannerBackgroundService : BackgroundService
}
}
var finalSettings = await settingsService.GetSettings();
finalSettings.CurrentScanningPage = 0;
await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
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.");
finalSettings.FinishedInitialScan = true;
await settingsService.SetSettingAsync(SettingKeys.ScannerFinishedInitialScan, true, stoppingToken);
}
await settingsService.SaveSettings(finalSettings);
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Full scan cycle completed. Waiting 1 minute before starting the next cycle.");
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
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 Task.Delay(TimeSpan.FromMinutes(cycleDelay), stoppingToken);
}
catch (Exception e) when (!stoppingToken.IsCancellationRequested)
{
@@ -129,7 +139,7 @@ public class AssetScannerBackgroundService : BackgroundService
private async Task HandleAssetType(
AssetType type,
ITradeRepublicService tradeRepublicService,
ISettingsDbService settingsDbService,
ISettingsService settingsService,
IAssetsDbService assetsDbService,
IAssetsIndexService indexService,
CancellationToken stoppingToken)
@@ -144,26 +154,28 @@ public class AssetScannerBackgroundService : BackgroundService
_currAssetsCount ??= new AssetsCount();
var currentItemOffset = 0;
var settings = await settingsDbService.GetSettings();
var pageSize = Math.Clamp(settings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : settings.TradeRepublicMaxRequestPageSize, 1, 100);
var configuredPageSize = await settingsService.GetSettingAsync(SettingKeys.ScannerMaxPageSize, stoppingToken);
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}).",
type, settings.CurrentScanningPage, currentItemOffset);
type, currentScanningPage, currentItemOffset);
}
while (currentItemOffset < totalCount && !stoppingToken.IsCancellationRequested)
{
var currentSettings = await settingsDbService.GetSettings();
pageSize = Math.Clamp(currentSettings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : currentSettings.TradeRepublicMaxRequestPageSize, 1, 100);
configuredPageSize = await settingsService.GetSettingAsync(SettingKeys.ScannerMaxPageSize, stoppingToken);
pageSize = Math.Clamp(configuredPageSize <= 0 ? 50 : configuredPageSize, 1, 100);
var currentPage = (currentItemOffset / pageSize) + 1;
currentSettings.CurrentScanningType = type;
currentSettings.CurrentScanningPage = currentPage;
await settingsDbService.SaveSettings(currentSettings);
await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningType, type.ToString(), stoppingToken);
await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, currentPage, stoppingToken);
await _finlyticLogger.LogDebugAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Fetching {AssetType} - Page {Page}. Numerical Offset: {Offset}/{Total}",
type, currentPage, currentItemOffset, totalCount);
@@ -173,8 +185,7 @@ public class AssetScannerBackgroundService : BackgroundService
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);
currentSettings.CurrentScanningPage = 0;
await settingsDbService.SaveSettings(currentSettings);
await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
break;
}
@@ -186,14 +197,14 @@ public class AssetScannerBackgroundService : BackgroundService
if (assets.Results.Count < pageSize)
{
await _finlyticLogger.LogInfoAsync(SettingKeys.AssetsChannel, "[AssetScannerBackgroundService] Reached the last page for {AssetType}.", type);
currentSettings.CurrentScanningPage = 0;
await settingsDbService.SaveSettings(currentSettings);
await settingsService.SetSettingAsync(SettingKeys.ScannerCurrentScanningPage, 0, stoppingToken);
break;
}
var delaySeconds = currentSettings.FinishedInitialScan
? currentSettings.BatchAssetUpdateDelay
: currentSettings.InitBatchAssetUpdateDelay;
var finishedInitial = await settingsService.GetSettingAsync(SettingKeys.ScannerFinishedInitialScan, stoppingToken);
var delaySeconds = finishedInitial
? await settingsService.GetSettingAsync(SettingKeys.ScannerBatchDelay, stoppingToken)
: await settingsService.GetSettingAsync(SettingKeys.ScannerInitBatchDelay, stoppingToken);
if (delaySeconds > 0)
{
@@ -219,6 +230,26 @@ public class AssetScannerBackgroundService : BackgroundService
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);
// 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)
{
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;
/// <summary>
/// Defines database operations for managing Trade Republic asset entities.
/// Defines database operations for managing Trade Republic asset entities and on-demand derivatives.
/// </summary>
public interface IAssetsDbService
{
@@ -24,7 +24,6 @@ public interface IAssetsDbService
public Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin);
public Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin);
public Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery);
public Task UpdateAssetImageIdAsync(string isin, string imageId);
public Task<bool> DeleteAssetAsync(string isin);
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);
@@ -63,7 +62,6 @@ public class AssetsDbService : IAssetsDbService
Asset = a,
Score = (a.Tags?.Count ?? 0) * 10
+ (a.HasCfd ? 5 : 0)
+ (string.IsNullOrEmpty(a.ImageId) ? 0 : 15)
+ (a.Name.Length > 3 ? 5 : 0)
})
.OrderByDescending(x => x.Score)
@@ -147,7 +145,6 @@ public class AssetsDbService : IAssetsDbService
existingEntity.Type = dtoAsset.Type;
existingEntity.InstrumentCategory = dtoAsset.InstrumentCategory;
existingEntity.HasCfd = dtoAsset.HasCfd;
existingEntity.ImageId = dtoAsset.ImageId;
existingEntity.LastUpdatedAt = now;
UpdateSubtypeProperties(existingEntity, dtoAsset);
@@ -220,14 +217,13 @@ public class AssetsDbService : IAssetsDbService
existingEntity.Type != dto.Type ||
existingEntity.InstrumentCategory != dto.InstrumentCategory ||
existingEntity.HasCfd != dto.HasCfd ||
existingEntity.ImageId != dto.ImageId ||
!existingEntity.Tags.SequenceEqual(mappedTags))
!existingEntity.Tags.Select(t => t.Id).Order().SequenceEqual(mappedTags.Select(t => t.Id).Order()) ||
HasSubtypeChanges(existingEntity, dto))
{
existingEntity.Name = dto.Name;
existingEntity.Type = dto.Type;
existingEntity.InstrumentCategory = dto.InstrumentCategory;
existingEntity.HasCfd = dto.HasCfd;
existingEntity.ImageId = dto.ImageId;
existingEntity.LastUpdatedAt = now;
existingEntity.Tags = mappedTags;
@@ -255,18 +251,8 @@ public class AssetsDbService : IAssetsDbService
Type = stock.Type,
InstrumentCategory = stock.InstrumentCategory,
HasCfd = stock.HasCfd,
ImageId = stock.ImageId,
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
{
Isin = etf.Isin,
@@ -274,7 +260,6 @@ public class AssetsDbService : IAssetsDbService
Type = etf.Type,
InstrumentCategory = etf.InstrumentCategory,
HasCfd = etf.HasCfd,
ImageId = etf.ImageId,
DerivativeProductCategories = etf.DerivativeProductCategories?.ToList() ?? new List<string>()
},
TradeRepublicSynthetic syn => new SyntheticEntity
@@ -284,39 +269,15 @@ public class AssetsDbService : IAssetsDbService
Type = syn.Type,
InstrumentCategory = syn.InstrumentCategory,
HasCfd = syn.HasCfd,
ImageId = syn.ImageId,
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
{
Isin = dto.Isin,
Name = dto.Name,
Type = dto.Type,
InstrumentCategory = dto.InstrumentCategory,
HasCfd = dto.HasCfd,
ImageId = dto.ImageId
HasCfd = dto.HasCfd
}
};
}
@@ -334,14 +295,21 @@ public class AssetsDbService : IAssetsDbService
case SyntheticEntity syn when dto is TradeRepublicSynthetic synDto:
syn.DerivativeProductCategories = synDto.DerivativeProductCategories?.ToList() ?? new List<string>();
break;
case BondEntity bond when dto is TradeRepublicBond b:
bond.BondIssuerName = b.BondIssuerName;
bond.SearchSubtitle = b.SearchSubtitle;
break;
case DerivativeEntity deriv when dto is TradeRepublicDerivative d:
deriv.UnderlyingIsin = d.UnderlyingIsin;
deriv.DerivativeProductCategories = d.DerivativeProductCategories?.ToList() ?? new List<string>();
break;
}
}
private static bool HasSubtypeChanges(AssetEntity entity, TradeRepublicAsset dto)
{
switch (entity)
{
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();
}
/// <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>
public async Task<bool> DeleteAssetAsync(string isin)
{
@@ -412,7 +362,6 @@ public class AssetsDbService : IAssetsDbService
int pageIndex = Math.Max(0, page ?? 0);
decimal levQuery = targetLeverage.HasValue && targetLeverage.Value > 0 ? targetLeverage.Value : 0m;
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})",
@@ -438,8 +387,7 @@ public class AssetsDbService : IAssetsDbService
{
var now = DateTime.UtcNow;
var isins = fetchedItems.Select(r => r.Isin).ToList();
var existingDerivatives = await _context.TradeRepublicAssets
.OfType<DerivativeEntity>()
var existingDerivatives = await _context.Derivatives
.Where(d => isins.Contains(d.Isin))
.ToDictionaryAsync(d => d.Isin, cancellationToken);
@@ -466,14 +414,13 @@ public class AssetsDbService : IAssetsDbService
existing.NextGenProductCategoryName = item.NextGenProductCategoryName;
existing.Issuer = item.Issuer;
existing.IssuerDisplayName = item.IssuerDisplayName;
existing.IssuerImageId = item.IssuerImageId;
existing.Size = item.Size;
existing.Factor = item.Factor;
existing.Delta = item.Delta;
existing.Currency = item.Currency;
existing.LastUpdatedAt = now;
_context.TradeRepublicAssets.Update(existing);
_context.Derivatives.Update(existing);
resultEntities.Add(existing);
}
else
@@ -482,8 +429,6 @@ public class AssetsDbService : IAssetsDbService
{
Isin = item.Isin,
Name = !string.IsNullOrWhiteSpace(item.ProductCategoryName) ? item.ProductCategoryName : item.Isin,
Type = "derivative",
InstrumentCategory = "derivative",
UnderlyingIsin = underlyingIsin,
Strike = item.Strike ?? 0m,
Barrier = item.Barrier ?? 0m,
@@ -494,27 +439,33 @@ public class AssetsDbService : IAssetsDbService
NextGenProductCategoryName = item.NextGenProductCategoryName,
Issuer = item.Issuer,
IssuerDisplayName = item.IssuerDisplayName,
IssuerImageId = item.IssuerImageId,
Size = item.Size,
Factor = item.Factor,
Delta = item.Delta,
Currency = item.Currency,
LastUpdatedAt = now
LastUpdatedAt = now,
CreatedAt = now
};
await _context.TradeRepublicAssets.AddAsync(newDeriv, cancellationToken);
await _context.Derivatives.AddAsync(newDeriv, cancellationToken);
resultEntities.Add(newDeriv);
}
}
await _context.SaveChangesAsync(cancellationToken);
return resultEntities;
return resultEntities.Where(d => d.Barrier > 0 && d.Leverage > 0).ToList();
}
return await _context.TradeRepublicAssets
.OfType<DerivativeEntity>()
var dbQuery = _context.Derivatives
.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)
.ToListAsync(cancellationToken);
}
@@ -5,7 +5,7 @@ using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FinlyticAssets.Models;
using FinlyticCore.Models.Assets;
using FinlyticAssets.Util;
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 FinlyticCore.Dtos.Assets;
@@ -14,6 +17,8 @@ public static class AssetMapper
Type = t.Type
}).ToList();
var dynamicLogoUrl = $"/api/v1/logo/{entity.Isin}";
return entity switch
{
StockEntity stock => new StockDto
@@ -23,7 +28,7 @@ public static class AssetMapper
Type = stock.Type,
InstrumentCategory = stock.InstrumentCategory,
HasCfd = stock.HasCfd,
ImageId = stock.ImageId,
ImageId = dynamicLogoUrl,
LastUpdatedAt = stock.LastUpdatedAt,
Tags = dtoTags,
DerivativeProductCategories = stock.DerivativeProductCategories
@@ -35,7 +40,7 @@ public static class AssetMapper
Type = etf.Type,
InstrumentCategory = etf.InstrumentCategory,
HasCfd = etf.HasCfd,
ImageId = etf.ImageId,
ImageId = dynamicLogoUrl,
LastUpdatedAt = etf.LastUpdatedAt,
Tags = dtoTags,
DerivativeProductCategories = etf.DerivativeProductCategories,
@@ -44,42 +49,44 @@ public static class AssetMapper
Subtitle = etf.Subtitle,
SearchSubtitle = etf.SearchSubtitle
},
CryptoEntity crypto => new CryptoDto
SyntheticEntity synth => new SyntheticDto
{
Isin = crypto.Isin,
Name = crypto.Name,
Type = crypto.Type,
InstrumentCategory = crypto.InstrumentCategory,
HasCfd = crypto.HasCfd,
ImageId = crypto.ImageId,
LastUpdatedAt = crypto.LastUpdatedAt,
Isin = synth.Isin,
Name = synth.Name,
Type = synth.Type,
InstrumentCategory = synth.InstrumentCategory,
HasCfd = synth.HasCfd,
ImageId = dynamicLogoUrl,
LastUpdatedAt = synth.LastUpdatedAt,
Tags = dtoTags,
Subtitle = crypto.Subtitle,
SearchSubtitle = crypto.SearchSubtitle
DerivativeProductCategories = synth.DerivativeProductCategories
},
BondEntity bond => new BondDto
_ => new StockDto
{
Isin = bond.Isin,
Name = bond.Name,
Type = bond.Type,
InstrumentCategory = bond.InstrumentCategory,
HasCfd = bond.HasCfd,
ImageId = bond.ImageId,
LastUpdatedAt = bond.LastUpdatedAt,
Tags = dtoTags,
BondIssuerName = bond.BondIssuerName,
SearchSubtitle = bond.SearchSubtitle
},
DerivativeEntity deriv => new DerivativeDto
Isin = entity.Isin,
Name = entity.Name,
Type = entity.Type,
InstrumentCategory = entity.InstrumentCategory,
HasCfd = entity.HasCfd,
ImageId = dynamicLogoUrl,
LastUpdatedAt = entity.LastUpdatedAt,
Tags = dtoTags
}
};
}
public static DerivativeDto ToDto(this DerivativeEntity deriv)
{
return new DerivativeDto
{
Isin = deriv.Isin,
Name = deriv.Name,
Type = deriv.Type,
InstrumentCategory = deriv.InstrumentCategory,
HasCfd = deriv.HasCfd,
ImageId = deriv.ImageId,
Type = "derivative",
InstrumentCategory = "derivative",
HasCfd = false,
ImageId = $"/api/v1/logo/{deriv.UnderlyingIsin}",
LastUpdatedAt = deriv.LastUpdatedAt,
Tags = dtoTags,
Tags = new List<TagDto>(),
DerivativeProductCategories = deriv.DerivativeProductCategories,
UnderlyingIsin = deriv.UnderlyingIsin,
OptionType = deriv.OptionType.ToString(),
@@ -95,21 +102,7 @@ public static class AssetMapper
Expiry = deriv.Expiry,
Issuer = deriv.Issuer,
IssuerDisplayName = deriv.IssuerDisplayName,
IssuerImageId = deriv.IssuerImageId
},
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.")
IssuerImageId = null
};
}
@@ -117,4 +110,9 @@ public static class AssetMapper
{
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.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
@@ -6,9 +7,14 @@ using System.Threading;
using System.Threading.Tasks;
using FinlyticAssets.Entities;
using FinlyticAssets.Services;
using FinlyticCore.Dtos;
using FinlyticCore.Dtos.Assets;
using FinlyticCore.Dtos.Settings;
using FinlyticCore.Dtos.TechnicalAnalysis;
using FinlyticCore.Models;
using FinlyticCore.Models.Assets;
using FinlyticCore.Services;
using FinlyticCore.Services.TradeRepublic;
using FinlyticCore.Util;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -18,13 +24,14 @@ using Microsoft.Extensions.Logging;
namespace FinlyticAssets.Util;
/// <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>
public class AssetsMqttClient : ManagedMqttClient, IHostedService
{
private readonly ILogger<AssetsMqttClient> _logger;
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
private readonly ConcurrentDictionary<string, (LivePriceDto Price, DateTime CachedAt)> _priceCache = new(StringComparer.OrdinalIgnoreCase);
public AssetsMqttClient(
ILogger<AssetsMqttClient> logger,
@@ -41,12 +48,7 @@ public class AssetsMqttClient : ManagedMqttClient, IHostedService
/// </summary>
public async Task StartAsync(CancellationToken cancellationToken)
{
var config = new MqttConfiguration()
{
Host = _configuration["MQTT:Host"] ?? _configuration["MQTT__Host"] ?? "localhost",
Port = Convert.ToInt32(_configuration["MQTT:Port"] ?? _configuration["MQTT__Port"] ?? "1883"),
ClientId = $"{(_configuration["MQTT:ClientId"] ?? _configuration["MQTT__ClientId"] ?? "FinlyticAssets")}_{Guid.NewGuid()}"
};
var config = MqttConfiguration.FromConfiguration(_configuration, "FinlyticAssets");
_logger.LogInformation("Starting Assets MQTT client. Host: {Host}, ClientId: {ClientId}", config.Host, config.ClientId);
await ConnectAsync(config);
@@ -66,278 +68,158 @@ public class AssetsMqttClient : ManagedMqttClient, IHostedService
/// </summary>
protected override async Task OnConnectedAsync()
{
_logger.LogInformation("Assets MQTT Client connected. Subscribing to topics...");
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/#");
_logger.LogInformation("Assets MQTT Client connected. Registering topic subscriptions...");
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))
{
await PublishAsync("finlytic/logs/FinlyticAssets", logDto);
await PublishAsync(MqttTopics.Logs("FinlyticAssets"), logDto);
}
};
}
/// <summary>
/// Processes incoming messages on the subscribed topics.
/// </summary>
protected override async Task OnMessageReceivedAsync(string topic, string payload)
private async Task<List<AssetDto>> HandleAssetsGetRpcAsync(GetValidAssetRequest? req, string correlationId)
{
if (topic.StartsWith("services/config/updated", StringComparison.OrdinalIgnoreCase))
{
await HandleConfigUpdatedAsync(topic, payload);
return;
if (req == null || string.IsNullOrWhiteSpace(req.Isin)) return [];
using var scope = _scopeFactory.CreateScope();
var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
var assets = await dbService.GetValidAssetsByIsinAsync(req.Isin.Trim().ToUpperInvariant());
return assets.ToDtoList();
}
var segments = topic.Split('/');
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
private async Task<List<AssetDto>> HandleAssetsGetDiscoveryRpcAsync(GetDiscoveryAssetsRequest? req, string correlationId)
{
using var scope = _scopeFactory.CreateScope();
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);
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;
if (req == null || string.IsNullOrWhiteSpace(req.UnderlyingIsin)) return [];
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);
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,
var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
var derivatives = await dbService.GetDerivativesByUnderlyingAsync(
req.UnderlyingIsin.Trim().ToUpperInvariant(),
req.OptionType,
req.TargetLeverage,
req.After,
req.Page,
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)
{
using var scope = _scopeFactory.CreateScope();
var finlyticLogger = scope.ServiceProvider.GetRequiredService<IFinlyticLogger<AssetsMqttClient>>();
await finlyticLogger.LogErrorAsync(SettingKeys.AssetsChannel, ex, "[AssetsMqttClient] Error parsing GetDerivativesRequest payload.");
await finlyticLogger.LogWarningAsync(SettingKeys.AssetsChannel, "[AssetsMqttClient] Failed or timed out fetching live price for {Isin}: {Message}", cleanIsin, ex.Message);
if (_priceCache.TryGetValue(cleanIsin, out var stale))
{
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> MqttChannel = new("Logging.Channel.MQTT", 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 ---
public static readonly SettingKey<bool> EnableAutoScan = new("Scanner.EnableAutoScan", true);
public static readonly SettingKey<int> ScanIntervalHours = new("Scanner.ScanIntervalHours", 12);
public static readonly SettingKey<int> MaxConcurrentScans = new("Scanner.MaxConcurrentScans", 5);
public static readonly SettingKey<bool> EnableDerivativeScanning = new("Scanner.EnableDerivativeScanning", true);
// --- Trade Republic Connection ---
public static readonly SettingKey<int> TradeRepublicWsReconnectInterval = new("TradeRepublic.WsReconnectIntervalSeconds", 5);
public static readonly SettingKey<int> TradeRepublicWsTimeout = new("TradeRepublic.WsTimeoutSeconds", 15);
// --- Logos & Media ---
public static readonly SettingKey<bool> AutoFetchLogos = new("Media.AutoFetchLogos", true);
public static readonly SettingKey<int> LogoFetchBatchSize = new("Media.LogoFetchBatchSize", 25);
public static readonly SettingKey<string> LogoStorageDirectory = new("Media.LogoStorageDirectory", "data/logos");
// --- Asset Scanning Config ---
public static readonly SettingKey<bool> ScannerEnableAutoScan = new("Scanner.EnableAutoScan", true);
public static readonly SettingKey<string> ScannerCurrentScanningType = new("Scanner.CurrentScanningType", "Stock");
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";
}
}