From 7e18257e3e08db30a5e6a6f6dfb366f4a477a09f Mon Sep 17 00:00:00 2001 From: Kleidukos Date: Sat, 15 Aug 2026 19:30:04 +0200 Subject: [PATCH] feat(fundamentals): preserve ticker selection and add deterministic key executives sorting --- .../asset_detail/utils/ticker_resolver.dart | 64 +++ .../layouts/asset_page_desktop_layout.dart | 59 ++- .../layouts/asset_page_mobile_layout.dart | 214 +++++---- .../widgets/header/asset_hero_header.dart | 186 +++++--- .../Database/FundamentalsDbContext.cs | 2 + .../Entities/KeyExecutiveEntity.cs | 3 +- ...29_AddSortOrderToKeyExecutives.Designer.cs | 429 ++++++++++++++++++ ...60815161429_AddSortOrderToKeyExecutives.cs | 47 ++ .../FundamentalsDbContextModelSnapshot.cs | 7 +- .../Services/FundamentalsDbService.cs | 165 ++++--- .../Services/YahooFinanceScraper.cs | 1 + 11 files changed, 942 insertions(+), 235 deletions(-) create mode 100644 FinlyticApp/lib/features/asset_detail/utils/ticker_resolver.dart create mode 100644 FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.Designer.cs create mode 100644 FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.cs diff --git a/FinlyticApp/lib/features/asset_detail/utils/ticker_resolver.dart b/FinlyticApp/lib/features/asset_detail/utils/ticker_resolver.dart new file mode 100644 index 0000000..3267903 --- /dev/null +++ b/FinlyticApp/lib/features/asset_detail/utils/ticker_resolver.dart @@ -0,0 +1,64 @@ +import '../../favorites/models/favorite_asset_model.dart'; +import '../models/fundamental_data_model.dart'; + +class TickerResolver { + /// Resolves the optimal ticker according to user prioritization: + /// 1. Candidate / active user-selected symbol (if valid and not equal to ISIN) + /// 2. Favorite selected ticker (if asset is in favorites and has a valid ticker) + /// 3. Primary Ticker (from Fundamentals header) + /// 4. First available ticker from AvailableTickers list + /// 5. ISIN fallback + static String? resolve({ + required String isin, + String? candidateSymbol, + List? favoriteDetails, + FundamentalDataModel? fundamentals, + }) { + final cleanIsin = isin.trim().toUpperCase(); + + // 1. Check Candidate / User-selected symbol + if (candidateSymbol != null && candidateSymbol.trim().isNotEmpty) { + final cClean = candidateSymbol.trim(); + if (cClean.toUpperCase() != cleanIsin) { + return cClean; + } + } + + // 2. Check Favorite selected ticker + if (favoriteDetails != null && favoriteDetails.isNotEmpty) { + for (final f in favoriteDetails) { + final fIsin = f.isin.trim().toUpperCase(); + final fSym = f.symbol.trim().toUpperCase(); + if (fIsin == cleanIsin || fSym == cleanIsin) { + if (f.symbol.trim().isNotEmpty && f.symbol.trim().toUpperCase() != cleanIsin) { + return f.symbol.trim(); + } + } + } + } + + // 3. Check Fundamentals Primary Ticker + if (fundamentals != null) { + final primary = fundamentals.primaryTicker.trim(); + if (primary.isNotEmpty && primary.toUpperCase() != cleanIsin) { + return primary; + } + + final fTicker = fundamentals.ticker.trim(); + if (fTicker.isNotEmpty && fTicker.toUpperCase() != cleanIsin) { + return fTicker; + } + + // 4. First available ticker in availableTickers + for (final t in fundamentals.availableTickers) { + final tTick = t.ticker.trim(); + if (tTick.isNotEmpty && tTick.toUpperCase() != cleanIsin) { + return tTick; + } + } + } + + // 5. Fallback: ISIN + return isin.trim().isNotEmpty ? isin.trim() : null; + } +} diff --git a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart index 93207e4..efea9c0 100644 --- a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart +++ b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_desktop_layout.dart @@ -4,10 +4,12 @@ import '../../../../core/theme/app_theme.dart'; import '../../../favorites/cubit/favorites_cubit.dart'; import '../../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../../bloc/fundamentals/asset_fundamentals_event.dart'; +import '../../bloc/fundamentals/asset_fundamentals_state.dart'; import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_event.dart'; import '../../bloc/trades/asset_trades_bloc.dart'; import '../../bloc/trades/asset_trades_event.dart'; +import '../../utils/ticker_resolver.dart'; import '../../widgets/header/asset_hero_header.dart'; import '../tabs/fundamentals_tab.dart'; import '../tabs/technical_tab.dart'; @@ -74,19 +76,48 @@ class _AssetPageDesktopLayoutState extends State Widget build(BuildContext context) { final theme = AppTheme.activePreset; - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. Hero Header - AssetHeroHeader( - isin: widget.isin, - name: widget.name ?? widget.isin, - symbol: _selectedTicker ?? widget.selectedTicker, - onExchangeChanged: _handleExchangeChanged, - onForceRefresh: _handleForceRefresh, - ), + return BlocListener( + listener: (context, state) { + if (state is AssetFundamentalsLoaded && state.data != null) { + if (_selectedTicker == null || + _selectedTicker!.trim().isEmpty || + _selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) { + final favList = context.read().state.favoriteDetails; + final bestTicker = TickerResolver.resolve( + isin: widget.isin, + candidateSymbol: widget.selectedTicker, + favoriteDetails: favList, + fundamentals: state.data, + ); + + if (bestTicker != null && + bestTicker.isNotEmpty && + bestTicker.toUpperCase() != widget.isin.toUpperCase()) { + setState(() { + _selectedTicker = bestTicker; + }); + context.read().add(LoadAssetTechnical( + widget.isin, + ticker: bestTicker, + forceRefresh: false, + )); + } + } + } + }, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Hero Header + AssetHeroHeader( + isin: widget.isin, + name: widget.name ?? widget.isin, + symbol: _selectedTicker ?? widget.selectedTicker, + onExchangeChanged: _handleExchangeChanged, + onForceRefresh: _handleForceRefresh, + ), // 2. Full-Width Interactive Chart Section Container( @@ -169,6 +200,6 @@ class _AssetPageDesktopLayoutState extends State const SizedBox(height: 24), ], ), - ); + )); } } diff --git a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart index d8a647c..b4b26ce 100644 --- a/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart +++ b/FinlyticApp/lib/features/asset_detail/views/layouts/asset_page_mobile_layout.dart @@ -4,10 +4,12 @@ import '../../../../core/theme/app_theme.dart'; import '../../../favorites/cubit/favorites_cubit.dart'; import '../../bloc/fundamentals/asset_fundamentals_bloc.dart'; import '../../bloc/fundamentals/asset_fundamentals_event.dart'; +import '../../bloc/fundamentals/asset_fundamentals_state.dart'; import '../../bloc/technical/asset_technical_bloc.dart'; import '../../bloc/technical/asset_technical_event.dart'; import '../../bloc/trades/asset_trades_bloc.dart'; import '../../bloc/trades/asset_trades_event.dart'; +import '../../utils/ticker_resolver.dart'; import '../../widgets/header/asset_hero_header.dart'; import '../tabs/fundamentals_tab.dart'; import '../tabs/technical_tab.dart'; @@ -74,102 +76,132 @@ class _AssetPageMobileLayoutState extends State Widget build(BuildContext context) { final theme = AppTheme.activePreset; - return SingleChildScrollView( - physics: const BouncingScrollPhysics(), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // 1. Hero Header - AssetHeroHeader( - isin: widget.isin, - name: widget.name ?? widget.isin, - symbol: _selectedTicker ?? widget.selectedTicker, - onExchangeChanged: _handleExchangeChanged, - onForceRefresh: _handleForceRefresh, - ), - - // 2. Interactive Chart - Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: theme.cardSurface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.glassBorder), - ), - child: TechnicalTab( + return BlocListener( + listener: (context, state) { + if (state is AssetFundamentalsLoaded && state.data != null) { + if (_selectedTicker == null || + _selectedTicker!.trim().isEmpty || + _selectedTicker!.trim().toUpperCase() == widget.isin.trim().toUpperCase()) { + final favList = context.read().state.favoriteDetails; + final bestTicker = TickerResolver.resolve( isin: widget.isin, - symbol: _selectedTicker, - showChartOnly: true, - chartHeight: 320, - ), - ), + candidateSymbol: widget.selectedTicker, + favoriteDetails: favList, + fundamentals: state.data, + ); - const SizedBox(height: 8), + if (bestTicker != null && + bestTicker.isNotEmpty && + bestTicker.toUpperCase() != widget.isin.toUpperCase()) { + setState(() { + _selectedTicker = bestTicker; + }); + context.read().add(LoadAssetTechnical( + widget.isin, + ticker: bestTicker, + forceRefresh: false, + )); + } + } + } + }, + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 1. Hero Header + AssetHeroHeader( + isin: widget.isin, + name: widget.name ?? widget.isin, + symbol: _selectedTicker ?? widget.selectedTicker, + onExchangeChanged: _handleExchangeChanged, + onForceRefresh: _handleForceRefresh, + ), - // 3. Tabbed Detailed Analysis - Container( - margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: theme.cardSurface, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.glassBorder), + // 2. Interactive Chart + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), + ), + child: TechnicalTab( + isin: widget.isin, + symbol: _selectedTicker, + showChartOnly: true, + chartHeight: 320, + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TabBar( - controller: _tabController, - labelColor: theme.primaryColor, - unselectedLabelColor: theme.textMuted, - indicatorColor: theme.primaryColor, - dividerColor: theme.glassBorder, - isScrollable: true, - tabAlignment: TabAlignment.start, - labelStyle: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 12), - tabs: const [ - Tab( - icon: Icon(Icons.analytics_outlined, size: 16), - text: 'FUNDAMENTALS'), - Tab( - icon: Icon(Icons.architecture_outlined, size: 16), - text: 'MUSTER & SIGNALE'), - Tab( - icon: Icon(Icons.candlestick_chart_outlined, size: 16), - text: 'TRADES'), - ], - ), - AnimatedBuilder( - animation: _tabController, - builder: (context, _) { - switch (_tabController.index) { - case 0: - return FundamentalsTab( - isin: widget.isin, - symbol: _selectedTicker, - isEmbedded: true, - ); - case 1: - return TechnicalTab( - isin: widget.isin, - symbol: _selectedTicker, - showDetailsOnly: true, - ); - case 2: - return SizedBox( - height: 500, - child: TradesTab(symbol: widget.isin), - ); - default: - return const SizedBox.shrink(); - } - }, - ), - ], + + const SizedBox(height: 8), + + // 3. Tabbed Detailed Analysis + Container( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: theme.cardSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.glassBorder), + ), + child: Column( + children: [ + TabBar( + controller: _tabController, + isScrollable: true, + tabAlignment: TabAlignment.start, + labelColor: theme.primaryColor, + unselectedLabelColor: theme.textSecondary, + indicatorColor: theme.primaryColor, + dividerColor: theme.glassBorder, + labelStyle: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 13), + tabs: const [ + Tab( + icon: Icon(Icons.analytics_outlined, size: 18), + text: 'ÜBERSICHT'), + Tab( + icon: Icon(Icons.architecture_outlined, size: 18), + text: 'MUSTER'), + Tab( + icon: + Icon(Icons.candlestick_chart_outlined, size: 18), + text: 'TRADES'), + ], + ), + AnimatedBuilder( + animation: _tabController, + builder: (context, _) { + switch (_tabController.index) { + case 0: + return FundamentalsTab( + isin: widget.isin, + symbol: _selectedTicker, + isEmbedded: true, + ); + case 1: + return TechnicalTab( + isin: widget.isin, + symbol: _selectedTicker, + showDetailsOnly: true, + ); + case 2: + return SizedBox( + height: 500, + child: TradesTab(symbol: widget.isin), + ); + default: + return const SizedBox.shrink(); + } + }, + ), + ], + ), ), - ), - const SizedBox(height: 24), - ], + const SizedBox(height: 24), + ], + ), ), ); } diff --git a/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart b/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart index c387fb9..9582f9a 100644 --- a/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart +++ b/FinlyticApp/lib/features/asset_detail/widgets/header/asset_hero_header.dart @@ -33,6 +33,7 @@ class AssetHeroHeader extends StatelessWidget { return BlocBuilder( builder: (context, fundState) { String displayName = name; + String primaryTicker = ''; final String? logoUrl = isin.isNotEmpty ? '/api/v1/logo/$isin' : null; List tickerOptions = [ TickerModel(ticker: symbol ?? 'Loading', exchange: 'Loading', tradingCurrency: '', currentPrice: null) @@ -43,14 +44,21 @@ class AssetHeroHeader extends StatelessWidget { if (data.companyName.isNotEmpty) { displayName = data.companyName; } + if (data.primaryTicker.isNotEmpty) { + primaryTicker = data.primaryTicker; + } if (data.availableTickers.isNotEmpty) { tickerOptions = data.availableTickers; } } final selectedOption = tickerOptions.firstWhere( - (t) => t.ticker == symbol || t.exchange == symbol, - orElse: () => tickerOptions.first, + (t) => (symbol != null && symbol!.isNotEmpty) && + (t.ticker.toLowerCase() == symbol!.toLowerCase() || (t.exchange != null && t.exchange!.toLowerCase() == symbol!.toLowerCase())), + orElse: () => tickerOptions.firstWhere( + (t) => primaryTicker.isNotEmpty && t.ticker.toLowerCase() == primaryTicker.toLowerCase(), + orElse: () => tickerOptions.first, + ), ); return Container( @@ -203,73 +211,125 @@ class AssetHeroHeader extends StatelessWidget { ); }, ), - // Interactive Ticker & Exchange Selector Dropdown - PopupMenuButton( - initialValue: selectedOption.ticker, - tooltip: 'Select Exchange & Ticker', - onSelected: (newTicker) { - if (onExchangeChanged != null) { - final opt = tickerOptions.firstWhere( - (t) => t.ticker == newTicker, - orElse: () => tickerOptions.first, - ); - onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker); - } - }, - itemBuilder: (context) { - return tickerOptions.map((opt) { - final ex = opt.exchange ?? 'Unknown'; - final tick = opt.ticker; - final label = '$tick ($ex)'; - final isSelected = tick == symbol || ex == symbol; + PopupMenuButton( + initialValue: selectedOption.ticker, + tooltip: 'Börsenplatz & Ticker auswählen', + color: theme.cardSurface, + onSelected: (newTicker) { + if (onExchangeChanged != null) { + final opt = tickerOptions.firstWhere( + (t) => t.ticker == newTicker, + orElse: () => tickerOptions.first, + ); + onExchangeChanged!(opt.exchange ?? 'Unknown', opt.ticker); + } + }, + itemBuilder: (context) { + return tickerOptions.map((opt) { + final ex = opt.exchange ?? 'Unknown'; + final tick = opt.ticker; + final isPrimary = primaryTicker.isNotEmpty && + (tick.toLowerCase() == primaryTicker.toLowerCase()); + final isSelected = tick == symbol || ex == symbol; - return PopupMenuItem( - value: tick, - child: Row( - children: [ - Icon( - Icons.business, - size: 16, - color: isSelected ? theme.primaryColor : theme.textMuted, + return PopupMenuItem( + value: tick, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 2), + child: Row( + children: [ + Icon( + isPrimary ? Icons.star_rounded : Icons.business, + size: 18, + color: isPrimary + ? AppTheme.accentCyan + : (isSelected ? theme.primaryColor : theme.textMuted), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '$tick ($ex)', + style: TextStyle( + fontWeight: (isSelected || isPrimary) ? FontWeight.bold : FontWeight.normal, + color: isSelected + ? theme.primaryColor + : (isPrimary ? Colors.white : theme.textPrimary), + ), + ), + ), + if (isPrimary) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AppTheme.accentCyan.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: AppTheme.accentCyan.withValues(alpha: 0.5)), + ), + child: Text( + 'PRIMARY', + style: TextStyle( + color: AppTheme.accentCyan, + fontSize: 9, + fontWeight: FontWeight.w900, + letterSpacing: 0.6, + ), + ), + ), + ], + ], ), - const SizedBox(width: 8), - Text( - label, - style: TextStyle( - fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, - color: isSelected ? theme.primaryColor : theme.textPrimary, - ), - ), - ], - ), - ); - }).toList(); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: theme.accentColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: theme.accentColor.withValues(alpha: 0.4)), - ), - child: Row( - children: [ - Icon(Icons.business, size: 14, color: theme.accentColor), - const SizedBox(width: 6), - Text( - '${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})', - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: theme.accentColor, ), + ); + }).toList(); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase()) + ? AppTheme.accentCyan.withValues(alpha: 0.15) + : theme.accentColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase()) + ? AppTheme.accentCyan.withValues(alpha: 0.5) + : theme.accentColor.withValues(alpha: 0.4), ), - const SizedBox(width: 4), - Icon(Icons.arrow_drop_down, size: 16, color: theme.accentColor), - ], + ), + child: Row( + children: [ + Icon( + (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase()) + ? Icons.star_rounded + : Icons.business, + size: 15, + color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase()) + ? AppTheme.accentCyan + : theme.accentColor, + ), + const SizedBox(width: 6), + Text( + '${selectedOption.ticker} (${selectedOption.exchange ?? 'Unknown'})', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase()) + ? AppTheme.accentCyan + : theme.accentColor, + ), + ), + const SizedBox(width: 4), + Icon( + Icons.arrow_drop_down, + size: 16, + color: (primaryTicker.isNotEmpty && selectedOption.ticker.toLowerCase() == primaryTicker.toLowerCase()) + ? AppTheme.accentCyan + : theme.accentColor, + ), + ], + ), ), ), - ), ], ), ], diff --git a/FinlyticFundamentals/Database/FundamentalsDbContext.cs b/FinlyticFundamentals/Database/FundamentalsDbContext.cs index 9537f59..157425b 100644 --- a/FinlyticFundamentals/Database/FundamentalsDbContext.cs +++ b/FinlyticFundamentals/Database/FundamentalsDbContext.cs @@ -77,6 +77,8 @@ public class FundamentalsDbContext : DbContext modelBuilder.Entity(entity => { entity.HasKey(e => e.Id); + entity.Property(e => e.SortOrder).HasDefaultValue(0); + entity.HasIndex(e => new { e.AssetDataIsin, e.SortOrder }); }); modelBuilder.Entity(entity => diff --git a/FinlyticFundamentals/Entities/KeyExecutiveEntity.cs b/FinlyticFundamentals/Entities/KeyExecutiveEntity.cs index 26a1ed9..b04d7ca 100644 --- a/FinlyticFundamentals/Entities/KeyExecutiveEntity.cs +++ b/FinlyticFundamentals/Entities/KeyExecutiveEntity.cs @@ -1,4 +1,4 @@ -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; namespace FinlyticFundamentals.Entities; @@ -10,6 +10,7 @@ public class KeyExecutiveEntity public string Name { get; set; } = string.Empty; public string Title { get; set; } = string.Empty; public string Payment { get; set; } = string.Empty; + public int SortOrder { get; set; } = 0; [Required] public string AssetDataIsin { get; set; } = string.Empty; diff --git a/FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.Designer.cs b/FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.Designer.cs new file mode 100644 index 0000000..2283cae --- /dev/null +++ b/FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.Designer.cs @@ -0,0 +1,429 @@ +// +using System; +using FinlyticFundamentals.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FinlyticFundamentals.Migrations +{ + [DbContext(typeof(FundamentalsDbContext))] + [Migration("20260815161429_AddSortOrderToKeyExecutives")] + partial class AddSortOrderToKeyExecutives + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FinlyticCore.Entities.Settings.SettingEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ServiceIdentifier") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ValueJson") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Key"); + + b.ToTable("DynamicSettings"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b => + { + b.Property("Isin") + .HasColumnType("text"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Isin"); + + b.ToTable("AssetData"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetDataIsin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssetDataIsin"); + + b.ToTable("AssetEvents"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b => + { + b.Property("Isin") + .HasColumnType("text"); + + b.Property("AssetDataIsin") + .IsRequired() + .HasColumnType("text"); + + b.Property("ConsensusRating") + .HasColumnType("text"); + + b.Property("CurrentRatio") + .HasColumnType("numeric"); + + b.Property("DebtToEquity") + .HasColumnType("numeric"); + + b.Property("DilutedEps") + .HasColumnType("numeric"); + + b.Property("Ebitda") + .HasColumnType("numeric"); + + b.Property("EnterpriseValue") + .HasColumnType("numeric"); + + b.Property("EvToEbitda") + .HasColumnType("numeric"); + + b.Property("FiftyTwoWeekHigh") + .HasColumnType("numeric"); + + b.Property("FiftyTwoWeekLow") + .HasColumnType("numeric"); + + b.Property("ForwardDividendYield") + .HasColumnType("numeric"); + + b.Property("ForwardPe") + .HasColumnType("numeric"); + + b.Property("FreeCashFlow") + .HasColumnType("numeric"); + + b.Property("GrossProfit") + .HasColumnType("numeric"); + + b.Property("LastUpdatedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MarketCap") + .HasColumnType("numeric"); + + b.Property("NetIncome") + .HasColumnType("numeric"); + + b.Property("OperatingCashFlow") + .HasColumnType("numeric"); + + b.Property("OperatingIncome") + .HasColumnType("numeric"); + + b.Property("PayoutRatio") + .HasColumnType("numeric"); + + b.Property("PegRatio") + .HasColumnType("numeric"); + + b.Property("PercentHeldByInsiders") + .HasColumnType("numeric"); + + b.Property("PercentHeldByInstitutions") + .HasColumnType("numeric"); + + b.Property("PriceTargetHigh") + .HasColumnType("numeric"); + + b.Property("PriceTargetLow") + .HasColumnType("numeric"); + + b.Property("PriceTargetMean") + .HasColumnType("numeric"); + + b.Property("PriceToBook") + .HasColumnType("numeric"); + + b.Property("PriceToSales") + .HasColumnType("numeric"); + + b.Property("ReturnOnAssets") + .HasColumnType("numeric"); + + b.Property("ReturnOnEquity") + .HasColumnType("numeric"); + + b.Property("RevenueGrowthYoY") + .HasColumnType("numeric"); + + b.Property("ShortPercentOfFloat") + .HasColumnType("numeric"); + + b.Property("ShortRatio") + .HasColumnType("numeric"); + + b.Property("TotalCash") + .HasColumnType("numeric"); + + b.Property("TotalDebt") + .HasColumnType("numeric"); + + b.Property("TotalRevenue") + .HasColumnType("numeric"); + + b.Property("TrailingPe") + .HasColumnType("numeric"); + + b.HasKey("Isin"); + + b.HasIndex("AssetDataIsin"); + + b.ToTable("FundamentalData"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AssetDataIsin") + .IsRequired() + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Payment") + .IsRequired() + .HasColumnType("text"); + + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AssetDataIsin", "SortOrder"); + + b.ToTable("KeyExecutives"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b => + { + b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "PrimaryTicker", b1 => + { + b1.Property("AssetDataEntityIsin") + .HasColumnType("text"); + + b1.Property("Exchange") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("PrimaryTickerExchange"); + + b1.Property("Ticker") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("PrimaryTicker"); + + b1.HasKey("AssetDataEntityIsin"); + + b1.ToTable("AssetData"); + + b1.WithOwner() + .HasForeignKey("AssetDataEntityIsin"); + }); + + b.OwnsMany("FinlyticFundamentals.Entities.TickerEntity", "AvailableTickers", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("AssetDataIsin") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Exchange") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Exchange"); + + b1.Property("Ticker") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Ticker"); + + b1.HasKey("Id"); + + b1.HasIndex("AssetDataIsin"); + + b1.HasIndex("Ticker"); + + b1.ToTable("Tickers", (string)null); + + b1.WithOwner() + .HasForeignKey("AssetDataIsin"); + }); + + b.Navigation("AvailableTickers"); + + b.Navigation("PrimaryTicker") + .IsRequired(); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetEventEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData") + .WithMany("AssetEvents") + .HasForeignKey("AssetDataIsin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 => + { + b1.Property("AssetEventEntityId") + .HasColumnType("uuid"); + + b1.Property("Exchange") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("TickerExchange"); + + b1.Property("Ticker") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("Ticker"); + + b1.HasKey("AssetEventEntityId"); + + b1.ToTable("AssetEvents"); + + b1.WithOwner() + .HasForeignKey("AssetEventEntityId"); + }); + + b.Navigation("AssetData"); + + b.Navigation("Ticker") + .IsRequired(); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.FundamentalDataEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData") + .WithMany("FundamentalData") + .HasForeignKey("AssetDataIsin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("FinlyticFundamentals.Entities.TickerEntity", "Ticker", b1 => + { + b1.Property("FundamentalDataEntityIsin") + .HasColumnType("text"); + + b1.Property("Exchange") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("TickerExchange"); + + b1.Property("Ticker") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("") + .HasColumnName("Ticker"); + + b1.HasKey("FundamentalDataEntityIsin"); + + b1.ToTable("FundamentalData"); + + b1.WithOwner() + .HasForeignKey("FundamentalDataEntityIsin"); + }); + + b.Navigation("AssetData"); + + b.Navigation("Ticker") + .IsRequired(); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.KeyExecutiveEntity", b => + { + b.HasOne("FinlyticFundamentals.Entities.AssetDataEntity", "AssetData") + .WithMany("KeyExecutives") + .HasForeignKey("AssetDataIsin") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AssetData"); + }); + + modelBuilder.Entity("FinlyticFundamentals.Entities.AssetDataEntity", b => + { + b.Navigation("AssetEvents"); + + b.Navigation("FundamentalData"); + + b.Navigation("KeyExecutives"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.cs b/FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.cs new file mode 100644 index 0000000..d682af9 --- /dev/null +++ b/FinlyticFundamentals/Migrations/20260815161429_AddSortOrderToKeyExecutives.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace FinlyticFundamentals.Migrations +{ + /// + public partial class AddSortOrderToKeyExecutives : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_KeyExecutives_AssetDataIsin", + table: "KeyExecutives"); + + migrationBuilder.AddColumn( + name: "SortOrder", + table: "KeyExecutives", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateIndex( + name: "IX_KeyExecutives_AssetDataIsin_SortOrder", + table: "KeyExecutives", + columns: new[] { "AssetDataIsin", "SortOrder" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_KeyExecutives_AssetDataIsin_SortOrder", + table: "KeyExecutives"); + + migrationBuilder.DropColumn( + name: "SortOrder", + table: "KeyExecutives"); + + migrationBuilder.CreateIndex( + name: "IX_KeyExecutives_AssetDataIsin", + table: "KeyExecutives", + column: "AssetDataIsin"); + } + } +} diff --git a/FinlyticFundamentals/Migrations/FundamentalsDbContextModelSnapshot.cs b/FinlyticFundamentals/Migrations/FundamentalsDbContextModelSnapshot.cs index de312d0..81e534f 100644 --- a/FinlyticFundamentals/Migrations/FundamentalsDbContextModelSnapshot.cs +++ b/FinlyticFundamentals/Migrations/FundamentalsDbContextModelSnapshot.cs @@ -236,13 +236,18 @@ namespace FinlyticFundamentals.Migrations .IsRequired() .HasColumnType("text"); + b.Property("SortOrder") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + b.Property("Title") .IsRequired() .HasColumnType("text"); b.HasKey("Id"); - b.HasIndex("AssetDataIsin"); + b.HasIndex("AssetDataIsin", "SortOrder"); b.ToTable("KeyExecutives"); }); diff --git a/FinlyticFundamentals/Services/FundamentalsDbService.cs b/FinlyticFundamentals/Services/FundamentalsDbService.cs index 5cb24c7..593c95f 100644 --- a/FinlyticFundamentals/Services/FundamentalsDbService.cs +++ b/FinlyticFundamentals/Services/FundamentalsDbService.cs @@ -129,92 +129,85 @@ public class FundamentalsDbService : IFundamentalsDbService "[DEBUG-TR-ERROR] Could not fetch Trade Republic details for {Isin}", cleanIsin); } - // --- STEP 2: Ticker auflösen (Null-safe) --- - TickerInfoDto primaryTicker; + // --- STEP 2: Ticker auflösen (Der Primary Ticker ist IMMER der 1. von Yahoo Finance) --- + var resolvedTickers = await _scraper.ResolveAllTickersFromIsinAsync(cleanIsin, cancellationToken); + var yahooPrimaryTicker = resolvedTickers.FirstOrDefault() + ?? (assetData?.PrimaryTicker != null && !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Ticker) + ? new TickerInfoDto { Ticker = assetData.PrimaryTicker.Ticker, Exchange = assetData.PrimaryTicker.Exchange ?? "Unknown" } + : new TickerInfoDto { Ticker = cleanIsin, Exchange = "Unknown" }); + if (string.IsNullOrWhiteSpace(yahooPrimaryTicker.Exchange)) + { + yahooPrimaryTicker = new TickerInfoDto + { + Ticker = yahooPrimaryTicker.Ticker, + Exchange = GetExchangeDisplayName(yahooPrimaryTicker.Ticker) + }; + } + + // Der activeQueryTicker wird für die aktuelle Kurs- und Modulabfrage verwendet (z. B. wenn der User im Web UI einen bestimmten Börsenplatz wählt) + TickerInfoDto activeQueryTicker; if (!string.IsNullOrWhiteSpace(requestedTicker)) { - var match = assetData?.AvailableTickers? - .FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)); + var matchDto = resolvedTickers.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)); + var matchEntity = assetData?.AvailableTickers?.FirstOrDefault(a => string.Equals(a.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)); - if (match != null) + if (matchDto != null) { - primaryTicker = new TickerInfoDto + activeQueryTicker = new TickerInfoDto { - Ticker = match.Ticker, - Exchange = !string.IsNullOrWhiteSpace(match.Exchange) - ? match.Exchange - : GetExchangeDisplayName(match.Ticker) + Ticker = matchDto.Ticker, + Exchange = !string.IsNullOrWhiteSpace(matchDto.Exchange) ? matchDto.Exchange : GetExchangeDisplayName(matchDto.Ticker) }; } - else if (assetData?.PrimaryTicker != null && - string.Equals(assetData.PrimaryTicker.Ticker, requestedTicker, StringComparison.OrdinalIgnoreCase)) + else if (matchEntity != null) { - primaryTicker = new TickerInfoDto + activeQueryTicker = new TickerInfoDto { - Ticker = assetData.PrimaryTicker.Ticker, - Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange) - ? assetData.PrimaryTicker.Exchange - : GetExchangeDisplayName(assetData.PrimaryTicker.Ticker) + Ticker = matchEntity.Ticker, + Exchange = !string.IsNullOrWhiteSpace(matchEntity.Exchange) ? matchEntity.Exchange : GetExchangeDisplayName(matchEntity.Ticker) }; } else { - primaryTicker = new TickerInfoDto + activeQueryTicker = new TickerInfoDto { Ticker = requestedTicker, Exchange = GetExchangeDisplayName(requestedTicker) }; } } - else if (assetData?.PrimaryTicker != null && !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Ticker)) - { - primaryTicker = new TickerInfoDto - { - Ticker = assetData.PrimaryTicker.Ticker, - Exchange = !string.IsNullOrWhiteSpace(assetData.PrimaryTicker.Exchange) - ? assetData.PrimaryTicker.Exchange - : GetExchangeDisplayName(assetData.PrimaryTicker.Ticker) - }; - } else { - var resolved = await _scraper.ResolveTickerFromIsinAsync(cleanIsin, cancellationToken); - primaryTicker = resolved != null && !string.IsNullOrWhiteSpace(resolved.Ticker) - ? resolved - : new TickerInfoDto - { - Ticker = cleanIsin, - Exchange = "Unknown" - }; + activeQueryTicker = yahooPrimaryTicker; } - if (string.IsNullOrWhiteSpace(primaryTicker.Exchange)) + if (string.IsNullOrWhiteSpace(activeQueryTicker.Exchange)) { - primaryTicker = new TickerInfoDto + activeQueryTicker = new TickerInfoDto { - Ticker = primaryTicker.Ticker, - Exchange = GetExchangeDisplayName(primaryTicker.Ticker) + Ticker = activeQueryTicker.Ticker, + Exchange = GetExchangeDisplayName(activeQueryTicker.Ticker) }; } await _finlyticLogger.LogInfoAsync(SettingKeys.FundamentalsChannel, - "[DEBUG-TICKER-RESOLVED] Ticker aufgelöst zu: '{Ticker}' (Exchange: '{Exchange}') für ISIN {Isin}", - primaryTicker.Ticker, primaryTicker.Exchange ?? "Unknown", cleanIsin); + "[DEBUG-TICKER-RESOLVED] PrimaryTicker: '{Primary}' | ActiveQueryTicker: '{Active}' für ISIN {Isin}", + yahooPrimaryTicker.Ticker, activeQueryTicker.Ticker, cleanIsin); // --- STEP 3 & 4: Yahoo Finance API & HTML Fallback über Scraper --- YahooQuoteSummaryModulesDto? modulesDto = null; - if (!string.IsNullOrWhiteSpace(primaryTicker.Ticker) && primaryTicker.Ticker != cleanIsin) + if (!string.IsNullOrWhiteSpace(activeQueryTicker.Ticker) && activeQueryTicker.Ticker != cleanIsin) { modulesDto = await _scraper.GetQuoteSummaryModulesAsync( - primaryTicker.Ticker, + activeQueryTicker.Ticker, forceHtmlScrape: false, cancellationToken: cancellationToken); } else { await _finlyticLogger.LogWarningAsync(SettingKeys.FundamentalsChannel, - "[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", primaryTicker.Ticker); + "[DEBUG-YAHOO-SKIPPED] Yahoo-Abruf übersprungen. Ticker: '{Ticker}'", activeQueryTicker.Ticker); } // --- Update AssetDataEntity --- @@ -227,8 +220,8 @@ public class FundamentalsDbService : IFundamentalsDbService Isin = cleanIsin, PrimaryTicker = new TickerEntity { - Ticker = primaryTicker.Ticker, - Exchange = primaryTicker.Exchange ?? "Unknown" + Ticker = yahooPrimaryTicker.Ticker, + Exchange = yahooPrimaryTicker.Exchange ?? "Unknown" }, KeyExecutives = new List(), AssetEvents = new List() @@ -241,27 +234,27 @@ public class FundamentalsDbService : IFundamentalsDbService string fallbackName = modulesDto?.QuoteType?.ShortName ?? modulesDto?.QuoteType?.LongName - ?? primaryTicker.Ticker; + ?? activeQueryTicker.Ticker; assetData.Name = !string.IsNullOrWhiteSpace(trName) ? trName : fallbackName; assetData.Description = !string.IsNullOrWhiteSpace(trDescription) ? trDescription : (modulesDto?.AssetProfile?.LongBusinessSummary ?? string.Empty); + // PrimaryTicker ist FEST der erste von Yahoo Finance assetData.PrimaryTicker = new TickerEntity { - Ticker = primaryTicker.Ticker, - Exchange = primaryTicker.Exchange ?? "Unknown" + Ticker = yahooPrimaryTicker.Ticker, + Exchange = yahooPrimaryTicker.Exchange ?? "Unknown" }; - var tickers = await _scraper.ResolveAllTickersFromIsinAsync(cleanIsin, cancellationToken); - if (!tickers.Any(t => string.Equals(t.Ticker, primaryTicker.Ticker, StringComparison.OrdinalIgnoreCase))) + if (!resolvedTickers.Any(t => string.Equals(t.Ticker, yahooPrimaryTicker.Ticker, StringComparison.OrdinalIgnoreCase))) { - tickers.Insert(0, primaryTicker); + resolvedTickers.Insert(0, yahooPrimaryTicker); } assetData.AvailableTickers.Clear(); - foreach (var a in tickers) + foreach (var a in resolvedTickers) { assetData.AvailableTickers.Add(new TickerEntity { @@ -278,7 +271,21 @@ public class FundamentalsDbService : IFundamentalsDbService // --- Process Trade Republic Corporate Events --- if (trDetails != null && (shouldUpdateAssetData || effectiveForceRefresh) && assetData != null) { - assetData.AssetEvents ??= new List(); + // 1. Alte Events direkt in der DB löschen (bypasses Change Tracker) + await context.AssetEvents + .Where(e => e.AssetDataIsin == cleanIsin) + .ExecuteDeleteAsync(cancellationToken); + + // 2. ALLE tracked AssetEventEntity-Einträge aus dem Change Tracker entfernen + foreach (var entry in context.ChangeTracker.Entries() + .Where(e => e.Entity.AssetDataIsin == cleanIsin) + .ToList()) + { + entry.State = EntityState.Detached; + } + + // 3. Navigation-Collection zurücksetzen + assetData.AssetEvents = new List(); var trEventList = new List(); if (trDetails.Events != null) trEventList.AddRange(trDetails.Events); @@ -298,17 +305,19 @@ public class FundamentalsDbService : IFundamentalsDbService if (!isDuplicate) { - assetData.AssetEvents.Add(new AssetEventEntity + var newEvent = new AssetEventEntity { AssetDataIsin = cleanIsin, Ticker = new TickerEntity { - Ticker = primaryTicker.Ticker, - Exchange = primaryTicker.Exchange ?? "Unknown" + Ticker = yahooPrimaryTicker.Ticker, + Exchange = yahooPrimaryTicker.Exchange ?? "Unknown" }, Type = evtType, Date = evtDate - }); + }; + context.AssetEvents.Add(newEvent); + assetData.AssetEvents.Add(newEvent); } } } @@ -339,6 +348,7 @@ public class FundamentalsDbService : IFundamentalsDbService // 4. Neue Executives aufbauen und direkt über den DbSet hinzufügen if (modulesDto.AssetProfile?.CompanyOfficers != null) { + int sortIdx = 0; foreach (var officer in modulesDto.AssetProfile.CompanyOfficers) { if (!string.IsNullOrWhiteSpace(officer.Name)) @@ -349,7 +359,8 @@ public class FundamentalsDbService : IFundamentalsDbService Name = officer.Name, Title = officer.Title ?? string.Empty, Payment = officer.TotalPay?.Fmt ?? - (officer.TotalPay?.Raw?.ToString() ?? string.Empty) + (officer.TotalPay?.Raw?.ToString() ?? string.Empty), + SortOrder = sortIdx++ }; context.KeyExecutives.Add(newExec); assetData.KeyExecutives.Add(newExec); @@ -377,8 +388,8 @@ public class FundamentalsDbService : IFundamentalsDbService fundamentalData.Ticker = new TickerEntity { - Ticker = primaryTicker.Ticker, - Exchange = primaryTicker.Exchange ?? "Unknown" + Ticker = activeQueryTicker.Ticker, + Exchange = activeQueryTicker.Exchange ?? "Unknown" }; fundamentalData.MarketCap = (decimal?)modulesDto.SummaryDetail?.MarketCap?.Raw; fundamentalData.EnterpriseValue = @@ -452,7 +463,10 @@ public class FundamentalsDbService : IFundamentalsDbService if (assetData == null) return null; - var executivesList = assetData.KeyExecutives?.ToList() ?? new List(); + var executivesList = (assetData.KeyExecutives ?? Enumerable.Empty()) + .OrderBy(e => e.SortOrder > 0 ? e.SortOrder : GetExecutiveRank(e.Title)) + .ThenBy(e => GetExecutiveRank(e.Title)) + .ToList(); var eventsList = assetData.AssetEvents?.ToList() ?? new List(); return MapToDto(assetData, fundamentalData, executivesList, eventsList); @@ -610,12 +624,16 @@ public class FundamentalsDbService : IFundamentalsDbService LastUpdatedUtc = fundData.LastUpdatedUtc } : null, - Executives = executives.Select(e => new KeyExecutiveDto + Executives = executives + .OrderBy(e => e.SortOrder > 0 ? e.SortOrder : GetExecutiveRank(e.Title)) + .ThenBy(e => GetExecutiveRank(e.Title)) + .Select(e => new KeyExecutiveDto { Id = e.Id, Name = e.Name, Title = e.Title, - Payment = e.Payment + Payment = e.Payment, + SortOrder = e.SortOrder }).ToList(), Events = events.Select(e => new CorporateEventDto { @@ -666,4 +684,21 @@ public class FundamentalsDbService : IFundamentalsDbService return "Other"; } + + private static int GetExecutiveRank(string title) + { + if (string.IsNullOrWhiteSpace(title)) return 99; + var t = title.ToUpperInvariant(); + + if (t.Contains("CEO") || t.Contains("CHIEF EXECUTIVE") || t.Contains("VORSTANDSVORSITZEND") || t.Contains("MANAGING DIRECTOR")) return 1; + if (t.Contains("CFO") || t.Contains("CHIEF FINANCIAL") || t.Contains("FINANZVORSTAND")) return 2; + if (t.Contains("COO") || t.Contains("CHIEF OPERATING")) return 3; + if (t.Contains("CTO") || t.Contains("CHIEF TECHNOLOGY") || t.Contains("CIO") || t.Contains("CHIEF INFORMATION")) return 4; + if (t.Contains("CMO") || t.Contains("CHIEF MARKETING") || t.Contains("CHIEF COMMERCIAL")) return 5; + if (t.Contains("PRESIDENT") || t.Contains("EXECUTIVE VICE PRESIDENT") || t.Contains("EVP") || t.Contains("GENERAL COUNSEL") || t.Contains("CHIEF LEGAL")) return 6; + if (t.Contains("SENIOR VICE PRESIDENT") || t.Contains("SVP") || t.Contains("VICE PRESIDENT") || t.Contains("VP")) return 7; + if (t.Contains("DIRECTOR") || t.Contains("AUFSICHTSRAT") || t.Contains("VORSTAND") || t.Contains("BOARD")) return 8; + + return 10; + } } \ No newline at end of file diff --git a/FinlyticFundamentals/Services/YahooFinanceScraper.cs b/FinlyticFundamentals/Services/YahooFinanceScraper.cs index 917e64f..7b2adb6 100644 --- a/FinlyticFundamentals/Services/YahooFinanceScraper.cs +++ b/FinlyticFundamentals/Services/YahooFinanceScraper.cs @@ -130,6 +130,7 @@ public class YahooFinanceScraper : IYahooFinanceScraper foreach (var q in validQuotes.Skip(1)) { + if (!symbols.Any(s => s.symbol.Equals(q.Symbol, StringComparison.OrdinalIgnoreCase))) { symbols.Add((q.Symbol, q.Exchange ?? string.Empty, Math.Max(1, GetExchangePriority(q.Symbol, cleanIsin))));