feat(core): add internal crypto isin resolution and subtitle mapping

This commit is contained in:
2026-08-15 19:29:32 +02:00
parent 5a6a50a609
commit 067f1bfdd5
9 changed files with 258 additions and 11 deletions
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace FinlyticCore.Converters;
/// <summary>
/// ValueConverter for DateTime guaranteeing DateTimeKind.Utc when writing to and reading from PostgreSQL.
/// </summary>
public class UtcDateTimeConverter : ValueConverter<DateTime, DateTime>
{
public UtcDateTimeConverter()
: base(
v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc),
v => DateTime.SpecifyKind(v, DateTimeKind.Utc))
{
}
}
/// <summary>
/// ValueConverter for nullable DateTime? guaranteeing DateTimeKind.Utc when writing to and reading from PostgreSQL.
/// </summary>
public class NullableUtcDateTimeConverter : ValueConverter<DateTime?, DateTime?>
{
public NullableUtcDateTimeConverter()
: base(
v => v.HasValue ? (v.Value.Kind == DateTimeKind.Utc ? v.Value : DateTime.SpecifyKind(v.Value, DateTimeKind.Utc)) : v,
v => v.HasValue ? DateTime.SpecifyKind(v.Value, DateTimeKind.Utc) : v)
{
}
}
@@ -1,4 +1,4 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
namespace FinlyticCore.Dtos.Fundamentals;
@@ -19,4 +19,7 @@ public record KeyExecutiveDto
[JsonPropertyName("payment")]
public string Payment { get; init; } = string.Empty;
[JsonPropertyName("sortOrder")]
public int SortOrder { get; init; } = 0;
}
+1
View File
@@ -12,6 +12,7 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
<PackageReference Include="Microsoft.Playwright" Version="1.49.0" />
<PackageReference Include="MQTTnet" Version="5.1.0.1559" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>
<ItemGroup>
@@ -9,5 +9,7 @@ public class CloseTradeRequest
{
public decimal UserExitPrice { get; set; }
public DateTime? UserExitTimestamp { get; set; }
public decimal ExitFee { get; set; } = 1.0m;
public string CloseReason { get; set; } = "ManualClosure"; // "TakeProfitHit", "StopLossHit", "ManualClosure", "TimeExpired"
}
@@ -61,6 +61,15 @@ public class TradeProposalDto
[JsonPropertyName("instrumentType")]
public string InstrumentType { get; set; } = "Stock"; // "Stock", "Option", "CFD", "Crypto"
[JsonPropertyName("assetType")]
public string AssetType { get; set; } = "stock"; // "stock", "etf", "crypto", "bond"
[JsonPropertyName("hasCfd")]
public bool HasCfd { get; set; }
[JsonPropertyName("derivativeProductCategories")]
public List<string> DerivativeProductCategories { get; set; } = new();
[JsonPropertyName("derivativeIsin")]
public string? DerivativeIsin { get; set; }
@@ -141,6 +150,21 @@ public class TradeProposalDto
[JsonPropertyName("pnlPercent")]
public decimal? PnlPercent { get; set; }
[JsonPropertyName("closeReason")]
public string? CloseReason { get; set; }
[JsonPropertyName("userExitTimestamp")]
public DateTime? UserExitTimestamp { get; set; }
[JsonPropertyName("hasPendingExitAlert")]
public bool HasPendingExitAlert { get; set; } = false;
[JsonPropertyName("pendingExitReason")]
public string? PendingExitReason { get; set; }
[JsonPropertyName("hourlyUpdates")]
public List<TradeHourlyUpdateDto>? HourlyUpdates { get; set; }
[JsonPropertyName("createdAt")]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
using System.Text.RegularExpressions;
using Npgsql;
namespace FinlyticCore.Utils;
/// <summary>
/// Resolves the crypto subtitle/ticker (e.g. "BTC", "ETH", "SOL") for Trade Republic internal ISINs starting with 'X'.
/// </summary>
public static class CryptoSubtitleResolver
{
private static readonly ConcurrentDictionary<string, (string Subtitle, string? Name)> _cache = new();
/// <summary>
/// Checks if an ISIN is a Trade Republic internal crypto ISIN (starts with 'X') and resolves its Subtitle from DB or heuristic.
/// </summary>
public static async Task<(string? Subtitle, string? Name)> ResolveCryptoInfoAsync(
string isin,
string? defaultConnectionString = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(isin)) return (null, null);
var cleanIsin = isin.Trim().ToUpperInvariant();
if (!cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase))
{
return (null, null);
}
if (_cache.TryGetValue(cleanIsin, out var cached))
{
return (cached.Subtitle, cached.Name);
}
// 1. Try querying PostgreSQL database (finlytic_assets)
if (!string.IsNullOrWhiteSpace(defaultConnectionString))
{
try
{
var assetsConnStr = Regex.Replace(defaultConnectionString, @"Database=[^;]+", "Database=finlytic_assets", RegexOptions.IgnoreCase);
await using var conn = new NpgsqlConnection(assetsConnStr);
await conn.OpenAsync(cancellationToken);
await using var cmd = new NpgsqlCommand(
"SELECT \"Subtitle\", \"SearchSubtitle\", \"Name\" FROM \"TradeRepublicAssets\" " +
"WHERE \"Isin\" = @isin AND (\"AssetType\" = 'Crypto' OR \"InstrumentCategory\" = 'crypto' OR \"Subtitle\" IS NOT NULL) " +
"LIMIT 1",
conn);
cmd.Parameters.AddWithValue("isin", cleanIsin);
await using var reader = await cmd.ExecuteReaderAsync(cancellationToken);
if (await reader.ReadAsync(cancellationToken))
{
string? sub = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrWhiteSpace(sub) && !reader.IsDBNull(1))
{
sub = reader.GetString(1);
}
string? name = reader.IsDBNull(2) ? null : reader.GetString(2);
if (!string.IsNullOrWhiteSpace(sub))
{
var cleanSub = sub.Trim().ToUpperInvariant();
_cache[cleanIsin] = (cleanSub, name);
return (cleanSub, name);
}
}
}
catch
{
// Fall through to heuristic if DB unreachable or different server
}
}
// 2. Heuristic fallback for Trade Republic internal ISIN patterns (e.g. XF000BTC0017 -> BTC)
var match = Regex.Match(cleanIsin, @"^X[A-Z0-9]*?000([A-Z0-9]{3,6})\d*$");
if (match.Success)
{
var extracted = match.Groups[1].Value;
_cache[cleanIsin] = (extracted, null);
return (extracted, null);
}
return (null, null);
}
/// <summary>
/// Convenience method returning just the crypto subtitle (e.g. "BTC").
/// </summary>
public static async Task<string?> ResolveCryptoSubtitleAsync(
string isin,
string? defaultConnectionString = null,
CancellationToken cancellationToken = default)
{
var (sub, _) = await ResolveCryptoInfoAsync(isin, defaultConnectionString, cancellationToken);
return sub;
}
}