Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,65 @@ public async Task<List<HoldingPriceHistoryPoint>> GetHoldingPriceHistoryAsync(
return snapShots;
}

public async Task<Dictionary<string, List<HoldingPriceHistoryPoint>>> GetHoldingPriceHistoryBulkAsync(
IEnumerable<string> 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(),
Comment on lines +80 to +84
Snapshots = x.CalculatedSnapshots
.Where(s => s.Date >= startDate && s.Date <= endDate)
})
.Where(x => x.Symbol != null)
.AsNoTracking()
.ToListAsync(cancellationToken);

var result = new Dictionary<string, List<HoldingPriceHistoryPoint>>();

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;
}
Comment on lines +94 to +113

// 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<List<PortfolioValueHistoryPoint>> GetPortfolioValueHistoryAsync(
DateOnly startDate,
DateOnly endDate,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,20 @@ Task<List<HoldingPriceHistoryPoint>> GetHoldingPriceHistoryAsync(
DateOnly endDate,
CancellationToken cancellationToken = default);

/// <summary>
/// Loads price history for multiple holdings in a single query (more efficient than calling GetHoldingPriceHistoryAsync per symbol)
/// </summary>
/// <param name="symbols">Symbols to fetch history for</param>
/// <param name="startDate">Start date for the price history</param>
/// <param name="endDate">End date for the price history</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Dictionary keyed by symbol, each containing a list of price history points</returns>
Task<Dictionary<string, List<HoldingPriceHistoryPoint>>> GetHoldingPriceHistoryBulkAsync(
IEnumerable<string> symbols,
DateOnly startDate,
DateOnly endDate,
CancellationToken cancellationToken = default);

/// <summary>
/// Loads the portfolio value history (time series)
/// </summary>
Expand All @@ -59,4 +73,4 @@ Task<List<PortfolioValueHistoryPoint>> GetPortfolioValueHistoryAsync(
CancellationToken cancellationToken = default);

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>? TransactionTypes => IsTransactionsPage ? _cachedTransactionTypes : null;
Expand Down Expand Up @@ -77,4 +78,4 @@ public void Dispose()
Navigation.LocationChanged -= OnLocationChanged;
}
}
}
}
85 changes: 41 additions & 44 deletions PortfolioViewer/PortfolioViewer.WASM/Pages/TopMovers.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 () =>
Expand Down Expand Up @@ -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<HoldingTimeRangePerformance>();
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)
Expand Down
Loading