feat(core): update DTOs, Trade Republic client, Yahoo scrapers, and dynamic settings
This commit is contained in:
+87
@@ -11,6 +11,93 @@ using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticCore.Services.Yahoo;
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Thread-sicherer Client für den Zugriff auf die internen Yahoo Finance APIs.
|
||||
/// Verwaltet automatisch den erforderlichen Cookie- (A3) und Crumb-Token-Authentifizierungs-Flow.
|
||||
/// </summary>
|
||||
public interface IYahooFinanceClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Stellt sicher, dass die aktuelle Session über ein gültiges Cookie und einen Crumb-Token verfügt.
|
||||
/// </summary>
|
||||
/// <param name="forceRefresh">Erzwingt das Erneuern des Authentifizierungs-Tokens, selbst wenn die Frist noch nicht abgelaufen ist.</param>
|
||||
/// <param name="cancellationToken">Ein Token zum Abbrechen der asynchronen Operation.</param>
|
||||
/// <returns>Der aktuelle Crumb-Token oder <c>null</c>, wenn die Authentifizierung fehlgeschlagen ist.</returns>
|
||||
Task<string?> EnsureAuthenticatedAsync(bool forceRefresh = false, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Sucht nach Tickern, Namen, ISINs oder Firmen über die Yahoo Finance Such-API.
|
||||
/// erfordert keine Cookie/Crumb-Authentifizierung.
|
||||
/// </summary>
|
||||
/// <param name="query">Der Suchbegriff (z. B. "Apple", "US0378331005", "AAPL").</param>
|
||||
/// <param name="quotesCount">Die maximale Anzahl an Treffern für Wertpapiere/Aktien.</param>
|
||||
/// <param name="newsCount">Die maximale Anzahl an News-Treffern.</param>
|
||||
/// <param name="cancellationToken">Ein Token zum Abbrechen der asynchronen Operation.</param>
|
||||
/// <returns>Das Suchergebnis-DTO oder <c>null</c> bei Fehlern.</returns>
|
||||
Task<YahooSearchResponseDto?> SearchAsync(
|
||||
string query,
|
||||
int quotesCount = 10,
|
||||
int newsCount = 0,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ruft Fundamentaldaten und Unternehmens-Metadaten für ein bestimmtes Symbol über den quoteSummary-Endpunkt ab.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Das Tickersymbol (z. B. "AAPL", "MSFT").</param>
|
||||
/// <param name="modules">Die abzufragenden Yahoo-Module (z. B. "assetProfile", "financialData").</param>
|
||||
/// <param name="cancellationToken">Ein Token zum Abbrechen der asynchronen Operation.</param>
|
||||
/// <returns>Die Abfrageergebnisse als DTO oder <c>null</c> bei Fehlern.</returns>
|
||||
Task<YahooQuoteSummaryResponseDto?> GetQuoteSummaryAsync(
|
||||
string symbol,
|
||||
IEnumerable<string> modules,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Hilfsmethode zum Abrufen aller vordefinierten Standard-Module für ein Tickersymbol.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Das Tickersymbol (z. B. "AAPL").</param>
|
||||
/// <param name="cancellationToken">Ein Token zum Abbrechen der asynchronen Operation.</param>
|
||||
/// <returns>Das vollständige QuoteSummary-DTO oder <c>null</c> bei Fehlern.</returns>
|
||||
Task<YahooQuoteSummaryResponseDto?> GetFullQuoteSummaryAsync(
|
||||
string symbol,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ruft historische Chart- und Kursdaten (OHLCV) für ein Symbol ab.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Das Tickersymbol (z. B. "AAPL").</param>
|
||||
/// <param name="range">Der Abfragezeitraum (z. B. "1d", "1m", "1y", "5y").</param>
|
||||
/// <param name="interval">Das Intervall der Datenpunkte (z. B. "1m", "5m", "1d", "1wk").</param>
|
||||
/// <param name="cancellationToken">Ein Token zum Abbrechen der asynchronen Operation.</param>
|
||||
/// <returns>Das Chart-Ergebnis-DTO oder <c>null</c> bei Fehlern.</returns>
|
||||
Task<YahooChartResponseDto?> GetChartAsync(
|
||||
string symbol,
|
||||
string range = "1y",
|
||||
string interval = "1d",
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Ruft schnelle Realtime-Preise für eine Liste von Tickersymbolen ab.
|
||||
/// </summary>
|
||||
/// <param name="symbols">Eine Liste von Tickersymbolen (z. B. <c>["AAPL", "MSFT", "^GSPC"]</c>).</param>
|
||||
/// <param name="cancellationToken">Ein Token zum Abbrechen der asynchronen Operation.</param>
|
||||
/// <returns>Das Quote-Ergebnis-DTO oder <c>null</c> bei Fehlern.</returns>
|
||||
Task<YahooQuoteResponseDto?> GetQuotesAsync(
|
||||
IEnumerable<string> symbols,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Bequeme Hilfsmethode, um den aktuellen regulären Marktpreis für ein einzelnes Tickersymbol abzufragen.
|
||||
/// </summary>
|
||||
/// <param name="symbol">Das Tickersymbol (z. B. "^VIX", "AAPL").</param>
|
||||
/// <param name="cancellationToken">Ein Token zum Abbrechen der asynchronen Operation.</param>
|
||||
/// <returns>Der aktuelle Preis als <see cref="decimal"/> oder <c>null</c>, wenn kein Preis ermittelt werden konnte.</returns>
|
||||
Task<decimal?> GetLivePriceAsync(
|
||||
string symbol,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Managed thread-safe HTTP client for Yahoo Finance APIs.
|
||||
/// Implements the two-step Cookie (A3) & Crumb token authentication flow.
|
||||
@@ -0,0 +1,519 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Dtos.Yahoo;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using FinlyticCore.Services;
|
||||
using FinlyticCore.Services.PlaywrightScrapper;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace FinlyticCore.Clients;
|
||||
|
||||
public interface IYahooFinanceHtmlClient
|
||||
{
|
||||
Task<YahooQuoteSummaryModulesDto?> ScrapeQuoteSummaryModulesAsync(
|
||||
string isinOrSymbol,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface IYahooFinanceHtmlClient<TDbContext> : IYahooFinanceHtmlClient
|
||||
where TDbContext : DbContext
|
||||
{
|
||||
}
|
||||
|
||||
public class YahooFinanceHtmlClient<TContextClass, TDbContext> : IYahooFinanceHtmlClient<TDbContext>
|
||||
where TDbContext : DbContext
|
||||
{
|
||||
private readonly IPlaywrightExecutionService _playwrightService;
|
||||
private readonly IFinlyticLogger<TContextClass, TDbContext> _finlyticLogger;
|
||||
private readonly string _serviceName;
|
||||
|
||||
public YahooFinanceHtmlClient(
|
||||
IPlaywrightExecutionService playwrightService,
|
||||
IFinlyticLogger<TContextClass, TDbContext> finlyticLogger)
|
||||
{
|
||||
_playwrightService = playwrightService;
|
||||
_finlyticLogger = finlyticLogger;
|
||||
_serviceName = typeof(TContextClass).Name;
|
||||
}
|
||||
|
||||
public async Task<YahooQuoteSummaryModulesDto?> ScrapeQuoteSummaryModulesAsync(
|
||||
string isinOrSymbol,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(isinOrSymbol)) return null;
|
||||
|
||||
var symbol = isinOrSymbol.Trim().ToUpperInvariant();
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.HtmlScrapperChannel, $"[{_serviceName}] [YahooFinanceHtmlClient] Starting parallel Playwright HTML scrape for symbol '{symbol}'...");
|
||||
|
||||
return await _playwrightService.ExecuteInContextAsync(async context =>
|
||||
{
|
||||
var keyStatsData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var financialsData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var analysisData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
ProfileExtractionResult? profileResult = null;
|
||||
|
||||
var statsUrl = $"https://finance.yahoo.com/quote/{Uri.EscapeDataString(symbol)}/key-statistics/";
|
||||
var profileUrl = $"https://finance.yahoo.com/quote/{Uri.EscapeDataString(symbol)}/profile/";
|
||||
var financialsUrl = $"https://finance.yahoo.com/quote/{Uri.EscapeDataString(symbol)}/financials/";
|
||||
var analysisUrl = $"https://finance.yahoo.com/quote/{Uri.EscapeDataString(symbol)}/analysis/";
|
||||
|
||||
var statsTask = ScrapePagePairsAsync(context, statsUrl, cancellationToken);
|
||||
var profileTask = ScrapeProfilePageAsync(context, profileUrl, cancellationToken);
|
||||
var financialsTask = ScrapePagePairsAsync(context, financialsUrl, cancellationToken);
|
||||
var analysisTask = ScrapePagePairsAsync(context, analysisUrl, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(statsTask, profileTask, financialsTask, analysisTask);
|
||||
|
||||
keyStatsData = await statsTask;
|
||||
profileResult = await profileTask;
|
||||
financialsData = await financialsTask;
|
||||
analysisData = await analysisTask;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogWarningAsync(CoreSettingKeys.HtmlScrapperChannel, ex, $"[{_serviceName}] Non-fatal error during parallel page scraping for {symbol}.");
|
||||
}
|
||||
|
||||
var profileDict = profileResult?.ProfileDict ?? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var officers = profileResult?.Officers ?? new List<YahooCompanyOfficerDto>();
|
||||
|
||||
await _finlyticLogger.LogInfoAsync(CoreSettingKeys.HtmlScrapperChannel, $"[{_serviceName}] Scrape complete for '{symbol}'. Officers: {officers.Count}, Stats Keys: {keyStatsData.Count}");
|
||||
|
||||
return BuildModulesDto(
|
||||
keyStatsData,
|
||||
profileDict,
|
||||
financialsData,
|
||||
analysisData,
|
||||
officers,
|
||||
profileResult?.Sector,
|
||||
profileResult?.Industry,
|
||||
profileResult?.Employees,
|
||||
profileResult?.Description);
|
||||
|
||||
}, PlaywrightBrowserFactory.GetDefaultContextOptions(), cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Dictionary<string, string>> ScrapePagePairsAsync(
|
||||
IBrowserContext context,
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var targetDict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var page = await context.NewPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 20_000
|
||||
});
|
||||
|
||||
await HandleConsentAsync(page);
|
||||
|
||||
var extracted = await page.EvaluateAsync<Dictionary<string, string>>(@"() => {
|
||||
const results = {};
|
||||
|
||||
const cleanKey = (str) => {
|
||||
return str.toLowerCase()
|
||||
.replace(/\(ttm\)|\(mrq\)|\(fye\)/g, '')
|
||||
.replace(/\s*\d+\s*$/, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
document.querySelectorAll('table tr').forEach(tr => {
|
||||
const cells = Array.from(tr.querySelectorAll('td, th')).map(c => c.innerText.trim());
|
||||
if (cells.length >= 2 && cells[0] && cells[1]) {
|
||||
const key = cleanKey(cells[0]);
|
||||
const val = cells[1].replace(/\s+/g, ' ').trim();
|
||||
if (key && val && val !== 'N/A' && val !== '--' && val !== '-') {
|
||||
results[key] = val;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}");
|
||||
|
||||
if (extracted != null)
|
||||
{
|
||||
foreach (var (k, v) in extracted)
|
||||
{
|
||||
targetDict[k] = v;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogDebugAsync(CoreSettingKeys.HtmlScrapperChannel, ex, $"[{_serviceName}] Non-critical error scraping URL {url}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await page.CloseAsync();
|
||||
}
|
||||
|
||||
return targetDict;
|
||||
}
|
||||
|
||||
private async Task<ProfileExtractionResult> ScrapeProfilePageAsync(
|
||||
IBrowserContext context,
|
||||
string url,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var profileDict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
var companyOfficers = new List<YahooCompanyOfficerDto>();
|
||||
string? sector = null;
|
||||
string? industry = null;
|
||||
int? fullTimeEmployees = null;
|
||||
string? description = null;
|
||||
|
||||
var page = await context.NewPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded,
|
||||
Timeout = 20_000
|
||||
});
|
||||
|
||||
await HandleConsentAsync(page);
|
||||
|
||||
var metaInfo = await page.EvaluateAsync<ProfileMetaJsResult>(@"() => {
|
||||
let sector = null, industry = null, employees = null, description = null;
|
||||
|
||||
const descEl = document.querySelector('section[data-testid=""description""] p, div[data-testid=""description""] p, p.business-summary');
|
||||
if (descEl) description = descEl.innerText.trim();
|
||||
|
||||
const profileSec = document.querySelector('section[data-testid=""asset-profile""], div.asset-profile-container, main');
|
||||
if (profileSec) {
|
||||
const text = profileSec.innerText;
|
||||
const sectorMatch = text.match(/Sector\(s\)\s*:?\s*([^\n\r]+)/i) || text.match(/Sector\s*:?\s*([^\n\r]+)/i);
|
||||
if (sectorMatch) sector = sectorMatch[1].trim();
|
||||
|
||||
const indMatch = text.match(/Industry\s*:?\s*([^\n\r]+)/i);
|
||||
if (indMatch) industry = indMatch[1].trim();
|
||||
|
||||
const empMatch = text.match(/Full Time Employees\s*:?\s*([\d,]+)/i);
|
||||
if (empMatch) {
|
||||
const cleanNum = empMatch[1].replace(/,/g, '');
|
||||
employees = parseInt(cleanNum, 10);
|
||||
}
|
||||
}
|
||||
|
||||
const officers = [];
|
||||
const officerRows = document.querySelectorAll('section[data-testid=""asset-profile""] table tr, table.officers tr, main table tr');
|
||||
officerRows.forEach((tr, index) => {
|
||||
if (index === 0) return;
|
||||
const tds = Array.from(tr.querySelectorAll('td')).map(td => td.innerText.trim());
|
||||
if (tds.length >= 2) {
|
||||
officers.push({
|
||||
name: tds[0] || null,
|
||||
title: tds[1] || null,
|
||||
pay: tds[2] || null,
|
||||
exercised: tds[3] || null,
|
||||
yearBorn: tds[4] ? parseInt(tds[4], 10) : null
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { sector, industry, employees, description, officers };
|
||||
}");
|
||||
|
||||
if (metaInfo != null)
|
||||
{
|
||||
sector = metaInfo.Sector;
|
||||
industry = metaInfo.Industry;
|
||||
fullTimeEmployees = metaInfo.Employees;
|
||||
description = metaInfo.Description;
|
||||
|
||||
if (metaInfo.Officers != null)
|
||||
{
|
||||
foreach (var off in metaInfo.Officers)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(off.Name))
|
||||
{
|
||||
companyOfficers.Add(new YahooCompanyOfficerDto(
|
||||
Name: off.Name,
|
||||
Age: off.YearBorn.HasValue ? (DateTime.UtcNow.Year - off.YearBorn.Value) : null,
|
||||
Title: off.Title,
|
||||
YearBorn: off.YearBorn,
|
||||
FiscalYear: null,
|
||||
TotalPay: ParseYahooValue(off.Pay),
|
||||
ExercisedValue: ParseYahooValue(off.Exercised),
|
||||
UnexercisedValue: null
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await _finlyticLogger.LogDebugAsync(CoreSettingKeys.HtmlScrapperChannel, ex, $"[{_serviceName}] Non-critical error scraping Profile page {url}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
await page.CloseAsync();
|
||||
}
|
||||
|
||||
return new ProfileExtractionResult(profileDict, companyOfficers, sector, industry, fullTimeEmployees, description);
|
||||
}
|
||||
|
||||
private static async Task HandleConsentAsync(IPage page)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (page.Url.Contains("consent.yahoo.com"))
|
||||
{
|
||||
var consentBtn = page.Locator("button[name='agree'], button[value='agree'], button.accept-all, form[action*='consent'] button");
|
||||
if (await consentBtn.CountAsync() > 0)
|
||||
{
|
||||
await consentBtn.First.ClickAsync();
|
||||
await page.WaitForLoadStateAsync(LoadState.DOMContentLoaded, new PageWaitForLoadStateOptions { Timeout = 10_000 });
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* Fallback */ }
|
||||
}
|
||||
|
||||
private YahooQuoteSummaryModulesDto BuildModulesDto(
|
||||
Dictionary<string, string> keyStatsData,
|
||||
Dictionary<string, string> profileData,
|
||||
Dictionary<string, string> financialsData,
|
||||
Dictionary<string, string> analysisData,
|
||||
List<YahooCompanyOfficerDto> companyOfficers,
|
||||
string? sector,
|
||||
string? industry,
|
||||
int? fullTimeEmployees,
|
||||
string? description)
|
||||
{
|
||||
var allStats = new Dictionary<string, string>(keyStatsData, StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var (k, v) in profileData) allStats[k] = v;
|
||||
foreach (var (k, v) in financialsData) allStats[k] = v;
|
||||
foreach (var (k, v) in analysisData) allStats[k] = v;
|
||||
|
||||
var assetProfile = new YahooAssetProfileDto(
|
||||
Address1: null, Address2: null, City: null, State: null, Zip: null, Country: null, Phone: null, Website: null,
|
||||
Industry: industry ?? GetString(allStats, "industry"),
|
||||
IndustryKey: null, IndustryDisp: null,
|
||||
Sector: sector ?? GetString(allStats, "sector"),
|
||||
SectorKey: null, SectorDisp: null,
|
||||
LongBusinessSummary: description,
|
||||
FullTimeEmployees: fullTimeEmployees,
|
||||
CompanyOfficers: companyOfficers.Count > 0 ? companyOfficers : null,
|
||||
AuditRisk: null, BoardRisk: null, CompensationRisk: null, ShareHolderRightsRisk: null, OverallRisk: null,
|
||||
GovernanceEpochDate: null, CompensationAsOfEpochDate: null
|
||||
);
|
||||
|
||||
var defaultKeyStatistics = new YahooDefaultKeyStatisticsDto(
|
||||
PriceToBook: GetVal(allStats, "price/book", "price / book"),
|
||||
EnterpriseValue: GetVal(allStats, "enterprise value"),
|
||||
ForwardPE: GetVal(allStats, "forward p/e"),
|
||||
ProfitMargins: GetVal(allStats, "profit margin"),
|
||||
FloatShares: GetVal(allStats, "float"),
|
||||
SharesOutstanding: GetVal(allStats, "shares outstanding"),
|
||||
SharesShort: GetVal(allStats, "shares short"),
|
||||
SharesShortPriorMonth: GetVal(allStats, "shares short (prior month)"),
|
||||
SharesShortPreviousMonthDate: null, DateShortInterest: null,
|
||||
SharesPercentSharesOut: GetVal(allStats, "% of shares outstanding"),
|
||||
HeldPercentInsiders: GetVal(allStats, "% held by insiders"),
|
||||
HeldPercentInstitutions: GetVal(allStats, "% held by institutions"),
|
||||
ShortRatio: GetVal(allStats, "short ratio"),
|
||||
ShortPercentOfFloat: GetVal(allStats, "short % of float"),
|
||||
Beta: GetVal(allStats, "beta (5y monthly)", "beta"),
|
||||
Category: null,
|
||||
BookValue: GetVal(allStats, "book value per share", "book value"),
|
||||
PriceToSalesTrailing12Months: GetVal(allStats, "price/sales", "price / sales"),
|
||||
LastFiscalYearEnd: GetVal(allStats, "last fiscal year end"),
|
||||
NextFiscalYearEnd: GetVal(allStats, "next fiscal year end"),
|
||||
MostRecentQuarter: GetVal(allStats, "most recent quarter"),
|
||||
EarningsQuarterlyGrowth: GetVal(allStats, "quarterly earnings growth"),
|
||||
NetIncomeToCommon: GetVal(allStats, "net income avi to common"),
|
||||
TrailingEps: GetVal(allStats, "diluted eps"),
|
||||
ForwardEps: GetVal(allStats, "forward eps"),
|
||||
PegRatio: GetVal(allStats, "peg ratio", "peg ratio (5yr expected)"),
|
||||
EnterpriseToRevenue: GetVal(allStats, "enterprise value/revenue"),
|
||||
EnterpriseToEbitda: GetVal(allStats, "enterprise value/ebitda"),
|
||||
FiftyTwoWeekChange: GetVal(allStats, "52-week change"),
|
||||
SandP52WeekChange: GetVal(allStats, "s&p500 52-week change")
|
||||
);
|
||||
|
||||
var financialData = new YahooFinancialDataDto(
|
||||
CurrentPrice: GetVal(allStats, "current price", "price"),
|
||||
TargetHighPrice: GetVal(allStats, "target high", "high target"),
|
||||
TargetLowPrice: GetVal(allStats, "target low", "low target"),
|
||||
TargetMeanPrice: GetVal(allStats, "target mean", "target est"),
|
||||
TargetMedianPrice: GetVal(allStats, "target median"),
|
||||
RecommendationMean: GetVal(allStats, "recommendation mean"),
|
||||
RecommendationKey: GetString(allStats, "recommendation key"),
|
||||
NumberOfAnalystOpinions: GetVal(allStats, "number of analysts"),
|
||||
TotalCash: GetVal(allStats, "total cash"),
|
||||
TotalCashPerShare: GetVal(allStats, "total cash per share"),
|
||||
Ebitda: GetVal(allStats, "ebitda"),
|
||||
TotalDebt: GetVal(allStats, "total debt"),
|
||||
QuickRatio: GetVal(allStats, "quick ratio"),
|
||||
CurrentRatio: GetVal(allStats, "current ratio"),
|
||||
TotalRevenue: GetVal(allStats, "revenue", "total revenue"),
|
||||
DebtToEquity: GetVal(allStats, "total debt/equity"),
|
||||
RevenuePerShare: GetVal(allStats, "revenue per share"),
|
||||
ReturnOnAssets: GetVal(allStats, "return on assets"),
|
||||
ReturnOnEquity: GetVal(allStats, "return on equity"),
|
||||
GrossProfits: GetVal(allStats, "gross profit"),
|
||||
FreeCashflow: GetVal(allStats, "levered free cash flow"),
|
||||
OperatingCashflow: GetVal(allStats, "operating cash flow"),
|
||||
RevenueGrowth: GetVal(allStats, "quarterly revenue growth"),
|
||||
GrossMargins: GetVal(allStats, "gross margin"),
|
||||
EbitdaMargins: GetVal(allStats, "ebitda margin"),
|
||||
OperatingMargins: GetVal(allStats, "operating margin"),
|
||||
ProfitMargins: GetVal(allStats, "profit margin"),
|
||||
FinancialCurrency: "USD"
|
||||
);
|
||||
|
||||
var summaryDetail = new YahooSummaryDetailDto(
|
||||
MaxAge: 86400, PriceHint: null,
|
||||
PreviousClose: GetVal(allStats, "previous close"),
|
||||
Open: GetVal(allStats, "open"),
|
||||
DayLow: GetVal(allStats, "day low"),
|
||||
DayHigh: GetVal(allStats, "day high"),
|
||||
RegularMarketPreviousClose: GetVal(allStats, "previous close"),
|
||||
RegularMarketOpen: GetVal(allStats, "open"),
|
||||
RegularMarketDayLow: GetVal(allStats, "day low"),
|
||||
RegularMarketDayHigh: GetVal(allStats, "day high"),
|
||||
DividendRate: GetVal(allStats, "forward dividend & yield", "dividend rate"),
|
||||
DividendYield: GetVal(allStats, "dividend yield", "forward annual dividend yield", "trailing annual dividend yield"),
|
||||
ExDividendDate: GetVal(allStats, "ex-dividend date"),
|
||||
PayoutRatio: GetVal(allStats, "payout ratio"),
|
||||
FiveYearAvgDividendYield: GetVal(allStats, "5 year avg dividend yield"),
|
||||
Beta: GetVal(allStats, "beta"),
|
||||
TrailingPE: GetVal(allStats, "trailing p/e"),
|
||||
ForwardPE: GetVal(allStats, "forward p/e"),
|
||||
Volume: GetVal(allStats, "volume"),
|
||||
RegularMarketVolume: GetVal(allStats, "volume"),
|
||||
AverageVolume: GetVal(allStats, "avg. volume", "average volume"),
|
||||
AverageVolume10days: GetVal(allStats, "avg. volume (10 day)"),
|
||||
AverageDailyVolume10Day: GetVal(allStats, "avg. volume (10 day)"),
|
||||
Bid: GetVal(allStats, "bid"), Ask: GetVal(allStats, "ask"),
|
||||
BidSize: null, AskSize: null,
|
||||
MarketCap: GetVal(allStats, "market cap (intraday)", "market cap"),
|
||||
FiftyTwoWeekLow: GetVal(allStats, "52 week low"),
|
||||
FiftyTwoWeekHigh: GetVal(allStats, "52 week high"),
|
||||
PriceToSalesTrailing12Months: GetVal(allStats, "price/sales"),
|
||||
Currency: "USD"
|
||||
);
|
||||
|
||||
return new YahooQuoteSummaryModulesDto(
|
||||
QuoteType: null,
|
||||
AssetProfile: assetProfile,
|
||||
FinancialData: financialData,
|
||||
DefaultKeyStatistics: defaultKeyStatistics,
|
||||
SummaryDetail: summaryDetail,
|
||||
IncomeStatementHistory: null,
|
||||
IncomeStatementHistoryQuarterly: null,
|
||||
BalanceSheetHistory: null,
|
||||
BalanceSheetHistoryQuarterly: null,
|
||||
CashflowStatementHistory: null,
|
||||
CashflowStatementHistoryQuarterly: null,
|
||||
CalendarEvents: null
|
||||
);
|
||||
}
|
||||
|
||||
private static YahooValueDto? GetVal(Dictionary<string, string> dict, params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
{
|
||||
if (dict.TryGetValue(k, out var val) && !string.IsNullOrWhiteSpace(val))
|
||||
return ParseYahooValue(val);
|
||||
|
||||
var match = dict.FirstOrDefault(kvp => kvp.Key.Equals(k, StringComparison.OrdinalIgnoreCase) || kvp.Key.StartsWith(k, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(match.Value))
|
||||
return ParseYahooValue(match.Value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? GetString(Dictionary<string, string> dict, params string[] keys)
|
||||
{
|
||||
foreach (var k in keys)
|
||||
{
|
||||
if (dict.TryGetValue(k, out var val) && !string.IsNullOrWhiteSpace(val))
|
||||
return val.Trim();
|
||||
|
||||
var match = dict.FirstOrDefault(kvp => kvp.Key.Equals(k, StringComparison.OrdinalIgnoreCase) || kvp.Key.StartsWith(k, StringComparison.OrdinalIgnoreCase));
|
||||
if (!string.IsNullOrWhiteSpace(match.Value))
|
||||
return match.Value.Trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parsen von Suffixen (M, B, T, K) und Prozentwerten gemäß den funktionierenden Regex-Regeln.
|
||||
/// </summary>
|
||||
public static YahooValueDto? ParseYahooValue(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text) || text == "N/A" || text == "---" || text == "--" || text == "-")
|
||||
return null;
|
||||
|
||||
var trimmed = text.Trim();
|
||||
bool isPercent = trimmed.EndsWith("%");
|
||||
|
||||
double multiplier = 1.0;
|
||||
if (trimmed.EndsWith("T", StringComparison.OrdinalIgnoreCase)) multiplier = 1_000_000_000_000.0;
|
||||
else if (trimmed.EndsWith("B", StringComparison.OrdinalIgnoreCase)) multiplier = 1_000_000_000.0;
|
||||
else if (trimmed.EndsWith("M", StringComparison.OrdinalIgnoreCase)) multiplier = 1_000_000.0;
|
||||
else if (trimmed.EndsWith("K", StringComparison.OrdinalIgnoreCase)) multiplier = 1_000.0;
|
||||
|
||||
// Beseitigt Einheiten und Tausenderpunkte, isoliert die reine Zahl mit Dezimalpunkt
|
||||
var numPart = Regex.Replace(trimmed, @"[^\d.-]", "");
|
||||
|
||||
if (double.TryParse(numPart, NumberStyles.Any, CultureInfo.InvariantCulture, out double parsedVal))
|
||||
{
|
||||
double finalVal = isPercent ? (parsedVal / 100.0) : (parsedVal * multiplier);
|
||||
return new YahooValueDto
|
||||
{
|
||||
Raw = finalVal,
|
||||
Fmt = trimmed,
|
||||
LongFmt = finalVal.ToString("N0", CultureInfo.InvariantCulture)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private record ProfileExtractionResult(
|
||||
Dictionary<string, string> ProfileDict,
|
||||
List<YahooCompanyOfficerDto> Officers,
|
||||
string? Sector,
|
||||
string? Industry,
|
||||
int? Employees,
|
||||
string? Description
|
||||
);
|
||||
|
||||
private class ProfileMetaJsResult
|
||||
{
|
||||
public string? Sector { get; set; }
|
||||
public string? Industry { get; set; }
|
||||
public int? Employees { get; set; }
|
||||
public string? Description { get; set; }
|
||||
public List<OfficerJsResult>? Officers { get; set; }
|
||||
}
|
||||
|
||||
private class OfficerJsResult
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Title { get; set; }
|
||||
public string? Pay { get; set; }
|
||||
public string? Exercised { get; set; }
|
||||
public int? YearBorn { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -5,277 +5,38 @@ using System.Text.Json.Serialization;
|
||||
namespace FinlyticCore.Dtos.Fundamentals;
|
||||
|
||||
/// <summary>
|
||||
/// Data transfer object representing the complete fundamental analysis dataset of an asset.
|
||||
/// Haupt-DTO für die aggregierte Anzeige der Stammdaten, Fundamentaldaten und Events eines Assets.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record AssetFundamentalsDto
|
||||
{
|
||||
[JsonPropertyName("isin")]
|
||||
public string Isin { get; init; } = string.Empty;
|
||||
[JsonPropertyName("primaryTicker")]
|
||||
public string PrimaryTicker { get; init; } = string.Empty;
|
||||
[JsonPropertyName("ticker")]
|
||||
public string Ticker { get; init; } = string.Empty;
|
||||
[JsonPropertyName("companyName")]
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
[JsonPropertyName("exchange")]
|
||||
public string? Exchange { get; init; }
|
||||
[JsonPropertyName("tradingCurrency")]
|
||||
public string? TradingCurrency { get; init; }
|
||||
[JsonPropertyName("businessSummary")]
|
||||
public string? BusinessSummary { get; init; }
|
||||
[JsonPropertyName("sector")]
|
||||
public string? Sector { get; init; }
|
||||
[JsonPropertyName("industry")]
|
||||
public string? Industry { get; init; }
|
||||
[JsonPropertyName("country")]
|
||||
public string? Country { get; init; }
|
||||
[JsonPropertyName("employees")]
|
||||
public int? Employees { get; init; }
|
||||
/// <summary>
|
||||
/// Grundlegende Unternehmens- und Stammdaten (Aus AssetDataEntity).
|
||||
/// </summary>
|
||||
[JsonPropertyName("asset")]
|
||||
public AssetHeaderDto Asset { get; init; } = new();
|
||||
|
||||
// Valuation Metrics (derived from Primary Ticker)
|
||||
[JsonPropertyName("currentPrice")]
|
||||
public decimal CurrentPrice { get; init; }
|
||||
[JsonPropertyName("dayChangeAbsolute")]
|
||||
public decimal DayChangeAbsolute { get; init; }
|
||||
[JsonPropertyName("dayChangePercent")]
|
||||
public decimal DayChangePercent { get; init; }
|
||||
[JsonPropertyName("fiftyTwoWeekHigh")]
|
||||
public decimal FiftyTwoWeekHigh { get; init; }
|
||||
[JsonPropertyName("fiftyTwoWeekLow")]
|
||||
public decimal FiftyTwoWeekLow { get; init; }
|
||||
[JsonPropertyName("marketCapitalization")]
|
||||
public decimal MarketCapitalization { get; init; }
|
||||
[JsonPropertyName("enterpriseValue")]
|
||||
public decimal EnterpriseValue { get; init; }
|
||||
[JsonPropertyName("peRatioTrailing")]
|
||||
public decimal? PeRatioTrailing { get; init; }
|
||||
[JsonPropertyName("peRatioForward")]
|
||||
public decimal? PeRatioForward { get; init; }
|
||||
[JsonPropertyName("pegRatio")]
|
||||
public decimal? PegRatio { get; init; }
|
||||
[JsonPropertyName("pbRatio")]
|
||||
public decimal? PbRatio { get; init; }
|
||||
[JsonPropertyName("psRatio")]
|
||||
public decimal? PsRatio { get; init; }
|
||||
[JsonPropertyName("evToEbitda")]
|
||||
public decimal? EvToEbitda { get; init; }
|
||||
[JsonPropertyName("evToRevenue")]
|
||||
public decimal? EvToRevenue { get; init; }
|
||||
/// <summary>
|
||||
/// Aktuellste Finanzeckdaten und Kennzahlen (Aus FundamentalDataEntity).
|
||||
/// </summary>
|
||||
[JsonPropertyName("fundamentals")]
|
||||
public FundamentalDataDto? Fundamentals { get; init; }
|
||||
|
||||
// Financial Health & Leverage
|
||||
[JsonPropertyName("grossMargin")]
|
||||
public decimal? GrossMargin { get; init; }
|
||||
[JsonPropertyName("operatingMargin")]
|
||||
public decimal? OperatingMargin { get; init; }
|
||||
[JsonPropertyName("netProfitMargin")]
|
||||
public decimal? NetProfitMargin { get; init; }
|
||||
[JsonPropertyName("returnOnEquity")]
|
||||
public decimal? ReturnOnEquity { get; init; }
|
||||
[JsonPropertyName("returnOnAssets")]
|
||||
public decimal? ReturnOnAssets { get; init; }
|
||||
[JsonPropertyName("returnOnInvestedCapital")]
|
||||
public decimal? ReturnOnInvestedCapital { get; init; }
|
||||
[JsonPropertyName("debtToEquity")]
|
||||
public decimal? DebtToEquity { get; init; }
|
||||
[JsonPropertyName("currentRatio")]
|
||||
public decimal? CurrentRatio { get; init; }
|
||||
[JsonPropertyName("quickRatio")]
|
||||
public decimal? QuickRatio { get; init; }
|
||||
[JsonPropertyName("interestCoverage")]
|
||||
public decimal? InterestCoverage { get; init; }
|
||||
|
||||
// Dividends & Ownership
|
||||
[JsonPropertyName("dividendYield")]
|
||||
public decimal? DividendYield { get; init; }
|
||||
[JsonPropertyName("payoutRatio")]
|
||||
public decimal? PayoutRatio { get; init; }
|
||||
[JsonPropertyName("exDividendDate")]
|
||||
public DateTime? ExDividendDate { get; init; }
|
||||
[JsonPropertyName("nextEarningsDate")]
|
||||
public DateTime? NextEarningsDate { get; init; }
|
||||
[JsonPropertyName("percentHeldByInstitutions")]
|
||||
public decimal? PercentHeldByInstitutions { get; init; }
|
||||
[JsonPropertyName("percentHeldByInsiders")]
|
||||
public decimal? PercentHeldByInsiders { get; init; }
|
||||
[JsonPropertyName("shortRatio")]
|
||||
public decimal? ShortRatio { get; init; }
|
||||
[JsonPropertyName("shortPercentOfFloat")]
|
||||
public decimal? ShortPercentOfFloat { get; init; }
|
||||
|
||||
// Forecasts
|
||||
[JsonPropertyName("consensusRating")]
|
||||
public string? ConsensusRating { get; init; }
|
||||
[JsonPropertyName("priceTargetLow")]
|
||||
public decimal? PriceTargetLow { get; init; }
|
||||
[JsonPropertyName("priceTargetHigh")]
|
||||
public decimal? PriceTargetHigh { get; init; }
|
||||
[JsonPropertyName("priceTargetMedian")]
|
||||
public decimal? PriceTargetMedian { get; init; }
|
||||
[JsonPropertyName("priceTargetMean")]
|
||||
public decimal? PriceTargetMean { get; init; }
|
||||
|
||||
// Timestamps
|
||||
[JsonPropertyName("lastUpdatedAt")]
|
||||
public DateTime LastUpdatedAt { get; init; }
|
||||
|
||||
// Relational Collections
|
||||
/// <summary>
|
||||
/// Liste der Führungskräfte/Vorstände (Aus KeyExecutiveEntity).
|
||||
/// </summary>
|
||||
[JsonPropertyName("executives")]
|
||||
public List<CompanyExecutiveDto> Executives { get; init; } = [];
|
||||
[JsonPropertyName("financialStatements")]
|
||||
public List<FinancialStatementDto> FinancialStatements { get; init; } = [];
|
||||
[JsonPropertyName("estimates")]
|
||||
public List<ForwardEstimateDto> Estimates { get; init; } = [];
|
||||
[JsonPropertyName("availableTickers")]
|
||||
public List<TickerDto> AvailableTickers { get; init; } = [];
|
||||
}
|
||||
public List<KeyExecutiveDto> Executives { get; init; } = [];
|
||||
|
||||
public record CompanyExecutiveDto
|
||||
{
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = string.Empty;
|
||||
[JsonPropertyName("age")]
|
||||
public int? Age { get; init; }
|
||||
[JsonPropertyName("compensation")]
|
||||
public decimal? Compensation { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Bevorstehende oder vergangene Termine wie Earnings oder Dividenden (Aus AssetEventEntity).
|
||||
/// </summary>
|
||||
[JsonPropertyName("events")]
|
||||
public List<CorporateEventDto> Events { get; init; } = [];
|
||||
|
||||
public record FinancialStatementDto
|
||||
{
|
||||
[JsonPropertyName("periodType")]
|
||||
public string PeriodType { get; init; } = string.Empty; // "Annual" or "Quarterly"
|
||||
[JsonPropertyName("endDate")]
|
||||
public DateTime EndDate { get; init; }
|
||||
|
||||
// Income Statement
|
||||
[JsonPropertyName("totalRevenue")]
|
||||
public decimal? TotalRevenue { get; init; }
|
||||
[JsonPropertyName("costOfRevenue")]
|
||||
public decimal? CostOfRevenue { get; init; }
|
||||
[JsonPropertyName("grossProfit")]
|
||||
public decimal? GrossProfit { get; init; }
|
||||
[JsonPropertyName("operatingExpenses")]
|
||||
public decimal? OperatingExpenses { get; init; }
|
||||
[JsonPropertyName("operatingIncome")]
|
||||
public decimal? OperatingIncome { get; init; }
|
||||
[JsonPropertyName("ebitda")]
|
||||
public decimal? Ebitda { get; init; }
|
||||
[JsonPropertyName("netIncome")]
|
||||
public decimal? NetIncome { get; init; }
|
||||
[JsonPropertyName("epsBasic")]
|
||||
public decimal? EpsBasic { get; init; }
|
||||
[JsonPropertyName("epsDiluted")]
|
||||
public decimal? EpsDiluted { get; init; }
|
||||
|
||||
// Balance Sheet
|
||||
[JsonPropertyName("cashAndCashEquivalents")]
|
||||
public decimal? CashAndCashEquivalents { get; init; }
|
||||
[JsonPropertyName("accountsReceivable")]
|
||||
public decimal? AccountsReceivable { get; init; }
|
||||
[JsonPropertyName("inventory")]
|
||||
public decimal? Inventory { get; init; }
|
||||
[JsonPropertyName("totalCurrentAssets")]
|
||||
public decimal? TotalCurrentAssets { get; init; }
|
||||
[JsonPropertyName("totalNonCurrentAssets")]
|
||||
public decimal? TotalNonCurrentAssets { get; init; }
|
||||
[JsonPropertyName("currentLiabilities")]
|
||||
public decimal? CurrentLiabilities { get; init; }
|
||||
[JsonPropertyName("longTermDebt")]
|
||||
public decimal? LongTermDebt { get; init; }
|
||||
[JsonPropertyName("totalLiabilities")]
|
||||
public decimal? TotalLiabilities { get; init; }
|
||||
[JsonPropertyName("totalStockholdersEquity")]
|
||||
public decimal? TotalStockholdersEquity { get; init; }
|
||||
|
||||
// Cash Flow Statement
|
||||
[JsonPropertyName("operatingCashFlow")]
|
||||
public decimal? OperatingCashFlow { get; init; }
|
||||
[JsonPropertyName("investingCashFlow")]
|
||||
public decimal? InvestingCashFlow { get; init; }
|
||||
[JsonPropertyName("capitalExpenditures")]
|
||||
public decimal? CapitalExpenditures { get; init; }
|
||||
[JsonPropertyName("financingCashFlow")]
|
||||
public decimal? FinancingCashFlow { get; init; }
|
||||
[JsonPropertyName("freeCashFlow")]
|
||||
public decimal? FreeCashFlow { get; init; } // OperatingCashFlow - CapEx
|
||||
}
|
||||
|
||||
public record ForwardEstimateDto
|
||||
{
|
||||
[JsonPropertyName("period")]
|
||||
public string Period { get; init; } = string.Empty; // "CurrentQuarter", "NextQuarter", "CurrentYear", "NextYear"
|
||||
[JsonPropertyName("expectedRevenue")]
|
||||
public decimal? ExpectedRevenue { get; init; }
|
||||
[JsonPropertyName("expectedEps")]
|
||||
public decimal? ExpectedEps { get; init; }
|
||||
[JsonPropertyName("expectedGrowthRate")]
|
||||
public decimal? ExpectedGrowthRate { get; init; }
|
||||
}
|
||||
|
||||
public record TickerDto
|
||||
{
|
||||
[JsonPropertyName("ticker")]
|
||||
public string Ticker { get; init; } = string.Empty;
|
||||
[JsonPropertyName("exchange")]
|
||||
public string? Exchange { get; init; }
|
||||
[JsonPropertyName("tradingCurrency")]
|
||||
public string? TradingCurrency { get; init; }
|
||||
[JsonPropertyName("currentPrice")]
|
||||
public decimal CurrentPrice { get; init; }
|
||||
[JsonPropertyName("dayChangeAbsolute")]
|
||||
public decimal DayChangeAbsolute { get; init; }
|
||||
[JsonPropertyName("dayChangePercent")]
|
||||
public decimal DayChangePercent { get; init; }
|
||||
[JsonPropertyName("fiftyTwoWeekHigh")]
|
||||
public decimal FiftyTwoWeekHigh { get; init; }
|
||||
[JsonPropertyName("fiftyTwoWeekLow")]
|
||||
public decimal FiftyTwoWeekLow { get; init; }
|
||||
[JsonPropertyName("marketCapitalization")]
|
||||
public decimal MarketCapitalization { get; init; }
|
||||
[JsonPropertyName("enterpriseValue")]
|
||||
public decimal EnterpriseValue { get; init; }
|
||||
|
||||
[JsonPropertyName("peRatioTrailing")]
|
||||
public decimal? PeRatioTrailing { get; init; }
|
||||
[JsonPropertyName("peRatioForward")]
|
||||
public decimal? PeRatioForward { get; init; }
|
||||
[JsonPropertyName("pegRatio")]
|
||||
public decimal? PegRatio { get; init; }
|
||||
[JsonPropertyName("pbRatio")]
|
||||
public decimal? PbRatio { get; init; }
|
||||
[JsonPropertyName("psRatio")]
|
||||
public decimal? PsRatio { get; init; }
|
||||
[JsonPropertyName("evToEbitda")]
|
||||
public decimal? EvToEbitda { get; init; }
|
||||
[JsonPropertyName("evToRevenue")]
|
||||
public decimal? EvToRevenue { get; init; }
|
||||
|
||||
[JsonPropertyName("grossMargin")]
|
||||
public decimal? GrossMargin { get; init; }
|
||||
[JsonPropertyName("operatingMargin")]
|
||||
public decimal? OperatingMargin { get; init; }
|
||||
[JsonPropertyName("netProfitMargin")]
|
||||
public decimal? NetProfitMargin { get; init; }
|
||||
[JsonPropertyName("returnOnEquity")]
|
||||
public decimal? ReturnOnEquity { get; init; }
|
||||
[JsonPropertyName("returnOnAssets")]
|
||||
public decimal? ReturnOnAssets { get; init; }
|
||||
[JsonPropertyName("returnOnInvestedCapital")]
|
||||
public decimal? ReturnOnInvestedCapital { get; init; }
|
||||
[JsonPropertyName("debtToEquity")]
|
||||
public decimal? DebtToEquity { get; init; }
|
||||
[JsonPropertyName("currentRatio")]
|
||||
public decimal? CurrentRatio { get; init; }
|
||||
[JsonPropertyName("quickRatio")]
|
||||
public decimal? QuickRatio { get; init; }
|
||||
[JsonPropertyName("interestCoverage")]
|
||||
public decimal? InterestCoverage { get; init; }
|
||||
|
||||
[JsonPropertyName("dividendYield")]
|
||||
public decimal? DividendYield { get; init; }
|
||||
[JsonPropertyName("payoutRatio")]
|
||||
public decimal? PayoutRatio { get; init; }
|
||||
[JsonPropertyName("exDividendDate")]
|
||||
public DateTime? ExDividendDate { get; init; }
|
||||
}
|
||||
/// <summary>
|
||||
/// Zeitstempel der letzten Gesamt-Aktualisierung.
|
||||
/// </summary>
|
||||
[JsonPropertyName("lastUpdatedAt")]
|
||||
public DateTime LastUpdatedAt { get; init; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.Fundamentals;
|
||||
|
||||
/// <summary>
|
||||
/// Enthält die Stammdaten eines Unternehmens/Assets.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record AssetHeaderDto
|
||||
{
|
||||
[JsonPropertyName("isin")]
|
||||
public string Isin { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("primaryTicker")]
|
||||
public TickerInfoDto PrimaryTicker { get; init; }
|
||||
|
||||
[JsonPropertyName("availableTickers")]
|
||||
public List<TickerInfoDto> AvailableTickers { get; init; } = [];
|
||||
}
|
||||
@@ -4,22 +4,29 @@ using System.Text.Json.Serialization;
|
||||
namespace FinlyticCore.Dtos.Fundamentals;
|
||||
|
||||
/// <summary>
|
||||
/// DTO representing a scheduled corporate event (e.g. Earnings, Ex-Dividend, Dividend Payout).
|
||||
/// Repräsentiert ein Unternehmensereignis (z. B. Quartalszahlen, Dividendentag).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record CorporateEventDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public Guid Id { get; init; }
|
||||
|
||||
[JsonPropertyName("isin")]
|
||||
public string Isin { get; init; } = string.Empty;
|
||||
|
||||
public string? Isin { get; init; }
|
||||
|
||||
[JsonPropertyName("ticker")]
|
||||
public string Ticker { get; init; } = string.Empty;
|
||||
|
||||
public TickerInfoDto Ticker { get; init; }
|
||||
|
||||
[JsonPropertyName("companyName")]
|
||||
public string CompanyName { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("eventType")]
|
||||
public string EventType { get; init; } = string.Empty; // "Quartalsergebnis", "Ex-Dividendentag", "Dividenden-Zahltag"
|
||||
|
||||
public string? CompanyName { get; init; }
|
||||
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; init; } = string.Empty; // z.B. "Earnings", "Ex-Dividend"
|
||||
|
||||
[JsonIgnore]
|
||||
public string EventType => Type;
|
||||
|
||||
[JsonPropertyName("date")]
|
||||
public DateTime Date { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.Fundamentals;
|
||||
|
||||
/// <summary>
|
||||
/// Repräsentiert die finanziellen Kennzahlen und Valuation-Multiples eines Assets.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record FundamentalDataDto
|
||||
{
|
||||
[JsonPropertyName("ticker")]
|
||||
public TickerInfoDto Ticker { get; init; }
|
||||
|
||||
// --- Valuation & Multiples ---
|
||||
[JsonPropertyName("marketCap")]
|
||||
public decimal? MarketCap { get; init; }
|
||||
|
||||
[JsonPropertyName("enterpriseValue")]
|
||||
public decimal? EnterpriseValue { get; init; }
|
||||
|
||||
[JsonPropertyName("trailingPe")]
|
||||
public decimal? TrailingPe { get; init; }
|
||||
|
||||
[JsonPropertyName("forwardPe")]
|
||||
public decimal? ForwardPe { get; init; }
|
||||
|
||||
[JsonPropertyName("pegRatio")]
|
||||
public decimal? PegRatio { get; init; }
|
||||
|
||||
[JsonPropertyName("priceToSales")]
|
||||
public decimal? PriceToSales { get; init; }
|
||||
|
||||
[JsonPropertyName("priceToBook")]
|
||||
public decimal? PriceToBook { get; init; }
|
||||
|
||||
[JsonPropertyName("evToEbitda")]
|
||||
public decimal? EvToEbitda { get; init; }
|
||||
|
||||
// --- Income Statement (TTM) ---
|
||||
[JsonPropertyName("totalRevenue")]
|
||||
public decimal? TotalRevenue { get; init; }
|
||||
|
||||
[JsonPropertyName("revenueGrowthYoY")]
|
||||
public decimal? RevenueGrowthYoY { get; init; }
|
||||
|
||||
[JsonPropertyName("grossProfit")]
|
||||
public decimal? GrossProfit { get; init; }
|
||||
|
||||
[JsonPropertyName("operatingIncome")]
|
||||
public decimal? OperatingIncome { get; init; }
|
||||
|
||||
[JsonPropertyName("ebitda")]
|
||||
public decimal? Ebitda { get; init; }
|
||||
|
||||
[JsonPropertyName("netIncome")]
|
||||
public decimal? NetIncome { get; init; }
|
||||
|
||||
[JsonPropertyName("dilutedEps")]
|
||||
public decimal? DilutedEps { get; init; }
|
||||
|
||||
// --- Balance Sheet & Cash Flow ---
|
||||
[JsonPropertyName("totalCash")]
|
||||
public decimal? TotalCash { get; init; }
|
||||
|
||||
[JsonPropertyName("totalDebt")]
|
||||
public decimal? TotalDebt { get; init; }
|
||||
|
||||
[JsonPropertyName("debtToEquity")]
|
||||
public decimal? DebtToEquity { get; init; }
|
||||
|
||||
[JsonPropertyName("currentRatio")]
|
||||
public decimal? CurrentRatio { get; init; }
|
||||
|
||||
[JsonPropertyName("operatingCashFlow")]
|
||||
public decimal? OperatingCashFlow { get; init; }
|
||||
|
||||
[JsonPropertyName("freeCashFlow")]
|
||||
public decimal? FreeCashFlow { get; init; }
|
||||
|
||||
// --- Dividenden & Profitabilität ---
|
||||
[JsonPropertyName("returnOnEquity")]
|
||||
public decimal? ReturnOnEquity { get; init; }
|
||||
|
||||
[JsonPropertyName("returnOnAssets")]
|
||||
public decimal? ReturnOnAssets { get; init; }
|
||||
|
||||
[JsonPropertyName("forwardDividendYield")]
|
||||
public decimal? ForwardDividendYield { get; init; }
|
||||
|
||||
[JsonPropertyName("payoutRatio")]
|
||||
public decimal? PayoutRatio { get; init; }
|
||||
|
||||
// --- 52-Wochen-Spannbreite ---
|
||||
[JsonPropertyName("fiftyTwoWeekHigh")]
|
||||
public decimal? FiftyTwoWeekHigh { get; init; }
|
||||
|
||||
[JsonPropertyName("fiftyTwoWeekLow")]
|
||||
public decimal? FiftyTwoWeekLow { get; init; }
|
||||
|
||||
// --- Analysten-Ratings & Kursziele ---
|
||||
[JsonPropertyName("consensusRating")]
|
||||
public string? ConsensusRating { get; init; }
|
||||
|
||||
[JsonPropertyName("priceTargetLow")]
|
||||
public decimal? PriceTargetLow { get; init; }
|
||||
|
||||
[JsonPropertyName("priceTargetMean")]
|
||||
public decimal? PriceTargetMean { get; init; }
|
||||
|
||||
[JsonPropertyName("priceTargetHigh")]
|
||||
public decimal? PriceTargetHigh { get; init; }
|
||||
|
||||
// --- Aktionärsstruktur & Short-Interesse ---
|
||||
[JsonPropertyName("percentHeldByInstitutions")]
|
||||
public decimal? PercentHeldByInstitutions { get; init; }
|
||||
|
||||
[JsonPropertyName("percentHeldByInsiders")]
|
||||
public decimal? PercentHeldByInsiders { get; init; }
|
||||
|
||||
[JsonPropertyName("shortPercentOfFloat")]
|
||||
public decimal? ShortPercentOfFloat { get; init; }
|
||||
|
||||
[JsonPropertyName("shortRatio")]
|
||||
public decimal? ShortRatio { get; init; }
|
||||
|
||||
[JsonPropertyName("lastUpdatedUtc")]
|
||||
public DateTime LastUpdatedUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.Fundamentals;
|
||||
|
||||
/// <summary>
|
||||
/// Repräsentiert eine Führungskraft/Vorstandsmitglied eines Unternehmens.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record KeyExecutiveDto
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public Guid Id { get; init; }
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("payment")]
|
||||
public string Payment { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.Fundamentals;
|
||||
|
||||
/// <summary>
|
||||
/// Enthält einen Ticker mit zugehörigem Börsenplatz.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public record TickerInfoDto
|
||||
{
|
||||
[JsonPropertyName("ticker")]
|
||||
public string Ticker { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("exchange")]
|
||||
public string? Exchange { get; init; }
|
||||
}
|
||||
+9
-15
@@ -1,4 +1,4 @@
|
||||
namespace FinlyticCore.Models.TradeRepublic;
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -20,7 +20,7 @@ public record TradeRepublicTag
|
||||
[JsonPropertyName("type")] public string Type { get; init; } = "";
|
||||
}
|
||||
|
||||
// 3. Die Basisklasse MIT UNSEREM CUSTOM CONVERTER (Kein [JsonPolymorphic] mehr!)
|
||||
// 3. Die Basisklasse MIT UNSEREM CUSTOM CONVERTER
|
||||
[JsonConverter(typeof(TradeRepublicAssetConverter))]
|
||||
public record TradeRepublicAsset
|
||||
{
|
||||
@@ -35,7 +35,7 @@ public record TradeRepublicAsset
|
||||
public IReadOnlyList<TradeRepublicTag> Tags { get; init; } = Array.Empty<TradeRepublicTag>();
|
||||
}
|
||||
|
||||
// 4. Die spezifischen Klassen (inklusive Bond und Derivative aus deinem JSON!)
|
||||
// 4. Die spezifischen Klassen
|
||||
|
||||
public record TradeRepublicStock : TradeRepublicAsset
|
||||
{
|
||||
@@ -65,14 +65,14 @@ public record TradeRepublicSynthetic : TradeRepublicAsset
|
||||
public IReadOnlyList<string> DerivativeProductCategories { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
// NEU: Anleihen
|
||||
// Anleihen
|
||||
public record TradeRepublicBond : TradeRepublicAsset
|
||||
{
|
||||
[JsonPropertyName("bondIssuerName")] public string BondIssuerName { get; init; } = "";
|
||||
[JsonPropertyName("searchSubtitle")] public string SearchSubtitle { get; init; } = "";
|
||||
}
|
||||
|
||||
// NEU: Derivate (Hebeleffekte etc.)
|
||||
// Derivate (Hebeleffekte etc.)
|
||||
public record TradeRepublicDerivative : TradeRepublicAsset
|
||||
{
|
||||
[JsonPropertyName("derivativeProductCategories")]
|
||||
@@ -83,21 +83,20 @@ public record TradeRepublicDerivative : TradeRepublicAsset
|
||||
{
|
||||
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 parts[1];
|
||||
}
|
||||
}
|
||||
return null; // Falls das Format mal anders ist
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Der Custom Converter - Die Maschine, die das JSON scannt und verteilt
|
||||
// 5. Custom Converter
|
||||
public class TradeRepublicAssetConverter : JsonConverter<TradeRepublicAsset>
|
||||
{
|
||||
public override TradeRepublicAsset Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
@@ -105,14 +104,12 @@ public class TradeRepublicAssetConverter : JsonConverter<TradeRepublicAsset>
|
||||
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),
|
||||
@@ -121,8 +118,6 @@ public class TradeRepublicAssetConverter : JsonConverter<TradeRepublicAsset>
|
||||
"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)
|
||||
};
|
||||
|
||||
@@ -135,5 +130,4 @@ public class TradeRepublicAssetConverter : JsonConverter<TradeRepublicAsset>
|
||||
}
|
||||
}
|
||||
|
||||
// Ein reiner Fallback-Record, der nur intern vom Converter genutzt wird
|
||||
file record TradeRepublicAssetFallback : TradeRepublicAsset;
|
||||
file record TradeRepublicAssetFallback : TradeRepublicAsset;
|
||||
+9
-5
@@ -1,16 +1,20 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Models.TradeRepublic;
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
/// <summary>
|
||||
/// DTO für den Verbindungsaufbau / Connect-Request an die Trade Republic WebSocket / API.
|
||||
/// </summary>
|
||||
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("locale")] string Locale = "de",
|
||||
[property: JsonPropertyName("platformId")] string PlatformId = "webtrading",
|
||||
[property: JsonPropertyName("platformVersion")] string PlatformVersion = "chrome - 149.0.0",
|
||||
[property: JsonPropertyName("platformVersion")] string PlatformVersion = "chrome - 151.0.0",
|
||||
[property: JsonPropertyName("clientId")] string ClientId = "app.traderepublic.com",
|
||||
[property: JsonPropertyName("clientVersion")] string ClientVersion = "1.2632.6",
|
||||
TradeRepublicHeaders? Headers = null
|
||||
)
|
||||
{
|
||||
[JsonPropertyName("__headers")]
|
||||
public TradeRepublicHeaders Headers { get; init; } = Headers ?? new TradeRepublicHeaders();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicDerivativesRequest(
|
||||
[property: JsonPropertyName("type")] string Type = "derivatives",
|
||||
[property: JsonPropertyName("jurisdiction")] string Jurisdiction = "DE",
|
||||
[property: JsonPropertyName("lang")] string Lang = "en",
|
||||
[property: JsonPropertyName("underlying")] string Underlying = "",
|
||||
[property: JsonPropertyName("productCategory")] string ProductCategory = "knockOutProduct",
|
||||
[property: JsonPropertyName("leverage")] decimal Leverage = 0,
|
||||
[property: JsonPropertyName("sortBy")] string SortBy = "leverage",
|
||||
[property: JsonPropertyName("sortDirection")] string SortDirection = "asc",
|
||||
[property: JsonPropertyName("optionType")] string OptionType = "long",
|
||||
[property: JsonPropertyName("pageSize")] int PageSize = 50,
|
||||
[property: JsonPropertyName("after")] string After = "0",
|
||||
TradeRepublicHeaders? Headers = null
|
||||
)
|
||||
{
|
||||
[JsonPropertyName("__headers")]
|
||||
public TradeRepublicHeaders Headers { get; init; } = Headers ?? new TradeRepublicHeaders();
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicDerivativeItemDto(
|
||||
[property: JsonPropertyName("isin")] string Isin = "",
|
||||
[property: JsonPropertyName("optionType")] string OptionType = "",
|
||||
[property: JsonPropertyName("productCategoryName")] string ProductCategoryName = "",
|
||||
[property: JsonPropertyName("nextGenProductCategoryName")] string NextGenProductCategoryName = "",
|
||||
[property: JsonPropertyName("barrier")] decimal? Barrier = null,
|
||||
[property: JsonPropertyName("leverage")] decimal? Leverage = null,
|
||||
[property: JsonPropertyName("strike")] decimal? Strike = null,
|
||||
[property: JsonPropertyName("size")] decimal? Size = null,
|
||||
[property: JsonPropertyName("factor")] decimal? Factor = null,
|
||||
[property: JsonPropertyName("delta")] decimal? Delta = null,
|
||||
[property: JsonPropertyName("currency")] string Currency = "EUR",
|
||||
[property: JsonPropertyName("expiry")] string? Expiry = null,
|
||||
[property: JsonPropertyName("issuerDisplayName")] string IssuerDisplayName = "",
|
||||
[property: JsonPropertyName("issuer")] string Issuer = "",
|
||||
[property: JsonPropertyName("issuerImageId")] string IssuerImageId = "",
|
||||
[property: JsonPropertyName("imageId")] string ImageId = ""
|
||||
);
|
||||
|
||||
public record TradeRepublicCursorsDto(
|
||||
[property: JsonPropertyName("before")] string? Before = null,
|
||||
[property: JsonPropertyName("after")] string? After = null
|
||||
);
|
||||
|
||||
public record TradeRepublicDerivativesResponse(
|
||||
[property: JsonPropertyName("results")] List<TradeRepublicDerivativeItemDto> Results,
|
||||
[property: JsonPropertyName("resultCount")] int ResultCount = 0,
|
||||
[property: JsonPropertyName("issuerCount")] Dictionary<string, int>? IssuerCount = null,
|
||||
[property: JsonPropertyName("cursors")] TradeRepublicCursorsDto? Cursors = null
|
||||
);
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using FinlyticCore.Util;
|
||||
|
||||
namespace FinlyticCore.Models.TradeRepublic;
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicHeaders(
|
||||
[property: JsonPropertyName("traceparent")] string Traceparent
|
||||
+3
-1
@@ -1,6 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Models.TradeRepublic;
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicFilter(
|
||||
[property: JsonPropertyName("key")] string Key,
|
||||
@@ -0,0 +1,14 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicStockDetailsRequest(
|
||||
[property: JsonPropertyName("id")] string Id,
|
||||
[property: JsonPropertyName("type")] string Type = "stockDetails",
|
||||
[property: JsonPropertyName("jurisdiction")] string Jurisdiction = "DE",
|
||||
TradeRepublicHeaders? Headers = null
|
||||
)
|
||||
{
|
||||
[JsonPropertyName("__headers")]
|
||||
public TradeRepublicHeaders Headers { get; init; } = Headers ?? new TradeRepublicHeaders();
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicCompanyDto(
|
||||
[property: JsonPropertyName("name")] string Name = "",
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("yearFounded")] int? YearFounded = null,
|
||||
[property: JsonPropertyName("tickerSymbol")] string? TickerSymbol = null,
|
||||
[property: JsonPropertyName("peRatioSnapshot")] decimal? PeRatioSnapshot = null,
|
||||
[property: JsonPropertyName("pbRatioSnapshot")] decimal? PbRatioSnapshot = null,
|
||||
[property: JsonPropertyName("dividendYieldSnapshot")] decimal? DividendYieldSnapshot = null,
|
||||
[property: JsonPropertyName("earningsCall")] string? EarningsCall = null,
|
||||
[property: JsonPropertyName("marketCapSnapshot")] decimal? MarketCapSnapshot = null,
|
||||
[property: JsonPropertyName("marketCapCurrency")] string? MarketCapCurrency = null,
|
||||
[property: JsonPropertyName("dailyCloseYearSD")] decimal? DailyCloseYearSd = null,
|
||||
[property: JsonPropertyName("beta")] decimal? Beta = null,
|
||||
[property: JsonPropertyName("countryCode")] string? CountryCode = null,
|
||||
[property: JsonPropertyName("ceoName")] string? CeoName = null,
|
||||
[property: JsonPropertyName("cfoName")] string? CfoName = null,
|
||||
[property: JsonPropertyName("cooName")] string? CooName = null,
|
||||
[property: JsonPropertyName("employeeCount")] long? EmployeeCount = null,
|
||||
[property: JsonPropertyName("eps")] decimal? Eps = null,
|
||||
[property: JsonPropertyName("epsCurrency")] string? EpsCurrency = null
|
||||
);
|
||||
|
||||
public record TradeRepublicSimilarStockTagDto(
|
||||
[property: JsonPropertyName("type")] string Type = "",
|
||||
[property: JsonPropertyName("id")] string Id = "",
|
||||
[property: JsonPropertyName("name")] string Name = "",
|
||||
[property: JsonPropertyName("icon")] string? Icon = null
|
||||
);
|
||||
|
||||
public record TradeRepublicSimilarStockDto(
|
||||
[property: JsonPropertyName("isin")] string Isin = "",
|
||||
[property: JsonPropertyName("name")] string Name = "",
|
||||
[property: JsonPropertyName("tags")] List<TradeRepublicSimilarStockTagDto>? Tags = null
|
||||
);
|
||||
|
||||
public record TradeRepublicDividendDto(
|
||||
[property: JsonPropertyName("id")] string Id = "",
|
||||
[property: JsonPropertyName("paymentDate")] string? PaymentDate = null,
|
||||
[property: JsonPropertyName("recordDate")] string? RecordDate = null,
|
||||
[property: JsonPropertyName("exDate")] string? ExDate = null,
|
||||
[property: JsonPropertyName("amount")] decimal? Amount = null,
|
||||
[property: JsonPropertyName("amountCurrency")] string? AmountCurrency = null,
|
||||
[property: JsonPropertyName("yield")] decimal? Yield = null,
|
||||
[property: JsonPropertyName("type")] string? Type = null
|
||||
);
|
||||
|
||||
public record TradeRepublicEventDto(
|
||||
[property: JsonPropertyName("id")] string Id = "",
|
||||
[property: JsonPropertyName("title")] string? Title = null,
|
||||
[property: JsonPropertyName("timestamp")] long? Timestamp = null,
|
||||
[property: JsonPropertyName("description")] string? Description = null,
|
||||
[property: JsonPropertyName("webcastUrl")] string? WebcastUrl = null,
|
||||
[property: JsonPropertyName("dividend")] TradeRepublicDividendDto? Dividend = null,
|
||||
[property: JsonPropertyName("type")] string? Type = null
|
||||
);
|
||||
|
||||
public record TradeRepublicTargetPriceDto(
|
||||
[property: JsonPropertyName("averageCurrency")] string? AverageCurrency = null,
|
||||
[property: JsonPropertyName("average")] decimal? Average = null,
|
||||
[property: JsonPropertyName("highCurrency")] string? HighCurrency = null,
|
||||
[property: JsonPropertyName("high")] decimal? High = null,
|
||||
[property: JsonPropertyName("lowCurrency")] string? LowCurrency = null,
|
||||
[property: JsonPropertyName("low")] decimal? Low = null
|
||||
);
|
||||
|
||||
public record TradeRepublicRecommendationsDto(
|
||||
[property: JsonPropertyName("buy")] int? Buy = null,
|
||||
[property: JsonPropertyName("outperform")] int? Outperform = null,
|
||||
[property: JsonPropertyName("hold")] int? Hold = null,
|
||||
[property: JsonPropertyName("underperform")] int? Underperform = null,
|
||||
[property: JsonPropertyName("sell")] int? Sell = null
|
||||
);
|
||||
|
||||
public record TradeRepublicAnalystRatingDto(
|
||||
[property: JsonPropertyName("targetPrice")] TradeRepublicTargetPriceDto? TargetPrice = null,
|
||||
[property: JsonPropertyName("recommendations")] TradeRepublicRecommendationsDto? Recommendations = null
|
||||
);
|
||||
|
||||
public record TradeRepublicAggregatedDividendDto(
|
||||
[property: JsonPropertyName("periodStartDate")] string? PeriodStartDate = null,
|
||||
[property: JsonPropertyName("projected")] bool? Projected = null,
|
||||
[property: JsonPropertyName("yieldValue")] decimal? YieldValue = null,
|
||||
[property: JsonPropertyName("amount")] decimal? Amount = null,
|
||||
[property: JsonPropertyName("amountCurrency")] string? AmountCurrency = null,
|
||||
[property: JsonPropertyName("count")] int? Count = null,
|
||||
[property: JsonPropertyName("projectedCount")] int? ProjectedCount = null,
|
||||
[property: JsonPropertyName("price")] decimal? Price = null,
|
||||
[property: JsonPropertyName("priceCurrency")] string? PriceCurrency = null
|
||||
);
|
||||
|
||||
public record TradeRepublicStockDetailsResponse(
|
||||
[property: JsonPropertyName("isin")] string Isin = "",
|
||||
[property: JsonPropertyName("company")] TradeRepublicCompanyDto? Company = null,
|
||||
[property: JsonPropertyName("similarStocks")] List<TradeRepublicSimilarStockDto>? SimilarStocks = null,
|
||||
[property: JsonPropertyName("expectedDividend")] TradeRepublicDividendDto? ExpectedDividend = null,
|
||||
[property: JsonPropertyName("dividends")] List<TradeRepublicDividendDto>? Dividends = null,
|
||||
[property: JsonPropertyName("totalDivendendCount")] int? TotalDivendendCount = null,
|
||||
[property: JsonPropertyName("events")] List<TradeRepublicEventDto>? Events = null,
|
||||
[property: JsonPropertyName("pastEvents")] List<TradeRepublicEventDto>? PastEvents = null,
|
||||
[property: JsonPropertyName("analystRating")] TradeRepublicAnalystRatingDto? AnalystRating = null,
|
||||
[property: JsonPropertyName("hasKpis")] bool? HasKpis = null,
|
||||
[property: JsonPropertyName("aggregatedDividends")] List<TradeRepublicAggregatedDividendDto>? AggregatedDividends = null,
|
||||
[property: JsonPropertyName("dividendFrequency")] string? DividendFrequency = null
|
||||
);
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Models.TradeRepublic;
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicTickerRequest(
|
||||
[property: JsonPropertyName("id")] string Id, // e.g. "US5398301094.TIB"
|
||||
+1
-1
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Globalization;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Models.TradeRepublic;
|
||||
namespace FinlyticCore.Dtos.TradeRepublic;
|
||||
|
||||
public record TradeRepublicPriceTick(
|
||||
[property: JsonPropertyName("time")] long Time,
|
||||
@@ -19,6 +19,7 @@ public record YahooQuoteSummaryResultDto(
|
||||
/// Contains module blocks requested via the modules query parameter.
|
||||
/// </summary>
|
||||
public record YahooQuoteSummaryModulesDto(
|
||||
[property: JsonPropertyName("quoteType")] YahooQuoteTypeDto? QuoteType, // <--- HIER HINZUGEFÜGT
|
||||
[property: JsonPropertyName("assetProfile")] YahooAssetProfileDto? AssetProfile,
|
||||
[property: JsonPropertyName("financialData")] YahooFinancialDataDto? FinancialData,
|
||||
[property: JsonPropertyName("defaultKeyStatistics")] YahooDefaultKeyStatisticsDto? DefaultKeyStatistics,
|
||||
@@ -34,6 +35,12 @@ public record YahooQuoteSummaryModulesDto(
|
||||
|
||||
#region Module DTOs
|
||||
|
||||
public record YahooQuoteTypeDto(
|
||||
[property: JsonPropertyName("shortName")] string? ShortName,
|
||||
[property: JsonPropertyName("longName")] string? LongName,
|
||||
[property: JsonPropertyName("symbol")] string? Symbol
|
||||
);
|
||||
|
||||
/// <summary>
|
||||
/// Asset profile details including address, industry, sector, officers, and corporate governance risks.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace FinlyticCore.Entities.Settings;
|
||||
|
||||
public class SettingEntity
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
/// <summary>
|
||||
/// Der eindeutige Schlüssel der Einstellung (z.B. "EnableTickerLog" oder "YahooClient.Timeout").
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MaxLength(150)]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Der Wert als JSON-String serialisiert.
|
||||
/// Erlaubt bool, int, string, Enums oder komplexe Objekte.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[Column(TypeName = "text")]
|
||||
public string ValueJson { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional: Ermöglicht instanz- oder servicespezifische Settings.
|
||||
/// "Global" = gilt für alle, "FundamentalService_Inst1" = spezifisch.
|
||||
/// </summary>
|
||||
[MaxLength(100)]
|
||||
public string ServiceIdentifier { get; set; } = "Global";
|
||||
|
||||
public DateTime LastUpdatedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
@@ -7,8 +7,15 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Abstractions" Version="10.0.9" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Services\Yahoo\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -25,4 +25,7 @@ public class ManualAnalysisResponseDto
|
||||
|
||||
[JsonPropertyName("proposal")]
|
||||
public TradeProposalDto? Proposal { get; set; }
|
||||
|
||||
[JsonPropertyName("message")]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace FinlyticCore.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Globale, mikroservice-übergreifende SettingKeys in FinlyticCore.
|
||||
/// </summary>
|
||||
public static class CoreSettingKeys
|
||||
{
|
||||
// --- Logging-Kanäle ---
|
||||
public static readonly SettingKey<bool> HealthPingChannel = new("Logging.Channel.Health", true);
|
||||
public static readonly SettingKey<bool> MqttChannel = new("Logging.Channel.MQTT", true);
|
||||
public static readonly SettingKey<bool> HtmlScrapperChannel = new("Logging.Channel.HtmlScrapper", true);
|
||||
public static readonly SettingKey<bool> YahooClientChannel = new("Logging.Channel.YahooClient", true);
|
||||
public static readonly SettingKey<bool> FundamentalsChannel = new("Logging.Channel.Fundamentals", true);
|
||||
|
||||
// --- Scraper & Feature-Toggles ---
|
||||
public static readonly SettingKey<bool> EnableHtmlFallback = new("Feature.EnableHtmlFallback", true);
|
||||
public static readonly SettingKey<bool> AllowForceRefresh = new("Feature.AllowForceRefresh", true);
|
||||
public static readonly SettingKey<int> ScraperTimeoutSeconds = new("Scraper.TimeoutSeconds", 30);
|
||||
public static readonly SettingKey<int> ScraperMaxRetries = new("Scraper.MaxRetries", 2);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace FinlyticCore.Models.Settings;
|
||||
|
||||
public enum LogLevelEnum
|
||||
{
|
||||
None,
|
||||
Debug,
|
||||
Info,
|
||||
Error
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace FinlyticCore.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Verknüpft einen Setting-Key typsicher mit seinem Rückgabetyp T und einem Standardwert.
|
||||
/// </summary>
|
||||
public record SettingKey<T>(string Name, T DefaultValue);
|
||||
@@ -1,31 +1,79 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace FinlyticCore.Models.Trades;
|
||||
|
||||
public class TradeAcceptanceDto
|
||||
{
|
||||
[JsonPropertyName("tradeId")]
|
||||
public string TradeId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isin")]
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; set; } = "default_user";
|
||||
|
||||
[JsonPropertyName("companyName")]
|
||||
public string? CompanyName { get; set; }
|
||||
|
||||
[JsonPropertyName("sector")]
|
||||
public string? Sector { get; set; }
|
||||
|
||||
[JsonPropertyName("actualEntryPrice")]
|
||||
public decimal? ActualEntryPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("positionSize")]
|
||||
public decimal? PositionSize { get; set; }
|
||||
|
||||
[JsonPropertyName("leverageUsed")]
|
||||
public decimal? LeverageUsed { get; set; } = 1;
|
||||
|
||||
[JsonPropertyName("entryFee")]
|
||||
public decimal? EntryFee { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("exitFee")]
|
||||
public decimal? ExitFee { get; set; } = 0;
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public string? Symbol { get; set; }
|
||||
|
||||
[JsonPropertyName("signalType")]
|
||||
public string? SignalType { get; set; }
|
||||
|
||||
[JsonPropertyName("entryPrice")]
|
||||
public decimal? EntryPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("stopLoss")]
|
||||
public decimal? StopLoss { get; set; }
|
||||
|
||||
[JsonPropertyName("takeProfit")]
|
||||
public decimal? TakeProfit { get; set; }
|
||||
|
||||
[JsonPropertyName("instrumentType")]
|
||||
public string? InstrumentType { get; set; }
|
||||
|
||||
[JsonPropertyName("derivativeIsin")]
|
||||
public string? DerivativeIsin { get; set; }
|
||||
|
||||
[JsonPropertyName("timeframe")]
|
||||
public string? Timeframe { get; set; }
|
||||
|
||||
[JsonPropertyName("reasoning")]
|
||||
public string? Reasoning { get; set; }
|
||||
|
||||
[JsonPropertyName("executionTimestamp")]
|
||||
public DateTime? ExecutionTimestamp { get; set; }
|
||||
|
||||
[JsonPropertyName("quantity")]
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
[JsonPropertyName("knockoutThreshold")]
|
||||
public decimal? KnockoutThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("isRecurring")]
|
||||
public bool IsRecurring { get; set; } = false;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using FinlyticCore.Models.Analyzer;
|
||||
|
||||
namespace FinlyticCore.Models.Trades;
|
||||
@@ -9,55 +10,137 @@ namespace FinlyticCore.Models.Trades;
|
||||
/// </summary>
|
||||
public class TradeProposalDto
|
||||
{
|
||||
[JsonPropertyName("tradeId")]
|
||||
public string TradeId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("userId")]
|
||||
public string? UserId { get; set; }
|
||||
|
||||
[JsonPropertyName("isGlobalProposal")]
|
||||
public bool IsGlobalProposal { get; set; } = true;
|
||||
|
||||
[JsonPropertyName("status")]
|
||||
public string Status { get; set; } = "Proposed";
|
||||
|
||||
[JsonPropertyName("analysisId")]
|
||||
public string AnalysisId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("eventId")]
|
||||
public string EventId { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("sector")]
|
||||
public string Sector { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("symbol")]
|
||||
public string Symbol { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("isin")]
|
||||
public string Isin { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("companyName")]
|
||||
public string CompanyName { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("entryPrice")]
|
||||
public decimal EntryPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("stopLoss")]
|
||||
public decimal StopLoss { get; set; }
|
||||
|
||||
[JsonPropertyName("takeProfit")]
|
||||
public decimal TakeProfit { get; set; }
|
||||
|
||||
[JsonPropertyName("signalType")]
|
||||
public string SignalType { get; set; } = "BUY"; // "BUY", "SELL"
|
||||
|
||||
[JsonPropertyName("riskTolerance")]
|
||||
public string RiskTolerance { get; set; } = "Moderate"; // "Conservative", "Moderate", "Aggressive"
|
||||
|
||||
[JsonPropertyName("timeframe")]
|
||||
public string Timeframe { get; set; } = "1D"; // "1H", "4H", "1D", "1W"
|
||||
|
||||
[JsonPropertyName("instrumentType")]
|
||||
public string InstrumentType { get; set; } = "Stock"; // "Stock", "Option", "CFD", "Crypto"
|
||||
|
||||
[JsonPropertyName("derivativeIsin")]
|
||||
public string? DerivativeIsin { get; set; }
|
||||
|
||||
[JsonPropertyName("winRate")]
|
||||
public double WinRate { get; set; }
|
||||
|
||||
[JsonPropertyName("vixRegime")]
|
||||
public VixMarketRegime VixRegime { get; set; }
|
||||
|
||||
[JsonPropertyName("vixValue")]
|
||||
public decimal VixValue { get; set; }
|
||||
|
||||
[JsonPropertyName("ttlMinutes")]
|
||||
public int TtlMinutes { get; set; } = 60;
|
||||
|
||||
[JsonPropertyName("reasoning")]
|
||||
public string Reasoning { get; set; } = string.Empty;
|
||||
|
||||
// --- New Fields for Detailed Execution & Rationale ---
|
||||
[JsonPropertyName("entryZoneMin")]
|
||||
public decimal? EntryZoneMin { get; set; }
|
||||
|
||||
[JsonPropertyName("entryZoneMax")]
|
||||
public decimal? EntryZoneMax { get; set; }
|
||||
|
||||
[JsonPropertyName("takeProfitTargets")]
|
||||
public List<decimal>? TakeProfitTargets { get; set; }
|
||||
|
||||
[JsonPropertyName("riskRewardRatio")]
|
||||
public decimal? RiskRewardRatio { get; set; }
|
||||
|
||||
[JsonPropertyName("maxLeverage")]
|
||||
public decimal? MaxLeverage { get; set; }
|
||||
|
||||
[JsonPropertyName("technicalRationale")]
|
||||
public string TechnicalRationale { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("fundamentalRationale")]
|
||||
public string FundamentalRationale { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("riskWarning")]
|
||||
public string RiskWarning { get; set; } = string.Empty;
|
||||
|
||||
// --- Real Trade Execution Data ---
|
||||
[JsonPropertyName("actualEntryPrice")]
|
||||
public decimal? ActualEntryPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("positionSize")]
|
||||
public decimal? PositionSize { get; set; }
|
||||
|
||||
[JsonPropertyName("leverageUsed")]
|
||||
public decimal? LeverageUsed { get; set; }
|
||||
|
||||
[JsonPropertyName("entryFee")]
|
||||
public decimal? EntryFee { get; set; }
|
||||
|
||||
[JsonPropertyName("exitFee")]
|
||||
public decimal? ExitFee { get; set; }
|
||||
|
||||
[JsonPropertyName("executionTimestamp")]
|
||||
public DateTime? ExecutionTimestamp { get; set; }
|
||||
|
||||
[JsonPropertyName("quantity")]
|
||||
public decimal? Quantity { get; set; }
|
||||
|
||||
[JsonPropertyName("knockoutThreshold")]
|
||||
public decimal? KnockoutThreshold { get; set; }
|
||||
|
||||
[JsonPropertyName("isRecurring")]
|
||||
public bool IsRecurring { get; set; } = false;
|
||||
|
||||
[JsonPropertyName("currentPrice")]
|
||||
public decimal? CurrentPrice { get; set; }
|
||||
|
||||
[JsonPropertyName("pnlAbsolute")]
|
||||
public decimal? PnlAbsolute { get; set; }
|
||||
|
||||
[JsonPropertyName("pnlPercent")]
|
||||
public decimal? PnlPercent { get; set; }
|
||||
|
||||
[JsonPropertyName("createdAt")]
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticCore.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bietet kanalbasierte, dynamisch steuerbare Logging-Funktionalitäten über den <see cref="ISettingsService{TContext}"/>.
|
||||
/// </summary>
|
||||
/// <typeparam name="TContextClass">Die aufrufende Klasse (für Log-Kategorien).</typeparam>
|
||||
/// <typeparam name="TDbContext">Der DbContext des Services für den Zugriff auf die Settings.</typeparam>
|
||||
public interface IFinlyticLogger<TContextClass, TDbContext> where TDbContext : DbContext
|
||||
{
|
||||
// --- Debug ---
|
||||
Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogDebugAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Info ---
|
||||
Task LogInfoAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogInfoAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Warning ---
|
||||
Task LogWarningAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogWarningAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Error ---
|
||||
Task LogErrorAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogErrorAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
|
||||
// --- Trace & Critical ---
|
||||
Task LogTraceAsync(SettingKey<bool> channelKey, string message, params object[] args);
|
||||
Task LogCriticalAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Kanalbasierte Logger-Implementierung, die Einstellungen und Stummschaltungen
|
||||
/// in Echtzeit aus dem <see cref="ISettingsService{TContext}"/> bezieht.
|
||||
/// </summary>
|
||||
public class FinlyticLogger<TContextClass, TDbContext> : IFinlyticLogger<TContextClass, TDbContext>
|
||||
where TDbContext : DbContext
|
||||
{
|
||||
private readonly ILogger<TContextClass> _logger;
|
||||
private readonly ISettingsService<TDbContext> _settingsService;
|
||||
|
||||
public FinlyticLogger(
|
||||
ILogger<TContextClass> logger,
|
||||
ISettingsService<TDbContext> settingsService)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService));
|
||||
}
|
||||
|
||||
#region Debug
|
||||
|
||||
public async Task LogDebugAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogDebugAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Debug))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogDebug(exception, message, args);
|
||||
else
|
||||
_logger.LogDebug(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Info
|
||||
|
||||
public async Task LogInfoAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Information))
|
||||
{
|
||||
_logger.LogInformation(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogInfoAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Information))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogInformation(exception, message, args);
|
||||
else
|
||||
_logger.LogInformation(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Warning
|
||||
|
||||
public async Task LogWarningAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Warning))
|
||||
{
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogWarningAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Warning))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogWarning(exception, message, args);
|
||||
else
|
||||
_logger.LogWarning(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Error
|
||||
|
||||
public async Task LogErrorAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Error))
|
||||
{
|
||||
_logger.LogError(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogErrorAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Error))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogError(exception, message, args);
|
||||
else
|
||||
_logger.LogError(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Trace & Critical
|
||||
|
||||
public async Task LogTraceAsync(SettingKey<bool> channelKey, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Trace))
|
||||
{
|
||||
_logger.LogTrace(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task LogCriticalAsync(SettingKey<bool> channelKey, Exception? exception, string message, params object[] args)
|
||||
{
|
||||
if (await ShouldLogAsync(channelKey, LogLevel.Critical))
|
||||
{
|
||||
if (exception != null)
|
||||
_logger.LogCritical(exception, message, args);
|
||||
else
|
||||
_logger.LogCritical(message, args);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Prüft, ob ein spezifischer Kanal und die aufrufende Klasse aktives Logging erlauben.
|
||||
/// </summary>
|
||||
private async Task<bool> ShouldLogAsync(SettingKey<bool> channelKey, LogLevel level)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(channelKey);
|
||||
|
||||
try
|
||||
{
|
||||
return await _settingsService.GetSettingAsync(channelKey);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return channelKey.DefaultValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace FinlyticCore.Services.PlaywrightScrapper;
|
||||
|
||||
public interface IPlaywrightExecutionService
|
||||
{
|
||||
/// <summary>
|
||||
/// Führt eine Scrape-Aktion auf einer einzelnen Seite innerhalb eines isolierten Kontexts aus.
|
||||
/// Der Kontext und die Page werden automatisch nach der Ausführung disposed.
|
||||
/// </summary>
|
||||
Task<T> ExecuteInPageAsync<T>(
|
||||
Func<IPage, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Führt eine Multi-Page Scrape-Aktion (z. B. bei Tabs/Popups) in einem Konfiguration-Kontext aus.
|
||||
/// </summary>
|
||||
Task<T> ExecuteInContextAsync<T>(
|
||||
Func<IBrowserContext, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class PlaywrightExecutionService : IPlaywrightExecutionService
|
||||
{
|
||||
private readonly IPlaywrightBrowserFactory _browserFactory;
|
||||
|
||||
public PlaywrightExecutionService(IPlaywrightBrowserFactory browserFactory)
|
||||
{
|
||||
_browserFactory = browserFactory;
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteInPageAsync<T>(
|
||||
Func<IPage, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _browserFactory.CreateContextAsync(contextOptions, cancellationToken);
|
||||
var page = await context.NewPageAsync();
|
||||
|
||||
try
|
||||
{
|
||||
return await action(page);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await page.CloseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<T> ExecuteInContextAsync<T>(
|
||||
Func<IBrowserContext, Task<T>> action,
|
||||
BrowserNewContextOptions? contextOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var context = await _browserFactory.CreateContextAsync(contextOptions, cancellationToken);
|
||||
return await action(context);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Playwright;
|
||||
|
||||
namespace FinlyticCore.Services.PlaywrightScrapper;
|
||||
|
||||
public interface IPlaywrightBrowserFactory : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Stellt sicher, dass die IBrowser-Instanz verbunden ist.
|
||||
/// </summary>
|
||||
Task<IBrowser> GetBrowserAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Erstellt einen isolierten, vorkonfigurierten Browser-Kontext.
|
||||
/// </summary>
|
||||
Task<IBrowserContext> CreateContextAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class PlaywrightBrowserFactory : IPlaywrightBrowserFactory
|
||||
{
|
||||
private readonly ILogger<PlaywrightBrowserFactory> _logger;
|
||||
private readonly SemaphoreSlim _browserLock = new(1, 1);
|
||||
|
||||
private IPlaywright? _playwright;
|
||||
private IBrowser? _browser;
|
||||
|
||||
public PlaywrightBrowserFactory(ILogger<PlaywrightBrowserFactory> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<IBrowser> GetBrowserAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_browser != null && _browser.IsConnected) return _browser;
|
||||
|
||||
await _browserLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (_browser != null && _browser.IsConnected) return _browser;
|
||||
|
||||
_playwright ??= await Playwright.CreateAsync();
|
||||
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions
|
||||
{
|
||||
Headless = true,
|
||||
Args = new[]
|
||||
{
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu"
|
||||
}
|
||||
});
|
||||
|
||||
_logger.LogInformation("[PlaywrightFactory] Shared Chromium Instance successfully launched.");
|
||||
return _browser;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_browserLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IBrowserContext> CreateContextAsync(BrowserNewContextOptions? options = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var browser = await GetBrowserAsync(cancellationToken);
|
||||
|
||||
options ??= GetDefaultContextOptions();
|
||||
return await browser.NewContextAsync(options);
|
||||
}
|
||||
|
||||
public static BrowserNewContextOptions GetDefaultContextOptions() => new()
|
||||
{
|
||||
UserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||||
ViewportSize = new ViewportSize { Width = 1280, Height = 900 },
|
||||
Locale = "en-US",
|
||||
ExtraHTTPHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Accept-Language"] = "en-US,en;q=0.9"
|
||||
}
|
||||
};
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_browser != null)
|
||||
{
|
||||
await _browser.CloseAsync();
|
||||
await _browser.DisposeAsync();
|
||||
}
|
||||
|
||||
_playwright?.Dispose();
|
||||
_browserLock.Dispose();
|
||||
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Entities.Settings;
|
||||
using FinlyticCore.Models.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace FinlyticCore.Services;
|
||||
|
||||
public interface ISettingsService<TContext> where TContext : DbContext
|
||||
{
|
||||
// --- 1. Typsicherer Zugriff über SettingKey<T> (Empfohlen) ---
|
||||
Task<T> GetSettingAsync<T>(SettingKey<T> key,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetSettingAsync<T>(SettingKey<T> key, T value,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// --- 2. Dynamischer Zugriff über Enum-Key ---
|
||||
Task<T> GetSettingAsync<TEnum, T>(TEnum enumKey, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default) where TEnum : struct, Enum;
|
||||
|
||||
Task SetSettingAsync<TEnum, T>(TEnum enumKey, T value,
|
||||
CancellationToken cancellationToken = default) where TEnum : struct, Enum;
|
||||
|
||||
// --- 3. Dynamischer Zugriff über String-Key ---
|
||||
Task<T> GetSettingAsync<T>(string key, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SetSettingAsync<T>(string key, T value,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class SettingsService<TContext> : ISettingsService<TContext> where TContext : DbContext
|
||||
{
|
||||
private readonly Microsoft.Extensions.DependencyInjection.IServiceScopeFactory _scopeFactory;
|
||||
private readonly ILogger<SettingsService<TContext>>? _logger;
|
||||
|
||||
// Fast In-Memory Cache: Key Schema: "KeyName"
|
||||
private readonly ConcurrentDictionary<string, string> _cache = new();
|
||||
|
||||
public SettingsService(Microsoft.Extensions.DependencyInjection.IServiceScopeFactory scopeFactory, ILogger<SettingsService<TContext>>? logger = null)
|
||||
{
|
||||
_scopeFactory = scopeFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
#region SettingKey<T> Overloads
|
||||
|
||||
public Task<T> GetSettingAsync<T>(SettingKey<T> key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetSettingInternalAsync(key.Name, key.DefaultValue, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetSettingAsync<T>(SettingKey<T> key, T value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SetSettingInternalAsync(key.Name, value, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Enum-Key Overloads
|
||||
|
||||
public Task<T> GetSettingAsync<TEnum, T>(TEnum enumKey, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
var keyName = $"{typeof(TEnum).Name}.{enumKey}";
|
||||
return GetSettingInternalAsync(keyName, defaultValue, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetSettingAsync<TEnum, T>(TEnum enumKey, T value, CancellationToken cancellationToken = default)
|
||||
where TEnum : struct, Enum
|
||||
{
|
||||
var keyName = $"{typeof(TEnum).Name}.{enumKey}";
|
||||
return SetSettingInternalAsync(keyName, value, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region String-Key Overloads
|
||||
|
||||
public Task<T> GetSettingAsync<T>(string key, T defaultValue = default!,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return GetSettingInternalAsync(key, defaultValue, cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetSettingAsync<T>(string key, T value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return SetSettingInternalAsync(key, value, cancellationToken);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Core Engine Logik
|
||||
|
||||
private async Task<T> GetSettingInternalAsync<T>(string key, T defaultValue, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Zuerst im In-Memory Cache prüfen
|
||||
if (_cache.TryGetValue(key, out var cachedJson))
|
||||
{
|
||||
return DeserializeValue(cachedJson, defaultValue);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<TContext>(scope.ServiceProvider);
|
||||
|
||||
// 2. Aus DB der spezifischen TContext-Instanz laden
|
||||
var entity = await dbContext.Set<SettingEntity>()
|
||||
.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
|
||||
// 3. Falls noch nicht vorhanden: In DB anlegen (Seed on Demand)
|
||||
if (entity == null)
|
||||
{
|
||||
var defaultJson = JsonSerializer.Serialize(defaultValue);
|
||||
entity = new SettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = defaultJson,
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
dbContext.Set<SettingEntity>().Add(entity);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
_cache[key] = defaultJson;
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// In Cache legen & Wert zurückgeben
|
||||
_cache[key] = entity.ValueJson;
|
||||
return DeserializeValue(entity.ValueJson, defaultValue);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Setting '{Key}' could not be loaded or initialized in DB. Using default value in memory.", key);
|
||||
_cache[key] = JsonSerializer.Serialize(defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SetSettingInternalAsync<T>(string key, T value, CancellationToken cancellationToken)
|
||||
{
|
||||
var jsonValue = JsonSerializer.Serialize(value);
|
||||
|
||||
try
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var dbContext = Microsoft.Extensions.DependencyInjection.ServiceProviderServiceExtensions.GetRequiredService<TContext>(scope.ServiceProvider);
|
||||
|
||||
var entity = await dbContext.Set<SettingEntity>()
|
||||
.FirstOrDefaultAsync(s => s.Key == key, cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
{
|
||||
entity = new SettingEntity
|
||||
{
|
||||
Key = key,
|
||||
ValueJson = jsonValue,
|
||||
LastUpdatedUtc = DateTime.UtcNow
|
||||
};
|
||||
dbContext.Set<SettingEntity>().Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
entity.ValueJson = jsonValue;
|
||||
entity.LastUpdatedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Setting '{Key}' could not be saved to DB.", key);
|
||||
}
|
||||
|
||||
// Cache trotzdem aktualisieren
|
||||
_cache[key] = jsonValue;
|
||||
}
|
||||
|
||||
private static T DeserializeValue<T>(string json, T defaultValue)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<T>(json);
|
||||
return result ?? defaultValue;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -3,7 +3,7 @@ using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FinlyticCore.Models.TradeRepublic;
|
||||
using FinlyticCore.Dtos.TradeRepublic;
|
||||
using FinlyticCore.Util;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -165,46 +165,60 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnMessageReceived(string message)
|
||||
protected override void OnMessageReceived(string message)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
|
||||
_logger.LogDebug("[{Channel}] TR WS Recv: {Message}", "TradeRepublicChannel", message);
|
||||
|
||||
var trimmed = message.Trim();
|
||||
|
||||
int subId;
|
||||
string type;
|
||||
string payload;
|
||||
|
||||
_logger.LogDebug("Trade republic response: " + message);
|
||||
|
||||
if (trimmed.Equals("connected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message)) return;
|
||||
|
||||
_logger.LogDebug("[{Channel}] TR WS Recv: {Message}", "TradeRepublicChannel", message);
|
||||
|
||||
// Trade Republic message formats:
|
||||
// "34 connected" -> subId = 34, type = "connected", payload = "connected"
|
||||
// "22A {...}" or "22A{...}" -> subId = 22, type = "A", payload = "{...}"
|
||||
subId = -1;
|
||||
type = "connected";
|
||||
payload = trimmed;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ziffern am Anfang zählen (Sub-ID)
|
||||
var digitLen = 0;
|
||||
while (digitLen < message.Length && char.IsDigit(message[digitLen]))
|
||||
while (digitLen < trimmed.Length && char.IsDigit(trimmed[digitLen]))
|
||||
{
|
||||
digitLen++;
|
||||
}
|
||||
|
||||
// Keine Ziffer am Anfang (Reines System-Event/Error ohne ID)
|
||||
if (digitLen == 0)
|
||||
{
|
||||
SystemMessageReceived?.Invoke(message);
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!int.TryParse(message.Substring(0, digitLen), out var subId))
|
||||
if (!int.TryParse(trimmed.Substring(0, digitLen), out subId))
|
||||
{
|
||||
SystemMessageReceived?.Invoke(message);
|
||||
SystemMessageReceived?.Invoke(trimmed);
|
||||
return;
|
||||
}
|
||||
|
||||
var remainder = message.Substring(digitLen).TrimStart();
|
||||
string type;
|
||||
string payload;
|
||||
var remainder = trimmed.Substring(digitLen).TrimStart();
|
||||
|
||||
if (remainder.StartsWith("connected"))
|
||||
// 2. FALL: "34 connected" oder "34connected"
|
||||
if (remainder.StartsWith("connected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
subId = -1; // Mapping auf deine interne -1 für InitAsync
|
||||
type = "connected";
|
||||
payload = remainder;
|
||||
}
|
||||
else if (remainder.Length > 0)
|
||||
{
|
||||
// Type is usually a single character like 'A' or 'E'
|
||||
// The JSON payload (or ack) starts immediately after or after a space
|
||||
// Standard Trade Republic Data Push (z.B. "22A {...}")
|
||||
type = remainder[0].ToString();
|
||||
payload = remainder.Substring(1).TrimStart();
|
||||
}
|
||||
@@ -213,21 +227,23 @@ public class TradeRepublicClient : ManagedWebSocket
|
||||
type = "ack";
|
||||
payload = string.Empty;
|
||||
}
|
||||
|
||||
var received = new ReceivedMessage(subId, type, payload);
|
||||
|
||||
if (_pendingRequests.TryGetValue(subId, out var tcs))
|
||||
{
|
||||
tcs.TrySetResult(received);
|
||||
}
|
||||
|
||||
if (_tickerSubscriptions.TryGetValue(subId, out var handler))
|
||||
{
|
||||
handler(payload);
|
||||
}
|
||||
|
||||
UnhandledMessageReceived?.Invoke(received);
|
||||
}
|
||||
|
||||
var received = new ReceivedMessage(subId, type, payload);
|
||||
|
||||
// Löst jetzt garantiert dein TaskCompletionSource(-1) in InitAsync auf!
|
||||
if (_pendingRequests.TryGetValue(subId, out var tcs))
|
||||
{
|
||||
tcs.TrySetResult(received);
|
||||
}
|
||||
|
||||
if (_tickerSubscriptions.TryGetValue(subId, out var handler))
|
||||
{
|
||||
handler(payload);
|
||||
}
|
||||
|
||||
UnhandledMessageReceived?.Invoke(received);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,7 +2,7 @@ using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using System.Timers;
|
||||
using FinlyticCore.Models.TradeRepublic;
|
||||
using FinlyticCore.Dtos.TradeRepublic;
|
||||
using FinlyticCore.Models.Assets;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
@@ -53,6 +53,22 @@ public interface ITradeRepublicService
|
||||
/// <param name="subId">The subscription ID to unsubscribe.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task UnsubscribeRealtimeTickerAsync(int subId);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches stock details (company description, events, earnings, analyst ratings) for a specific ISIN.
|
||||
/// </summary>
|
||||
/// <param name="isin">The ISIN of the stock.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The stock details response, or null if failed.</returns>
|
||||
Task<TradeRepublicStockDetailsResponse?> GetStockDetailsAsync(string isin, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches derivative products (KnockOuts, Warrants, Factor Certificates) for an underlying ISIN.
|
||||
/// </summary>
|
||||
/// <param name="request">The derivative query parameters.</param>
|
||||
/// <param name="cancellationToken">A cancellation token.</param>
|
||||
/// <returns>The derivatives response, or null if failed.</returns>
|
||||
Task<TradeRepublicDerivativesResponse?> GetDerivativesAsync(TradeRepublicDerivativesRequest request, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
@@ -179,6 +195,37 @@ public class TradeRepublicService : ITradeRepublicService, IDisposable
|
||||
await _client.UnsubscribeTickerAsync(subId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TradeRepublicStockDetailsResponse?> GetStockDetailsAsync(string isin, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureConnectedAsync();
|
||||
var req = new TradeRepublicStockDetailsRequest(Id: isin);
|
||||
return await _client.SendRequestAsync<TradeRepublicStockDetailsResponse, TradeRepublicStockDetailsRequest>(req, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error while fetching stock details for ISIN {Isin}", "TradeRepublicChannel", isin);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<TradeRepublicDerivativesResponse?> GetDerivativesAsync(TradeRepublicDerivativesRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await EnsureConnectedAsync();
|
||||
return await _client.SendRequestAsync<TradeRepublicDerivativesResponse, TradeRepublicDerivativesRequest>(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "[{Channel}] Error while fetching derivatives for underlying {Underlying}", "TradeRepublicChannel", request.Underlying);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnInactivityTimeout(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
using FinlyticCore.Dtos.Assets;
|
||||
using FinlyticCore.Entities.Assets;
|
||||
|
||||
namespace FinlyticCore.Util;
|
||||
|
||||
public static class AssetMapper
|
||||
{
|
||||
|
||||
public static AssetDto ToDto(this AssetEntity entity)
|
||||
{
|
||||
var dtoTags = entity.Tags.Select(t => new TagDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Name = t.Name,
|
||||
Type = t.Type
|
||||
}).ToList();
|
||||
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
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,
|
||||
LastUpdatedAt = synth.LastUpdatedAt,
|
||||
Tags = dtoTags,
|
||||
DerivativeProductCategories = synth.DerivativeProductCategories
|
||||
},
|
||||
_ => throw new NotSupportedException($"Mapping for type {entity.GetType().Name} is not supported.")
|
||||
};
|
||||
}
|
||||
|
||||
public static List<AssetDto> ToDtoList(this IEnumerable<AssetEntity> entities)
|
||||
{
|
||||
return entities.Select(e => e.ToDto()).ToList();
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,8 @@ namespace FinlyticCore.Util;
|
||||
[JsonSerializable(typeof(FilteredAssetPayload))]
|
||||
[JsonSerializable(typeof(AssetFundamentalsDto))]
|
||||
[JsonSerializable(typeof(List<AssetFundamentalsDto>))]
|
||||
[JsonSerializable(typeof(TickerInfoDto))]
|
||||
[JsonSerializable(typeof(List<TickerInfoDto>))]
|
||||
[JsonSerializable(typeof(CorporateEventDto))]
|
||||
[JsonSerializable(typeof(List<CorporateEventDto>))]
|
||||
[JsonSerializable(typeof(CalendarEventResponseDto))]
|
||||
@@ -94,12 +96,18 @@ namespace FinlyticCore.Util;
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetValidAssetRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.SearchAssetsRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDiscoveryAssetsRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.TradeRepublic.TradeRepublicTickerResponse))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.TradeRepublic.TradeRepublicTickerRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.TradeRepublic.TradeRepublicConnectRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.TradeRepublic.TradeRepublicSearchRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.TradeRepublic.TradeRepublicAssetResponse))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Models.Assets.GetDerivativesRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerResponse))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicTickerRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicConnectRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicSearchRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicAssetResponse))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicDerivativesRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicDerivativesResponse))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicStockDetailsRequest))]
|
||||
[JsonSerializable(typeof(FinlyticCore.Dtos.TradeRepublic.TradeRepublicStockDetailsResponse))]
|
||||
[JsonSerializable(typeof(YahooValueDto))]
|
||||
[JsonSerializable(typeof(YahooQuoteTypeDto))]
|
||||
[JsonSerializable(typeof(YahooQuoteSummaryResponseDto))]
|
||||
[JsonSerializable(typeof(YahooQuoteSummaryResultDto))]
|
||||
[JsonSerializable(typeof(YahooQuoteSummaryModulesDto))]
|
||||
|
||||
@@ -242,37 +242,42 @@ public abstract class ManagedMqttClient : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleIncomingMessageAsync(MqttApplicationMessageReceivedEventArgs e)
|
||||
private Task HandleIncomingMessageAsync(MqttApplicationMessageReceivedEventArgs e)
|
||||
{
|
||||
try
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
var topic = e.ApplicationMessage.Topic;
|
||||
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
|
||||
_logger.LogInformation("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0);
|
||||
|
||||
// Intercept message if it belongs to the RPC response convention
|
||||
if (topic.StartsWith("services/response/"))
|
||||
try
|
||||
{
|
||||
var lastSlashIndex = topic.LastIndexOf('/');
|
||||
if (lastSlashIndex != -1)
|
||||
{
|
||||
string correlationId = topic[(lastSlashIndex + 1)..];
|
||||
var topic = e.ApplicationMessage.Topic;
|
||||
var payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
|
||||
_logger.LogInformation("MQTT message received on topic '{Topic}', length={Length}", topic, payload?.Length ?? 0);
|
||||
|
||||
if (_pendingRequests.TryRemove(correlationId, out var tcs))
|
||||
// Intercept message if it belongs to the RPC response convention
|
||||
if (topic.StartsWith("services/response/"))
|
||||
{
|
||||
var lastSlashIndex = topic.LastIndexOf('/');
|
||||
if (lastSlashIndex != -1)
|
||||
{
|
||||
tcs.SetResult(payload);
|
||||
return; // Sinks the message, avoiding triggering OnMessageReceivedAsync for active RPC handles
|
||||
string correlationId = topic[(lastSlashIndex + 1)..];
|
||||
|
||||
if (_pendingRequests.TryRemove(correlationId, out var tcs))
|
||||
{
|
||||
tcs.SetResult(payload ?? string.Empty);
|
||||
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);
|
||||
}
|
||||
// Regular Pub/Sub message propagation
|
||||
await OnMessageReceivedAsync(topic, payload ?? string.Empty);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
OnError(ex);
|
||||
}
|
||||
});
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task HandleDisconnectAsync(MqttClientDisconnectedEventArgs e)
|
||||
|
||||
Reference in New Issue
Block a user