Skip to content

Commit 4313afd

Browse files
committed
2 parents e504f20 + e41b72c commit 4313afd

File tree

7 files changed

+148
-128
lines changed

7 files changed

+148
-128
lines changed

GhostfolioSidekick/MarketDataMaintainer/PredictDividendsTask.cs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,18 @@ public async Task DoWork(ILogger logger)
225225
private static int Median(List<int> intervals)
226226
{
227227
var sorted = intervals.OrderBy(x => x).ToList();
228-
return sorted[sorted.Count / 2];
228+
int count = sorted.Count;
229+
if (count == 0)
230+
return 0;
231+
if (count % 2 == 0)
232+
{
233+
// Average of two middle values
234+
return (sorted[count / 2 - 1] + sorted[count / 2]) / 2;
235+
}
236+
else
237+
{
238+
return sorted[count / 2];
239+
}
229240
}
230241
}
231242
}

PortfolioViewer/PortfolioViewer.ApiService/PortfolioViewer.ApiService.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
<ItemGroup>
2121
<PackageReference Include="HtmlAgilityPack" Version="1.12.4" />
2222
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.3" />
23-
<PackageReference Include="Scalar.AspNetCore" Version="2.12.47" />
23+
<PackageReference Include="Scalar.AspNetCore" Version="2.12.50" />
2424
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
2525
<PackageReference Include="Grpc.AspNetCore.Web" Version="2.76.0" />
2626
</ItemGroup>

PortfolioViewer/PortfolioViewer.WASM.Data/Models/UpcomingDividendModel.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,9 @@ public class UpcomingDividendModel
1313
public decimal DividendPerShare { get; set; }
1414

1515
// Primary currency equivalent
16-
public decimal AmountPrimaryCurrency { get; set; }
16+
public decimal? AmountPrimaryCurrency { get; set; }
1717
public string PrimaryCurrency { get; set; } = string.Empty;
18-
public decimal DividendPerSharePrimaryCurrency { get; set; }
18+
public decimal? DividendPerSharePrimaryCurrency { get; set; }
1919

2020
public decimal Quantity { get; set; }
2121

Lines changed: 73 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
using GhostfolioSidekick.Database;
22
using GhostfolioSidekick.Database.Repository;
33
using GhostfolioSidekick.Model.Market;
4+
using GhostfolioSidekick.Model.Symbols;
45
using GhostfolioSidekick.PortfolioViewer.WASM.Data.Models;
56
using Microsoft.EntityFrameworkCore;
67

