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
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ public static IEndpointRouteBuilder MapInvestments(this IEndpointRouteBuilder ap
q.AllocationAsync(new PortfolioScope(broker, accountId), ct));
portfolio.MapGet("/dividends", (PortfolioQueries q, DataSource? broker, Guid? accountId, DateOnly? from,
DateOnly? to, CancellationToken ct) => q.DividendsAsync(new PortfolioScope(broker, accountId), from, to, ct));
portfolio.MapGet("/dividend-calendar", (PortfolioQueries q, DataSource? broker, Guid? accountId,
CancellationToken ct) => q.DividendCalendarAsync(new PortfolioScope(broker, accountId), ct));
// Either a period (measured as the summary's, up to today's live value) or an explicit from/to range.
portfolio.MapGet("/performance", (PortfolioQueries q, DataSource? broker, Guid? accountId, DateOnly? from,
DateOnly? to, string? period, CancellationToken ct) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,19 @@ public sealed record DividendSummary(decimal TotalNetBase, IReadOnlyList<PeriodA

public sealed record PeriodAmount(string Period, decimal Amount);

/// <summary>
/// What each position paid in each calendar month over the last 12 months (net, EUR), including positions that paid
/// nothing — to see which months are thin and where a payer would even out a monthly income.
/// </summary>
/// <param name="Months">Totals for January … December.</param>
public sealed record DividendCalendar(DateOnly From, DateOnly To, IReadOnlyList<DividendCalendarRow> Rows,
IReadOnlyList<decimal> Months, decimal Total, decimal MonthlyAverage);

/// <param name="Months">What it paid in January … December of the last 12 months (net, EUR).</param>
/// <param name="Yield">The last 12 months' dividends over today's value; null when it is no longer held.</param>
public sealed record DividendCalendarRow(Guid SecurityId, string Symbol, string Name, AssetClass AssetClass,
decimal MarketValueBase, decimal PortfolioWeight, IReadOnlyList<decimal> Months, decimal Total, decimal? Yield);

public sealed record SecurityAmount(string Symbol, string Name, decimal Amount);

/// <param name="ReconstructedBefore">History before this day was rebuilt from transactions and public closing
Expand Down Expand Up @@ -478,6 +491,59 @@ where ids.Contains(d.AccountId)
r.d.WithholdingTax, r.d.NetAmount, r.d.Currency, r.d.NetBaseAmount, r.d.WithholdingDerived)).ToList());
}

/// <summary>
/// The last 12 months of dividends (this month and the 11 before) by calendar month, for every position held
/// (crypto only when it paid) and anything that paid in that time.
/// </summary>
public async Task<DividendCalendar> DividendCalendarAsync(PortfolioScope scope, CancellationToken ct)
{
var end = Today;
var start = new DateOnly(end.Year, end.Month, 1).AddMonths(-11);
var ids = (await BrokerAccountsAsync(scope, ct)).Keys.ToList();
var paid = await (from d in db.Dividends.AsNoTracking()
join s in db.Securities.AsNoTracking() on d.SecurityId equals s.Id
where ids.Contains(d.AccountId) && d.PaidOn >= start && d.PaidOn <= end
select new { d.SecurityId, d.PaidOn.Month, d.NetBaseAmount, s.Symbol, s.Name, s.AssetClass })
.ToListAsync(ct);
var positions = await PositionsAsync(scope, ct);

decimal[] MonthsOf(Guid security)
{
var months = new decimal[12];
foreach (var p in paid.Where(p => p.SecurityId == security))
{
months[p.Month - 1] += p.NetBaseAmount;
}

return months.Select(m => decimal.Round(m, 2)).ToArray();
}

var rows = positions
.Where(p => p.AssetClass != AssetClass.Crypto || paid.Any(d => d.SecurityId == p.SecurityId))
.Select(p =>
{
var months = MonthsOf(p.SecurityId);
var total = months.Sum();
return new DividendCalendarRow(p.SecurityId, p.Symbol, p.Name, p.AssetClass, p.MarketValueBase,
p.PortfolioWeight, months, total,
p.MarketValueBase > 0 ? decimal.Round(total / p.MarketValueBase, 4) : null);
})
.Concat(paid.Where(d => positions.All(p => p.SecurityId != d.SecurityId))
.GroupBy(d => d.SecurityId)
.Select(g =>
{
var months = MonthsOf(g.Key);
return new DividendCalendarRow(g.Key, g.First().Symbol, g.First().Name, g.First().AssetClass, 0, 0,
months, months.Sum(), null);
}))
.OrderByDescending(r => r.Total).ThenByDescending(r => r.MarketValueBase)
.ToList();

var totals = Enumerable.Range(0, 12).Select(i => rows.Sum(r => r.Months[i])).ToList();
var sum = totals.Sum();
return new DividendCalendar(start, end, rows, totals, sum, decimal.Round(sum / 12, 2));
}

