From 50759e397fd14ad7ce0f1e55777fc6d0a32c7467 Mon Sep 17 00:00:00 2001 From: VibeNL <30174292+VibeNL@users.noreply.github.com> Date: Fri, 29 May 2026 20:48:56 +0200 Subject: [PATCH 1/6] fix: show date & account filters on /top-movers page --- .../PortfolioViewer.WASM/Layout/MainLayout.razor.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/PortfolioViewer/PortfolioViewer.WASM/Layout/MainLayout.razor.cs b/PortfolioViewer/PortfolioViewer.WASM/Layout/MainLayout.razor.cs index 0fa0b244e..fe7f12148 100644 --- a/PortfolioViewer/PortfolioViewer.WASM/Layout/MainLayout.razor.cs +++ b/PortfolioViewer/PortfolioViewer.WASM/Layout/MainLayout.razor.cs @@ -14,18 +14,19 @@ public partial class MainLayout : LayoutComponentBase, IDisposable // Determine which filters to show based on current page private bool ShouldShowFilters => ShouldShowDateFilters || ShouldShowAccountFilters || ShouldShowSymbolFilter || ShouldShowTransactionTypeFilter || ShouldShowSearchFilter; - private bool ShouldShowDateFilters => CurrentPageSupportsFilters && (IsTimeSeriesPage || IsHoldingDetailPage || IsTransactionsPage || IsAccountsPage); - private bool ShouldShowAccountFilters => CurrentPageSupportsFilters && (IsTimeSeriesPage || IsTransactionsPage || IsHoldingsPage); + private bool ShouldShowDateFilters => CurrentPageSupportsFilters && (IsTimeSeriesPage || IsHoldingDetailPage || IsTransactionsPage || IsAccountsPage || IsTopMoversPage); + private bool ShouldShowAccountFilters => CurrentPageSupportsFilters && (IsTimeSeriesPage || IsTransactionsPage || IsHoldingsPage || IsTopMoversPage); private bool ShouldShowSymbolFilter => CurrentPageSupportsFilters && IsTransactionsPage; private bool ShouldShowTransactionTypeFilter => CurrentPageSupportsFilters && IsTransactionsPage; private bool ShouldShowSearchFilter => CurrentPageSupportsFilters && IsTransactionsPage; - private bool CurrentPageSupportsFilters => IsTimeSeriesPage || IsHoldingDetailPage || IsHoldingsPage || IsTransactionsPage || IsAccountsPage; + private bool CurrentPageSupportsFilters => IsTimeSeriesPage || IsHoldingDetailPage || IsHoldingsPage || IsTransactionsPage || IsAccountsPage || IsTopMoversPage; private bool IsTimeSeriesPage => Navigation.Uri.Contains("/portfolio-timeseries"); private bool IsHoldingDetailPage => Navigation.Uri.Contains("/holding/"); private bool IsHoldingsPage => Navigation.Uri.Contains("/holdings"); private bool IsTransactionsPage => Navigation.Uri.Contains("/transactions"); private bool IsAccountsPage => Navigation.Uri.Contains("/accounts"); + private bool IsTopMoversPage => Navigation.Uri.Contains("/top-movers"); // Get transaction types for filtering private List? TransactionTypes => IsTransactionsPage ? _cachedTransactionTypes : null; @@ -77,4 +78,4 @@ public void Dispose() Navigation.LocationChanged -= OnLocationChanged; } } -} \ No newline at end of file +} From 63eb8f4ac08842f494b2fcb17863332de330fb86 Mon Sep 17 00:00:00 2001 From: VibeNL <30174292+VibeNL@users.noreply.github.com> Date: Fri, 29 May 2026 20:49:29 +0200 Subject: [PATCH 2/6] fix: parallelize price history fetches and use IsEqual for filter change detection --- .../Pages/TopMovers.razor.cs | 75 ++++++++++--------- 1 file changed, 38 insertions(+), 37 deletions(-) diff --git a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs index 6b6ad78f7..fd2f6215c 100644 --- a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs +++ b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs @@ -39,7 +39,7 @@ protected override async Task OnInitializedAsync() protected override async Task OnParametersSetAsync() { - if (_previousFilterState == null || HasFilterStateChanged()) + if (_previousFilterState == null || !FilterState.IsEqual(_previousFilterState)) { if (_previousFilterState != null) { @@ -49,19 +49,11 @@ protected override async Task OnParametersSetAsync() { FilterState.PropertyChanged += OnFilterStateChanged; } - _previousFilterState = FilterState; + _previousFilterState = new(FilterState); await LoadMoversAsync(); } } - private bool HasFilterStateChanged() - { - if (_previousFilterState == null) return true; - return _previousFilterState.StartDate != FilterState.StartDate || - _previousFilterState.EndDate != FilterState.EndDate || - _previousFilterState.SelectedAccountId != FilterState.SelectedAccountId; - } - private async void OnFilterStateChanged(object? sender, PropertyChangedEventArgs e) { await InvokeAsync(async () => @@ -108,46 +100,55 @@ protected async Task LoadMoversAsync() private async Task PrepareRisersAndLosers() { - var timeRangePerformances = new List(); - foreach (var holding in HoldingsData.Where(h => h.Quantity > 0 && h.CurrentValue.Amount > 0)) + var holdings = HoldingsData.Where(h => h.Quantity > 0 && h.CurrentValue.Amount > 0).ToList(); + + // Fetch all price histories in parallel for better performance + var tasks = holdings.Select(async holding => { try { + var symbol = holding.Symbols.FirstOrDefault() ?? string.Empty; var priceHistory = await HoldingsDataService.GetHoldingPriceHistoryAsync( - holding.Symbols.FirstOrDefault() ?? string.Empty, + symbol, StartDate, EndDate ); - if (priceHistory.Count != 0) + if (priceHistory.Count == 0) + { + return null; + } + + var startPricePoint = priceHistory.OrderBy(p => p.Date).First(); + var endPricePoint = priceHistory.OrderByDescending(p => p.Date).First(); + if (startPricePoint == null || endPricePoint == null || startPricePoint.Price <= 0) { - var startPricePoint = priceHistory.OrderBy(p => p.Date).First(); - var endPricePoint = priceHistory.OrderByDescending(p => p.Date).First(); - if (startPricePoint != null && endPricePoint != null && startPricePoint.Price > 0) - { - var currency = holding.CurrentPrice.Currency; - var startPrice = new Money(currency, startPricePoint.Price); - var endPrice = new Money(currency, endPricePoint.Price); - var percentageChange = (endPricePoint.Price - startPricePoint.Price) / startPricePoint.Price; - var absoluteChange = new Money(currency, endPricePoint.Price - startPricePoint.Price); - timeRangePerformances.Add(new HoldingTimeRangePerformance - { - Symbol = holding.Symbols.FirstOrDefault() ?? string.Empty, - Name = holding.Name, - StartPrice = startPrice, - EndPrice = endPrice, - PercentageChange = percentageChange, - AbsoluteChange = absoluteChange, - CurrentValue = holding.CurrentValue, - Quantity = holding.Quantity - }); - } + return null; } + + var currency = holding.CurrentPrice.Currency; + var percentageChange = (endPricePoint.Price - startPricePoint.Price) / startPricePoint.Price; + return new HoldingTimeRangePerformance + { + Symbol = symbol, + Name = holding.Name, + StartPrice = new Money(currency, startPricePoint.Price), + EndPrice = new Money(currency, endPricePoint.Price), + PercentageChange = percentageChange, + AbsoluteChange = new Money(currency, endPricePoint.Price - startPricePoint.Price), + CurrentValue = holding.CurrentValue, + Quantity = holding.Quantity + }; } catch (Exception ex) { - System.Diagnostics.Debug.WriteLine($"Error calculating performance for {holding.Symbols.FirstOrDefault() ?? string.Empty}: {ex.Message}"); + System.Diagnostics.Debug.WriteLine($"Error calculating performance for {holding.Symbols.FirstOrDefault() ?? string.Empty}: {ex.Message}"); + return null; } - } + }); + + var results = await Task.WhenAll(tasks); + var timeRangePerformances = results.Where(r => r != null).Cast().ToList(); + TopRisers = [.. timeRangePerformances .Where(h => h.PercentageChange > 0) .OrderByDescending(h => h.PercentageChange) From 2c3a0b7eb4f6295de5166942007c8fcb8582d9d1 Mon Sep 17 00:00:00 2001 From: VibeNL <30174292+VibeNL@users.noreply.github.com> Date: Fri, 29 May 2026 20:53:25 +0200 Subject: [PATCH 3/6] feat: add bulk price history method to IHoldingsDataService --- .../Services/IHoldingsDataService.cs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/PortfolioViewer/PortfolioViewer.WASM.Data/Services/IHoldingsDataService.cs b/PortfolioViewer/PortfolioViewer.WASM.Data/Services/IHoldingsDataService.cs index 261ccabc6..6476ee701 100644 --- a/PortfolioViewer/PortfolioViewer.WASM.Data/Services/IHoldingsDataService.cs +++ b/PortfolioViewer/PortfolioViewer.WASM.Data/Services/IHoldingsDataService.cs @@ -43,6 +43,20 @@ Task> GetHoldingPriceHistoryAsync( DateOnly endDate, CancellationToken cancellationToken = default); + /// + /// Loads price history for multiple holdings in a single query (more efficient than calling GetHoldingPriceHistoryAsync per symbol) + /// + /// Symbols to fetch history for + /// Start date for the price history + /// End date for the price history + /// Cancellation token + /// Dictionary keyed by symbol, each containing a list of price history points + Task>> GetHoldingPriceHistoryBulkAsync( + IEnumerable symbols, + DateOnly startDate, + DateOnly endDate, + CancellationToken cancellationToken = default); + /// /// Loads the portfolio value history (time series) /// @@ -59,4 +73,4 @@ Task> GetPortfolioValueHistoryAsync( CancellationToken cancellationToken = default); } -} \ No newline at end of file +} From d1ab9ee4b94ccad7ef5aedaf505655317ed39569 Mon Sep 17 00:00:00 2001 From: VibeNL <30174292+VibeNL@users.noreply.github.com> Date: Fri, 29 May 2026 20:54:28 +0200 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20implement=20GetHoldingPriceHistoryB?= =?UTF-8?q?ulkAsync=20=E2=80=94=20single=20query=20for=20all=20symbols?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/HoldingsDataService.cs | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/PortfolioViewer/PortfolioViewer.WASM.Data/Services/HoldingsDataService.cs b/PortfolioViewer/PortfolioViewer.WASM.Data/Services/HoldingsDataService.cs index b5ebe425c..3b295b29e 100644 --- a/PortfolioViewer/PortfolioViewer.WASM.Data/Services/HoldingsDataService.cs +++ b/PortfolioViewer/PortfolioViewer.WASM.Data/Services/HoldingsDataService.cs @@ -63,6 +63,65 @@ public async Task> GetHoldingPriceHistoryAsync( return snapShots; } + public async Task>> GetHoldingPriceHistoryBulkAsync( + IEnumerable symbols, + DateOnly startDate, + DateOnly endDate, + CancellationToken cancellationToken = default) + { + var symbolList = symbols.ToList(); + using var databaseContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + // Single query fetching snapshots for all requested symbols at once + var rawSnapShots = await databaseContext.Holdings + .Where(x => x.SymbolProfiles.Any(sp => symbolList.Contains(sp.Symbol))) + .Select(x => new + { + Symbol = x.SymbolProfiles + .Where(sp => symbolList.Contains(sp.Symbol)) + .OrderBy(sp => Datasource.GetPriority(sp.DataSource)) + .Select(sp => sp.Symbol) + .FirstOrDefault(), + Snapshots = x.CalculatedSnapshots + .Where(s => s.Date >= startDate && s.Date <= endDate) + }) + .Where(x => x.Symbol != null) + .AsNoTracking() + .ToListAsync(cancellationToken); + + var result = new Dictionary>(); + + foreach (var holding in rawSnapShots) + { + if (holding.Symbol == null) continue; + + var byDate = holding.Snapshots + .GroupBy(s => s.Date) + .Select(g => + { + var totalQuantity = g.Sum(x => x.Quantity); + return new HoldingPriceHistoryPoint + { + Date = g.Key, + Price = g.Min(x => x.CurrentUnitPrice), + AveragePrice = totalQuantity == 0 ? 0 : g.Sum(y => y.AverageCostPrice * y.Quantity) / totalQuantity, + }; + }) + .ToList(); + + result[holding.Symbol] = byDate; + } + + // Ensure every requested symbol has an entry (empty list if no data) + foreach (var symbol in symbolList) + { + if (!result.ContainsKey(symbol)) + result[symbol] = []; + } + + return result; + } + public async Task> GetPortfolioValueHistoryAsync( DateOnly startDate, DateOnly endDate, From a26c3617d440e51ca71517bda438e7e8c7ba198d Mon Sep 17 00:00:00 2001 From: VibeNL <30174292+VibeNL@users.noreply.github.com> Date: Fri, 29 May 2026 20:55:06 +0200 Subject: [PATCH 5/6] fix: use bulk price history query in TopMovers for better performance --- .../Pages/TopMovers.razor.cs | 42 +++++++++---------- 1 file changed, 20 insertions(+), 22 deletions(-) diff --git a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs index fd2f6215c..f7b4a011e 100644 --- a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs +++ b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs @@ -102,32 +102,34 @@ private async Task PrepareRisersAndLosers() { var holdings = HoldingsData.Where(h => h.Quantity > 0 && h.CurrentValue.Amount > 0).ToList(); - // Fetch all price histories in parallel for better performance - var tasks = holdings.Select(async holding => + // Collect all symbols and fetch price history in a single bulk query + var symbols = holdings + .Select(h => h.Symbols.FirstOrDefault() ?? string.Empty) + .Where(s => !string.IsNullOrEmpty(s)) + .Distinct() + .ToList(); + + var priceHistoryBySymbol = await HoldingsDataService.GetHoldingPriceHistoryBulkAsync( + symbols, StartDate, EndDate); + + var timeRangePerformances = new List(); + foreach (var holding in holdings) { try { var symbol = holding.Symbols.FirstOrDefault() ?? string.Empty; - var priceHistory = await HoldingsDataService.GetHoldingPriceHistoryAsync( - symbol, - StartDate, - EndDate - ); - if (priceHistory.Count == 0) - { - return null; - } + if (string.IsNullOrEmpty(symbol)) continue; + + if (!priceHistoryBySymbol.TryGetValue(symbol, out var priceHistory) || priceHistory.Count == 0) + continue; var startPricePoint = priceHistory.OrderBy(p => p.Date).First(); var endPricePoint = priceHistory.OrderByDescending(p => p.Date).First(); - if (startPricePoint == null || endPricePoint == null || startPricePoint.Price <= 0) - { - return null; - } + if (startPricePoint.Price <= 0) continue; var currency = holding.CurrentPrice.Currency; var percentageChange = (endPricePoint.Price - startPricePoint.Price) / startPricePoint.Price; - return new HoldingTimeRangePerformance + timeRangePerformances.Add(new HoldingTimeRangePerformance { Symbol = symbol, Name = holding.Name, @@ -137,17 +139,13 @@ private async Task PrepareRisersAndLosers() AbsoluteChange = new Money(currency, endPricePoint.Price - startPricePoint.Price), CurrentValue = holding.CurrentValue, Quantity = holding.Quantity - }; + }); } catch (Exception ex) { System.Diagnostics.Debug.WriteLine($"Error calculating performance for {holding.Symbols.FirstOrDefault() ?? string.Empty}: {ex.Message}"); - return null; } - }); - - var results = await Task.WhenAll(tasks); - var timeRangePerformances = results.Where(r => r != null).Cast().ToList(); + } TopRisers = [.. timeRangePerformances .Where(h => h.PercentageChange > 0) From a38bdc0ceb2c9e0d39936cc6712785ff2c66bcbe Mon Sep 17 00:00:00 2001 From: Vincent <30174292+VibeNL@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:18:59 +0200 Subject: [PATCH 6/6] compile fix --- .../PortfolioViewer.WASM/Pages/TopMovers.razor.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs index f7b4a011e..a72a6efe0 100644 --- a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs +++ b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs @@ -45,10 +45,8 @@ protected override async Task OnParametersSetAsync() { _previousFilterState.PropertyChanged -= OnFilterStateChanged; } - if (FilterState != null) - { - FilterState.PropertyChanged += OnFilterStateChanged; - } + + FilterState.PropertyChanged += OnFilterStateChanged; _previousFilterState = new(FilterState); await LoadMoversAsync(); }