Browse Source

renaming annualizedDividendYield

pull/6258/head
Sven Günther 5 days ago
parent
commit
86b5bef849
  1. 119
      CLAUDE.md
  2. 33
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  3. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts
  4. 33
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  5. 23
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  6. 1
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  7. 6
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  8. 4
      apps/api/src/app/portfolio/portfolio.service.ts
  9. 10
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  10. 2
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  11. 2
      libs/common/src/lib/models/portfolio-snapshot.ts
  12. 2
      libs/common/src/lib/models/timeline-position.ts

119
CLAUDE.md

@ -0,0 +1,119 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Ghostfolio is an open source wealth management software built with TypeScript in an Nx monorepo workspace. It's a full-stack application with Angular frontend and NestJS backend, using PostgreSQL with Prisma ORM and Redis for caching.
## Development Commands
### Environment Setup
```bash
npm install
docker compose -f docker/docker-compose.dev.yml up -d # Start PostgreSQL and Redis
npm run database:setup # Initialize database schema
```
### Development Servers
```bash
npm run start:server # Start NestJS API server
npm run start:client # Start Angular client (English)
npm run watch:server # Start server in watch mode for debugging
```
### Build and Production
```bash
npm run build:production # Build both API and client for production
npm run start:production # Run production build with database migration
```
### Database Operations
```bash
npm run database:push # Sync schema with database (development)
npm run database:migrate # Apply migrations (production)
npm run database:seed # Seed database with initial data
npm run database:gui # Open Prisma Studio
npm run database:format-schema # Format Prisma schema
npm run database:generate-typings # Generate Prisma client
```
### Testing and Quality
```bash
npm test # Run all tests (API + common)
npm run test:api # Run API tests only
npm run test:common # Run common library tests
npm run test:single # Run single test file (example provided)
npm run lint # Run ESLint on all projects
npm run format # Format code with Prettier
npm run format:check # Check code formatting
```
### Nx Workspace Commands
```bash
nx affected:build # Build affected projects
nx affected:test # Test affected projects
nx affected:lint # Lint affected projects
nx dep-graph # View dependency graph
```
### Storybook (Component Library)
```bash
npm run start:storybook # Start Storybook development server
npm run build:storybook # Build Storybook for production
```
## Architecture
### Monorepo Structure
- **apps/api**: NestJS backend application
- **apps/client**: Angular frontend application
- **apps/client-e2e**: E2E tests for client
- **apps/ui-e2e**: E2E tests for UI components
- **libs/common**: Shared TypeScript libraries and utilities
- **libs/ui**: Angular UI component library
### Technology Stack
- **Frontend**: Angular 20 with Angular Material, Bootstrap utility classes
- **Backend**: NestJS with TypeScript
- **Database**: PostgreSQL with Prisma ORM
- **Caching**: Redis with Bull for job queues
- **Build Tool**: Nx workspace
- **Testing**: Jest for unit tests, Cypress for E2E tests
### Key Dependencies
- **Authentication**: Passport (JWT, Google OAuth, WebAuthn)
- **Data Sources**: Yahoo Finance, CoinGecko APIs for market data
- **Charts**: Chart.js with various plugins
- **Payment**: Stripe integration
- **Internationalization**: Angular i18n with multiple language support
### Database Schema
The Prisma schema defines models for:
- User management and access control
- Account and portfolio tracking
- Trading activities and orders
- Market data and asset information
- Platform integrations
### Development Notes
- Node.js version >=22.18.0 required
- Uses Nx generators for consistent code scaffolding
- Husky for git hooks and code quality enforcement
- Environment files: `.env.dev` for development, `.env.example` as template
- SSL certificates can be generated for localhost development
- Experimental features can be toggled via user settings
- always run the .husky pre commit hooks after code changes

33
apps/api/src/app/portfolio/calculator/portfolio-calculator.ts

