Init
This commit is contained in:
@@ -0,0 +1,25 @@
|
|||||||
|
**/.dockerignore
|
||||||
|
**/.env
|
||||||
|
**/.git
|
||||||
|
**/.gitignore
|
||||||
|
**/.project
|
||||||
|
**/.settings
|
||||||
|
**/.toolstarget
|
||||||
|
**/.vs
|
||||||
|
**/.vscode
|
||||||
|
**/.idea
|
||||||
|
**/*.*proj.user
|
||||||
|
**/*.dbmdl
|
||||||
|
**/*.jfm
|
||||||
|
**/azds.yaml
|
||||||
|
**/bin
|
||||||
|
**/charts
|
||||||
|
**/docker-compose*
|
||||||
|
**/Dockerfile*
|
||||||
|
**/node_modules
|
||||||
|
**/npm-debug.log
|
||||||
|
**/obj
|
||||||
|
**/secrets.dev.yaml
|
||||||
|
**/values.dev.yaml
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
+32
@@ -0,0 +1,32 @@
|
|||||||
|
## Build outputs
|
||||||
|
**/bin/
|
||||||
|
**/obj/
|
||||||
|
|
||||||
|
## Rider / JetBrains / VS Code / Visual Studio
|
||||||
|
.idea/
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
*.sln.DotSettings.user
|
||||||
|
*.csproj.user
|
||||||
|
_ReSharper.*/
|
||||||
|
*.resharper.user
|
||||||
|
|
||||||
|
## OS Files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
## Local assets volume & generated cache files
|
||||||
|
assets/
|
||||||
|
**/assets/index/
|
||||||
|
|
||||||
|
## Secrets & local environment files
|
||||||
|
.env
|
||||||
|
*.env.local
|
||||||
|
appsettings.Development.json
|
||||||
|
|
||||||
|
## User-specific files
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticAssets", "FinlyticAssets\FinlyticAssets.csproj", "{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{F6AC7FB2-7669-42B3-8155-7E6F76873C21}"
|
||||||
|
ProjectSection(SolutionItems) = preProject
|
||||||
|
compose.yaml = compose.yaml
|
||||||
|
EndProjectSection
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticCore", "FinlyticCore\FinlyticCore.csproj", "{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FinlyticNews", "FinlyticNews\FinlyticNews.csproj", "{03B4D920-6173-44E8-A1E2-8945D8393CEA}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B5AC2C29-44D5-4538-815F-F02CDDE9D01F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{03E2ECBB-0FF9-43E4-994E-F6A522860AD5}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{03B4D920-6173-44E8-A1E2-8945D8393CEA}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using FinlyticAssets.Entities;
|
||||||
|
using FinlyticCore.Entities.Assets;
|
||||||
|
using FinlyticCore.Models.Assets;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Database;
|
||||||
|
|
||||||
|
public class AssetsDbContext : DbContext
|
||||||
|
{
|
||||||
|
public AssetsDbContext(DbContextOptions<AssetsDbContext> options) : base(options)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public DbSet<Settings> Settings { get; set; }
|
||||||
|
public DbSet<AssetEntity> TradeRepublicAssets { get; set; }
|
||||||
|
public DbSet<TagEntity> TradeRepublicTags { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<AssetEntity>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(e => new {e.Isin, e.InstrumentCategory});
|
||||||
|
|
||||||
|
entity.HasDiscriminator<string>("AssetType")
|
||||||
|
.HasValue<StockEntity>("Stock")
|
||||||
|
.HasValue<CryptoEntity>("Crypto")
|
||||||
|
.HasValue<EtfEntity>("Etf")
|
||||||
|
.HasValue<SyntheticEntity>("Synthetic")
|
||||||
|
.HasValue<BondEntity>("Bond")
|
||||||
|
.HasValue<DerivativeEntity>("Derivative");
|
||||||
|
});
|
||||||
|
|
||||||
|
var stringListConverter =
|
||||||
|
new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter<List<string>, string>(
|
||||||
|
v => System.Text.Json.JsonSerializer.Serialize(v, (System.Text.Json.JsonSerializerOptions?)null),
|
||||||
|
v => System.Text.Json.JsonSerializer.Deserialize<List<string>>(v,
|
||||||
|
(System.Text.Json.JsonSerializerOptions?)null) ?? new List<string>()
|
||||||
|
);
|
||||||
|
|
||||||
|
var stringListComparer = new ValueComparer<List<string>>(
|
||||||
|
(c1, c2) => c1 != null && c2 != null ? c1.SequenceEqual(c2) : c1 == c2,
|
||||||
|
c => c.Aggregate(0, (a, v) => HashCode.Combine(a, v.GetHashCode())),
|
||||||
|
c => c.ToList()
|
||||||
|
);
|
||||||
|
|
||||||
|
modelBuilder.Entity<StockEntity>()
|
||||||
|
.Property(e => e.DerivativeProductCategories)
|
||||||
|
.HasConversion(stringListConverter, stringListComparer);
|
||||||
|
|
||||||
|
modelBuilder.Entity<SyntheticEntity>()
|
||||||
|
.Property(e => e.DerivativeProductCategories)
|
||||||
|
.HasConversion(stringListConverter, stringListComparer);
|
||||||
|
|
||||||
|
modelBuilder.Entity<DerivativeEntity>()
|
||||||
|
.Property(e => e.DerivativeProductCategories)
|
||||||
|
.HasConversion(stringListConverter, stringListComparer);
|
||||||
|
|
||||||
|
modelBuilder.Entity<EtfEntity>()
|
||||||
|
.Property(e => e.DerivativeProductCategories)
|
||||||
|
.HasConversion(stringListConverter, stringListComparer);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["FinlyticAssets/FinlyticAssets.csproj", "FinlyticAssets/"]
|
||||||
|
RUN dotnet restore "FinlyticAssets/FinlyticAssets.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/FinlyticAssets"
|
||||||
|
RUN dotnet build "./FinlyticAssets.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./FinlyticAssets.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "FinlyticAssets.dll"]
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
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 minimum number of days to wait before an asset becomes eligible for re-validation.
|
||||||
|
/// Combined with <see cref="MaxRandomUpdateDay"/> to achieve a flat 2-to-3-month rotation cycle.
|
||||||
|
/// </summary>
|
||||||
|
public int MinRandomUpdateDay { get; set; } = 60;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the maximum number of days to wait before an asset must be re-validated (roughly 3 months).
|
||||||
|
/// </summary>
|
||||||
|
public int MaxRandomUpdateDay { get; set; } = 90;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the earliest hour (0-23) of the day when background synchronization is allowed to execute.
|
||||||
|
/// Prevents unusual nocturnal API traffic.
|
||||||
|
/// </summary>
|
||||||
|
public int UpdateDayTimeStart { get; set; } = 8;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the latest hour (0-23) of the day when background synchronization is allowed to execute.
|
||||||
|
/// </summary>
|
||||||
|
public int UpdateDayTimeStop { get; set; } = 21;
|
||||||
|
|
||||||
|
/// <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.
|
||||||
|
/// Faster than standard mode but kept high enough to prevent early rate limiting. (Default 3600s = 1 hour).
|
||||||
|
/// </summary>
|
||||||
|
public int InitAssetUpdateTypeDelay { get; set; } = 3600;
|
||||||
|
|
||||||
|
/// <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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-FinlyticAssets-a64604ac-cbde-4284-838a-2173f640d19a</UserSecretsId>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Analyzers" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1"/>
|
||||||
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\FinlyticCore\FinlyticCore.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
// <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("20260628184437_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>("AssetsIsin")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("TagsId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("AssetsIsin", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
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<bool>("FinishedInitialScan")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<int>("InitAssetUpdateTypeDelay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("InitBatchAssetUpdateDelay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MaxRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MinRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TradeRepublicMaxRequestPageSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStart")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStop")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Assets.AssetEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.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<string>("InstrumentCategory")
|
||||||
|
.IsRequired()
|
||||||
|
.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.Property<DateTime>("UpdateAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Isin");
|
||||||
|
|
||||||
|
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.AssetEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsIsin")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("FinlyticCore.Entities.Assets.TagEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class Init : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "Settings",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
FinishedInitialScan = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
MinRandomUpdateDay = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
MaxRandomUpdateDay = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UpdateDayTimeStart = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
UpdateDayTimeStop = table.Column<int>(type: "integer", 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)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_Settings", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TradeRepublicAssets",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Isin = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "text", nullable: false),
|
||||||
|
InstrumentCategory = table.Column<string>(type: "text", nullable: false),
|
||||||
|
HasCfd = table.Column<bool>(type: "boolean", nullable: false),
|
||||||
|
ImageId = table.Column<string>(type: "text", nullable: true),
|
||||||
|
UpdateAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
LastUpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
AssetType = table.Column<string>(type: "character varying(13)", maxLength: 13, nullable: false),
|
||||||
|
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),
|
||||||
|
StockEntity_DerivativeProductCategories = table.Column<string>(type: "text", nullable: true),
|
||||||
|
SyntheticEntity_DerivativeProductCategories = table.Column<string>(type: "text", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TradeRepublicAssets", x => x.Isin);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TradeRepublicTags",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
Type = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TradeRepublicTags", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "AssetEntityTagEntity",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
AssetsIsin = table.Column<string>(type: "text", nullable: false),
|
||||||
|
TagsId = table.Column<string>(type: "text", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_AssetEntityTagEntity", x => new { x.AssetsIsin, x.TagsId });
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AssetEntityTagEntity_TradeRepublicAssets_AssetsIsin",
|
||||||
|
column: x => x.AssetsIsin,
|
||||||
|
principalTable: "TradeRepublicAssets",
|
||||||
|
principalColumn: "Isin",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_AssetEntityTagEntity_TradeRepublicTags_TagsId",
|
||||||
|
column: x => x.TagsId,
|
||||||
|
principalTable: "TradeRepublicTags",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AssetEntityTagEntity_TagsId",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
column: "TagsId");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "Settings");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TradeRepublicAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TradeRepublicTags");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
// <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("20260628195842_AddedScannerState")]
|
||||||
|
partial class AddedScannerState
|
||||||
|
{
|
||||||
|
/// <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>("AssetsIsin")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("TagsId")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("AssetsIsin", "TagsId");
|
||||||
|
|
||||||
|
b.HasIndex("TagsId");
|
||||||
|
|
||||||
|
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>("MaxRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MinRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TradeRepublicMaxRequestPageSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStart")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStop")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Settings");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("FinlyticCore.Entities.Assets.AssetEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Isin")
|
||||||
|
.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<string>("InstrumentCategory")
|
||||||
|
.IsRequired()
|
||||||
|
.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.Property<DateTime>("UpdateAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Isin");
|
||||||
|
|
||||||
|
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.AssetEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("AssetsIsin")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("FinlyticCore.Entities.Assets.TagEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("TagsId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddedScannerState : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "CurrentScanningPage",
|
||||||
|
table: "Settings",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "CurrentScanningType",
|
||||||
|
table: "Settings",
|
||||||
|
type: "character varying(50)",
|
||||||
|
maxLength: 50,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CurrentScanningPage",
|
||||||
|
table: "Settings");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "CurrentScanningType",
|
||||||
|
table: "Settings");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,295 @@
|
|||||||
|
// <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("20260628204647_FixIsinKey")]
|
||||||
|
partial class FixIsinKey
|
||||||
|
{
|
||||||
|
/// <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>("MaxRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MinRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TradeRepublicMaxRequestPageSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStart")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStop")
|
||||||
|
.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.Property<DateTime>("UpdateAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Isin", "InstrumentCategory");
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class FixIsinKey : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AssetEntityTagEntity_TradeRepublicAssets_AssetsIsin",
|
||||||
|
table: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_TradeRepublicAssets",
|
||||||
|
table: "TradeRepublicAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_AssetEntityTagEntity",
|
||||||
|
table: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AssetEntityTagEntity_TagsId",
|
||||||
|
table: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "AssetsInstrumentCategory",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
type: "text",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_TradeRepublicAssets",
|
||||||
|
table: "TradeRepublicAssets",
|
||||||
|
columns: new[] { "Isin", "InstrumentCategory" });
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_AssetEntityTagEntity",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
columns: new[] { "TagsId", "AssetsIsin", "AssetsInstrumentCategory" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AssetEntityTagEntity_AssetsIsin_AssetsInstrumentCategory",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
columns: new[] { "AssetsIsin", "AssetsInstrumentCategory" });
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AssetEntityTagEntity_TradeRepublicAssets_AssetsIsin_AssetsI~",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
columns: new[] { "AssetsIsin", "AssetsInstrumentCategory" },
|
||||||
|
principalTable: "TradeRepublicAssets",
|
||||||
|
principalColumns: new[] { "Isin", "InstrumentCategory" },
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "FK_AssetEntityTagEntity_TradeRepublicAssets_AssetsIsin_AssetsI~",
|
||||||
|
table: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_TradeRepublicAssets",
|
||||||
|
table: "TradeRepublicAssets");
|
||||||
|
|
||||||
|
migrationBuilder.DropPrimaryKey(
|
||||||
|
name: "PK_AssetEntityTagEntity",
|
||||||
|
table: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_AssetEntityTagEntity_AssetsIsin_AssetsInstrumentCategory",
|
||||||
|
table: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "AssetsInstrumentCategory",
|
||||||
|
table: "AssetEntityTagEntity");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_TradeRepublicAssets",
|
||||||
|
table: "TradeRepublicAssets",
|
||||||
|
column: "Isin");
|
||||||
|
|
||||||
|
migrationBuilder.AddPrimaryKey(
|
||||||
|
name: "PK_AssetEntityTagEntity",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
columns: new[] { "AssetsIsin", "TagsId" });
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_AssetEntityTagEntity_TagsId",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
column: "TagsId");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "FK_AssetEntityTagEntity_TradeRepublicAssets_AssetsIsin",
|
||||||
|
table: "AssetEntityTagEntity",
|
||||||
|
column: "AssetsIsin",
|
||||||
|
principalTable: "TradeRepublicAssets",
|
||||||
|
principalColumn: "Isin",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using FinlyticAssets.Database;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AssetsDbContext))]
|
||||||
|
partial class AssetsDbContextModelSnapshot : ModelSnapshot
|
||||||
|
{
|
||||||
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.9")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("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>("MaxRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("MinRandomUpdateDay")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("TradeRepublicMaxRequestPageSize")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStart")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<int>("UpdateDayTimeStop")
|
||||||
|
.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.Property<DateTime>("UpdateAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.HasKey("Isin", "InstrumentCategory");
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Models;
|
||||||
|
|
||||||
|
public record AssetIndex(
|
||||||
|
[property: JsonPropertyName("isin")] string Isin,
|
||||||
|
[property: JsonPropertyName("name")] string Name);
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using FinlyticCore.Models.Assets;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Models;
|
||||||
|
|
||||||
|
public class AssetsCount
|
||||||
|
{
|
||||||
|
public int Stock { get; set; }
|
||||||
|
public int Funds { get; set; }
|
||||||
|
public int Derivatives { get; set; }
|
||||||
|
public int Crypto { get; set; }
|
||||||
|
public int Bond { get; set; }
|
||||||
|
|
||||||
|
public int GetCountFromType(AssetType type)
|
||||||
|
{
|
||||||
|
return type switch
|
||||||
|
{
|
||||||
|
AssetType.Stock => Stock,
|
||||||
|
AssetType.Fund => Funds,
|
||||||
|
AssetType.Derivative => Derivatives,
|
||||||
|
AssetType.Crypto => Crypto,
|
||||||
|
AssetType.Bond => Bond,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public void SetCountOfType(AssetType type, int count)
|
||||||
|
{
|
||||||
|
_ = type switch
|
||||||
|
{
|
||||||
|
AssetType.Stock => Stock = count,
|
||||||
|
AssetType.Fund => Funds = count,
|
||||||
|
AssetType.Derivative => Derivatives = count,
|
||||||
|
AssetType.Crypto => Crypto = count,
|
||||||
|
AssetType.Bond => Bond = count,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
namespace FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
// 1. Der Response-Wrapper
|
||||||
|
public record TradeRepublicAssetResponse(
|
||||||
|
[property: JsonPropertyName("correlationId")] string CorrelationId,
|
||||||
|
[property: JsonPropertyName("resultCount")] int ResultCount,
|
||||||
|
[property: JsonPropertyName("results")] IList<TradeRepublicAsset> Results
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Das Tag-Objekt
|
||||||
|
public record TradeRepublicTag
|
||||||
|
{
|
||||||
|
[JsonPropertyName("id")] public string Id { get; init; } = "";
|
||||||
|
[JsonPropertyName("name")] public string Name { get; init; } = "";
|
||||||
|
[JsonPropertyName("type")] public string Type { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Die Basisklasse MIT UNSEREM CUSTOM CONVERTER (Kein [JsonPolymorphic] mehr!)
|
||||||
|
[JsonConverter(typeof(TradeRepublicAssetConverter))]
|
||||||
|
public record TradeRepublicAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("isin")] public string Isin { get; init; } = "";
|
||||||
|
[JsonPropertyName("name")] public string Name { get; init; } = "";
|
||||||
|
[JsonPropertyName("type")] public string Type { get; init; } = "";
|
||||||
|
[JsonPropertyName("instrumentCategory")] public string InstrumentCategory { get; init; } = "";
|
||||||
|
[JsonPropertyName("hasCfd")] public bool HasCfd { get; init; }
|
||||||
|
[JsonPropertyName("imageId")] public string? ImageId { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("tags")]
|
||||||
|
public IReadOnlyList<TradeRepublicTag> Tags { get; init; } = Array.Empty<TradeRepublicTag>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Die spezifischen Klassen (inklusive Bond und Derivative aus deinem JSON!)
|
||||||
|
|
||||||
|
public record TradeRepublicStock : TradeRepublicAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("derivativeProductCategories")]
|
||||||
|
public IReadOnlyList<string> DerivativeProductCategories { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TradeRepublicCrypto : TradeRepublicAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("subtitle")] public string Subtitle { get; init; } = "";
|
||||||
|
[JsonPropertyName("searchSubtitle")] public string SearchSubtitle { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TradeRepublicEtf : TradeRepublicAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("derivativeProductCategories")]
|
||||||
|
public IReadOnlyList<string> DerivativeProductCategories { get; init; } = Array.Empty<string>();
|
||||||
|
[JsonPropertyName("etfDescription")] public string EtfDescription { get; init; } = "";
|
||||||
|
[JsonPropertyName("mappedEtfIndexName")] public string MappedEtfIndexName { get; init; } = "";
|
||||||
|
[JsonPropertyName("subtitle")] public string Subtitle { get; init; } = "";
|
||||||
|
[JsonPropertyName("searchSubtitle")] public string SearchSubtitle { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TradeRepublicSynthetic : TradeRepublicAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("derivativeProductCategories")]
|
||||||
|
public IReadOnlyList<string> DerivativeProductCategories { get; init; } = Array.Empty<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU: Anleihen
|
||||||
|
public record TradeRepublicBond : TradeRepublicAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("bondIssuerName")] public string BondIssuerName { get; init; } = "";
|
||||||
|
[JsonPropertyName("searchSubtitle")] public string SearchSubtitle { get; init; } = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// NEU: Derivate (Hebeleffekte etc.)
|
||||||
|
public record TradeRepublicDerivative : TradeRepublicAsset
|
||||||
|
{
|
||||||
|
[JsonPropertyName("derivativeProductCategories")]
|
||||||
|
public IReadOnlyList<string> DerivativeProductCategories { get; init; } = Array.Empty<string>();
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
public string? UnderlyingIsin
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
// Wenn die ImageId z.B. "logos/US0378331005/v2" ist...
|
||||||
|
if (!string.IsNullOrEmpty(ImageId) && ImageId.StartsWith("logos/"))
|
||||||
|
{
|
||||||
|
var parts = ImageId.Split('/');
|
||||||
|
if (parts.Length >= 2)
|
||||||
|
{
|
||||||
|
return parts[1]; // Gibt "US0378331005" zurück
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null; // Falls das Format mal anders ist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Der Custom Converter - Die Maschine, die das JSON scannt und verteilt
|
||||||
|
public class TradeRepublicAssetConverter : JsonConverter<TradeRepublicAsset>
|
||||||
|
{
|
||||||
|
public override TradeRepublicAsset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.ParseValue(ref reader);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
// Wir scannen nach instrumentType, egal wo im JSON es steht!
|
||||||
|
string? instrumentType = null;
|
||||||
|
if (root.TryGetProperty("instrumentType", out var typeElement))
|
||||||
|
{
|
||||||
|
instrumentType = typeElement.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wir werfen das JSON gezielt in die richtige Klasse
|
||||||
|
TradeRepublicAsset? result = instrumentType switch
|
||||||
|
{
|
||||||
|
"stock" => JsonSerializer.Deserialize<TradeRepublicStock>(root.GetRawText(), options),
|
||||||
|
"crypto" => JsonSerializer.Deserialize<TradeRepublicCrypto>(root.GetRawText(), options),
|
||||||
|
"fund" => JsonSerializer.Deserialize<TradeRepublicEtf>(root.GetRawText(), options),
|
||||||
|
"synthetic" => JsonSerializer.Deserialize<TradeRepublicSynthetic>(root.GetRawText(), options),
|
||||||
|
"bond" => JsonSerializer.Deserialize<TradeRepublicBond>(root.GetRawText(), options),
|
||||||
|
"derivative" => JsonSerializer.Deserialize<TradeRepublicDerivative>(root.GetRawText(), options),
|
||||||
|
|
||||||
|
// Wenn TR einen Typ schickt, den wir noch nicht kennen: Fallback nutzen!
|
||||||
|
_ => JsonSerializer.Deserialize<TradeRepublicAssetFallback>(root.GetRawText(), options)
|
||||||
|
};
|
||||||
|
|
||||||
|
return result ?? new TradeRepublicAssetFallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override void Write(Utf8JsonWriter writer, TradeRepublicAsset value, JsonSerializerOptions options)
|
||||||
|
{
|
||||||
|
JsonSerializer.Serialize(writer, value, value.GetType(), options);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein reiner Fallback-Record, der nur intern vom Converter genutzt wird
|
||||||
|
file record TradeRepublicAssetFallback : TradeRepublicAsset;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
|
||||||
|
public record TradeRepublicConnectRequest(
|
||||||
|
[property: JsonPropertyName("clientId")] string ClientId = "app.traderepublic.com",
|
||||||
|
[property: JsonPropertyName("clientVersion")] string ClientVersion = "15.65.6",
|
||||||
|
[property: JsonPropertyName("locale")] string Locale = "en",
|
||||||
|
[property: JsonPropertyName("platformId")] string PlatformId = "webtrading",
|
||||||
|
[property: JsonPropertyName("platformVersion")] string PlatformVersion = "chrome - 149.0.0",
|
||||||
|
TradeRepublicHeaders? Headers = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
[JsonPropertyName("__headers")]
|
||||||
|
public TradeRepublicHeaders Headers { get; init; } = Headers ?? new TradeRepublicHeaders();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinlyticAssets.Util;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
|
||||||
|
public record TradeRepublicHeaders(
|
||||||
|
[property: JsonPropertyName("traceparent")] string Traceparent
|
||||||
|
)
|
||||||
|
{
|
||||||
|
public TradeRepublicHeaders() : this(StringCodeGenerator.GenerateTraceparent())
|
||||||
|
{}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinlyticAssets.Util;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
|
||||||
|
public record TradeRepublicFilter(
|
||||||
|
[property: JsonPropertyName("key")] string Key,
|
||||||
|
[property: JsonPropertyName("value")] string Value
|
||||||
|
);
|
||||||
|
|
||||||
|
public record TradeRepublicSearchData(
|
||||||
|
[property: JsonPropertyName("q")] string Query = "",
|
||||||
|
[property: JsonPropertyName("page")] int Page = 1,
|
||||||
|
[property: JsonPropertyName("pageSize")] int PageSize = 50,
|
||||||
|
IReadOnlyList<TradeRepublicFilter>? Filter = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
[JsonPropertyName("filter")]
|
||||||
|
public IReadOnlyList<TradeRepublicFilter> Filter { get; init; } = Filter ?? Array.Empty<TradeRepublicFilter>();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record TradeRepublicSearchRequest(
|
||||||
|
[property: JsonPropertyName("data")] TradeRepublicSearchData Data,
|
||||||
|
[property: JsonPropertyName("type")] string Type = "neonSearch",
|
||||||
|
TradeRepublicHeaders? Headers = null
|
||||||
|
)
|
||||||
|
{
|
||||||
|
[JsonPropertyName("__headers")]
|
||||||
|
public TradeRepublicHeaders Headers { get; init; } = Headers ?? new TradeRepublicHeaders();
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinlyticAssets;
|
||||||
|
using FinlyticAssets.Database;
|
||||||
|
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
using FinlyticAssets.Services;
|
||||||
|
using FinlyticAssets.Util;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
builder.Services.AddDbContext<AssetsDbContext>(options =>
|
||||||
|
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||||
|
|
||||||
|
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<MqttConnectionService>();
|
||||||
|
|
||||||
|
builder.Services.AddHostedService<AssetsFullScanService>();
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
|
||||||
|
using (var scope = host.Services.CreateScope())
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var context = scope.ServiceProvider.GetRequiredService<AssetsDbContext>();
|
||||||
|
await context.Database.MigrateAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"Critical error during database migration: {ex.Message}");
|
||||||
|
Console.WriteLine(ex.StackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
host.Run();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"FinlyticAssets": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
using FinlyticAssets.Database;
|
||||||
|
using FinlyticAssets.Entities;
|
||||||
|
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
using FinlyticCore.Entities.Assets;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Defines database operations for managing Trade Republic asset entities.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAssetsDbService
|
||||||
|
{
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all active assets from the database that have been updated within the last 14 days, including their associated tags.
|
||||||
|
/// Assets older than 14 days are filtered out as they are considered de-listed or inactive.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation. The task result contains a list of all active <see cref="AssetEntity"/> instances.</returns>
|
||||||
|
public Task<List<AssetEntity>> GetAllValidAssetsAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Adds a new asset or updates an existing one based on the composite key of ISIN and InstrumentType.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dtoAsset">The incoming asset data transfer object from the API.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation. The task result contains <c>true</c> if the asset was updated or created; otherwise, <c>false</c>.</returns>
|
||||||
|
public Task<bool> AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Batches processing for a collection of asset items, returning the total amount of modified or newly added entries.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dtoAssets">The collection of incoming asset objects to process.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation. The task result contains the count of changed or added entries.</returns>
|
||||||
|
public Task<int> AddOrUpdateAssetsAsync(IEnumerable<TradeRepublicAsset> dtoAssets);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all assets matching a specific International Securities Identification Number (ISIN).
|
||||||
|
/// Can return multiple entities (e.g., both the stock and the derivative tracking asset for the same ISIN).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isin">The ISIN value to look up.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation. The task result contains a list of matching <see cref="AssetEntity"/> instances.</returns>
|
||||||
|
public Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves all assets matching a specific ISIN only if they have been updated within the last 14 days.
|
||||||
|
/// Assets older than 14 days are considered de-listed or inactive.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isin">The ISIN value to look up.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation. The task result contains a list of matching active <see cref="AssetEntity"/> instances.</returns>
|
||||||
|
public Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Scans the database for active assets matching a combined, comma-separated search query.
|
||||||
|
/// The search applies an AND-logic approach where every extracted keyword must be found within an asset's ISIN, name, or tags.
|
||||||
|
/// Evaluates local records first and utilizes a targeted JIT-fallback to the external API for any search term formatted as a valid, completely unknown ISIN.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="searchQuery">A comma-separated string containing the keywords, ISINs, or tags to search for (e.g., "Siemens, Medic" or "IE00B4L5Y983, ETF").</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation. The task result contains a list of all affected active <see cref="AssetEntity"/> instances.</returns>
|
||||||
|
public Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deletes all asset records associated with a specific ISIN from the database.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Use with caution. For standard maintenance and handling de-listed instruments,
|
||||||
|
/// rely on the 14-day recency filter provided by <see cref="GetValidAssetsByIsinAsync"/> instead of hard deletion.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="isin">The ISIN value of the assets to remove.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation. The task result contains <c>true</c> if any records were successfully deleted; otherwise, <c>false</c>.</returns>
|
||||||
|
public Task<bool> DeleteAssetAsync(string isin);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public class AssetsDbService : IAssetsDbService
|
||||||
|
{
|
||||||
|
private readonly AssetsDbContext _context;
|
||||||
|
private readonly ITradeRepublicService _tradeRepublicService;
|
||||||
|
private readonly ILogger<AssetsDbService> _logger;
|
||||||
|
private readonly Random _random = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AssetsDbService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
public AssetsDbService(AssetsDbContext context, ILogger<AssetsDbService> logger,
|
||||||
|
ITradeRepublicService tradeRepublicService)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_logger = logger;
|
||||||
|
_tradeRepublicService = tradeRepublicService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<AssetEntity>> GetAllValidAssetsAsync()
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||||
|
|
||||||
|
var existingEntity = await _context.TradeRepublicAssets
|
||||||
|
.Include(a => a.Tags)
|
||||||
|
.Where(a => a.UpdateAt >= cutoff)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
return existingEntity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> AddOrUpdateAssetAsync(TradeRepublicAsset dtoAsset)
|
||||||
|
{
|
||||||
|
var existingEntity = await _context.TradeRepublicAssets
|
||||||
|
.Include(a => a.Tags)
|
||||||
|
.FirstOrDefaultAsync(a => a.Isin == dtoAsset.Isin);
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
var nextUpdateScheduledAt = await CalculateNextUpdateDateAsync(now);
|
||||||
|
|
||||||
|
if (existingEntity == null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Asset with ISIN {Isin} not found. Mapping and creating a new entity.", dtoAsset.Isin);
|
||||||
|
|
||||||
|
var newEntity = MapDtoToEntity(dtoAsset);
|
||||||
|
newEntity.LastUpdatedAt = now;
|
||||||
|
newEntity.UpdateAt = nextUpdateScheduledAt;
|
||||||
|
|
||||||
|
newEntity.Tags = await MapTagsAsync(dtoAsset.Tags);
|
||||||
|
|
||||||
|
await _context.TradeRepublicAssets.AddAsync(newEntity);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Asset with ISIN {Isin} exists. Merging properties and updating database record.",
|
||||||
|
dtoAsset.Isin);
|
||||||
|
|
||||||
|
existingEntity.Name = dtoAsset.Name;
|
||||||
|
existingEntity.Type = dtoAsset.Type;
|
||||||
|
existingEntity.InstrumentCategory = dtoAsset.InstrumentCategory;
|
||||||
|
existingEntity.HasCfd = dtoAsset.HasCfd;
|
||||||
|
existingEntity.ImageId = dtoAsset.ImageId;
|
||||||
|
|
||||||
|
existingEntity.LastUpdatedAt = now;
|
||||||
|
existingEntity.UpdateAt = nextUpdateScheduledAt;
|
||||||
|
|
||||||
|
UpdateSubtypeProperties(existingEntity, dtoAsset);
|
||||||
|
existingEntity.Tags = await MapTagsAsync(dtoAsset.Tags);
|
||||||
|
|
||||||
|
_context.TradeRepublicAssets.Update(existingEntity);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<int> AddOrUpdateAssetsAsync(IEnumerable<TradeRepublicAsset> dtoAssets)
|
||||||
|
{
|
||||||
|
int changedCount = 0;
|
||||||
|
|
||||||
|
foreach (var dto in dtoAssets)
|
||||||
|
{
|
||||||
|
var isChanged = await AddOrUpdateAssetAsync(dto);
|
||||||
|
if (isChanged)
|
||||||
|
{
|
||||||
|
changedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changedCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<AssetEntity>> GetAssetsByIsinAsync(string isin)
|
||||||
|
{
|
||||||
|
var localAssets = await _context.TradeRepublicAssets
|
||||||
|
.Include(a => a.Tags)
|
||||||
|
.Where(a => a.Isin == isin)
|
||||||
|
.ToListAsync();
|
||||||
|
|
||||||
|
if (localAssets.Any())
|
||||||
|
{
|
||||||
|
return localAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
var trAssetDto = await _tradeRepublicService.GetAsset(isin);
|
||||||
|
if (trAssetDto != null)
|
||||||
|
{
|
||||||
|
foreach (var asset in trAssetDto.Results)
|
||||||
|
{
|
||||||
|
_ = await AddOrUpdateAssetAsync(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await GetAssetsByIsinAsync(isin);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<AssetEntity>> GetValidAssetsByIsinAsync(string isin)
|
||||||
|
{
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||||
|
|
||||||
|
return await _context.TradeRepublicAssets
|
||||||
|
.AsNoTracking()
|
||||||
|
.Include(a => a.Tags)
|
||||||
|
.Where(a => a.Isin == isin && a.UpdateAt >= cutoff)
|
||||||
|
.ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<List<AssetEntity>> FindAffectedActiveAssetsAsync(string searchQuery)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(searchQuery))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var cutoff = DateTime.UtcNow.AddDays(-14);
|
||||||
|
|
||||||
|
var searchTerms = searchQuery
|
||||||
|
.Split(',')
|
||||||
|
.Select(t => t.Trim().ToLower())
|
||||||
|
.Where(t => !string.IsNullOrEmpty(t))
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (searchTerms.Count == 0) return [];
|
||||||
|
|
||||||
|
var query = _context.TradeRepublicAssets
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.UpdateAt >= cutoff)
|
||||||
|
.Include(a => a.Tags)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
|
||||||
|
foreach (var term in searchTerms)
|
||||||
|
{
|
||||||
|
query = query.Where(a =>
|
||||||
|
a.Isin.ToLower().Contains(term) ||
|
||||||
|
a.Name.ToLower().Contains(term) ||
|
||||||
|
a.Tags.Any(tag => tag.Name.ToLower().Contains(term)));
|
||||||
|
}
|
||||||
|
|
||||||
|
var localAssets = await query.ToListAsync();
|
||||||
|
|
||||||
|
var possibleIsins = searchTerms
|
||||||
|
.Where(t => t.Length == 12 && char.IsLetter(t[0]) && char.IsLetter(t[1]))
|
||||||
|
.Select(t => t.ToUpper())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (possibleIsins.Any())
|
||||||
|
{
|
||||||
|
var foundIsins = localAssets.Select(a => a.Isin).ToHashSet();
|
||||||
|
var missingIsins = possibleIsins.Where(isin => !foundIsins.Contains(isin)).ToList();
|
||||||
|
|
||||||
|
if (missingIsins.Any())
|
||||||
|
{
|
||||||
|
var fetchedNewAsset = false;
|
||||||
|
foreach (var missingIsin in missingIsins)
|
||||||
|
{
|
||||||
|
var trAssetDto = await _tradeRepublicService.GetAsset(missingIsin);
|
||||||
|
if (trAssetDto?.Results != null)
|
||||||
|
{
|
||||||
|
foreach (var asset in trAssetDto.Results)
|
||||||
|
{
|
||||||
|
await AddOrUpdateAssetAsync(asset);
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchedNewAsset = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fetchedNewAsset)
|
||||||
|
{
|
||||||
|
return await query.ToListAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return localAssets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<bool> DeleteAssetAsync(string isin)
|
||||||
|
{
|
||||||
|
var asset = await _context.TradeRepublicAssets.FirstOrDefaultAsync(a => a.Isin == isin);
|
||||||
|
if (asset == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Delete execution cancelled. Asset with ISIN {Isin} does not exist.", isin);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_context.TradeRepublicAssets.Remove(asset);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
_logger.LogInformation("Asset with ISIN {Isin} has been successfully deleted.", isin);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Helper & Mapping Methods
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates the next synchronization/update date for an asset using configured parameters and a random offset.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="baseDate">The baseline date to add the offsets to.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation, returning a DateTime representing the next scheduled update time (UTC).</returns>
|
||||||
|
private async Task<DateTime> CalculateNextUpdateDateAsync(DateTime baseDate)
|
||||||
|
{
|
||||||
|
var settings = await _context.Set<Settings>().FirstOrDefaultAsync() ?? new Settings();
|
||||||
|
|
||||||
|
int randomDays = _random.Next(settings.MinRandomUpdateDay, settings.MaxRandomUpdateDay + 1);
|
||||||
|
var targetDate = baseDate.AddDays(randomDays);
|
||||||
|
|
||||||
|
int randomHour = _random.Next(settings.UpdateDayTimeStart, settings.UpdateDayTimeStop);
|
||||||
|
int randomMinute = _random.Next(0, 60);
|
||||||
|
int randomSecond = _random.Next(0, 60);
|
||||||
|
|
||||||
|
return new DateTime(
|
||||||
|
targetDate.Year,
|
||||||
|
targetDate.Month,
|
||||||
|
targetDate.Day,
|
||||||
|
randomHour,
|
||||||
|
randomMinute,
|
||||||
|
randomSecond,
|
||||||
|
DateTimeKind.Utc
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a raw Trade Republic asset data transfer object (DTO) to its matching database entity subtype.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dto">The source Trade Republic asset data transfer object.</param>
|
||||||
|
/// <returns>A newly created subtype instance of <see cref="AssetEntity"/> mapped with the DTO properties.</returns>
|
||||||
|
/// <exception cref="NotSupportedException">Thrown when the DTO type is unrecognized or unsupported.</exception>
|
||||||
|
private AssetEntity MapDtoToEntity(TradeRepublicAsset dto)
|
||||||
|
{
|
||||||
|
AssetEntity entity = dto switch
|
||||||
|
{
|
||||||
|
TradeRepublicStock stock => new StockEntity
|
||||||
|
{ Isin = stock.Isin, DerivativeProductCategories = stock.DerivativeProductCategories.ToList() },
|
||||||
|
TradeRepublicCrypto crypto => new CryptoEntity
|
||||||
|
{ Isin = crypto.Isin, Subtitle = crypto.Subtitle, SearchSubtitle = crypto.SearchSubtitle },
|
||||||
|
TradeRepublicEtf etf => new EtfEntity
|
||||||
|
{
|
||||||
|
Isin = etf.Isin, EtfDescription = etf.EtfDescription, MappedEtfIndexName = etf.MappedEtfIndexName,
|
||||||
|
Subtitle = etf.Subtitle, SearchSubtitle = etf.SearchSubtitle,
|
||||||
|
DerivativeProductCategories = etf.DerivativeProductCategories.ToList()
|
||||||
|
},
|
||||||
|
TradeRepublicSynthetic synth => new SyntheticEntity
|
||||||
|
{ Isin = synth.Isin, DerivativeProductCategories = synth.DerivativeProductCategories.ToList() },
|
||||||
|
TradeRepublicBond bond => new BondEntity
|
||||||
|
{ Isin = bond.Isin, BondIssuerName = bond.BondIssuerName, SearchSubtitle = bond.SearchSubtitle },
|
||||||
|
TradeRepublicDerivative deriv => new DerivativeEntity
|
||||||
|
{
|
||||||
|
Isin = deriv.Isin, UnderlyingIsin = deriv.UnderlyingIsin,
|
||||||
|
DerivativeProductCategories = deriv.DerivativeProductCategories.ToList()
|
||||||
|
},
|
||||||
|
_ => throw new NotSupportedException($"Type {dto.GetType().Name} is not supported.")
|
||||||
|
};
|
||||||
|
|
||||||
|
return PopulateBaseProperties(entity, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Populates common base properties of a database asset entity using a Trade Republic DTO.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entity">The target database entity.</param>
|
||||||
|
/// <param name="dto">The source Trade Republic DTO.</param>
|
||||||
|
/// <returns>The updated database asset entity.</returns>
|
||||||
|
private AssetEntity PopulateBaseProperties(AssetEntity entity, TradeRepublicAsset dto)
|
||||||
|
{
|
||||||
|
entity.Name = dto.Name;
|
||||||
|
entity.Type = dto.Type;
|
||||||
|
entity.InstrumentCategory = dto.InstrumentCategory;
|
||||||
|
entity.HasCfd = dto.HasCfd;
|
||||||
|
entity.ImageId = dto.ImageId;
|
||||||
|
return entity;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges/updates the subtype-specific properties from a Trade Republic DTO into an existing database entity.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="entity">The existing database entity to update.</param>
|
||||||
|
/// <param name="dto">The source Trade Republic DTO.</param>
|
||||||
|
private void UpdateSubtypeProperties(AssetEntity entity, TradeRepublicAsset dto)
|
||||||
|
{
|
||||||
|
switch (entity)
|
||||||
|
{
|
||||||
|
case StockEntity stockEntity when dto is TradeRepublicStock stockDto:
|
||||||
|
stockEntity.DerivativeProductCategories = stockDto.DerivativeProductCategories.ToList();
|
||||||
|
break;
|
||||||
|
case CryptoEntity cryptoEntity when dto is TradeRepublicCrypto cryptoDto:
|
||||||
|
cryptoEntity.Subtitle = cryptoDto.Subtitle;
|
||||||
|
cryptoEntity.SearchSubtitle = cryptoDto.SearchSubtitle;
|
||||||
|
break;
|
||||||
|
case EtfEntity etfEntity when dto is TradeRepublicEtf etfDto:
|
||||||
|
etfEntity.DerivativeProductCategories = etfDto.DerivativeProductCategories.ToList();
|
||||||
|
etfEntity.EtfDescription = etfDto.EtfDescription;
|
||||||
|
etfEntity.MappedEtfIndexName = etfDto.MappedEtfIndexName;
|
||||||
|
etfEntity.Subtitle = etfDto.Subtitle;
|
||||||
|
etfEntity.SearchSubtitle = etfDto.SearchSubtitle;
|
||||||
|
break;
|
||||||
|
case SyntheticEntity synthEntity when dto is TradeRepublicSynthetic synthDto:
|
||||||
|
synthEntity.DerivativeProductCategories = synthDto.DerivativeProductCategories.ToList();
|
||||||
|
break;
|
||||||
|
case BondEntity bondEntity when dto is TradeRepublicBond bondDto:
|
||||||
|
bondEntity.BondIssuerName = bondDto.BondIssuerName;
|
||||||
|
bondEntity.SearchSubtitle = bondDto.SearchSubtitle;
|
||||||
|
break;
|
||||||
|
case DerivativeEntity derivEntity when dto is TradeRepublicDerivative derivDto:
|
||||||
|
derivEntity.DerivativeProductCategories = derivDto.DerivativeProductCategories.ToList();
|
||||||
|
derivEntity.UnderlyingIsin = derivDto.UnderlyingIsin;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maps a list of Trade Republic tags to database entity instances, registering new tags in the database context if they do not yet exist.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="dtos">The read-only collection of Trade Republic tags.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation, returning the list of mapped database tag entities.</returns>
|
||||||
|
private async Task<List<TagEntity>> MapTagsAsync(IReadOnlyList<TradeRepublicTag> dtos)
|
||||||
|
{
|
||||||
|
var tags = new List<TagEntity>();
|
||||||
|
foreach (var tagDto in dtos)
|
||||||
|
{
|
||||||
|
var existingTag = await _context.TradeRepublicTags.FirstOrDefaultAsync(t => t.Id == tagDto.Id);
|
||||||
|
if (existingTag == null)
|
||||||
|
{
|
||||||
|
_logger.LogTrace("Creating missing asset tag object in storage cache context. ID: {TagId}", tagDto.Id);
|
||||||
|
existingTag = new TagEntity { Id = tagDto.Id, Name = tagDto.Name, Type = tagDto.Type };
|
||||||
|
await _context.TradeRepublicTags.AddAsync(existingTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
tags.Add(existingTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tags;
|
||||||
|
}
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using FinlyticAssets.Models;
|
||||||
|
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
using FinlyticCore.Models.Assets;
|
||||||
|
|
||||||
|
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.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetsFullScanService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _serviceScopeFactory;
|
||||||
|
private readonly ILogger<AssetsFullScanService> _logger;
|
||||||
|
|
||||||
|
private AssetsCount? _assetsCount;
|
||||||
|
private AssetsCount? _currAssetsCount;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AssetsFullScanService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="serviceScopeFactory">Factory used to create service scopes for database and API requests.</param>
|
||||||
|
/// <param name="logger">Logger for service lifecycle and scanning progress messages.</param>
|
||||||
|
public AssetsFullScanService(IServiceScopeFactory serviceScopeFactory, ILogger<AssetsFullScanService> logger)
|
||||||
|
{
|
||||||
|
_serviceScopeFactory = serviceScopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Executes the background scanning task, handling initial startup, recovery, and periodic full-scan cycles.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="stoppingToken">Triggered when the host is shutting down.</param>
|
||||||
|
/// <returns>A task that represents the background operation.</returns>
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("AssetsFullScanService has started.");
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var scope = _serviceScopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
|
||||||
|
_logger.LogInformation("Building initial asset index on service startup...");
|
||||||
|
await indexService.ReCreateIndexFileAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to build initial asset index on startup. Continuing service execution.");
|
||||||
|
}
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using (var scope = _serviceScopeFactory.CreateScope())
|
||||||
|
{
|
||||||
|
var tradeRepublicService = scope.ServiceProvider.GetRequiredService<ITradeRepublicService>();
|
||||||
|
var settingsService = scope.ServiceProvider.GetRequiredService<ISettingsDbService>();
|
||||||
|
var assetsDbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
|
||||||
|
var indexService = scope.ServiceProvider.GetRequiredService<IAssetsIndexService>();
|
||||||
|
|
||||||
|
_logger.LogInformation("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;
|
||||||
|
|
||||||
|
foreach (var type in Enum.GetValues<AssetType>())
|
||||||
|
{
|
||||||
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
|
if (isRecoveryMode)
|
||||||
|
{
|
||||||
|
if (type != initSettings.CurrentScanningType)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Recovery: {AssetType} was already processed. Skipping.", type);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
isRecoveryMode = false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var settings = await settingsService.GetSettings();
|
||||||
|
settings.CurrentScanningType = type;
|
||||||
|
settings.CurrentScanningPage = 0;
|
||||||
|
await settingsService.SaveSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("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 jitter = Random.Shared.Next(0, 480);
|
||||||
|
_logger.LogDebug("Waiting {Delay} seconds before the next asset type.", delaySeconds + jitter);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(delaySeconds + jitter), stoppingToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalSettings = await settingsService.GetSettings();
|
||||||
|
finalSettings.CurrentScanningPage = 0;
|
||||||
|
|
||||||
|
if (!finalSettings.FinishedInitialScan && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Initial scan successfully completed. Switching FinishedInitialScan to true.");
|
||||||
|
finalSettings.FinishedInitialScan = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await settingsService.SaveSettings(finalSettings);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Full scan cycle completed. Waiting 1 minute before starting the next cycle.");
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
_logger.LogError(e, "An unhandled exception occurred in AssetsFullScanService. Retrying in 10 seconds.");
|
||||||
|
try { await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); } catch { /* Ignore */ }
|
||||||
|
}
|
||||||
|
} while (!stoppingToken.IsCancellationRequested);
|
||||||
|
|
||||||
|
_logger.LogInformation("AssetsFullScanService is stopping.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles the scanning process for a specific asset type, iterating through its paginated results.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The type of assets to process (e.g., Stock, Crypto).</param>
|
||||||
|
/// <param name="tradeRepublicService">Service to fetch asset data from Trade Republic.</param>
|
||||||
|
/// <param name="settingsDbService">Service to read and write application state/settings.</param>
|
||||||
|
/// <param name="assetsDbService">Service to insert or update assets in the database.</param>
|
||||||
|
/// <param name="indexService">Service to recreate the local index file if needed.</param>
|
||||||
|
/// <param name="stoppingToken">Cancellation token monitored for cancellation requests.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
private async Task HandleAssetType(AssetType type, ITradeRepublicService tradeRepublicService,
|
||||||
|
ISettingsDbService settingsDbService, IAssetsDbService assetsDbService, IAssetsIndexService indexService, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var totalCount = _assetsCount?.GetCountFromType(type) ?? 0;
|
||||||
|
if (totalCount == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No assets found for type {AssetType}.", type);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_currAssetsCount ??= new AssetsCount();
|
||||||
|
var currentItemOffset = 0;
|
||||||
|
|
||||||
|
var settings = await settingsDbService.GetSettings();
|
||||||
|
if (settings.CurrentScanningType == type && settings.CurrentScanningPage > 0)
|
||||||
|
{
|
||||||
|
var pageSize = settings.TradeRepublicMaxRequestPageSize <= 0 ? 50 : settings.TradeRepublicMaxRequestPageSize;
|
||||||
|
if (pageSize > 100) pageSize = 100;
|
||||||
|
|
||||||
|
currentItemOffset = (settings.CurrentScanningPage - 1) * pageSize;
|
||||||
|
_logger.LogInformation("Resuming full scan for {AssetType} from Page {Page} (Offset: {Offset}).",
|
||||||
|
type, settings.CurrentScanningPage, currentItemOffset);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (currentItemOffset < totalCount && !stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
var currentSettings = await settingsDbService.GetSettings();
|
||||||
|
var pageSize = currentSettings.TradeRepublicMaxRequestPageSize;
|
||||||
|
if (pageSize <= 0 || pageSize > 100) pageSize = 100;
|
||||||
|
|
||||||
|
var currentPage = (currentItemOffset / pageSize) + 1;
|
||||||
|
|
||||||
|
currentSettings.CurrentScanningType = type;
|
||||||
|
currentSettings.CurrentScanningPage = currentPage;
|
||||||
|
await settingsDbService.SaveSettings(currentSettings);
|
||||||
|
|
||||||
|
_logger.LogDebug("Fetching {AssetType} - Page {Page} (Size: {PageSize}). Offset: {Offset}/{Total}",
|
||||||
|
type, currentPage, pageSize, currentItemOffset, totalCount);
|
||||||
|
|
||||||
|
var assets = await tradeRepublicService.GetAssets(type, currentPage, pageSize, stoppingToken);
|
||||||
|
|
||||||
|
if (assets?.Results == null || assets.Results.Count == 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Fetch for {AssetType} (Page {Page}) returned no results. Retrying in 5 seconds...", type, currentPage);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ProcessAssets(assets.Results, assetsDbService, indexService,stoppingToken);
|
||||||
|
_currAssetsCount.SetCountOfType(type, _currAssetsCount.GetCountFromType(type) + assets.Results.Count);
|
||||||
|
|
||||||
|
currentItemOffset = currentPage * pageSize;
|
||||||
|
|
||||||
|
if (assets.Results.Count < pageSize)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Reached the last page for {AssetType}.", type);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var delaySeconds = currentSettings.FinishedInitialScan
|
||||||
|
? currentSettings.BatchAssetUpdateDelay
|
||||||
|
: currentSettings.InitBatchAssetUpdateDelay;
|
||||||
|
|
||||||
|
var jitter = Random.Shared.Next(0, 360);
|
||||||
|
_logger.LogDebug("Waiting {Delay} seconds before the next batch.", delaySeconds + jitter);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(delaySeconds + jitter), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes a list of fetched Trade Republic assets, updates them in the database,
|
||||||
|
/// and triggers an index recreation if changes were detected.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="assets">The list of Trade Republic assets to process.</param>
|
||||||
|
/// <param name="assetsDbService">Service to insert or update assets in the database.</param>
|
||||||
|
/// <param name="indexService">Service to recreate the local index file.</param>
|
||||||
|
/// <param name="stoppingToken">Cancellation token monitored for cancellation requests.</param>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
private async Task ProcessAssets(IList<TradeRepublicAsset> assets, IAssetsDbService assetsDbService, IAssetsIndexService indexService, CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (assets == null || assets.Count == 0) return;
|
||||||
|
|
||||||
|
var changedRows = await assetsDbService.AddOrUpdateAssetsAsync(assets);
|
||||||
|
_logger.LogInformation("[Scan] {Count} assets passed to the DB service. {Changed} modifications/inserts executed.", assets.Count, changedRows);
|
||||||
|
|
||||||
|
if (changedRows > 0)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Database modifications detected. Recreating the asset index file...");
|
||||||
|
await indexService.ReCreateIndexFileAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using FinlyticAssets.Models;
|
||||||
|
using FinlyticAssets.Util;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides methods for generating and maintaining the indexed asset reference file used for pre-filtering.
|
||||||
|
/// </summary>
|
||||||
|
public interface IAssetsIndexService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recreates the index file containing basic asset identifiers (ISIN and Name) for all active, valid assets.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
|
public Task ReCreateIndexFileAsync(CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Implements the <see cref="IAssetsIndexService"/> to maintain local asset index references.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetsIndexService : IAssetsIndexService
|
||||||
|
{
|
||||||
|
private readonly ILogger<AssetsIndexService> _logger;
|
||||||
|
private readonly IAssetsDbService _assetsDbService;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AssetsIndexService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger for documenting indexing events and errors.</param>
|
||||||
|
/// <param name="assetsDbService">The database service to query the assets from.</param>
|
||||||
|
public AssetsIndexService(ILogger<AssetsIndexService> logger, IAssetsDbService assetsDbService)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_assetsDbService = assetsDbService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task ReCreateIndexFileAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var assets = await _assetsDbService.GetAllValidAssetsAsync();
|
||||||
|
if (assets == null || !assets.Any())
|
||||||
|
{
|
||||||
|
_logger.LogWarning("No valid assets found in the database to index.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var indexAssets = assets.Select(a => new AssetIndex(a.Isin, a.Name)).ToList();
|
||||||
|
|
||||||
|
var directoryPath = Volumes.IndexRelativePath;
|
||||||
|
var filePath = Path.Combine(directoryPath, "index.json");
|
||||||
|
|
||||||
|
if (!Directory.Exists(directoryPath))
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(directoryPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 4096,
|
||||||
|
useAsync: true))
|
||||||
|
{
|
||||||
|
await JsonSerializer.SerializeAsync(fileStream, indexAssets, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Successfully recreated asset index file with {Count} entries at {Path}",
|
||||||
|
indexAssets.Count, filePath);
|
||||||
|
}
|
||||||
|
catch (IOException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Disk I/O error occurred while writing the asset index file.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (JsonException ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to serialize the asset index data to JSON.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "An unexpected error occurred while recreating the asset index file.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using FinlyticAssets.Util;
|
||||||
|
using FinlyticCore.Models;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A hosted service responsible for managing the lifecycle of the MQTT client connection
|
||||||
|
/// when the application starts up and shuts down.
|
||||||
|
/// </summary>
|
||||||
|
public class MqttConnectionService : IHostedService
|
||||||
|
{
|
||||||
|
private readonly AssetsMqttClient _mqttClient;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="MqttConnectionService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="mqttClient">The MQTT client wrapper instance.</param>
|
||||||
|
/// <param name="configuration">The application configuration provider.</param>
|
||||||
|
public MqttConnectionService(AssetsMqttClient mqttClient, IConfiguration configuration)
|
||||||
|
{
|
||||||
|
_mqttClient = mqttClient;
|
||||||
|
_configuration = configuration;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Starts the MQTT client connection using settings resolved from configuration.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||||
|
/// <returns>A task representing the asynchronous start operation.</returns>
|
||||||
|
public async Task StartAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var config = new MqttConfiguration()
|
||||||
|
{
|
||||||
|
Host = _configuration["MQTT__Host"]!,
|
||||||
|
Port = Convert.ToInt32(_configuration["MQTT__Port"]!),
|
||||||
|
ClientId = $"{_configuration["MQTT__ClientId"]!}_{Guid.NewGuid()}"
|
||||||
|
};
|
||||||
|
|
||||||
|
await _mqttClient.ConnectAsync(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Stops and disconnects the MQTT client connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||||
|
/// <returns>A task representing the asynchronous stop operation.</returns>
|
||||||
|
public async Task StopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _mqttClient.DisconnectAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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>
|
||||||
|
/// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async Task<Settings> GetSettings()
|
||||||
|
{
|
||||||
|
var settings = await _context.Settings.AsNoTracking().FirstOrDefaultAsync();
|
||||||
|
|
||||||
|
if (settings == null)
|
||||||
|
{
|
||||||
|
settings = new Settings
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid()
|
||||||
|
};
|
||||||
|
|
||||||
|
await SaveSettings(settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
await _context.Settings.AddAsync(settings);
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_context.Entry(existing).CurrentValues.SetValues(settings);
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync();
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
using System.Timers;
|
||||||
|
using FinlyticAssets.Models;
|
||||||
|
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
using FinlyticAssets.Util;
|
||||||
|
using FinlyticCore.Models.Assets;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Services;
|
||||||
|
|
||||||
|
public interface ITradeRepublicService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Holt die Anzahl der Assets pro Typ für die Paginierung des Initial-Scans.
|
||||||
|
/// </summary>
|
||||||
|
public Task<AssetsCount> GetAssetsCount(CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holt eine spezifische Seite an Assets für den Initial-Scan.
|
||||||
|
/// </summary>
|
||||||
|
public Task<TradeRepublicAssetResponse?> GetAssets(AssetType type, int page, int pageSize,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Holt die aktuellen Stammdaten für eine spezifische ISIN (Gezieltes Update).
|
||||||
|
/// Gibt null zurück, wenn das Asset bei TR nicht mehr existiert.
|
||||||
|
/// </summary>
|
||||||
|
public Task<TradeRepublicAssetResponse?> GetAsset(string isin, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Provides a managed service to interact with the Trade Republic API via WebSockets,
|
||||||
|
/// featuring an automatic inactivity timeout to mimic human behavior.
|
||||||
|
/// </summary>
|
||||||
|
public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||||
|
{
|
||||||
|
private readonly TradeRepublicClient _client;
|
||||||
|
private readonly ILogger<TradeRepublicService> _logger;
|
||||||
|
private readonly System.Timers.Timer _inactivityTimer;
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="TradeRepublicService"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The underlying managed WebSocket client.</param>
|
||||||
|
/// <param name="logger">The logger instance.</param>
|
||||||
|
public TradeRepublicService(TradeRepublicClient client, ILogger<TradeRepublicService> logger)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
_logger = logger;
|
||||||
|
|
||||||
|
_inactivityTimer = new System.Timers.Timer(TimeSpan.FromSeconds(461).TotalMilliseconds);
|
||||||
|
_inactivityTimer.AutoReset = false;
|
||||||
|
_inactivityTimer.Elapsed += OnInactivityTimeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ensures that the Trade Republic WebSocket client is connected, initiating a new connection if necessary.
|
||||||
|
/// Also handles resetting the inactivity timer.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A task representing the asynchronous operation.</returns>
|
||||||
|
private async Task EnsureConnectedAsync()
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_inactivityTimer.Stop();
|
||||||
|
|
||||||
|
if (!_client.IsConnected)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Trade Republic API is not connected. Establishing automated connection...");
|
||||||
|
|
||||||
|
// Nutzt den boolschen Rückgabewert von InitAsync
|
||||||
|
bool connected = await _client.InitAsync();
|
||||||
|
|
||||||
|
if (connected)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Successfully connected to Trade Republic API.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Trade Republic API connection initialization failed (InitAsync returned false).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_inactivityTimer.Start();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to establish a connection to the Trade Republic API.");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the total count of available assets grouped by their types.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||||
|
/// <returns>An <see cref="AssetsCount"/> object containing the metrics.</returns>
|
||||||
|
public async Task<AssetsCount> GetAssetsCount(CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await EnsureConnectedAsync();
|
||||||
|
|
||||||
|
var counts = new AssetsCount();
|
||||||
|
|
||||||
|
foreach (var type in Enum.GetValues<AssetType>())
|
||||||
|
{
|
||||||
|
var reqData = new TradeRepublicSearchData()
|
||||||
|
{
|
||||||
|
Query = "",
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 1,
|
||||||
|
Filter =
|
||||||
|
[
|
||||||
|
new TradeRepublicFilter("type", type.ToString().ToLowerInvariant()),
|
||||||
|
new TradeRepublicFilter("jurisdiction", "DE"),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
var request = new TradeRepublicSearchRequest(Data: reqData);
|
||||||
|
var response =
|
||||||
|
await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request);
|
||||||
|
|
||||||
|
var count = response?.ResultCount ?? 0;
|
||||||
|
|
||||||
|
counts.SetCountOfType(type, count);
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMilliseconds(320), cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves a paginated chunk of assets filtered by a specific type.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="type">The type of assets to retrieve (e.g., Stock, Etf).</param>
|
||||||
|
/// <param name="page">The zero-based page index.</param>
|
||||||
|
/// <param name="pageSize">The number of elements per page.</param>
|
||||||
|
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||||
|
/// <returns>A <see cref="TradeRepublicAssetResponse"/> containing the elements, or null if the request fails.</returns>
|
||||||
|
public async Task<TradeRepublicAssetResponse?> GetAssets(AssetType type, int page, int pageSize,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await EnsureConnectedAsync();
|
||||||
|
|
||||||
|
var reqData = new TradeRepublicSearchData()
|
||||||
|
{
|
||||||
|
Query = "",
|
||||||
|
Page = page,
|
||||||
|
PageSize = pageSize,
|
||||||
|
Filter =
|
||||||
|
[
|
||||||
|
new TradeRepublicFilter("type", type.ToString().ToLowerInvariant()),
|
||||||
|
new TradeRepublicFilter("jurisdiction", "DE"),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var request = new TradeRepublicSearchRequest(Data: reqData);
|
||||||
|
|
||||||
|
return await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retrieves the static metadata for a single specific asset via its ISIN.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="isin">The International Securities Identification Number of the target asset.</param>
|
||||||
|
/// <param name="cancellationToken">A token to monitor for cancellation requests.</param>
|
||||||
|
/// <returns>A <see cref="TradeRepublicAssetResponse"/> containing instrument details, or null if the asset is not found.</returns>
|
||||||
|
public async Task<TradeRepublicAssetResponse?> GetAsset(string isin, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await EnsureConnectedAsync();
|
||||||
|
|
||||||
|
|
||||||
|
var reqData = new TradeRepublicSearchData()
|
||||||
|
{
|
||||||
|
Query = isin,
|
||||||
|
Page = 1,
|
||||||
|
PageSize = 1,
|
||||||
|
Filter =
|
||||||
|
[
|
||||||
|
new TradeRepublicFilter("jurisdiction", "DE"),
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
var request = new TradeRepublicSearchRequest(Data: reqData);
|
||||||
|
|
||||||
|
return await _client.SendRequestAsync<TradeRepublicAssetResponse, TradeRepublicSearchRequest>(request);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error while fetching asset metadata for ISIN {Isin}", isin);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Event handler executed when the inactivity timer expires.
|
||||||
|
/// Gracefully disconnects the WebSocket client.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="sender">The source of the event.</param>
|
||||||
|
/// <param name="e">An EventData object that contains the event data.</param>
|
||||||
|
private async void OnInactivityTimeout(object? sender, ElapsedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _lock.WaitAsync();
|
||||||
|
|
||||||
|
if (!_client.IsConnected) return;
|
||||||
|
|
||||||
|
_logger.LogInformation("No active requests detected for 5 minutes. Automatically disconnecting WebSocket.");
|
||||||
|
await _client.DisconnectAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error during automatic inactivity disconnect procedure.");
|
||||||
|
//ignore
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes the underlying timer and synchronization primitives.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_inactivityTimer.Dispose();
|
||||||
|
_lock.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using FinlyticAssets.Services;
|
||||||
|
using FinlyticCore.Entities.Assets;
|
||||||
|
using FinlyticCore.Models.Assets;
|
||||||
|
using FinlyticCore.Util;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Util;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a managed MQTT client acting as a server-side RPC provider within the asset microservice.
|
||||||
|
/// It subscribes to request topics, processes incoming JSON payloads via the database service, and publishes
|
||||||
|
/// the requested asset entities back to the corresponding response topic.
|
||||||
|
/// </summary>
|
||||||
|
public class AssetsMqttClient : ManagedMqttClient
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly ILogger<AssetsMqttClient> _logger;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="AssetsMqttClient"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger used to record connection, error, and status messages.</param>
|
||||||
|
/// <param name="dbService">The database service used for querying and validating assets.</param>
|
||||||
|
public AssetsMqttClient(ILogger<AssetsMqttClient> logger, IServiceScopeFactory scopeFactory)
|
||||||
|
: base(logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Invoked automatically once the connection to the MQTT broker is successfully established or restored.
|
||||||
|
/// Registers the required wildcard subscriptions for incoming asset validation and search requests.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous subscription operation.</returns>
|
||||||
|
protected override async Task OnConnectedAsync()
|
||||||
|
{
|
||||||
|
await SubscribeAsync("services/request/assets_Get/#");
|
||||||
|
await SubscribeAsync("services/request/assets_Search/#");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Processes incoming messages on the subscribed topics, executes the corresponding database query,
|
||||||
|
/// and publishes the result to the response topic while preserving the correlation ID.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="topic">The MQTT topic on which the message was received.</param>
|
||||||
|
/// <param name="payload">The incoming message as a UTF-8 encoded JSON string.</param>
|
||||||
|
/// <returns>A <see cref="Task"/> representing the asynchronous message processing operation.</returns>
|
||||||
|
protected override async Task OnMessageReceivedAsync(string topic, string payload)
|
||||||
|
{
|
||||||
|
var segments = topic.Split('/');
|
||||||
|
if (segments.Length < 4) return;
|
||||||
|
|
||||||
|
var channel = segments[2];
|
||||||
|
var correlationId = segments[3];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
List<AssetEntity> responseData = [];
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var dbService = scope.ServiceProvider.GetRequiredService<IAssetsDbService>();
|
||||||
|
|
||||||
|
switch (channel)
|
||||||
|
{
|
||||||
|
case "assets_Get":
|
||||||
|
var validReq = JsonSerializer.Deserialize<GetValidAssetRequest>(payload);
|
||||||
|
if (validReq != null)
|
||||||
|
{
|
||||||
|
responseData = await dbService.GetValidAssetsByIsinAsync(validReq.Isin);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "assets_Search":
|
||||||
|
var searchReq = JsonSerializer.Deserialize<SearchAssetsRequest>(payload);
|
||||||
|
if (searchReq != null)
|
||||||
|
{
|
||||||
|
responseData = await dbService.FindAffectedActiveAssetsAsync(searchReq.SearchQuery);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
string responseTopic = $"services/response/{channel}/{correlationId}";
|
||||||
|
await PublishAsync(responseTopic, responseData.ToDtoList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnError(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
namespace FinlyticAssets.Util;
|
||||||
|
|
||||||
|
public class StringCodeGenerator
|
||||||
|
{
|
||||||
|
|
||||||
|
public static string GenerateTraceparent()
|
||||||
|
{
|
||||||
|
var traceId = Guid.NewGuid().ToString("N");
|
||||||
|
var spanId = Guid.NewGuid().ToString("N").Substring(0, 16);
|
||||||
|
|
||||||
|
return $"00-{traceId}-{spanId}-01";
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FinlyticAssets.Models.DataToObject.TradeRepublic;
|
||||||
|
|
||||||
|
namespace FinlyticAssets.Util;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A managed WebSocket client designed to communicate with the Trade Republic API.
|
||||||
|
/// Handles asynchronous requests, generic serialization, and automatic subscription management.
|
||||||
|
/// </summary>
|
||||||
|
public class TradeRepublicClient : ManagedWebSocket
|
||||||
|
{
|
||||||
|
private readonly ILogger<TradeRepublicClient> _logger;
|
||||||
|
private int _currentSub;
|
||||||
|
private readonly ConcurrentDictionary<int, TaskCompletionSource<ReceivedMessage>> _pendingRequests = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wird ausgelöst, wenn Trade Republic asynchrone Updates (z.B. Live-Preise) schickt,
|
||||||
|
/// auf die niemand aktiv per SendRequestAsync wartet.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<ReceivedMessage>? UnhandledMessageReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wird ausgelöst, wenn Trade Republic Systemnachrichten oder Fehler ohne ID schickt.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string>? SystemMessageReceived;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the TradeRepublicClient.
|
||||||
|
/// Call InitAsync() afterwards to establish the connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="logger">The logger instance for tracking socket events and errors.</param>
|
||||||
|
public TradeRepublicClient(ILogger<TradeRepublicClient> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Asynchronously establishes the WebSocket connection to the Trade Republic API.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<bool> InitAsync()
|
||||||
|
{
|
||||||
|
await ConnectAsync("wss://api.traderepublic.com/", TimeSpan.FromSeconds(10));
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
_pendingRequests.TryAdd(-1, tcs);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(new TradeRepublicConnectRequest());
|
||||||
|
await SendAsync($"connect 34 {json}");
|
||||||
|
|
||||||
|
var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(res.Type)) return false;
|
||||||
|
|
||||||
|
var isConnected = res.Type == "connected";
|
||||||
|
|
||||||
|
if (isConnected)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("WebSocket connection to Trade Republic established.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return isConnected;
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_pendingRequests.TryRemove(-1, out _);
|
||||||
|
_logger.LogWarning("Timeout while waiting for response to ID {Id}.", -1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Trade Republic immediately rejected the request for ID {Id} (e.g., invalid ISIN or access denied).",
|
||||||
|
-1);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a strongly-typed request to the API and waits for the corresponding response.
|
||||||
|
/// Automatically handles the subscription ID and unsubscribes after completion or failure.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TResponse">The expected type of the response payload.</typeparam>
|
||||||
|
/// <typeparam name="TRequest">The type of the request payload.</typeparam>
|
||||||
|
/// <param name="request">The request data to be serialized and sent.</param>
|
||||||
|
/// <returns>The deserialized response object, or null if the request timed out or was canceled.</returns>
|
||||||
|
public async Task<TResponse?> SendRequestAsync<TResponse, TRequest>(TRequest request)
|
||||||
|
where TResponse : class where TRequest : class
|
||||||
|
{
|
||||||
|
var tempSub = Interlocked.Increment(ref _currentSub);
|
||||||
|
|
||||||
|
var msg = $"sub {tempSub} {JsonSerializer.Serialize(request)}";
|
||||||
|
|
||||||
|
Console.WriteLine(msg);
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<ReceivedMessage>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
|
||||||
|
_pendingRequests.TryAdd(tempSub, tcs);
|
||||||
|
|
||||||
|
await SendAsync(msg);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var res = await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
if (res.Type == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Trade Republic rejected the request for ID {Id} with message type '{Type}'.", tempSub, res.Type);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.Type.Contains('A'))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(res.Data)) return null;
|
||||||
|
|
||||||
|
if (typeof(TResponse) == typeof(string))
|
||||||
|
{
|
||||||
|
return res.Data as TResponse;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return JsonSerializer.Deserialize<TResponse>(res.Data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_pendingRequests.TryRemove(tempSub, out _);
|
||||||
|
_logger.LogWarning("Timeout while waiting for response to ID {Id}.", tempSub);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch (TaskCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Trade Republic immediately rejected the request for ID {Id} (e.g., invalid ISIN or access denied).",
|
||||||
|
tempSub);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (IsConnected)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SendAsync($"unsub {tempSub}");
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
//ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Processes incoming WebSocket messages, extracting the JSON payload and resolving pending tasks.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The raw text message received from the server.</param>
|
||||||
|
protected override void OnMessageReceived(string message)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(message)) return;
|
||||||
|
|
||||||
|
// 1. Handshake-Nachricht direkt abfangen
|
||||||
|
if (message == "connected")
|
||||||
|
{
|
||||||
|
if (_pendingRequests.TryRemove(-1, out var tcs))
|
||||||
|
{
|
||||||
|
tcs.SetResult(new ReceivedMessage(-1, "connected", null));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Erstes Leerzeichen finden, um die ID zu isolieren
|
||||||
|
var firstSpaceIndex = message.IndexOf(' ');
|
||||||
|
if (firstSpaceIndex <= 0)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Unknown message format received: {Message}", message);
|
||||||
|
SystemMessageReceived?.Invoke(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idString = message[..firstSpaceIndex];
|
||||||
|
if (!int.TryParse(idString, out var responseId))
|
||||||
|
{
|
||||||
|
SystemMessageReceived?.Invoke(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Rest nach der ID (z. B. "A {...}" oder "C")
|
||||||
|
var remainder = message[firstSpaceIndex..].Trim();
|
||||||
|
|
||||||
|
// 3. Nachrichtentyp ("A", "C", etc.) und JSON-Inhalt sauber trennen
|
||||||
|
var nextSpaceIndex = remainder.IndexOf(' ');
|
||||||
|
string msgType;
|
||||||
|
string? json = null;
|
||||||
|
|
||||||
|
if (nextSpaceIndex == -1)
|
||||||
|
{
|
||||||
|
// Kein weiteres Leerzeichen vorhanden (wie bei "2 C")
|
||||||
|
msgType = remainder;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Typ und JSON trennen (wie bei "2 A {...}")
|
||||||
|
msgType = remainder[..nextSpaceIndex].Trim();
|
||||||
|
json = remainder[nextSpaceIndex..].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. KORREKTUR: "C" signalisiert nur das Ende des Datenstroms auf dieser ID.
|
||||||
|
// Wir ignorieren es, da die Daten bereits im Typ "A" übertragen wurden.
|
||||||
|
if (msgType == "C")
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Trade Republic closed subscription channel for ID {ResponseId}.", responseId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Task auflösen, falls jemand auf diese ID wartet
|
||||||
|
if (_pendingRequests.TryRemove(responseId, out var pendingTcs))
|
||||||
|
{
|
||||||
|
pendingTcs.SetResult(new ReceivedMessage(responseId, msgType, json));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
UnhandledMessageReceived?.Invoke(new ReceivedMessage(responseId, msgType, json));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the incoming message is a keep-alive echo response.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The raw text message.</param>
|
||||||
|
/// <returns>True if the message is an echo response; otherwise, false.</returns>
|
||||||
|
protected override bool IsKeepAliveMessage(string message)
|
||||||
|
{
|
||||||
|
return message.StartsWith("echo");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a periodic keep-alive echo to maintain the WebSocket connection.
|
||||||
|
/// </summary>
|
||||||
|
protected override Task SendLifeMessageAsync()
|
||||||
|
{
|
||||||
|
var echo = $"echo {DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||||
|
return SendAsync(echo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReceivedMessage(int? Sub, string? Type, string? Data);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace FinlyticAssets.Util;
|
||||||
|
|
||||||
|
public class Volumes
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Der relative Pfad für die schlanke Index-Datei (ISINs + Namen) zur Asset-Erkennung.
|
||||||
|
/// </summary>
|
||||||
|
public const string IndexRelativePath = "assets/index";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||||
|
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace FinlyticCore.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents the network and security settings used to connect to the central MQTT broker.
|
||||||
|
/// </summary>
|
||||||
|
public class MqttConfiguration
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the host address or IP of the MQTT broker.
|
||||||
|
/// </summary>
|
||||||
|
public string Host { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the port number for the connection. Defaults to 1883.
|
||||||
|
/// </summary>
|
||||||
|
public int Port { get; set; } = 1883;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the unique client identifier used when registering with the broker.
|
||||||
|
/// </summary>
|
||||||
|
public string ClientId { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the username for authentication (optional).
|
||||||
|
/// </summary>
|
||||||
|
public string? Username { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the password for authentication (optional).
|
||||||
|
/// </summary>
|
||||||
|
public string? Password { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
using FinlyticCore.Dtos.Assets;
|
||||||
|
using FinlyticCore.Entities.Assets;
|
||||||
|
|
||||||
|
namespace FinlyticCore.Util;
|
||||||
|
|
||||||
|
public static class AssetMapper
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Mappt eine AssetEntity (Datenbank) sicher auf ein zyklusfreies AssetDto (MQTT Payload).
|
||||||
|
/// </summary>
|
||||||
|
public static AssetDto ToDto(this AssetEntity entity)
|
||||||
|
{
|
||||||
|
// 1. Tags zyklusfrei mappen
|
||||||
|
var dtoTags = entity.Tags.Select(t => new TagDto
|
||||||
|
{
|
||||||
|
Id = t.Id,
|
||||||
|
Name = t.Name,
|
||||||
|
Type = t.Type
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
// 2. Polymorphes Mapping basierend auf dem Laufzeittyp
|
||||||
|
return entity switch
|
||||||
|
{
|
||||||
|
StockEntity stock => new StockDto
|
||||||
|
{
|
||||||
|
Isin = stock.Isin, Name = stock.Name, Type = stock.Type, InstrumentCategory = stock.InstrumentCategory, HasCfd = stock.HasCfd, ImageId = stock.ImageId, UpdateAt = stock.UpdateAt, LastUpdatedAt = stock.LastUpdatedAt, Tags = dtoTags,
|
||||||
|
DerivativeProductCategories = stock.DerivativeProductCategories
|
||||||
|
},
|
||||||
|
EtfEntity etf => new EtfDto
|
||||||
|
{
|
||||||
|
Isin = etf.Isin, Name = etf.Name, Type = etf.Type, InstrumentCategory = etf.InstrumentCategory, HasCfd = etf.HasCfd, ImageId = etf.ImageId, UpdateAt = etf.UpdateAt, LastUpdatedAt = etf.LastUpdatedAt, Tags = dtoTags,
|
||||||
|
DerivativeProductCategories = etf.DerivativeProductCategories, EtfDescription = etf.EtfDescription, MappedEtfIndexName = etf.MappedEtfIndexName, Subtitle = etf.Subtitle, SearchSubtitle = etf.SearchSubtitle
|
||||||
|
},
|
||||||
|
CryptoEntity crypto => new CryptoDto
|
||||||
|
{
|
||||||
|
Isin = crypto.Isin, Name = crypto.Name, Type = crypto.Type, InstrumentCategory = crypto.InstrumentCategory, HasCfd = crypto.HasCfd, ImageId = crypto.ImageId, UpdateAt = crypto.UpdateAt, LastUpdatedAt = crypto.LastUpdatedAt, Tags = dtoTags,
|
||||||
|
Subtitle = crypto.Subtitle, SearchSubtitle = crypto.SearchSubtitle
|
||||||
|
},
|
||||||
|
BondEntity bond => new BondDto
|
||||||
|
{
|
||||||
|
Isin = bond.Isin, Name = bond.Name, Type = bond.Type, InstrumentCategory = bond.InstrumentCategory, HasCfd = bond.HasCfd, ImageId = bond.ImageId, UpdateAt = bond.UpdateAt, LastUpdatedAt = bond.LastUpdatedAt, Tags = dtoTags,
|
||||||
|
BondIssuerName = bond.BondIssuerName, SearchSubtitle = bond.SearchSubtitle
|
||||||
|
},
|
||||||
|
DerivativeEntity deriv => new DerivativeDto
|
||||||
|
{
|
||||||
|
Isin = deriv.Isin, Name = deriv.Name, Type = deriv.Type, InstrumentCategory = deriv.InstrumentCategory, HasCfd = deriv.HasCfd, ImageId = deriv.ImageId, UpdateAt = deriv.UpdateAt, LastUpdatedAt = deriv.LastUpdatedAt, Tags = dtoTags,
|
||||||
|
DerivativeProductCategories = deriv.DerivativeProductCategories, UnderlyingIsin = deriv.UnderlyingIsin
|
||||||
|
},
|
||||||
|
SyntheticEntity synth => new SyntheticDto
|
||||||
|
{
|
||||||
|
Isin = synth.Isin, Name = synth.Name, Type = synth.Type, InstrumentCategory = synth.InstrumentCategory, HasCfd = synth.HasCfd, ImageId = synth.ImageId, UpdateAt = synth.UpdateAt, LastUpdatedAt = synth.LastUpdatedAt, Tags = dtoTags,
|
||||||
|
DerivativeProductCategories = synth.DerivativeProductCategories
|
||||||
|
},
|
||||||
|
_ => throw new NotSupportedException($"Mapping for type {entity.GetType().Name} is not supported.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mappt direkt eine ganze Liste von AssetEntities.
|
||||||
|
/// </summary>
|
||||||
|
public static List<AssetDto> ToDtoList(this IEnumerable<AssetEntity> entities)
|
||||||
|
{
|
||||||
|
return entities.Select(e => e.ToDto()).ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FinlyticCore.Models;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using MQTTnet;
|
||||||
|
|
||||||
|
namespace FinlyticCore.Util;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An abstract, resilient MQTT client wrapper designed for microservice architectures.
|
||||||
|
/// Handles automatic reconnection, structured JSON publishing, thread-safe subscription management, and synchronous Request-Reply (RPC).
|
||||||
|
/// </summary>
|
||||||
|
public abstract class ManagedMqttClient : IDisposable
|
||||||
|
{
|
||||||
|
private readonly ILogger<ManagedMqttClient> _logger;
|
||||||
|
private readonly IMqttClient _mqttClient;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
// Tracks pending RPC requests waiting for a specific correlation ID reply
|
||||||
|
private readonly ConcurrentDictionary<string, TaskCompletionSource<string>> _pendingRequests = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the client is currently connected to the MQTT broker.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsConnected => _mqttClient.IsConnected;
|
||||||
|
|
||||||
|
protected ManagedMqttClient(ILogger<ManagedMqttClient> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
_mqttClient = new MqttClientFactory().CreateMqttClient();
|
||||||
|
|
||||||
|
_mqttClient.ApplicationMessageReceivedAsync += HandleIncomingMessageAsync;
|
||||||
|
_mqttClient.DisconnectedAsync += HandleDisconnectAsync;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Establishes a connection to the MQTT broker and initializes the background auto-reconnection loop.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="config">The network and credential configuration options for the broker.</param>
|
||||||
|
public async Task ConnectAsync(MqttConfiguration config)
|
||||||
|
{
|
||||||
|
if (IsConnected)
|
||||||
|
throw new InvalidOperationException("MQTT client is already connected.");
|
||||||
|
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
var optionsBuilder = new MqttClientOptionsBuilder()
|
||||||
|
.WithTcpServer(config.Host, config.Port)
|
||||||
|
.WithClientId(config.ClientId)
|
||||||
|
.WithCleanSession();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(config.Username))
|
||||||
|
{
|
||||||
|
optionsBuilder.WithCredentials(config.Username, config.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
var options = optionsBuilder.Build();
|
||||||
|
|
||||||
|
_logger.LogInformation("Connecting to MQTT broker at {Host}:{Port}...", config.Host, config.Port);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mqttClient.ConnectAsync(options, _cts.Token);
|
||||||
|
_logger.LogInformation("Successfully connected to MQTT broker.");
|
||||||
|
|
||||||
|
await OnConnectedAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to establish initial connection to MQTT broker. Reconnection loop will handle recovery.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gracefully disconnects from the broker and stops all ongoing background loops.
|
||||||
|
/// </summary>
|
||||||
|
public async Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
if (_cts != null)
|
||||||
|
{
|
||||||
|
await _cts.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_mqttClient.IsConnected)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _mqttClient.DisconnectAsync(new MqttClientDisconnectOptions
|
||||||
|
{
|
||||||
|
Reason = MqttClientDisconnectOptionsReason.NormalDisconnection
|
||||||
|
});
|
||||||
|
_logger.LogInformation("MQTT connection gracefully closed.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "An error occurred while disconnecting from the MQTT broker.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Subscribes to a specific MQTT topic filter.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="topic">The topic pattern or wildcard to subscribe to.</param>
|
||||||
|
/// <param name="noLocal">If set to <c>true</c>, the broker will not forward messages published by this client back to itself.</param>
|
||||||
|
protected async Task SubscribeAsync(string topic, bool noLocal = false)
|
||||||
|
{
|
||||||
|
if (!IsConnected)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Subscription to topic '{Topic}' delayed: Client is currently offline.", topic);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var filterBuilder = new MqttTopicFilterBuilder().WithTopic(topic);
|
||||||
|
if (noLocal)
|
||||||
|
{
|
||||||
|
filterBuilder.WithNoLocal();
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribeOptions = new MqttClientFactory().CreateSubscribeOptionsBuilder()
|
||||||
|
.WithTopicFilter(filterBuilder.Build())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await _mqttClient.SubscribeAsync(subscribeOptions, CancellationToken.None);
|
||||||
|
_logger.LogDebug("Successfully subscribed to topic: {Topic} (NoLocal: {NoLocal})", topic, noLocal);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Publishes a raw string message payload to the specified topic.
|
||||||
|
/// </summary>
|
||||||
|
public async Task PublishAsync(string topic, string payload, bool retain = false)
|
||||||
|
{
|
||||||
|
if (!IsConnected)
|
||||||
|
throw new InvalidOperationException("Cannot publish message: MQTT client is offline.");
|
||||||
|
|
||||||
|
var message = new MqttApplicationMessageBuilder()
|
||||||
|
.WithTopic(topic)
|
||||||
|
.WithPayload(payload)
|
||||||
|
.WithQualityOfServiceLevel(MQTTnet.Protocol.MqttQualityOfServiceLevel.AtLeastOnce)
|
||||||
|
.WithRetainFlag(retain)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await _mqttClient.PublishAsync(message, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes a generic object into a structured JSON string and publishes it to the specified topic.
|
||||||
|
/// </summary>
|
||||||
|
public Task PublishAsync<T>(string topic, T data, bool retain = false)
|
||||||
|
{
|
||||||
|
var jsonOptions = new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
ReferenceHandler = ReferenceHandler.IgnoreCycles
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(data, jsonOptions);
|
||||||
|
return PublishAsync(topic, json, retain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a generic request payload to an RPC channel and asynchronously blocks until a matching response arrives.
|
||||||
|
/// Uses the topic conventions: <c>services/request/{channel}/{correlationId}</c> and <c>services/response/{channel}/{correlationId}</c>.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="TResponse">The expected strongly-typed object type of the reply.</typeparam>
|
||||||
|
/// <typeparam name="TRequest">The type of the payload being transmitted.</typeparam>
|
||||||
|
/// <param name="channel">The target sub-channel or service name (e.g., "sentix", "assets").</param>
|
||||||
|
/// <param name="requestData">The object that will be serialized to JSON and sent.</param>
|
||||||
|
/// <param name="timeout">Optional. Maximum time to wait before returning null. Defaults to 10 seconds.</param>
|
||||||
|
public async Task<TResponse?> SendRpcRequestAsync<TResponse, TRequest>(
|
||||||
|
string channel,
|
||||||
|
TRequest requestData,
|
||||||
|
TimeSpan? timeout = null)
|
||||||
|
where TResponse : class
|
||||||
|
where TRequest : class
|
||||||
|
{
|
||||||
|
if (!IsConnected)
|
||||||
|
throw new InvalidOperationException("Cannot execute RPC request: MQTT client is offline.");
|
||||||
|
|
||||||
|
// 1. Generate a unique Correlation ID for this specific transaction
|
||||||
|
string correlationId = Guid.NewGuid().ToString("N");
|
||||||
|
|
||||||
|
var tcs = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
_pendingRequests.TryAdd(correlationId, tcs);
|
||||||
|
|
||||||
|
string requestTopic = $"services/request/{channel}/{correlationId}";
|
||||||
|
|
||||||
|
// 2. Serialize and dispatch via the existing JSON helper
|
||||||
|
await PublishAsync(requestTopic, requestData);
|
||||||
|
_logger.LogDebug("RPC request published to '{Topic}' [CorrelationId: {Id}]", requestTopic, correlationId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// 3. Block asynchronously until the response loop resolves the token
|
||||||
|
var effectiveTimeout = timeout ?? TimeSpan.FromSeconds(10);
|
||||||
|
var rawJsonResult = await tcs.Task.WaitAsync(effectiveTimeout);
|
||||||
|
|
||||||
|
if (typeof(TResponse) == typeof(string))
|
||||||
|
{
|
||||||
|
return rawJsonResult as TResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonSerializer.Deserialize<TResponse>(rawJsonResult);
|
||||||
|
}
|
||||||
|
catch (TimeoutException)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("RPC request timed out on channel '{Channel}' [CorrelationId: {Id}]", channel, correlationId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Always clean up the dictionary to prevent memory leaks
|
||||||
|
_pendingRequests.TryRemove(correlationId, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleIncomingMessageAsync(MqttApplicationMessageReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var topic = e.ApplicationMessage.Topic;
|
||||||
|
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
|
||||||
|
|
||||||
|
// Intercept message if it belongs to the RPC response convention
|
||||||
|
if (topic.StartsWith("services/response/"))
|
||||||
|
{
|
||||||
|
var lastSlashIndex = topic.LastIndexOf('/');
|
||||||
|
if (lastSlashIndex != -1)
|
||||||
|
{
|
||||||
|
string correlationId = topic[(lastSlashIndex + 1)..];
|
||||||
|
|
||||||
|
if (_pendingRequests.TryRemove(correlationId, out var tcs))
|
||||||
|
{
|
||||||
|
tcs.SetResult(payload);
|
||||||
|
return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular Pub/Sub message propagation
|
||||||
|
await OnMessageReceivedAsync(topic, payload);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnError(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleDisconnectAsync(MqttClientDisconnectedEventArgs e)
|
||||||
|
{
|
||||||
|
// Prevent trigger during deliberate connection shutdowns
|
||||||
|
if (_cts == null || _cts.IsCancellationRequested)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_logger.LogWarning("Lost connection to MQTT broker (Reason: {Reason}). Initiating auto-reconnect loop in 5 seconds...", e.Reason);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(5), _cts.Token);
|
||||||
|
|
||||||
|
await _mqttClient.ReconnectAsync(_cts.Token);
|
||||||
|
|
||||||
|
if (_mqttClient.IsConnected)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("MQTT client reconnected successfully.");
|
||||||
|
await OnConnectedAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* Expected swallow on application shutdown */ }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Reconnection attempt to the MQTT broker failed.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired automatically whenever a connection or reconnection is successfully established.
|
||||||
|
/// Ideal place to trigger <see cref="SubscribeAsync"/> operations.
|
||||||
|
/// </summary>
|
||||||
|
protected abstract Task OnConnectedAsync();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired whenever a new message lands on a registered subscription channel.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="topic">The specific topic where the message was broadcasted.</param>
|
||||||
|
/// <param name="payload">The deserialized UTF-8 payload string.</param>
|
||||||
|
protected abstract Task OnMessageReceivedAsync(string topic, string payload);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Virtual fallback method to catch and handle processing level exceptions inside the incoming pipeline.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual void OnError(Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "An unhandled exception occurred within the ManagedMqttClient messaging pipeline.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisconnectAsync().GetAwaiter().GetResult();
|
||||||
|
_cts?.Dispose();
|
||||||
|
_mqttClient.Dispose();
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
namespace FinlyticAssets.Util;
|
||||||
|
|
||||||
|
using System;
|
||||||
|
using System.IO;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
public abstract class ManagedWebSocket : IDisposable
|
||||||
|
{
|
||||||
|
private ClientWebSocket? _webSocket;
|
||||||
|
private CancellationTokenSource? _cts;
|
||||||
|
private Task? _receiveTask;
|
||||||
|
private Task? _keepAliveTask;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets or sets the interval for keep-alive messages.
|
||||||
|
/// If set to null, the periodic timer is not started and no life messages are sent.
|
||||||
|
/// </summary>
|
||||||
|
public TimeSpan? KeepAliveInterval { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets a value indicating whether the WebSocket is currently open.
|
||||||
|
/// </summary>
|
||||||
|
public bool IsConnected => _webSocket?.State == WebSocketState.Open;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the current state of the WebSocket connection.
|
||||||
|
/// </summary>
|
||||||
|
public WebSocketState State => _webSocket?.State ?? WebSocketState.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Connects to the specified WebSocket URI and starts the background tasks for receiving and keep-alive.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="uri">The URI of the WebSocket server to connect to.</param>
|
||||||
|
/// <param name="keepAliveInterval">Optional. Sets or overrides the keep-alive interval for this connection.</param>
|
||||||
|
public async Task ConnectAsync(string uri, TimeSpan? keepAliveInterval = null)
|
||||||
|
{
|
||||||
|
if (IsConnected)
|
||||||
|
throw new InvalidOperationException("WebSocket is already connected.");
|
||||||
|
|
||||||
|
if (keepAliveInterval.HasValue)
|
||||||
|
{
|
||||||
|
KeepAliveInterval = keepAliveInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
_webSocket = new ClientWebSocket();
|
||||||
|
_cts = new CancellationTokenSource();
|
||||||
|
|
||||||
|
await _webSocket.ConnectAsync(new Uri(uri), _cts.Token);
|
||||||
|
|
||||||
|
_receiveTask = ReceiveLoopAsync(_cts.Token);
|
||||||
|
|
||||||
|
if (KeepAliveInterval.HasValue && KeepAliveInterval.Value > TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
_keepAliveTask = KeepAliveLoopAsync(_cts.Token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Closes the WebSocket connection gracefully and cancels all running background tasks.
|
||||||
|
/// </summary>
|
||||||
|
public async Task DisconnectAsync()
|
||||||
|
{
|
||||||
|
if (_webSocket != null)
|
||||||
|
{
|
||||||
|
if (_cts != null)
|
||||||
|
{
|
||||||
|
await _cts.CancelAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tasksToWait = new List<Task>();
|
||||||
|
if (_receiveTask != null) tasksToWait.Add(_receiveTask);
|
||||||
|
if (_keepAliveTask != null) tasksToWait.Add(_keepAliveTask);
|
||||||
|
|
||||||
|
if (tasksToWait.Count > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.WhenAll(tasksToWait);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* Ignore */ }
|
||||||
|
catch { /* Ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_webSocket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Connection closed by client", CancellationToken.None);
|
||||||
|
}
|
||||||
|
catch { /* Ignore exceptions during closure */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
_webSocket.Dispose();
|
||||||
|
_webSocket = null;
|
||||||
|
|
||||||
|
_receiveTask = null;
|
||||||
|
_keepAliveTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Sends a raw string message over the WebSocket connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The text message to send.</param>
|
||||||
|
public async Task SendAsync(string message)
|
||||||
|
{
|
||||||
|
if (!IsConnected || _webSocket == null || _cts == null)
|
||||||
|
throw new InvalidOperationException("WebSocket is not connected.");
|
||||||
|
|
||||||
|
var buffer = Encoding.UTF8.GetBytes(message);
|
||||||
|
await _webSocket.SendAsync(new ArraySegment<byte>(buffer), WebSocketMessageType.Text, true, _cts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serializes the given generic object to JSON and sends it over the WebSocket connection.
|
||||||
|
/// </summary>
|
||||||
|
/// <typeparam name="T">The type of the object to send.</typeparam>
|
||||||
|
/// <param name="data">The object to serialize and send.</param>
|
||||||
|
public Task SendAsync<T>(T data)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(data);
|
||||||
|
|
||||||
|
return SendAsync(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReceiveLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var buffer = new byte[8192];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (_webSocket?.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
WebSocketReceiveResult result;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
result = await _webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), cancellationToken);
|
||||||
|
ms.Write(buffer, 0, result.Count);
|
||||||
|
}
|
||||||
|
while (!result.EndOfMessage);
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
await DisconnectAsync();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Text)
|
||||||
|
{
|
||||||
|
var message = Encoding.UTF8.GetString(ms.ToArray());
|
||||||
|
|
||||||
|
if (!IsKeepAliveMessage(message))
|
||||||
|
{
|
||||||
|
OnMessageReceived(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* Expected behavior when cancellation is requested */ }
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
OnError(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task KeepAliveLoopAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (!KeepAliveInterval.HasValue) return;
|
||||||
|
|
||||||
|
using var timer = new PeriodicTimer(KeepAliveInterval.Value);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
while (await timer.WaitForNextTickAsync(cancellationToken))
|
||||||
|
{
|
||||||
|
if (IsConnected)
|
||||||
|
{
|
||||||
|
await SendLifeMessageAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* Expected behavior when cancellation is requested */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Triggered when a new text message is received that is not filtered as a keep-alive message.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The received message content.</param>
|
||||||
|
protected abstract void OnMessageReceived(string message);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Triggered periodically based on the KeepAliveInterval to send a life message or ping to the server.
|
||||||
|
/// Override this method in the derived class if you want to send actual keep-alive payloads.
|
||||||
|
/// </summary>
|
||||||
|
protected virtual Task SendLifeMessageAsync()
|
||||||
|
{
|
||||||
|
// Default implementation does nothing.
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Determines whether the incoming message is a keep-alive response (e.g., a "pong").
|
||||||
|
/// If true is returned, the message is filtered out and OnMessageReceived is not triggered.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="message">The incoming text message.</param>
|
||||||
|
/// <returns>True if the message should be filtered; otherwise, false.</returns>
|
||||||
|
protected virtual bool IsKeepAliveMessage(string message)
|
||||||
|
{
|
||||||
|
// Default implementation does not filter any messages.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Optionally overridable method to handle exceptions that occur within the receive loop.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="ex">The exception that was caught.</param>
|
||||||
|
protected virtual void OnError(Exception ex)
|
||||||
|
{
|
||||||
|
// Default implementation is empty. Can be overridden in the derived class.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disposes the WebSocket connection and releases all associated resources.
|
||||||
|
/// </summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
DisconnectAsync().Wait();
|
||||||
|
_cts?.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base
|
||||||
|
USER $APP_UID
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
WORKDIR /src
|
||||||
|
COPY ["FinlyticNews/FinlyticNews.csproj", "FinlyticNews/"]
|
||||||
|
RUN dotnet restore "FinlyticNews/FinlyticNews.csproj"
|
||||||
|
COPY . .
|
||||||
|
WORKDIR "/src/FinlyticNews"
|
||||||
|
RUN dotnet build "./FinlyticNews.csproj" -c $BUILD_CONFIGURATION -o /app/build
|
||||||
|
|
||||||
|
FROM build AS publish
|
||||||
|
ARG BUILD_CONFIGURATION=Release
|
||||||
|
RUN dotnet publish "./FinlyticNews.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
FROM base AS final
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=publish /app/publish .
|
||||||
|
ENTRYPOINT ["dotnet", "FinlyticNews.dll"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Worker">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<UserSecretsId>dotnet-FinlyticNews-59a2d28c-0353-4726-b60f-9ac53f5100a0</UserSecretsId>
|
||||||
|
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.1"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="..\.dockerignore">
|
||||||
|
<Link>.dockerignore</Link>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Database\" />
|
||||||
|
<Folder Include="Services\" />
|
||||||
|
<Folder Include="Util\" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
var builder = Host.CreateApplicationBuilder(args);
|
||||||
|
|
||||||
|
var host = builder.Build();
|
||||||
|
host.Run();
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||||
|
"profiles": {
|
||||||
|
"FinlyticNews": {
|
||||||
|
"commandName": "Project",
|
||||||
|
"dotnetRunMessages": true,
|
||||||
|
"environmentVariables": {
|
||||||
|
"DOTNET_ENVIRONMENT": "Development"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.Hosting.Lifetime": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+66
@@ -0,0 +1,66 @@
|
|||||||
|
# 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 support 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. Stealth Scheduling with Randomized Jitter
|
||||||
|
- **Dynamic Delays**: Scanner wait times are read dynamically from database configuration (`Settings`).
|
||||||
|
- **Jitter Offset**: High/low jitter values (randomized offsets between batches and asset types) are injected to avoid predictable traffic patterns and prevent Trade Republic rate limiting.
|
||||||
|
|
||||||
|
### 3. Active vs. Dead Asset Tracking
|
||||||
|
- **Recency Filter**: Assets are continuously updated with their latest metadata. If an asset has not been updated within **14 days**, it is considered inactive, de-listed, or "dead".
|
||||||
|
- **Implicit Filtration**: Queries by other services only return active/valid records unless explicitly requested otherwise.
|
||||||
|
|
||||||
|
### 4. MQTT RPC Interface
|
||||||
|
- **Get Asset by ISIN**:
|
||||||
|
- Subscribes to: `services/request/assets_Get/#`
|
||||||
|
- Returns a list of all active assets matching the specified ISIN across different instrument types (e.g., matching both a stock and its derivative tracker).
|
||||||
|
- **Omnibox Search**:
|
||||||
|
- Subscribes to: `services/request/assets_Search/#`
|
||||||
|
- Evaluates search keywords against local records (matching `ISIN`, `Name`, or tags like `Region`, `Country`, `Sector`, etc.).
|
||||||
|
- **JIT Fallback**: If a search query is formatted as an unknown valid ISIN, it performs a JIT-lookup directly against the Trade Republic API and registers the discovered asset before replying.
|
||||||
|
|
||||||
|
### 5. Local Indexing File (`index.json`)
|
||||||
|
- **Automatic Regeneration**: On database modifications, `AssetsIndexService` is triggered to regenerate `assets/index/index.json`.
|
||||||
|
- **Pre-Filtering**: This lean index (containing only `ISIN` and `Name` properties) is written to a shared volume to allow other services to perform instant pre-filtering without querying the database or sending network requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Components
|
||||||
|
|
||||||
|
| Component | Class / Interface | Responsibility |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **Ingestion Worker** | `AssetsFullScanService` | Runs the main cron loop, scraping and paginating all Trade Republic asset types. |
|
||||||
|
| **Database Store** | [IAssetsDbService](file:///e:/Projects/FinlyticAssets/FinlyticAssets/Services/AssetsDbService.cs) | Handles CRUD operations, stateful merges, JIT lookups, and active record filtering. |
|
||||||
|
| **WebSocket Handler** | [ITradeRepublicService](file:///e:/Projects/FinlyticAssets/FinlyticAssets/Services/TradeRepublicService.cs) | Wraps the connection and request dispatching for the Trade Republic API. |
|
||||||
|
| **Local Indexer** | [IAssetsIndexService](file:///e:/Projects/FinlyticAssets/FinlyticAssets/Services/AssetsIndexService.cs) | Serializes the active assets list to `index.json` for external service indexing. |
|
||||||
|
| **Messaging Broker** | `AssetsMqttClient` | Listens for incoming MQTT requests, queries the database, and responds to RPC channels. |
|
||||||
|
| **Config Service** | [ISettingsDbService](file:///e:/Projects/FinlyticAssets/FinlyticAssets/Services/SettingsDbService.cs) | Manages scanner configurations (scan intervals, active pointers, offsets). |
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Development Rules & Guidelines
|
||||||
|
|
||||||
|
To ensure scalability, readability, and consistency across all microservices, the following architectural and coding rules must be adhered to:
|
||||||
|
|
||||||
|
## 1. Service Interfaces and Implementations
|
||||||
|
- **Single File Co-location**: Every service interface (e.g., `IAssetsDbService`) and its corresponding implementation class (e.g., `AssetsDbService`) **must** reside in the same file.
|
||||||
|
- **Naming Convention**: The file must be named after the implementation class (e.g., `AssetsDbService.cs`).
|
||||||
|
|
||||||
|
## 2. Mandatory Method Documentation
|
||||||
|
- **English Language**: All documentation must be written in English.
|
||||||
|
- **XML Documentation**: Every method (public, private, internal, or protected) must have proper XML documentation.
|
||||||
|
- **Required Fields**:
|
||||||
|
- `<summary>`: Clarifying the purpose and behavior of the method.
|
||||||
|
- `<param>`: Explaining each parameter (if applicable).
|
||||||
|
- `<returns>`: Specifying what the method returns (if applicable).
|
||||||
|
- **Implementation Inheritdoc**: For class methods implementing interfaces or overriding base methods, use `/// <inheritdoc />` to inherit documentation unless customization is needed. Note that any custom helper or private methods in the implementation must still have their own explicit XML comments.
|
||||||
|
|
||||||
|
## 3. Data Class & Model Architecture
|
||||||
|
- **Core Placement**: All data classes, DTOs, and shared enums (e.g., `AssetType`) that are or could be used by multiple services must be defined in the `FinlyticCore` project.
|
||||||
|
- **Service-Specific Exception**: Data classes and models may only reside in a specific service project (e.g., `FinlyticAssets`) if they strictly concern the internal operations of that service (e.g., raw API request/response structures for an integration that only that service manages).
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
finlyticassets:
|
||||||
|
image: finlyticassets
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: FinlyticAssets/Dockerfile
|
||||||
|
networks:
|
||||||
|
- postgres-network
|
||||||
|
environment:
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=OmniDB;Database=finlytic_assets;Username=admin;Password=${DB_PASSWORD}
|
||||||
|
- MQTT__Host=host.docker.internal
|
||||||
|
- MQTT__Port=4545
|
||||||
|
#- MQTT__Username=admin
|
||||||
|
#- MQTT__Password=${MQTT_PASSWORD}
|
||||||
|
- MQTT__ClientId=finlytic_assets
|
||||||
|
volumes:
|
||||||
|
- C:\Users\larsh\Documents\docker\finlytic\assets\index:/app/assets/index
|
||||||
|
|
||||||
|
finlyticnews:
|
||||||
|
image: finlyticnews
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: FinlyticNews/Dockerfile
|
||||||
|
networks:
|
||||||
|
- postgres-network
|
||||||
|
environment:
|
||||||
|
- ConnectionStrings__DefaultConnection=Host=OmniDB;Database=finlytic_news;Username=admin;Password=${DB_PASSWORD}
|
||||||
|
- MQTT__Host=host.docker.internal
|
||||||
|
- MQTT__Port=4545
|
||||||
|
#- MQTT__Username=admin
|
||||||
|
#- MQTT__Password=${MQTT_PASSWORD}
|
||||||
|
- MQTT__ClientId=finlytic_news
|
||||||
|
networks:
|
||||||
|
postgres-network:
|
||||||
|
external: true
|
||||||
Reference in New Issue
Block a user