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);