public Task<PerformanceReport> PerformanceAsync(PortfolioScope scope, DateOnly? from, DateOnly? to,
CancellationToken ct) => PerformanceAsync(scope, from, to, null, ct);

Expand Down
10 changes: 10 additions & 0 deletions backend/tests/Integration.Tests/PortfolioTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,16 @@ public async Task Demo_brokers_sync_into_a_consolidated_portfolio()
var dividends = await api.GetJsonAsync("/api/portfolio/dividends");
dividends.GetProperty("items")[0].GetProperty("withholdingTax").GetDecimal().ShouldBeGreaterThan(0);

// The calendar: every position, payers or not, by calendar month over the last 12 months.
var calendar = await api.GetJsonAsync("/api/portfolio/dividend-calendar");
var rows = calendar.GetProperty("rows").EnumerateArray().ToList();
rows.Count.ShouldBeGreaterThanOrEqualTo(positions.Count(p => p.GetProperty("assetClass").GetString() != "Crypto"));
rows.ShouldAllBe(r => r.GetProperty("months").GetArrayLength() == 12);
var months = calendar.GetProperty("months").EnumerateArray().Select(m => m.GetDecimal()).ToList();
months.Count.ShouldBe(12);
months.Sum().ShouldBe(calendar.GetProperty("total").GetDecimal());
rows.Sum(r => r.GetProperty("total").GetDecimal()).ShouldBe(calendar.GetProperty("total").GetDecimal());