78
namespace GhostfolioSidekick.PortfolioViewer.WASM.Data.Services
89
{
10+
/// <summary>
11+
/// Service for retrieving upcoming dividends for portfolio holdings.
12+
/// </summary>
913
public class UpcomingDividendsService(
1014
IDbContextFactory<DatabaseContext> dbContextFactory,
1115
ICurrencyExchange currencyExchange,
@@ -14,11 +18,7 @@ public class UpcomingDividendsService(
1418
public async Task<List<UpcomingDividendModel>> GetUpcomingDividendsAsync()
1519
{
1620
await using var databaseContext = await dbContextFactory.CreateDbContextAsync();
17-
18-
// Get the primary currency to convert all amounts to
1921
var primaryCurrency = await serverConfigurationService.GetPrimaryCurrencyAsync();
20-
21-
// Get the latest date for calculated snapshots
2222
var lastKnownDate = await databaseContext.CalculatedSnapshots
2323
.MaxAsync(x => (DateOnly?)x.Date);
2424

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

30-
// Fetch all holdings and their symbol profiles
31-
var holdingsWithProfiles = await databaseContext.Holdings
32-
.Include(h => h.SymbolProfiles)
33-
.ToListAsync();
34-
35-
// Fetch all calculated snapshots for the latest date
36-
var snapshots = await databaseContext.CalculatedSnapshots
37-
.Where(s => s.Date == lastKnownDate)
38-
.ToListAsync();
39-
40-
// Build holdings dictionary: symbol -> total quantity
41-
var holdingsDict = new Dictionary<string, decimal>();
42-
foreach (var holding in holdingsWithProfiles)
43-
{
44-
var quantity = snapshots
45-
.Where(s => s.HoldingId == holding.Id)
46-
.Sum(s => s.Quantity);
47-
48-
var sp = holding.SymbolProfiles.FirstOrDefault();
49-
var symbol = sp?.Symbol;
50-
if (!string.IsNullOrEmpty(symbol) && quantity > 0)
51-
{
52-
if (holdingsDict.ContainsKey(symbol))
53-
holdingsDict[symbol] += quantity;
54-
else
55-
holdingsDict[symbol] = quantity;
56-
}
57-
}
58-
59-
// Get upcoming dividends, join with SymbolProfiles using explicit properties
60-
var today = DateOnly.FromDateTime(DateTime.Today);
61-
var dividends = await databaseContext.Dividends
62-
.Where(dividend => dividend.PaymentDate >= today)
63-
.Join(databaseContext.SymbolProfiles,
64-
dividend => new { Symbol = dividend.SymbolProfileSymbol, DataSource = dividend.SymbolProfileDataSource },
65-
symbolProfile => new { Symbol = (string?)symbolProfile.Symbol, DataSource = (string?)symbolProfile.DataSource },
66-
(dividend, symbolProfile) => new { Dividend = dividend, SymbolProfile = symbolProfile })
67-
.Where(x => x.Dividend.Amount.Amount > 0)
68-
.ToListAsync();
30+
var holdingsDict = await GetHoldingsDictionaryAsync(databaseContext, lastKnownDate.Value);
31+
var dividendsWithProfiles = await GetUpcomingDividendsWithProfilesAsync(databaseContext);
6932

7033
var result = new List<UpcomingDividendModel>();
71-
foreach (var item in dividends)
34+
foreach (var item in dividendsWithProfiles)
7235
{
7336
var symbol = item.SymbolProfile.Symbol ?? string.Empty;
7437
var companyName = item.SymbolProfile.Name ?? string.Empty;
75-
holdingsDict.TryGetValue(symbol, out var quantity);
76-
77-
if (quantity <= 0)
38+
if (!holdingsDict.TryGetValue(symbol, out var quantity) || quantity <= 0)
7839
{
7940
continue;
8041
}
8142

82-
// Native currency values (original dividend currency)
8343
var dividendPerShare = item.Dividend.Amount.Amount;
8444
var expectedAmount = dividendPerShare * quantity;
8545
var nativeCurrency = item.Dividend.Amount.Currency.Symbol;
8646

87-
// Convert dividend per share to primary currency
88-
var dividendPerShareConverted = await currencyExchange.ConvertMoney(
89-
item.Dividend.Amount,
90-
primaryCurrency,
91-
item.Dividend.ExDividendDate);
92-
93-
var dividendPerSharePrimaryCurrency = dividendPerShareConverted.Amount;
94-
var expectedAmountPrimaryCurrency = dividendPerSharePrimaryCurrency * quantity;
47+
decimal? dividendPerSharePrimaryCurrency = null;
48+
decimal? expectedAmountPrimaryCurrency = null;
49+
string primaryCurrencyLabel = primaryCurrency.Symbol;
50+
try
51+
{
52+
var dividendPerShareConverted = await currencyExchange.ConvertMoney(
53+
item.Dividend.Amount,
54+
primaryCurrency,
55+
item.Dividend.ExDividendDate);
56+
dividendPerSharePrimaryCurrency = dividendPerShareConverted.Amount;
57+
expectedAmountPrimaryCurrency = dividendPerSharePrimaryCurrency * quantity;
58+
}
59+
catch
60+
{
61+
// Fallback: conversion failed, do not claim value is in primary currency
62+
dividendPerSharePrimaryCurrency = null;
63+
expectedAmountPrimaryCurrency = null;
64+
primaryCurrencyLabel = nativeCurrency;
65+
}
9566

9667
result.Add(new UpcomingDividendModel
9768
{
9869
Symbol = symbol,
9970
CompanyName = companyName,
10071
ExDate = DateTime.SpecifyKind(item.Dividend.ExDividendDate.ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc),
10172
PaymentDate = DateTime.SpecifyKind(item.Dividend.PaymentDate.ToDateTime(TimeOnly.MinValue), DateTimeKind.Utc),
102-
103-
// Native currency (original dividend currency)
10473
Amount = expectedAmount,
10574
Currency = nativeCurrency,
10675
DividendPerShare = dividendPerShare,
107-
108-
// Primary currency equivalent
10976
AmountPrimaryCurrency = expectedAmountPrimaryCurrency,
110-
PrimaryCurrency = primaryCurrency.Symbol,
77+
PrimaryCurrency = primaryCurrencyLabel,
11178
DividendPerSharePrimaryCurrency = dividendPerSharePrimaryCurrency,
112-
11379
Quantity = quantity,
11480
IsPredicted = item.Dividend.DividendState == DividendState.Predicted
11581
});
11682
}
11783

11884
return result;
11985
}
86+
87+
private static async Task<Dictionary<string, decimal>> GetHoldingsDictionaryAsync(DatabaseContext databaseContext, DateOnly lastKnownDate)
88+
{
89+
var holdingsWithProfiles = await databaseContext.Holdings
90+
.Include(h => h.SymbolProfiles)
91+
.ToListAsync();
92+
93+
var snapshots = await databaseContext.CalculatedSnapshots
94+
.Where(s => s.Date == lastKnownDate)
95+
.ToListAsync();
96+
97+
var snapshotLookup = snapshots
98+
.GroupBy(s => s.HoldingId)
99+
.ToDictionary(g => g.Key, g => g.Sum(s => s.Quantity));
100+
101+
return holdingsWithProfiles
102+
.Select(h => new
103+
{
104+
Symbol = h.SymbolProfiles.FirstOrDefault()?.Symbol,
105+
Quantity = snapshotLookup.TryGetValue(h.Id, out var qty) ? qty : 0
106+
})
107+
.Where(x => !string.IsNullOrEmpty(x.Symbol) && x.Quantity > 0)
108+
.GroupBy(x => x.Symbol)
109+
.ToDictionary(g => g.Key!, g => g.Sum(x => x.Quantity));
110+
}
111+
112+
private sealed class DividendWithProfile
113+
{
114+
public Dividend Dividend { get; set; } = default!;
115+
public SymbolProfile SymbolProfile { get; set; } = default!;
116+
}
117+
118+
private static async Task<List<DividendWithProfile>> GetUpcomingDividendsWithProfilesAsync(DatabaseContext databaseContext)
119+
{
120+
var today = DateOnly.FromDateTime(DateTime.Today);
121+
return await databaseContext.Dividends
122+
.Where(dividend => dividend.PaymentDate >= today)
123+
.Join(databaseContext.SymbolProfiles,
124+
dividend => new { Symbol = dividend.SymbolProfileSymbol, DataSource = dividend.SymbolProfileDataSource },
125+
symbolProfile => new { Symbol = (string?)symbolProfile.Symbol, DataSource = (string?)symbolProfile.DataSource },
126+
(dividend, symbolProfile) => new DividendWithProfile { Dividend = dividend, SymbolProfile = symbolProfile })
127+
.Where(x => x.Dividend.Amount.Amount > 0)
128+
.ToListAsync();
129+
}
120130
}
121-
}
131+
}