@ -119,6 +119,7 @@ export abstract class PortfolioCalculator {
this.activities = activities this.activities = activities
.map( .map(
({ ({
currency,
date, date,
feeInAssetProfileCurrency, feeInAssetProfileCurrency,
feeInBaseCurrency, feeInBaseCurrency,
@ -139,6 +140,7 @@ export abstract class PortfolioCalculator {
} }
return { return {
currency,
SymbolProfile, SymbolProfile,
tags, tags,
type, type,
@ -186,7 +188,7 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) { if (!transactionPoints.length) {
return { return {
activitiesCount: 0, activitiesCount: 0,
annualizedDividendYield: 0, dividendYieldTrailingTwelveMonths: 0,
createdAt: new Date(), createdAt: new Date(),
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
errors: [], errors: [],
@ -405,38 +407,43 @@ export abstract class PortfolioCalculator {
}; };
} }
// Calculate annualized dividend yield based on investment (cost basis) // Calculate dividend yield based on trailing twelve months of dividends and investment (cost basis)
const twelveMonthsAgo = subYears(this.endDate, 1); const twelveMonthsAgo = subYears(this.endDate, 1);
const dividendsLast12Months = this.activities const dividendsLast12Months = this.activities
.filter(({ SymbolProfile, type, date }) => { .filter(({ SymbolProfile, type, date }) => {
return ( return (
SymbolProfile.symbol === item.symbol && SymbolProfile.symbol === item.symbol &&
type === 'DIVIDEND' && type === 'DIVIDEND' &&
new Date(date) >= twelveMonthsAgo && isWithinInterval(new Date(date), {
new Date(date) <= this.endDate start: twelveMonthsAgo,
end: this.endDate
})
); );
}) })
.reduce((sum, activity) => { .reduce((sum, activity) => {
const activityCurrency =
activity.currency ?? activity.SymbolProfile.currency;
const exchangeRate = const exchangeRate =
exchangeRatesByCurrency[ exchangeRatesByCurrency[`${activityCurrency}${this.currency}`]?.[
`${activity.SymbolProfile.currency}${this.currency}` format(new Date(activity.date), DATE_FORMAT)
]?.[format(new Date(activity.date), DATE_FORMAT)] ?? 1; ] ?? 1;
const dividendAmount = activity.quantity.mul(activity.unitPrice); const dividendAmount = activity.quantity.mul(activity.unitPrice);
return sum.plus(dividendAmount.mul(exchangeRate)); return sum.plus(dividendAmount.mul(exchangeRate));
}, new Big(0)); }, new Big(0));
const annualizedDividendYield = totalInvestmentWithCurrencyEffect.gt(0) const dividendYieldTrailingTwelveMonths =
? dividendsLast12Months totalInvestmentWithCurrencyEffect.gt(0)
.div(totalInvestmentWithCurrencyEffect) ? dividendsLast12Months
.toNumber() .div(totalInvestmentWithCurrencyEffect)
: 0; .toNumber()
: 0;
positions.push({ positions.push({
includeInTotalAssetValue, includeInTotalAssetValue,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
activitiesCount: item.activitiesCount, activitiesCount: item.activitiesCount,
annualizedDividendYield, dividendYieldTrailingTwelveMonths,
averagePrice: item.averagePrice, averagePrice: item.averagePrice,
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,

2
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts

@ -231,7 +231,7 @@ describe('PortfolioCalculator', () => {
*/ */
expect(position).toMatchObject<TimelinePosition>({ expect(position).toMatchObject<TimelinePosition>({
activitiesCount: 2, activitiesCount: 2,
annualizedDividendYield: 0, dividendYieldTrailingTwelveMonths: 0,
averagePrice: new Big(1), averagePrice: new Big(1),
currency: 'USD', currency: 'USD',
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,

33
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -296,8 +296,8 @@ describe('PortfolioCalculator', () => {
}); });
const position = portfolioSnapshot.positions[0]; const position = portfolioSnapshot.positions[0];
expect(position).toHaveProperty('annualizedDividendYield'); expect(position).toHaveProperty('dividendYieldTrailingTwelveMonths');
expect(position.annualizedDividendYield).toBeGreaterThan(0); expect(position.dividendYieldTrailingTwelveMonths).toBeGreaterThan(0);
// Verify that the snapshot data is sufficient for portfolio summary calculation // Verify that the snapshot data is sufficient for portfolio summary calculation
// Portfolio summary annualized dividend yield = totalDividend / totalInvestment // Portfolio summary annualized dividend yield = totalDividend / totalInvestment
@ -305,7 +305,7 @@ describe('PortfolioCalculator', () => {
.div(position.investmentWithCurrencyEffect) .div(position.investmentWithCurrencyEffect)
.toNumber(); .toNumber();
expect(position.annualizedDividendYield).toBeCloseTo( expect(position.dividendYieldTrailingTwelveMonths).toBeCloseTo(
expectedPortfolioYield, expectedPortfolioYield,
10 10
); );
@ -501,12 +501,18 @@ describe('PortfolioCalculator', () => {
// MSFT: 2.60 dividends / 300 investment = 0.00867 (0.867%) // MSFT: 2.60 dividends / 300 investment = 0.00867 (0.867%)
expect(msftPosition.dividendInBaseCurrency).toEqual(new Big('2.6')); expect(msftPosition.dividendInBaseCurrency).toEqual(new Big('2.6'));
expect(msftPosition.investmentWithCurrencyEffect).toEqual(new Big('300')); expect(msftPosition.investmentWithCurrencyEffect).toEqual(new Big('300'));
expect(msftPosition.annualizedDividendYield).toBeCloseTo(2.6 / 300, 5); expect(msftPosition.dividendYieldTrailingTwelveMonths).toBeCloseTo(
2.6 / 300,
5
);
// IBM: 6.60 dividends / 200 investment = 0.033 (3.3%) // IBM: 6.60 dividends / 200 investment = 0.033 (3.3%)
expect(ibmPosition.dividendInBaseCurrency).toEqual(new Big('6.6')); expect(ibmPosition.dividendInBaseCurrency).toEqual(new Big('6.6'));
expect(ibmPosition.investmentWithCurrencyEffect).toEqual(new Big('200')); expect(ibmPosition.investmentWithCurrencyEffect).toEqual(new Big('200'));
expect(ibmPosition.annualizedDividendYield).toBeCloseTo(6.6 / 200, 5); expect(ibmPosition.dividendYieldTrailingTwelveMonths).toBeCloseTo(
6.6 / 200,
5
);
// Portfolio-wide: (2.60 + 6.60) / (300 + 200) = 9.20 / 500 = 0.0184 (1.84%) // Portfolio-wide: (2.60 + 6.60) / (300 + 200) = 9.20 / 500 = 0.0184 (1.84%)
const totalDividends = new Big(msftPosition.dividendInBaseCurrency).plus( const totalDividends = new Big(msftPosition.dividendInBaseCurrency).plus(
@ -519,9 +525,14 @@ describe('PortfolioCalculator', () => {
expect(totalDividends.toNumber()).toBe(9.2); expect(totalDividends.toNumber()).toBe(9.2);
expect(totalInvestment.toNumber()).toBe(500); expect(totalInvestment.toNumber()).toBe(500);
// Test that portfolioSnapshot has aggregated annualizedDividendYield // Test that portfolioSnapshot has aggregated dividendYieldTrailingTwelveMonths
expect(portfolioSnapshot).toHaveProperty('annualizedDividendYield'); expect(portfolioSnapshot).toHaveProperty(
expect(portfolioSnapshot.annualizedDividendYield).toBeCloseTo(0.0184, 4); 'dividendYieldTrailingTwelveMonths'
);
expect(portfolioSnapshot.dividendYieldTrailingTwelveMonths).toBeCloseTo(
0.0184,
4
);
}); });
it('ignores dividends older than 12 months when aggregating portfolio yield', async () => { it('ignores dividends older than 12 months when aggregating portfolio yield', async () => {
@ -667,11 +678,11 @@ describe('PortfolioCalculator', () => {
const ibmDividendLast12Months = new Big('1.65'); const ibmDividendLast12Months = new Big('1.65');
const totalInvestment = new Big('500'); const totalInvestment = new Big('500');
expect(msftPosition.annualizedDividendYield).toBeCloseTo( expect(msftPosition.dividendYieldTrailingTwelveMonths).toBeCloseTo(
msftDividendLast12Months.div(new Big('300')).toNumber(), msftDividendLast12Months.div(new Big('300')).toNumber(),
6 6
); );
expect(ibmPosition.annualizedDividendYield).toBeCloseTo( expect(ibmPosition.dividendYieldTrailingTwelveMonths).toBeCloseTo(
ibmDividendLast12Months.div(new Big('200')).toNumber(), ibmDividendLast12Months.div(new Big('200')).toNumber(),
6 6
); );
@ -681,7 +692,7 @@ describe('PortfolioCalculator', () => {
.div(totalInvestment) .div(totalInvestment)
.toNumber(); .toNumber();
expect(portfolioSnapshot.annualizedDividendYield).toBeCloseTo( expect(portfolioSnapshot.dividendYieldTrailingTwelveMonths).toBeCloseTo(
expectedAnnualizedDividendYield, expectedAnnualizedDividendYield,
6 6
); );

23
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -34,7 +34,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
let grossPerformanceWithCurrencyEffect = new Big(0); let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false; let hasErrors = false;
let netPerformance = new Big(0); let netPerformance = new Big(0);
let totalDividendsLast12MonthsInBaseCurrency = new Big(0); let totalDividendsTrailingTwelveMonthsInBaseCurrency = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0); let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0); const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
@ -48,9 +48,9 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
} }
)) { )) {
if (currentPosition.investmentWithCurrencyEffect) { if (currentPosition.investmentWithCurrencyEffect) {
totalDividendsLast12MonthsInBaseCurrency = totalDividendsTrailingTwelveMonthsInBaseCurrency =
totalDividendsLast12MonthsInBaseCurrency.plus( totalDividendsTrailingTwelveMonthsInBaseCurrency.plus(
new Big(currentPosition.annualizedDividendYield ?? 0).mul( new Big(currentPosition.dividendYieldTrailingTwelveMonths ?? 0).mul(
currentPosition.investmentWithCurrencyEffect currentPosition.investmentWithCurrencyEffect
) )
); );
@ -115,12 +115,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
} }
} }
// Calculate annualized dividend yield for the entire portfolio // Calculate dividend yield for the entire portfolio based on trailing twelve months
const annualizedDividendYield = totalInvestmentWithCurrencyEffect.gt(0) const dividendYieldTrailingTwelveMonths =
? totalDividendsLast12MonthsInBaseCurrency totalInvestmentWithCurrencyEffect.gt(0)
.div(totalInvestmentWithCurrencyEffect) ? totalDividendsTrailingTwelveMonthsInBaseCurrency
.toNumber() .div(totalInvestmentWithCurrencyEffect)
: 0; .toNumber()
: 0;
return { return {
currentValueInBaseCurrency, currentValueInBaseCurrency,
@ -133,7 +134,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
activitiesCount: this.activities.filter(({ type }) => { activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type); return ['BUY', 'SELL'].includes(type);
}).length, }).length,
annualizedDividendYield, dividendYieldTrailingTwelveMonths,
createdAt: new Date(), createdAt: new Date(),
errors: [], errors: [],
historicalData: [], historicalData: [],

1
apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts

@ -1,6 +1,7 @@
import { Activity } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> { export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
currency?: string;
date: string; date: string;
fee: Big; fee: Big;
feeInBaseCurrency: Big; feeInBaseCurrency: Big;

6
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -24,7 +24,7 @@ describe('PortfolioService', () => {
jest.restoreAllMocks(); jest.restoreAllMocks();
}); });
it('returns annualizedDividendYield from the calculator snapshot', async () => { it('returns dividendYieldTrailingTwelveMonths from the calculator snapshot', async () => {
const activities: Activity[] = [ const activities: Activity[] = [
{ {
...activityDummyData, ...activityDummyData,
@ -111,7 +111,7 @@ describe('PortfolioService', () => {
getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(1)), getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(1)),
getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(6)), getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(6)),
getSnapshot: jest.fn().mockResolvedValue({ getSnapshot: jest.fn().mockResolvedValue({
annualizedDividendYield: 0.0123, dividendYieldTrailingTwelveMonths: 0.0123,
currentValueInBaseCurrency: new Big(500), currentValueInBaseCurrency: new Big(500),
totalInvestment: new Big(400) totalInvestment: new Big(400)
}), }),
@ -155,7 +155,7 @@ describe('PortfolioService', () => {
expect(portfolioCalculator.getSnapshot).toHaveBeenCalledTimes(1); expect(portfolioCalculator.getSnapshot).toHaveBeenCalledTimes(1);
expect(summary).toMatchObject({ expect(summary).toMatchObject({
annualizedDividendYield: 0.0123, dividendYieldTrailingTwelveMonths: 0.0123,
cash: 1000, cash: 1000,
committedFunds: 60, committedFunds: 60,
dividendInBaseCurrency: 12, dividendInBaseCurrency: 12,

4
apps/api/src/app/portfolio/portfolio.service.ts

@ -1861,7 +1861,7 @@ export class PortfolioService {
} }
const { const {
annualizedDividendYield, dividendYieldTrailingTwelveMonths,
currentValueInBaseCurrency, currentValueInBaseCurrency,
totalInvestment totalInvestment
} = await portfolioCalculator.getSnapshot(); } = await portfolioCalculator.getSnapshot();
@ -1962,7 +1962,7 @@ export class PortfolioService {
})?.toNumber(); })?.toNumber();
return { return {
annualizedDividendYield, dividendYieldTrailingTwelveMonths,
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect, annualizedPerformancePercentWithCurrencyEffect,
cash, cash,

10
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -376,8 +376,10 @@
</div> </div>
@if (user?.settings?.isExperimentalFeatures) { @if (user?.settings?.isExperimentalFeatures) {
<div class="flex-nowrap px-3 py-1 row"> <div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n> <div class="flex-grow-1 ml-3 text-truncate">
Annualized Dividend Yield <ng-container i18n>Dividend Yield</ng-container> (<ng-container i18n
>Trailing Twelve Months</ng-container
>)
</div> </div>
<div class="flex-column flex-wrap justify-content-end"> <div class="flex-column flex-wrap justify-content-end">
<gf-value <gf-value
@ -386,7 +388,9 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : summary?.annualizedDividendYield" [value]="
isLoading ? undefined : summary?.dividendYieldTrailingTwelveMonths
"
/> />
</div> </div>
</div> </div>

2
libs/common/src/lib/interfaces/portfolio-summary.interface.ts

@ -3,7 +3,7 @@ import { PortfolioPerformance } from './portfolio-performance.interface';
export interface PortfolioSummary extends PortfolioPerformance { export interface PortfolioSummary extends PortfolioPerformance {
activityCount: number; activityCount: number;
annualizedDividendYield: number; dividendYieldTrailingTwelveMonths: number;
annualizedPerformancePercent: number; annualizedPerformancePercent: number;
annualizedPerformancePercentWithCurrencyEffect: number; annualizedPerformancePercentWithCurrencyEffect: number;
cash: number; cash: number;

2
libs/common/src/lib/models/portfolio-snapshot.ts

@ -10,7 +10,7 @@ import { Transform, Type } from 'class-transformer';
export class PortfolioSnapshot { export class PortfolioSnapshot {
activitiesCount: number; activitiesCount: number;
annualizedDividendYield: number; dividendYieldTrailingTwelveMonths: number;
createdAt: Date; createdAt: Date;

2
libs/common/src/lib/models/timeline-position.ts

@ -10,7 +10,7 @@ import { Transform, Type } from 'class-transformer';
export class TimelinePosition { export class TimelinePosition {
activitiesCount: number; activitiesCount: number;
annualizedDividendYield: number; dividendYieldTrailingTwelveMonths: number;
@Transform(transformToBig, { toClassOnly: true }) @Transform(transformToBig, { toClassOnly: true })
@Type(() => Big) @Type(() => Big)

Loading…
Cancel
Save