From 067f1bfdd5e4e73ef625c1237c62e82b381cd2aa Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Sat, 15 Aug 2026 19:29:32 +0200 Subject: [PATCH] feat(core): add internal crypto isin resolution and subtitle mapping --- .../Converters/UtcDateTimeConverter.cs | 29 ++++++ .../Dtos/Fundamentals/KeyExecutiveDto.cs | 5 +- FinlyticCore/FinlyticCore.csproj | 1 + .../Models/Trades/CloseTradeRequest.cs | 2 + .../Models/Trades/TradeProposalDto.cs | 24 +++++ FinlyticCore/Utils/CryptoSubtitleResolver.cs | 99 +++++++++++++++++++ .../Services/YahooFinanceScraper.cs | 66 +++++++++++-- .../Services/TechnicalAnalysisDbService.cs | 6 +- .../Services/YahooMarketDataScraper.cs | 37 ++++++- 9 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 FinlyticCore/Converters/UtcDateTimeConverter.cs create mode 100644 FinlyticCore/Utils/CryptoSubtitleResolver.cs diff --git a/FinlyticCore/Converters/UtcDateTimeConverter.cs b/FinlyticCore/Converters/UtcDateTimeConverter.cs new file mode 100644 index 0000000..921cd66 --- /dev/null +++ b/FinlyticCore/Converters/UtcDateTimeConverter.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +namespace FinlyticCore.Converters; + +/// +/// ValueConverter for DateTime guaranteeing DateTimeKind.Utc when writing to and reading from PostgreSQL. +/// +public class UtcDateTimeConverter : ValueConverter +{ + public UtcDateTimeConverter() + : base( + v => v.Kind == DateTimeKind.Utc ? v : DateTime.SpecifyKind(v, DateTimeKind.Utc), + v => DateTime.SpecifyKind(v, DateTimeKind.Utc)) + { + } +} + +/// +/// ValueConverter for nullable DateTime? guaranteeing DateTimeKind.Utc when writing to and reading from PostgreSQL. +/// +public class NullableUtcDateTimeConverter : ValueConverter +{ + 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) + { + } +} diff --git a/FinlyticCore/Dtos/Fundamentals/KeyExecutiveDto.cs b/FinlyticCore/Dtos/Fundamentals/KeyExecutiveDto.cs index dc013c6..a87b69e 100644 --- a/FinlyticCore/Dtos/Fundamentals/KeyExecutiveDto.cs +++ b/FinlyticCore/Dtos/Fundamentals/KeyExecutiveDto.cs @@ -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; } \ No newline at end of file diff --git a/FinlyticCore/FinlyticCore.csproj b/FinlyticCore/FinlyticCore.csproj index bae8d58..d1ad2e7 100644 --- a/FinlyticCore/FinlyticCore.csproj +++ b/FinlyticCore/FinlyticCore.csproj @@ -12,6 +12,7 @@ + diff --git a/FinlyticCore/Models/Trades/CloseTradeRequest.cs b/FinlyticCore/Models/Trades/CloseTradeRequest.cs index 6e717de..3399e59 100644 --- a/FinlyticCore/Models/Trades/CloseTradeRequest.cs +++ b/FinlyticCore/Models/Trades/CloseTradeRequest.cs @@ -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" } + diff --git a/FinlyticCore/Models/Trades/TradeProposalDto.cs b/FinlyticCore/Models/Trades/TradeProposalDto.cs index 87af71d..98d66a3 100644 --- a/FinlyticCore/Models/Trades/TradeProposalDto.cs +++ b/FinlyticCore/Models/Trades/TradeProposalDto.cs @@ -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 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? HourlyUpdates { get; set; } + [JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/FinlyticCore/Utils/CryptoSubtitleResolver.cs b/FinlyticCore/Utils/CryptoSubtitleResolver.cs new file mode 100644 index 0000000..dc03d55 --- /dev/null +++ b/FinlyticCore/Utils/CryptoSubtitleResolver.cs @@ -0,0 +1,99 @@ +using System.Collections.Concurrent; +using System.Text.RegularExpressions; +using Npgsql; + +namespace FinlyticCore.Utils; + +/// +/// Resolves the crypto subtitle/ticker (e.g. "BTC", "ETH", "SOL") for Trade Republic internal ISINs starting with 'X'. +/// +public static class CryptoSubtitleResolver +{ + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Checks if an ISIN is a Trade Republic internal crypto ISIN (starts with 'X') and resolves its Subtitle from DB or heuristic. + /// + 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); + } + + /// + /// Convenience method returning just the crypto subtitle (e.g. "BTC"). + /// + public static async Task ResolveCryptoSubtitleAsync( + string isin, + string? defaultConnectionString = null, + CancellationToken cancellationToken = default) + { + var (sub, _) = await ResolveCryptoInfoAsync(isin, defaultConnectionString, cancellationToken); + return sub; + } +} diff --git a/FinlyticFundamentals/Services/YahooFinanceScraper.cs b/FinlyticFundamentals/Services/YahooFinanceScraper.cs index 912defe..917e64f 100644 --- a/FinlyticFundamentals/Services/YahooFinanceScraper.cs +++ b/FinlyticFundamentals/Services/YahooFinanceScraper.cs @@ -45,15 +45,18 @@ public class YahooFinanceScraper : IYahooFinanceScraper { private readonly YahooFinanceClient _yahooApiClient; private readonly IYahooFinanceHtmlClient _htmlScraperClient; + private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration; private readonly IFinlyticLogger _finlyticLogger; public YahooFinanceScraper( YahooFinanceClient yahooApiClient, IYahooFinanceHtmlClient htmlScraperClient, + Microsoft.Extensions.Configuration.IConfiguration configuration, IFinlyticLogger finlyticLogger) { _yahooApiClient = yahooApiClient; _htmlScraperClient = htmlScraperClient; + _configuration = configuration; _finlyticLogger = finlyticLogger; } @@ -72,21 +75,72 @@ public class YahooFinanceScraper : IYahooFinanceScraper var cleanIsin = isin.Trim().ToUpperInvariant(); var symbols = new List<(string symbol, string exchange, int priority)>(); + // Crypto / Trade Republic interne ISINs (beginnend mit 'X', z. B. XF000BTC0017) + if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase)) + { + var (cryptoSubtitle, cryptoName) = await FinlyticCore.Utils.CryptoSubtitleResolver.ResolveCryptoInfoAsync( + cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken); + + if (!string.IsNullOrWhiteSpace(cryptoSubtitle)) + { + var cryptoEur = $"{cryptoSubtitle}-EUR"; + var cryptoUsd = $"{cryptoSubtitle}-USD"; + + symbols.Add((cryptoEur, "Crypto", 0)); + symbols.Add((cryptoUsd, "Crypto", 1)); + + try + { + var searchRes = await _yahooApiClient.SearchAsync(cryptoSubtitle, quotesCount: 10, cancellationToken: cancellationToken); + if (searchRes?.Quotes != null) + { + foreach (var q in searchRes.Quotes.Where(q => !string.IsNullOrEmpty(q.Symbol))) + { + if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase))) + { + symbols.Add((q.Symbol, q.Exchange ?? "Crypto", 2)); + } + } + } + } + catch { } + + await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, + "[YahooFinanceScraper] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", + cleanIsin, cryptoEur, cryptoSubtitle); + + return symbols + .OrderBy(s => s.priority) + .Select(s => new TickerInfoDto { Ticker = s.symbol, Exchange = s.exchange }) + .ToList(); + } + } + try { - // 1. Suche via ISIN + // 1. Suche via ISIN - der allererste Ticker von Yahoo Finance ist der absolute Primary Ticker var primary = await _yahooApiClient.SearchAsync(cleanIsin, quotesCount: 20, cancellationToken: cancellationToken); var quotes = primary?.Quotes ?? new List(); + var validQuotes = quotes.Where(q => !string.IsNullOrEmpty(q.Symbol)).ToList(); - foreach (var q in quotes.Where(q => !string.IsNullOrEmpty(q.Symbol))) + if (validQuotes.Count > 0) { - symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin))); + var first = validQuotes[0]; + symbols.Add((first.Symbol, first.Exchange ?? string.Empty, 0)); + + foreach (var q in validQuotes.Skip(1)) + { + if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase))) + { + symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin)))); + } + } } // 2. Falls Ticker gefunden, aber mit Unternehmensname noch mehr Exchangeticker auffindbar sind - if (quotes.Count > 0) + if (validQuotes.Count > 0) { - var companyName = quotes[0].LongName ?? quotes[0].ShortName; + var companyName = validQuotes[0].LongName ?? validQuotes[0].ShortName; if (!string.IsNullOrWhiteSpace(companyName)) { var secondary = await _yahooApiClient.SearchAsync(companyName, quotesCount: 20, cancellationToken: cancellationToken); @@ -95,7 +149,7 @@ public class YahooFinanceScraper : IYahooFinanceScraper if (!string.IsNullOrEmpty(q.Symbol) && !symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase))) { - symbols.Add((q.Symbol, q.Exchange ?? string.Empty, GetExchangePriority(q.Symbol, cleanIsin))); + symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin)))); } } } diff --git a/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs index 37b99d9..f038632 100644 --- a/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs +++ b/FinlyticTechnicalAnalysis/Services/TechnicalAnalysisDbService.cs @@ -125,7 +125,7 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService var macroTask = FetchMacroDataAsync(cancellationToken); string? ticker = requestedTicker; - if (string.IsNullOrWhiteSpace(ticker)) + if (string.IsNullOrWhiteSpace(ticker) || string.Equals(ticker.Trim(), cleanIsin, StringComparison.OrdinalIgnoreCase)) { ticker = await _yahooScraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken); } @@ -326,7 +326,9 @@ public class TechnicalAnalysisDbService : ITechnicalAnalysisDbService if (cached != null && DateTime.UtcNow - cached.CalculatedAt < DbCacheTtl) { - if (!string.IsNullOrWhiteSpace(requestedTicker) && !string.Equals(cached.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)) + if (!string.IsNullOrWhiteSpace(requestedTicker) && + !string.Equals(requestedTicker.Trim(), cleanIsin, StringComparison.OrdinalIgnoreCase) && + !string.Equals(cached.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)) { return null; // Ticker mismatch, force refresh required } diff --git a/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs b/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs index f3b1637..22d9864 100644 --- a/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs +++ b/FinlyticTechnicalAnalysis/Services/YahooMarketDataScraper.cs @@ -40,16 +40,21 @@ public interface IYahooMarketDataScraper public class YahooMarketDataScraper : IYahooMarketDataScraper { private readonly YahooFinanceClient _yahooClient; + private readonly Microsoft.Extensions.Configuration.IConfiguration _configuration; private readonly ILogger _logger; - public YahooMarketDataScraper(YahooFinanceClient yahooClient, ILogger logger) + public YahooMarketDataScraper( + YahooFinanceClient yahooClient, + Microsoft.Extensions.Configuration.IConfiguration configuration, + ILogger logger) { _yahooClient = yahooClient; + _configuration = configuration; _logger = logger; } /// - /// Resolves ticker from ISIN using Yahoo Search API. + /// Resolves ticker from ISIN using Yahoo Search API or Crypto Subtitle resolution for internal ISINs. /// public async Task ResolveTickerFromIsinAsync(string isin, CancellationToken cancellationToken = default) { @@ -61,6 +66,34 @@ public class YahooMarketDataScraper : IYahooMarketDataScraper return cleanIsin; } + // Crypto / Trade Republic interne ISINs (beginnend mit 'X', z. B. XF000BTC0017) + if (cleanIsin.StartsWith("X", StringComparison.OrdinalIgnoreCase)) + { + var (cryptoSubtitle, cryptoName) = await FinlyticCore.Utils.CryptoSubtitleResolver.ResolveCryptoInfoAsync( + cleanIsin, _configuration.GetConnectionString("DefaultConnection"), cancellationToken); + + if (!string.IsNullOrWhiteSpace(cryptoSubtitle)) + { + var candidates = new[] { $"{cryptoSubtitle}-EUR", $"{cryptoSubtitle}-USD", cryptoSubtitle }; + foreach (var candidate in candidates) + { + try + { + var res = await FetchHistoricalCandlesWithCurrencyAsync(candidate, "5d", "1d", cancellationToken); + if (res.Candles.Count > 0) + { + _logger.LogInformation("[{Channel}] Resolved Crypto ISIN {Isin} to {Symbol} using Subtitle {Sub}", + "TechnicalAnalysisChannel", cleanIsin, candidate, cryptoSubtitle); + return candidate; + } + } + catch { } + } + + return $"{cryptoSubtitle}-EUR"; + } + } + try { var searchResult = await _yahooClient.SearchAsync(cleanIsin, quotesCount: 10, newsCount: 0, cancellationToken);