Skip to content
Merged
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
13 changes: 12 additions & 1 deletion GhostfolioSidekick/MarketDataMaintainer/PredictDividendsTask.cs
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,18 @@ public async Task DoWork(ILogger logger)
private static int Median(List<int> intervals)
{
var sorted = intervals.OrderBy(x => x).ToList();
return sorted[sorted.Count / 2];
int count = sorted.Count;
if (count == 0)
return 0;
if (count % 2 == 0)
{
// Average of two middle values
return (sorted[count / 2 - 1] + sorted[count / 2]) / 2;
}
else
{
return sorted[count / 2];
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ public class UpcomingDividendModel
public decimal DividendPerShare { get; set; }

// Primary currency equivalent
public decimal AmountPrimaryCurrency { get; set; }
public decimal? AmountPrimaryCurrency { get; set; }
public string PrimaryCurrency { get; set; } = string.Empty;
public decimal DividendPerSharePrimaryCurrency { get; set; }
public decimal? DividendPerSharePrimaryCurrency { get; set; }

public decimal Quantity { get; set; }

Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
using GhostfolioSidekick.Database;
using GhostfolioSidekick.Database.Repository;
using GhostfolioSidekick.Model.Market;
using GhostfolioSidekick.Model.Symbols;
using GhostfolioSidekick.PortfolioViewer.WASM.Data.Models;
using Microsoft.EntityFrameworkCore;

namespace GhostfolioSidekick.PortfolioViewer.WASM.Data.Services
{
/// <summary>
/// Service for retrieving upcoming dividends for portfolio holdings.
/// </summary>
public class UpcomingDividendsService(
IDbContextFactory<DatabaseContext> dbContextFactory,
ICurrencyExchange currencyExchange,
Expand All @@ -14,11 +18,7 @@ public class UpcomingDividendsService(
public async Task<List<UpcomingDividendModel>> GetUpcomingDividendsAsync()
{
await using var databaseContext = await dbContextFactory.CreateDbContextAsync();

// Get the primary currency to convert all amounts to
var primaryCurrency = await serverConfigurationService.GetPrimaryCurrencyAsync();

// Get the latest date for calculated snapshots
var lastKnownDate = await databaseContext.CalculatedSnapshots
.MaxAsync(x => (DateOnly?)x.Date);

Expand All @@ -27,95 +27,105 @@ public async Task<List<UpcomingDividendModel>> GetUpcomingDividendsAsync()
return [];
}

// Fetch all holdings and their symbol profiles
var holdingsWithProfiles = await databaseContext.Holdings
.Include(h => h.SymbolProfiles)
.ToListAsync();

// Fetch all calculated snapshots for the latest date
var snapshots = await databaseContext.CalculatedSnapshots
.Where(s => s.Date == lastKnownDate)
.ToListAsync();

// Build holdings dictionary: symbol -> total quantity
var holdingsDict = new Dictionary<string, decimal>();
foreach (var holding in holdingsWithProfiles)
{
var quantity = snapshots
.Where(s => s.HoldingId == holding.Id)
.Sum(s => s.Quantity);

var sp = holding.SymbolProfiles.FirstOrDefault();
var symbol = sp?.Symbol;
if (!string.IsNullOrEmpty(symbol) && quantity > 0)
{
if (holdingsDict.ContainsKey(symbol))
holdingsDict[symbol] += quantity;
else
holdingsDict[symbol] = quantity;
}
}

// Get upcoming dividends, join with SymbolProfiles using explicit properties
var today = DateOnly.FromDateTime(DateTime.Today);
var dividends = await databaseContext.Dividends
.Where(dividend => dividend.PaymentDate >= today)
.Join(databaseContext.SymbolProfiles,
dividend => new { Symbol = dividend.SymbolProfileSymbol, DataSource = dividend.SymbolProfileDataSource },
symbolProfile => new { Symbol = (string?)symbolProfile.Symbol, DataSource = (string?)symbolProfile.DataSource },
(dividend, symbolProfile) => new { Dividend = dividend, SymbolProfile = symbolProfile })
.Where(x => x.Dividend.Amount.Amount > 0)
.ToListAsync();
var holdingsDict = await GetHoldingsDictionaryAsync(databaseContext, lastKnownDate.Value);
var dividendsWithProfiles = await GetUpcomingDividendsWithProfilesAsync(databaseContext);

var result = new List<UpcomingDividendModel>();
foreach (var item in dividends)
foreach (var item in dividendsWithProfiles)
{
var symbol = item.SymbolProfile.Symbol ?? string.Empty;
var companyName = item.SymbolProfile.Name ?? string.Empty;
holdingsDict.TryGetValue(symbol, out var quantity);

if (quantity <= 0)
if (!holdingsDict.TryGetValue(symbol, out var quantity) || quantity <= 0)
{
continue;
}

// Native currency values (original dividend currency)
var dividendPerShare = item.Dividend.Amount.Amount;
var expectedAmount = dividendPerShare * quantity;
var nativeCurrency = item.Dividend.Amount.Currency.Symbol;

// Convert dividend per share to primary currency
var dividendPerShareConverted = await currencyExchange.ConvertMoney(
item.Dividend.Amount,
primaryCurrency,
item.Dividend.ExDividendDate);

var dividendPerSharePrimaryCurrency = dividendPerShareConverted.Amount;
var expectedAmountPrimaryCurrency = dividendPerSharePrimaryCurrency * quantity;
decimal? dividendPerSharePrimaryCurrency = null;
decimal? expectedAmountPrimaryCurrency = null;
string primaryCurrencyLabel = primaryCurrency.Symbol;
try
{
var dividendPerShareConverted = await currencyExchange.ConvertMoney(
item.Dividend.Amount,
primaryCurrency,
item.Dividend.ExDividendDate);
dividendPerSharePrimaryCurrency = dividendPerShareConverted.Amount;
expectedAmountPrimaryCurrency = dividendPerSharePrimaryCurrency * quantity;
}
catch
{
// Fallback: conversion failed, do not claim value is in primary currency
dividendPerSharePrimaryCurrency = null;
expectedAmountPrimaryCurrency = null;
primaryCurrencyLabel = nativeCurrency;
}

result.Add(new UpcomingDividendModel
{
Symbol = symbol,
CompanyName = companyName,
ExDate = DateTime.SpecifyKind(item.Dividend.ExDividendDate.ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc),
PaymentDate = DateTime.SpecifyKind(item.Dividend.PaymentDate.ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc),

// Native currency (original dividend currency)
Amount = expectedAmount,
Currency = nativeCurrency,
DividendPerShare = dividendPerShare,

// Primary currency equivalent
AmountPrimaryCurrency = expectedAmountPrimaryCurrency,
PrimaryCurrency = primaryCurrency.Symbol,
PrimaryCurrency = primaryCurrencyLabel,
DividendPerSharePrimaryCurrency = dividendPerSharePrimaryCurrency,

Quantity = quantity,
IsPredicted = item.Dividend.DividendState == DividendState.Predicted
});
}

return result;
}

private static async Task<Dictionary<string, decimal>> GetHoldingsDictionaryAsync(DatabaseContext databaseContext, DateOnly lastKnownDate)
{
var holdingsWithProfiles = await databaseContext.Holdings
.Include(h => h.SymbolProfiles)
.ToListAsync();

var snapshots = await databaseContext.CalculatedSnapshots
.Where(s => s.Date == lastKnownDate)
.ToListAsync();

var snapshotLookup = snapshots
.GroupBy(s => s.HoldingId)
.ToDictionary(g => g.Key, g => g.Sum(s => s.Quantity));

return holdingsWithProfiles
.Select(h => new
{
Symbol = h.SymbolProfiles.FirstOrDefault()?.Symbol,
Quantity = snapshotLookup.TryGetValue(h.Id, out var qty) ? qty : 0
})
.Where(x => !string.IsNullOrEmpty(x.Symbol) && x.Quantity > 0)
.GroupBy(x => x.Symbol)
.ToDictionary(g => g.Key!, g => g.Sum(x => x.Quantity));
}

private sealed class DividendWithProfile
{
public Dividend Dividend { get; set; } = default!;
public SymbolProfile SymbolProfile { get; set; } = default!;
}

private static async Task<List<DividendWithProfile>> GetUpcomingDividendsWithProfilesAsync(DatabaseContext databaseContext)
{
var today = DateOnly.FromDateTime(DateTime.Today);
return await databaseContext.Dividends
.Where(dividend => dividend.PaymentDate >= today)
.Join(databaseContext.SymbolProfiles,
dividend => new { Symbol = dividend.SymbolProfileSymbol, DataSource = dividend.SymbolProfileDataSource },
symbolProfile => new { Symbol = (string?)symbolProfile.Symbol, DataSource = (string?)symbolProfile.DataSource },
(dividend, symbolProfile) => new DividendWithProfile { Dividend = dividend, SymbolProfile = symbolProfile })
.Where(x => x.Dividend.Amount.Amount > 0)
.ToListAsync();
}
}
}
}
17 changes: 2 additions & 15 deletions PortfolioViewer/PortfolioViewer.WASM/Pages/UpcomingDividends.razor
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@
<dd class="col-7">
@if (selectedDividend.Currency != selectedDividend.PrimaryCurrency)
{
@selectedDividend.AmountPrimaryCurrency.ToString("F2") @selectedDividend.PrimaryCurrency
@selectedDividend.AmountPrimaryCurrency?.ToString("F2") @selectedDividend.PrimaryCurrency
}
else
{
Expand All @@ -136,7 +136,7 @@
@selectedDividend.DividendPerShare.ToString("F4") @selectedDividend.Currency
@if (selectedDividend.Currency != selectedDividend.PrimaryCurrency)
{
<br /><small class="text-muted">(@selectedDividend.DividendPerSharePrimaryCurrency.ToString("F4") @selectedDividend.PrimaryCurrency)</small>
<br /><small class="text-muted">(@selectedDividend.DividendPerSharePrimaryCurrency?.ToString("F4") @selectedDividend.PrimaryCurrency)</small>
}
</dd>
<dt class="col-5">Status</dt>
Expand All @@ -161,16 +161,3 @@
<div class="modal-backdrop fade show"></div>
}

@code {
private dynamic? selectedDividend;

private void ShowDetails(dynamic div)
{
selectedDividend = div;
}

private void CloseDetails()
{
selectedDividend = null;
}
}
102 changes: 57 additions & 45 deletions PortfolioViewer/PortfolioViewer.WASM/Pages/UpcomingDividends.razor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,40 +31,40 @@ protected override async Task OnInitializedAsync()
}