var netWorth = await api.GetJsonAsync("/api/net-worth");
netWorth.GetProperty("current").GetProperty("investments").GetDecimal()
.ShouldBe(summary.GetProperty("totalValue").GetDecimal());
Expand Down
10 changes: 9 additions & 1 deletion web/public/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,15 @@
"change24h": "24h",
"change24hHint": "Crypto trades around the clock: change over the last 24 hours, as exchanges show it.",
"todayMixedHint": "Shares and ETFs since the previous close; crypto over the last 24 hours.",
"positionsNote": "Gain is since purchase, whatever the period. Today is since the previous close; crypto shows the last 24 hours."
"positionsNote": "Gain is since purchase, whatever the period. Today is since the previous close; crypto shows the last 24 hours.",
"calendar": "Dividend calendar · last 12 months",
"payersOnly": "Only positions that pay",
"calendarSummary": "{{average}} a month on average ({{total}} in 12 months)",
"thinMonths": "thin months: {{months}}",
"calendarTotal": "Total",
"yield": "Yield",
"noDividends": "no dividends",
"calendarHint": "Net dividends received in each calendar month over the last 12 months, in euros. Months under half the average are where a position paying then would even out a monthly income; positions without dividends are listed so you can weigh them too."
},
"netWorth": {
"restricted": "Restricted use",
Expand Down
10 changes: 9 additions & 1 deletion web/public/i18n/pt-PT.json
Original file line number Diff line number Diff line change
Expand Up @@ -659,7 +659,15 @@
"change24h": "24h",
"change24hHint": "As criptomoedas negociam sem parar: variação nas últimas 24 horas, como nas corretoras.",
"todayMixedHint": "Ações e ETFs desde o fecho anterior; cripto nas últimas 24 horas.",
"positionsNote": "O lucro é desde a compra, seja qual for o período. Hoje é desde o fecho anterior; cripto mostra as últimas 24 horas."
"positionsNote": "O lucro é desde a compra, seja qual for o período. Hoje é desde o fecho anterior; cripto mostra as últimas 24 horas.",
"calendar": "Calendário de dividendos · últimos 12 meses",
"payersOnly": "Só posições que pagam",
"calendarSummary": "{{average}} por mês em média ({{total}} em 12 meses)",
"thinMonths": "meses fracos: {{months}}",
"calendarTotal": "Total",
"yield": "Rendimento",
"noDividends": "sem dividendos",
"calendarHint": "Dividendos líquidos recebidos em cada mês do calendário nos últimos 12 meses, em euros. Os meses abaixo de metade da média são onde uma posição que pague nessa altura tornaria o rendimento mensal mais regular; as posições sem dividendos aparecem para as poderes pesar também."
},
"netWorth": {
"restricted": "Uso restrito",
Expand Down
5 changes: 5 additions & 0 deletions web/src/app/core/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
Connection,
SentCoins,
DividendSummary,
DividendCalendar,
CoinMatch,
ManualAsset,
ManualHoldingsView,
Expand Down Expand Up @@ -233,6 +234,10 @@ export class Api {
this.http.get<PositionLine[]>('/api/portfolio/positions', { params: scopeParams(scope) });
allocation = (scope: PortfolioScope = {}) =>
this.http.get<AllocationLine[]>('/api/portfolio/allocation', { params: scopeParams(scope) });
dividendCalendar = (scope: PortfolioScope = {}) =>
this.http.get<DividendCalendar>('/api/portfolio/dividend-calendar', {
params: scopeParams(scope),
});
dividends = (scope: PortfolioScope = {}) =>
this.http.get<DividendSummary>('/api/portfolio/dividends', { params: scopeParams(scope) });
/** The value chart and its returns over a period, measured as the summary's period return (up to the live value). */
Expand Down
22 changes: 22 additions & 0 deletions web/src/app/core/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -766,6 +766,28 @@ export interface DividendSummary {
}[];
}

/** The last 12 months of dividends by calendar month, for every position (payers or not). */
export interface DividendCalendar {
from: string;
to: string;
rows: {
securityId: string;
symbol: string;
name: string;
assetClass: AssetClass;
marketValueBase: number;
portfolioWeight: number;
/** January … December. */
months: number[];
total: number;
yield: number | null;
}[];
/** Totals for January … December. */
months: number[];
total: number;
monthlyAverage: number;
}

export interface PerformanceReport {
from: string;
to: string;
Expand Down
123 changes: 123 additions & 0 deletions web/src/app/features/portfolio/portfolio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,101 @@ type RecentIncome = DividendSummary['items'][number] & { month: boolean; count:
</table>
</div>
</section>

<!-- Dividends by calendar month, every position included: the thin months show where to allocate. -->
@if (calendar.value(); as cal) {
@if (cal.rows.length) {
<section class="card mt-6 overflow-x-auto !p-0" data-testid="dividend-calendar">
<div class="flex flex-wrap items-baseline justify-between gap-2 px-5 pt-5">
<h2 class="card-title !mb-0">
<ng-icon name="lucideCalendarDays" />{{ 'portfolio.calendar' | translate }}
</h2>
<label class="text-muted-foreground flex items-center gap-2 text-xs">
<input
type="checkbox"
[checked]="payersOnly()"
(change)="payersOnly.set($any($event.target).checked)"
/>
{{ 'portfolio.payersOnly' | translate }}
</label>
</div>
<p class="text-muted-foreground px-5 pt-1 text-xs">
{{
'portfolio.calendarSummary'
| translate
: { average: (cal.monthlyAverage | money), total: (cal.total | money) }
}}
@if (thinMonths().length) {
·
<span class="text-amber-700 dark:text-amber-300">{{
'portfolio.thinMonths' | translate: { months: thinMonths().join(', ') }
}}</span>
}
</p>
<table hlmTable class="mt-3 text-xs">
<thead hlmTHead>
<tr hlmTr>
<th hlmTh class="min-w-32"></th>
@for (m of monthNames(); track $index) {
<th hlmTh class="text-right font-medium">{{ m }}</th>
}
<th hlmTh class="text-right">{{ 'portfolio.calendarTotal' | translate }}</th>
<th hlmTh class="text-right">{{ 'portfolio.yield' | translate }}</th>
</tr>
</thead>
<tbody hlmTBody>
@for (r of calendarRows(); track r.securityId) {
<tr hlmTr [class.text-muted-foreground]="!r.total">
<td hlmTd class="font-medium">
<span [title]="r.name">{{ r.symbol }}</span>
@if (!r.total) {
<span class="badge bg-muted ml-1 !px-1.5 !py-0 text-[10px] font-normal">{{
'portfolio.noDividends' | translate
}}</span>
}
</td>
@for (v of r.months; track $index) {
<td
hlmTd
class="num text-right"
[style.background-color]="v ? heat(v, cal) : null"
>
{{ v ? (v | money) : '·' }}
</td>
}
<td hlmTd class="num text-right font-medium">
{{ r.total ? (r.total | money) : '—' }}
</td>
<td hlmTd class="num text-right">
{{ r.yield ? (r.yield | pct: 1) : '—' }}
</td>
</tr>
}
<tr hlmTr class="bg-muted/50 font-medium">
<td hlmTd>{{ 'portfolio.calendarTotal' | translate }}</td>
@for (v of cal.months; track $index) {
<td
hlmTd
[class]="
v < cal.monthlyAverage / 2
? 'num text-right text-amber-700 dark:text-amber-300'
: 'num text-right'
"
>
{{ v | money }}
</td>
}
<td hlmTd class="num text-right">{{ cal.total | money }}</td>
<td hlmTd></td>
</tr>
</tbody>
</table>
<p class="text-muted-foreground px-5 py-3 text-[11px]">
{{ 'portfolio.calendarHint' | translate }}
</p>
</section>
}
}
}
}

Expand Down Expand Up @@ -766,6 +861,34 @@ export class PortfolioComponent {
params: this.key,
stream: ({ params }) => this.api.dividends(params.scope),
});
protected readonly calendar = liveResource({
params: this.key,
stream: ({ params }) => this.api.dividendCalendar(params.scope),
});
protected readonly payersOnly = signal(false);
protected readonly calendarRows = computed(() =>
(this.calendar.value()?.rows ?? []).filter((r) => !this.payersOnly() || r.total > 0),
);
/** "jan", "fev", … in the display language. */
protected readonly monthNames = computed(() => {
const format = new Intl.DateTimeFormat(this.prefs.locale(), { month: 'short' });
return Array.from({ length: 12 }, (_, i) =>
format.format(new Date(2000, i, 1)).replace('.', ''),
);
});
/** Months under half the monthly average: where a payer would even out the income. */
protected readonly thinMonths = computed(() => {
const cal = this.calendar.value();
if (!cal || !cal.total) return [];
return cal.months
.map((v, i) => (v < cal.monthlyAverage / 2 ? this.monthNames()[i] : null))
.filter((m): m is string => !!m);
});
/** A cell's shade: stronger the more it pays, relative to the largest monthly payment. */
protected heat(value: number, cal: { rows: { months: number[] }[] }): string {
const max = Math.max(...cal.rows.flatMap((r) => r.months), 1);
return `color-mix(in oklch, var(--primary) ${Math.round(8 + (value / max) * 32)}%, transparent)`;
}
protected readonly performance = liveResource({
params: () => ({ ...this.key(), period: this.period() }),
stream: ({ params }) => this.api.performance(params.scope, params.period),
Expand Down
Loading