PortfolioViewer/PortfolioViewer.WASM/Pages/UpcomingDividends.razor

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@
122122
<dd class="col-7">
123123
@if (selectedDividend.Currency != selectedDividend.PrimaryCurrency)
124124
{
125-
@selectedDividend.AmountPrimaryCurrency.ToString("F2") @selectedDividend.PrimaryCurrency
125+
@selectedDividend.AmountPrimaryCurrency?.ToString("F2") @selectedDividend.PrimaryCurrency
126126
}
127127
else
128128
{
@@ -136,7 +136,7 @@
136136
@selectedDividend.DividendPerShare.ToString("F4") @selectedDividend.Currency
137137
@if (selectedDividend.Currency != selectedDividend.PrimaryCurrency)
138138
{
139-
<br /><small class="text-muted">(@selectedDividend.DividendPerSharePrimaryCurrency.ToString("F4") @selectedDividend.PrimaryCurrency)</small>
139+
<br /><small class="text-muted">(@selectedDividend.DividendPerSharePrimaryCurrency?.ToString("F4") @selectedDividend.PrimaryCurrency)</small>
140140
}
141141
</dd>
142142
<dt class="col-5">Status</dt>
@@ -161,16 +161,3 @@
161161
<div class="modal-backdrop fade show"></div>
162162
}
163163

164-
@code {
165-
private dynamic? selectedDividend;
166-
167-
private void ShowDetails(dynamic div)
168-
{
169-
selectedDividend = div;
170-
}
171-
172-
private void CloseDetails()
173-
{
174-
selectedDividend = null;
175-
}
176-
}

PortfolioViewer/PortfolioViewer.WASM/Pages/UpcomingDividends.razor.cs

Lines changed: 57 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -31,40 +31,40 @@ protected override async Task OnInitializedAsync()
3131
}
3232