private void BuildChart()
{
if (dividends == null || dividends.Count == 0)
return;
{
if (dividends == null || dividends.Count == 0)
return;

var allMonths = dividends
.Select(d => (d.PaymentDate.Year, d.PaymentDate.Month))
.Distinct()
.OrderBy(m => m.Year).ThenBy(m => m.Month)
.ToList();
var allMonths = dividends
.Select(d => (d.PaymentDate.Year, d.PaymentDate.Month))
.Distinct()
.OrderBy(m => m.Year).ThenBy(m => m.Month)
.ToList();

var monthLabels = allMonths
.Select(m => new DateTime(m.Year, m.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("MMM yyyy", CultureInfo.InvariantCulture))
.ToArray();
var monthLabels = allMonths
.Select(m => new DateTime(m.Year, m.Month, 1, 0, 0, 0, DateTimeKind.Utc).ToString("MMM yyyy", CultureInfo.InvariantCulture))
.ToArray();

var confirmed = dividends.Where(d => !d.IsPredicted).ToList();
var predicted = dividends.Where(d => d.IsPredicted).ToList();
var confirmed = dividends.Where(d => !d.IsPredicted).ToList();
var predicted = dividends.Where(d => d.IsPredicted).ToList();

var confirmedY = allMonths
.Select(m => (object)confirmed
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
.Sum(d => d.AmountPrimaryCurrency))
.ToArray();
var confirmedY = allMonths
.Select(m => (object)confirmed
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
.Sum(d => d.AmountPrimaryCurrency ?? 0))
.ToArray();

var predictedY = allMonths
.Select(m => (object)predicted
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
.Sum(d => d.AmountPrimaryCurrency))
.ToArray();
var predictedY = allMonths
.Select(m => (object)predicted
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
.Sum(d => d.AmountPrimaryCurrency ?? 0))
.ToArray();

var currencySymbol = ServerConfigurationService.PrimaryCurrency.Symbol;
var currencySymbol = ServerConfigurationService.PrimaryCurrency.Symbol;

chartData =
[
new Bar
chartData =
[
new Bar
{
X = monthLabels,
Y = confirmedY,
Expand All @@ -78,26 +78,38 @@ private void BuildChart()
Name = "Predicted",
Marker = new Plotly.Blazor.Traces.BarLib.Marker { Color = "#adb5bd" }
}
];

chartLayout = new Plotly.Blazor.Layout
{
Title = new Plotly.Blazor.LayoutLib.Title { Text = "Expected Monthly Dividends" },
XAxis = [new Plotly.Blazor.LayoutLib.XAxis { Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = "Month" } }],
YAxis = [new Plotly.Blazor.LayoutLib.YAxis { Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = $"Amount ({currencySymbol})" } }],
BarMode = Plotly.Blazor.LayoutLib.BarModeEnum.Stack,
Margin = new Plotly.Blazor.LayoutLib.Margin { T = 40, L = 60, R = 30, B = 40 },
AutoSize = true,
ShowLegend = true,
Legend =
[
new Plotly.Blazor.LayoutLib.Legend
];

chartLayout = new Plotly.Blazor.Layout
{
Title = new Plotly.Blazor.LayoutLib.Title { Text = "Expected Monthly Dividends" },
XAxis = [new Plotly.Blazor.LayoutLib.XAxis { Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = "Month" } }],
YAxis = [new Plotly.Blazor.LayoutLib.YAxis { Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = $"Amount ({currencySymbol})" } }],
BarMode = Plotly.Blazor.LayoutLib.BarModeEnum.Stack,
Margin = new Plotly.Blazor.LayoutLib.Margin { T = 40, L = 60, R = 30, B = 40 },
AutoSize = true,
ShowLegend = true,
Legend =
[
new Plotly.Blazor.LayoutLib.Legend
{
Orientation = Plotly.Blazor.LayoutLib.LegendLib.OrientationEnum.H,
}
]
};
chartConfig = new Config { Responsive = true };
}
]
};
chartConfig = new Config { Responsive = true };
}

private UpcomingDividendModel? selectedDividend;

private void ShowDetails(UpcomingDividendModel div)
{
selectedDividend = div;
}

private void CloseDetails()
{
selectedDividend = null;
}
}
}
Loading