diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..8ea8b51a0 --- /dev/null +++ b/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 diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts index e87ec0e51..e76dcfd58 100644 --- a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -119,6 +119,7 @@ export abstract class PortfolioCalculator { this.activities = activities .map( ({ + currency, date, feeInAssetProfileCurrency, feeInBaseCurrency, @@ -139,6 +140,7 @@ export abstract class PortfolioCalculator { } return { + currency, SymbolProfile, tags, type, @@ -186,7 +188,7 @@ export abstract class PortfolioCalculator { if (!transactionPoints.length) { return { activitiesCount: 0, - annualizedDividendYield: 0, + dividendYieldTrailingTwelveMonths: 0, createdAt: new Date(), currentValueInBaseCurrency: new Big(0), 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 dividendsLast12Months = this.activities .filter(({ SymbolProfile, type, date }) => { return ( SymbolProfile.symbol === item.symbol && type === 'DIVIDEND' && - new Date(date) >= twelveMonthsAgo && - new Date(date) <= this.endDate + isWithinInterval(new Date(date), { + start: twelveMonthsAgo, + end: this.endDate + }) ); }) .reduce((sum, activity) => { + const activityCurrency = + activity.currency ?? activity.SymbolProfile.currency; const exchangeRate = - exchangeRatesByCurrency[ - `${activity.SymbolProfile.currency}${this.currency}` - ]?.[format(new Date(activity.date), DATE_FORMAT)] ?? 1; + exchangeRatesByCurrency[`${activityCurrency}${this.currency}`]?.[ + format(new Date(activity.date), DATE_FORMAT) + ] ?? 1; const dividendAmount = activity.quantity.mul(activity.unitPrice); return sum.plus(dividendAmount.mul(exchangeRate)); }, new Big(0)); - const annualizedDividendYield = totalInvestmentWithCurrencyEffect.gt(0) - ? dividendsLast12Months - .div(totalInvestmentWithCurrencyEffect) - .toNumber() - : 0; + const dividendYieldTrailingTwelveMonths = + totalInvestmentWithCurrencyEffect.gt(0) + ? dividendsLast12Months + .div(totalInvestmentWithCurrencyEffect) + .toNumber() + : 0; positions.push({ includeInTotalAssetValue, timeWeightedInvestment, timeWeightedInvestmentWithCurrencyEffect, activitiesCount: item.activitiesCount, - annualizedDividendYield, + dividendYieldTrailingTwelveMonths, averagePrice: item.averagePrice, currency: item.currency, dataSource: item.dataSource, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts index fc046eb98..d9cb4f808 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -231,7 +231,7 @@ describe('PortfolioCalculator', () => { */ expect(position).toMatchObject({ activitiesCount: 2, - annualizedDividendYield: 0, + dividendYieldTrailingTwelveMonths: 0, averagePrice: new Big(1), currency: 'USD', dataSource: DataSource.YAHOO, diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts index e2f1deec2..1725ef046 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts +++ b/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]; - expect(position).toHaveProperty('annualizedDividendYield'); - expect(position.annualizedDividendYield).toBeGreaterThan(0); + expect(position).toHaveProperty('dividendYieldTrailingTwelveMonths'); + expect(position.dividendYieldTrailingTwelveMonths).toBeGreaterThan(0); // Verify that the snapshot data is sufficient for portfolio summary calculation // Portfolio summary annualized dividend yield = totalDividend / totalInvestment @@ -305,7 +305,7 @@ describe('PortfolioCalculator', () => { .div(position.investmentWithCurrencyEffect) .toNumber(); - expect(position.annualizedDividendYield).toBeCloseTo( + expect(position.dividendYieldTrailingTwelveMonths).toBeCloseTo( expectedPortfolioYield, 10 ); @@ -501,12 +501,18 @@ describe('PortfolioCalculator', () => { // MSFT: 2.60 dividends / 300 investment = 0.00867 (0.867%) expect(msftPosition.dividendInBaseCurrency).toEqual(new Big('2.6')); 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%) expect(ibmPosition.dividendInBaseCurrency).toEqual(new Big('6.6')); 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%) const totalDividends = new Big(msftPosition.dividendInBaseCurrency).plus( @@ -519,9 +525,14 @@ describe('PortfolioCalculator', () => { expect(totalDividends.toNumber()).toBe(9.2); expect(totalInvestment.toNumber()).toBe(500); - // Test that portfolioSnapshot has aggregated annualizedDividendYield - expect(portfolioSnapshot).toHaveProperty('annualizedDividendYield'); - expect(portfolioSnapshot.annualizedDividendYield).toBeCloseTo(0.0184, 4); + // Test that portfolioSnapshot has aggregated dividendYieldTrailingTwelveMonths + expect(portfolioSnapshot).toHaveProperty( + 'dividendYieldTrailingTwelveMonths' + ); + expect(portfolioSnapshot.dividendYieldTrailingTwelveMonths).toBeCloseTo( + 0.0184, + 4 + ); }); 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 totalInvestment = new Big('500'); - expect(msftPosition.annualizedDividendYield).toBeCloseTo( + expect(msftPosition.dividendYieldTrailingTwelveMonths).toBeCloseTo( msftDividendLast12Months.div(new Big('300')).toNumber(), 6 ); - expect(ibmPosition.annualizedDividendYield).toBeCloseTo( + expect(ibmPosition.dividendYieldTrailingTwelveMonths).toBeCloseTo( ibmDividendLast12Months.div(new Big('200')).toNumber(), 6 ); @@ -681,7 +692,7 @@ describe('PortfolioCalculator', () => { .div(totalInvestment) .toNumber(); - expect(portfolioSnapshot.annualizedDividendYield).toBeCloseTo( + expect(portfolioSnapshot.dividendYieldTrailingTwelveMonths).toBeCloseTo( expectedAnnualizedDividendYield, 6 ); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts index a971d1c8e..b20200766 100644 --- a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts +++ b/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 hasErrors = false; let netPerformance = new Big(0); - let totalDividendsLast12MonthsInBaseCurrency = new Big(0); + let totalDividendsTrailingTwelveMonthsInBaseCurrency = new Big(0); let totalFeesWithCurrencyEffect = new Big(0); const totalInterestWithCurrencyEffect = new Big(0); let totalInvestment = new Big(0); @@ -48,9 +48,9 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } )) { if (currentPosition.investmentWithCurrencyEffect) { - totalDividendsLast12MonthsInBaseCurrency = - totalDividendsLast12MonthsInBaseCurrency.plus( - new Big(currentPosition.annualizedDividendYield ?? 0).mul( + totalDividendsTrailingTwelveMonthsInBaseCurrency = + totalDividendsTrailingTwelveMonthsInBaseCurrency.plus( + new Big(currentPosition.dividendYieldTrailingTwelveMonths ?? 0).mul( currentPosition.investmentWithCurrencyEffect ) ); @@ -115,12 +115,13 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { } } - // Calculate annualized dividend yield for the entire portfolio - const annualizedDividendYield = totalInvestmentWithCurrencyEffect.gt(0) - ? totalDividendsLast12MonthsInBaseCurrency - .div(totalInvestmentWithCurrencyEffect) - .toNumber() - : 0; + // Calculate dividend yield for the entire portfolio based on trailing twelve months + const dividendYieldTrailingTwelveMonths = + totalInvestmentWithCurrencyEffect.gt(0) + ? totalDividendsTrailingTwelveMonthsInBaseCurrency + .div(totalInvestmentWithCurrencyEffect) + .toNumber() + : 0; return { currentValueInBaseCurrency, @@ -133,7 +134,7 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator { activitiesCount: this.activities.filter(({ type }) => { return ['BUY', 'SELL'].includes(type); }).length, - annualizedDividendYield, + dividendYieldTrailingTwelveMonths, createdAt: new Date(), errors: [], historicalData: [], diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts index 2dbd68f12..96e116e9a 100644 --- a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -1,6 +1,7 @@ import { Activity } from '@ghostfolio/common/interfaces'; export interface PortfolioOrder extends Pick { + currency?: string; date: string; fee: Big; feeInBaseCurrency: Big; diff --git a/apps/api/src/app/portfolio/portfolio.service.spec.ts b/apps/api/src/app/portfolio/portfolio.service.spec.ts index 3fe6438dd..3c7a293c5 100644 --- a/apps/api/src/app/portfolio/portfolio.service.spec.ts +++ b/apps/api/src/app/portfolio/portfolio.service.spec.ts @@ -24,7 +24,7 @@ describe('PortfolioService', () => { jest.restoreAllMocks(); }); - it('returns annualizedDividendYield from the calculator snapshot', async () => { + it('returns dividendYieldTrailingTwelveMonths from the calculator snapshot', async () => { const activities: Activity[] = [ { ...activityDummyData, @@ -111,7 +111,7 @@ describe('PortfolioService', () => { getInterestInBaseCurrency: jest.fn().mockResolvedValue(new Big(1)), getLiabilitiesInBaseCurrency: jest.fn().mockResolvedValue(new Big(6)), getSnapshot: jest.fn().mockResolvedValue({ - annualizedDividendYield: 0.0123, + dividendYieldTrailingTwelveMonths: 0.0123, currentValueInBaseCurrency: new Big(500), totalInvestment: new Big(400) }), @@ -155,7 +155,7 @@ describe('PortfolioService', () => { expect(portfolioCalculator.getSnapshot).toHaveBeenCalledTimes(1); expect(summary).toMatchObject({ - annualizedDividendYield: 0.0123, + dividendYieldTrailingTwelveMonths: 0.0123, cash: 1000, committedFunds: 60, dividendInBaseCurrency: 12, diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts index 4052c3515..a288dc60f 100644 --- a/apps/api/src/app/portfolio/portfolio.service.ts +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -1861,7 +1861,7 @@ export class PortfolioService { } const { - annualizedDividendYield, + dividendYieldTrailingTwelveMonths, currentValueInBaseCurrency, totalInvestment } = await portfolioCalculator.getSnapshot(); @@ -1962,7 +1962,7 @@ export class PortfolioService { })?.toNumber(); return { - annualizedDividendYield, + dividendYieldTrailingTwelveMonths, annualizedPerformancePercent, annualizedPerformancePercentWithCurrencyEffect, cash, diff --git a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html index 0ea84e84c..116690c09 100644 --- a/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html +++ b/apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html @@ -376,8 +376,10 @@ @if (user?.settings?.isExperimentalFeatures) {
-
- Annualized Dividend Yield +
+ Dividend Yield (Trailing Twelve Months)
diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts index 3db88761a..532870791 100644 --- a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -3,7 +3,7 @@ import { PortfolioPerformance } from './portfolio-performance.interface'; export interface PortfolioSummary extends PortfolioPerformance { activityCount: number; - annualizedDividendYield: number; + dividendYieldTrailingTwelveMonths: number; annualizedPerformancePercent: number; annualizedPerformancePercentWithCurrencyEffect: number; cash: number; diff --git a/libs/common/src/lib/models/portfolio-snapshot.ts b/libs/common/src/lib/models/portfolio-snapshot.ts index f4cad3bdc..33bab356c 100644 --- a/libs/common/src/lib/models/portfolio-snapshot.ts +++ b/libs/common/src/lib/models/portfolio-snapshot.ts @@ -10,7 +10,7 @@ import { Transform, Type } from 'class-transformer'; export class PortfolioSnapshot { activitiesCount: number; - annualizedDividendYield: number; + dividendYieldTrailingTwelveMonths: number; createdAt: Date; diff --git a/libs/common/src/lib/models/timeline-position.ts b/libs/common/src/lib/models/timeline-position.ts index 058cd8ac3..ba4a93482 100644 --- a/libs/common/src/lib/models/timeline-position.ts +++ b/libs/common/src/lib/models/timeline-position.ts @@ -10,7 +10,7 @@ import { Transform, Type } from 'class-transformer'; export class TimelinePosition { activitiesCount: number; - annualizedDividendYield: number; + dividendYieldTrailingTwelveMonths: number; @Transform(transformToBig, { toClassOnly: true }) @Type(() => Big)