3333
private void BuildChart()
34-
{
35-
if (dividends == null || dividends.Count == 0)
36-
return;
34+
{
35+
if (dividends == null || dividends.Count == 0)
36+
return;
3737

38-
var allMonths = dividends
39-
.Select(d => (d.PaymentDate.Year, d.PaymentDate.Month))
40-
.Distinct()
41-
.OrderBy(m => m.Year).ThenBy(m => m.Month)
42-
.ToList();
38+
var allMonths = dividends
39+
.Select(d => (d.PaymentDate.Year, d.PaymentDate.Month))
40+
.Distinct()
41+
.OrderBy(m => m.Year).ThenBy(m => m.Month)
42+
.ToList();
4343

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

48-
var confirmed = dividends.Where(d => !d.IsPredicted).ToList();
49-
var predicted = dividends.Where(d => d.IsPredicted).ToList();
48+
var confirmed = dividends.Where(d => !d.IsPredicted).ToList();
49+
var predicted = dividends.Where(d => d.IsPredicted).ToList();
5050

51-
var confirmedY = allMonths
52-
.Select(m => (object)confirmed
53-
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
54-
.Sum(d => d.AmountPrimaryCurrency))
55-
.ToArray();
51+
var confirmedY = allMonths
52+
.Select(m => (object)confirmed
53+
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
54+
.Sum(d => d.AmountPrimaryCurrency ?? 0))
55+
.ToArray();
5656

57-
var predictedY = allMonths
58-
.Select(m => (object)predicted
59-
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
60-
.Sum(d => d.AmountPrimaryCurrency))
61-
.ToArray();
57+
var predictedY = allMonths
58+
.Select(m => (object)predicted
59+
.Where(d => d.PaymentDate.Year == m.Year && d.PaymentDate.Month == m.Month)
60+
.Sum(d => d.AmountPrimaryCurrency ?? 0))
61+
.ToArray();
6262

63-
var currencySymbol = ServerConfigurationService.PrimaryCurrency.Symbol;
63+
var currencySymbol = ServerConfigurationService.PrimaryCurrency.Symbol;
6464

65-
chartData =
66-
[
67-
new Bar
65+
chartData =
66+
[
67+
new Bar
6868
{
6969
X = monthLabels,
7070
Y = confirmedY,
@@ -78,26 +78,38 @@ private void BuildChart()
7878
Name = "Predicted",
7979
Marker = new Plotly.Blazor.Traces.BarLib.Marker { Color = "#adb5bd" }
8080
}
81-
];
82-
83-
chartLayout = new Plotly.Blazor.Layout
84-
{
85-
Title = new Plotly.Blazor.LayoutLib.Title { Text = "Expected Monthly Dividends" },
86-
XAxis = [new Plotly.Blazor.LayoutLib.XAxis { Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = "Month" } }],
87-
YAxis = [new Plotly.Blazor.LayoutLib.YAxis { Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = $"Amount ({currencySymbol})" } }],
88-
BarMode = Plotly.Blazor.LayoutLib.BarModeEnum.Stack,
89-
Margin = new Plotly.Blazor.LayoutLib.Margin { T = 40, L = 60, R = 30, B = 40 },
90-
AutoSize = true,
91-
ShowLegend = true,
92-
Legend =
93-
[
94-
new Plotly.Blazor.LayoutLib.Legend
81+
];
82+
83+
chartLayout = new Plotly.Blazor.Layout
84+
{
85+
Title = new Plotly.Blazor.LayoutLib.Title { Text = "Expected Monthly Dividends" },
86+
XAxis = [new Plotly.Blazor.LayoutLib.XAxis { Title = new Plotly.Blazor.LayoutLib.XAxisLib.Title { Text = "Month" } }],
87+
YAxis = [new Plotly.Blazor.LayoutLib.YAxis { Title = new Plotly.Blazor.LayoutLib.YAxisLib.Title { Text = $"Amount ({currencySymbol})" } }],
88+
BarMode = Plotly.Blazor.LayoutLib.BarModeEnum.Stack,
89+
Margin = new Plotly.Blazor.LayoutLib.Margin { T = 40, L = 60, R = 30, B = 40 },
90+
AutoSize = true,
91+
ShowLegend = true,
92+
Legend =
93+
[
94+
new Plotly.Blazor.LayoutLib.Legend
9595
{
9696
Orientation = Plotly.Blazor.LayoutLib.LegendLib.OrientationEnum.H,
9797
}
98-
]
99-
};
100-
chartConfig = new Config { Responsive = true };
101-
}
98+
]
99+
};
100+
chartConfig = new Config { Responsive = true };
101+
}
102+
103+
private UpcomingDividendModel? selectedDividend;
104+
105+
private void ShowDetails(UpcomingDividendModel div)
106+
{
107+
selectedDividend = div;
108+
}
109+
110+
private void CloseDetails()
111+
{
112+
selectedDividend = null;
113+
}
102114
}
103115
}

0 commit comments

Comments
 (0)