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, 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 +} 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 +} diff --git a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs index 6b6ad78f7..a72a6efe0 100644 --- a/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs +++ b/PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs @@ -39,29 +39,19 @@ protected override async Task OnInitializedAsync() protected override async Task OnParametersSetAsync() { - if (_previousFilterState == null || HasFilterStateChanged()) + if (_previousFilterState == null || !FilterState.IsEqual(_previousFilterState)) { if (_previousFilterState != null) { _previousFilterState.PropertyChanged -= OnFilterStateChanged; } - if (FilterState != null) - { - FilterState.PropertyChanged += OnFilterStateChanged; - } - _previousFilterState = FilterState; + + FilterState.PropertyChanged += OnFilterStateChanged; + _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 +98,53 @@ protected async Task LoadMoversAsync() private async Task PrepareRisersAndLosers() { + var holdings = HoldingsData.Where(h => h.Quantity > 0 && h.CurrentValue.Amount > 0).ToList(); + + // 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 HoldingsData.Where(h => h.Quantity > 0 && h.CurrentValue.Amount > 0)) + foreach (var holding in holdings) { try { - var priceHistory = await HoldingsDataService.GetHoldingPriceHistoryAsync( - holding.Symbols.FirstOrDefault() ?? string.Empty, - StartDate, - EndDate - ); - if (priceHistory.Count != 0) + var symbol = holding.Symbols.FirstOrDefault() ?? string.Empty; + 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.Price <= 0) continue; + + var currency = holding.CurrentPrice.Currency; + var percentageChange = (endPricePoint.Price - startPricePoint.Price) / startPricePoint.Price; + timeRangePerformances.Add(new HoldingTimeRangePerformance { - 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 - }); - } - } + 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}"); } } + TopRisers = [.. timeRangePerformances .Where(h => h.PercentageChange > 0) .OrderByDescending(h => h.PercentageChange)