Browse Source

Merge branch 'feature/migrate-blog-page-to-standalone' of https://github.com/HarjobandeepSingh/ghostfolio into feature/migrate-blog-page-to-standalone

pull/5742/head
HarjobandeepSingh 2 weeks ago
parent
commit
ac4e9cf840
  1. 12
      CHANGELOG.md
  2. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  3. 20
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  4. 6
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  5. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  6. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  7. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  8. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  9. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  10. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  11. 2
      apps/api/src/app/portfolio/portfolio.service.ts
  12. 4
      apps/api/src/helper/object.helper.spec.ts
  13. 21
      apps/api/src/services/data-provider/data-provider.service.ts
  14. 7
      apps/api/src/services/data-provider/errors/asset-profile-invalid.error.ts
  15. 77
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  16. 5
      apps/client/src/app/app-routing.module.ts
  17. 2
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  18. 10
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  19. 43
      apps/client/src/app/pages/pricing/pricing-page.html
  20. 17
      apps/client/src/app/pages/resources/glossary/resources-glossary.component.html
  21. 10
      libs/common/src/lib/helper.ts
  22. 2
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  23. 12
      libs/ui/src/lib/activities-table/activities-table.component.html
  24. 20
      libs/ui/src/lib/logo-carousel/logo-carousel.component.scss
  25. 7
      package-lock.json
  26. 1
      package.json
  27. 7
      test/import/ok/novn-buy-and-sell-partially.json
  28. 7
      test/import/ok/novn-buy-and-sell.json

12
CHANGELOG.md

@ -9,14 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Extended the glossary of the resources page by _Stealth Wealth_
- Extended the content of the pricing page
- Added a _Storybook_ story for the holdings table component - Added a _Storybook_ story for the holdings table component
### Changed ### Changed
- Refactored the blog page component to standalone - Refactored the blog page component to standalone
- Disabled the zoom functionality in the _Progressive Web App_ (PWA) - Disabled the zoom functionality in the _Progressive Web App_ (PWA)
- Improved the currency validation in the get asset profiles functionality of the data provider service
- Improved the currency validation in the search functionality of the data provider service
- Optimized the get quotes functionality by utilizing the asset profile resolutions in the _Financial Modeling Prep_ service - Optimized the get quotes functionality by utilizing the asset profile resolutions in the _Financial Modeling Prep_ service
- Extracted the footer to a component - Extracted the footer to a component
- Improved the portfolio calculator unit tests to load the user currency from the exported file
### Fixed
- Respected the include indices flag in the search functionality of the _Financial Modeling Prep_ service
- Fixed an issue where the scroll position was not restored when changing pages
- Fixed the word wrap in the menus of the activities table component
- Fixed the dark mode in the _As seen in_ section on the landing page
## 2.208.0 - 2025-10-11 ## 2.208.0 - 2025-10-11

10
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -1,5 +1,6 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { AssetProfileInvalidError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-invalid.error';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse, DataProviderGhostfolioAssetProfileResponse,
@ -66,7 +67,14 @@ export class GhostfolioController {
}); });
return assetProfile; return assetProfile;
} catch { } catch (error) {
if (error instanceof AssetProfileInvalidError) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR StatusCodes.INTERNAL_SERVER_ERROR

20
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -40,10 +40,7 @@ export class GhostfolioService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public async getAssetProfile({ public async getAssetProfile({ symbol }: GetAssetProfileParams) {
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {}; let result: DataProviderGhostfolioAssetProfileResponse = {};
try { try {
@ -51,12 +48,15 @@ export class GhostfolioService {
for (const dataProviderService of this.getDataProviderServices()) { for (const dataProviderService of this.getDataProviderServices()) {
promises.push( promises.push(
dataProviderService this.dataProviderService
.getAssetProfile({ .getAssetProfiles([
requestTimeout, {
symbol symbol,
}) dataSource: dataProviderService.getName()
.then(async (assetProfile) => { }
])
.then(async (assetProfiles) => {
const assetProfile = assetProfiles[symbol];
const dataSourceOrigin = DataSource.GHOSTFOLIO; const dataSourceOrigin = DataSource.GHOSTFOLIO;
if (assetProfile) { if (assetProfile) {

6
apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts

@ -1,3 +1,5 @@
import { Export } from '@ghostfolio/common/interfaces';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
export const activityDummyData = { export const activityDummyData = {
@ -37,6 +39,6 @@ export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
}; };
export function loadActivityExportFile(filePath: string) { export function loadExportFile(filePath: string): Export {
return JSON.parse(readFileSync(filePath, 'utf8')).activities; return JSON.parse(readFileSync(filePath, 'utf8'));
} }

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadActivityExportFile, loadExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[]; let exportResponse: Export;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json') join(__dirname, '../../../../../../../test/import/ok/btceur.json')
); );
}); });
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in EUR)', async () => { it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({ const activities: Activity[] = exportResponse.activities.map(
...activityDummyData, (activity) => ({
...activity, ...activityDummyData,
date: parseDate(activity.date), ...activity,
feeInAssetProfileCurrency: 4.46, date: parseDate(activity.date),
SymbolProfile: { feeInAssetProfileCurrency: 4.46,
...symbolProfileDummyData, SymbolProfile: {
currency: 'USD', ...symbolProfileDummyData,
dataSource: activity.dataSource, currency: 'USD',
name: 'Bitcoin', dataSource: activity.dataSource,
symbol: activity.symbol name: 'Bitcoin',
}, symbol: activity.symbol
tags: activity.tags?.map((id) => { },
return { id } as Tag; unitPriceInAssetProfileCurrency: 44558.42
}), })
unitPriceInAssetProfileCurrency: 44558.42 );
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: exportResponse.user.settings.currency,
userId: userDummyData.id userId: userDummyData.id
}); });

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadActivityExportFile, loadExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[]; let exportResponse: Export;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json') join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json')
); );
}); });
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD short sell (in USD)', async () => { it.only('with BTCUSD short sell (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({ const activities: Activity[] = exportResponse.activities.map(
...activityDummyData, (activity) => ({
...activity, ...activityDummyData,
date: parseDate(activity.date), ...activity,
feeInAssetProfileCurrency: activity.fee, date: parseDate(activity.date),
SymbolProfile: { feeInAssetProfileCurrency: activity.fee,
...symbolProfileDummyData, SymbolProfile: {
currency: 'USD', ...symbolProfileDummyData,
dataSource: activity.dataSource, currency: 'USD',
name: 'Bitcoin', dataSource: activity.dataSource,
symbol: activity.symbol name: 'Bitcoin',
}, symbol: activity.symbol
tags: activity.tags?.map((id) => { },
return { id } as Tag; unitPriceInAssetProfileCurrency: activity.unitPrice
}), })
unitPriceInAssetProfileCurrency: activity.unitPrice );
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: exportResponse.user.settings.currency,
userId: userDummyData.id userId: userDummyData.id
}); });

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadActivityExportFile, loadExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[]; let exportResponse: Export;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd.json') join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
); );
}); });
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in USD)', async () => { it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({ const activities: Activity[] = exportResponse.activities.map(
...activityDummyData, (activity) => ({
...activity, ...activityDummyData,
date: parseDate(activity.date), ...activity,
feeInAssetProfileCurrency: 4.46, date: parseDate(activity.date),
SymbolProfile: { feeInAssetProfileCurrency: 4.46,
...symbolProfileDummyData, SymbolProfile: {
currency: 'USD', ...symbolProfileDummyData,
dataSource: activity.dataSource, currency: 'USD',
name: 'Bitcoin', dataSource: activity.dataSource,
symbol: activity.symbol name: 'Bitcoin',
}, symbol: activity.symbol
tags: activity.tags?.map((id) => { },
return { id } as Tag; unitPriceInAssetProfileCurrency: 44558.42
}), })
unitPriceInAssetProfileCurrency: 44558.42 );
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: exportResponse.user.settings.currency,
userId: userDummyData.id userId: userDummyData.id
}); });

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadActivityExportFile, loadExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[]; let exportResponse: Export;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( exportResponse = loadExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell-partially.json' '../../../../../../../test/import/ok/novn-buy-and-sell-partially.json'
@ -100,28 +99,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell partially', async () => { it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({ const activities: Activity[] = exportResponse.activities.map(
...activityDummyData, (activity) => ({
...activity, ...activityDummyData,
date: parseDate(activity.date), ...activity,
feeInAssetProfileCurrency: activity.fee, date: parseDate(activity.date),
SymbolProfile: { feeInAssetProfileCurrency: activity.fee,
...symbolProfileDummyData, SymbolProfile: {
currency: activity.currency, ...symbolProfileDummyData,
dataSource: activity.dataSource, currency: activity.currency,
name: 'Novartis AG', dataSource: activity.dataSource,
symbol: activity.symbol name: 'Novartis AG',
}, symbol: activity.symbol
tags: activity.tags?.map((id) => { },
return { id } as Tag; unitPriceInAssetProfileCurrency: activity.unitPrice
}), })
unitPriceInAssetProfileCurrency: activity.unitPrice );
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: exportResponse.user.settings.currency,
userId: userDummyData.id userId: userDummyData.id
}); });

44
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,8 +1,7 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { import {
activityDummyData, activityDummyData,
loadActivityExportFile, loadExportFile,
symbolProfileDummyData, symbolProfileDummyData,
userDummyData userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
@ -16,9 +15,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { join } from 'node:path'; import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
}); });
describe('PortfolioCalculator', () => { describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[]; let exportResponse: Export;
let configurationService: ConfigurationService; let configurationService: ConfigurationService;
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService; let redisCacheService: RedisCacheService;
beforeAll(() => { beforeAll(() => {
activityDtos = loadActivityExportFile( exportResponse = loadExportFile(
join( join(
__dirname, __dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json' '../../../../../../../test/import/ok/novn-buy-and-sell.json'
@ -100,28 +99,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell', async () => { it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({ const activities: Activity[] = exportResponse.activities.map(
...activityDummyData, (activity) => ({
...activity, ...activityDummyData,
date: parseDate(activity.date), ...activity,
feeInAssetProfileCurrency: activity.fee, date: parseDate(activity.date),
SymbolProfile: { feeInAssetProfileCurrency: activity.fee,
...symbolProfileDummyData, SymbolProfile: {
currency: activity.currency, ...symbolProfileDummyData,
dataSource: activity.dataSource, currency: activity.currency,
name: 'Novartis AG', dataSource: activity.dataSource,
symbol: activity.symbol name: 'Novartis AG',
}, symbol: activity.symbol
tags: activity.tags?.map((id) => { },
return { id } as Tag; unitPriceInAssetProfileCurrency: activity.unitPrice
}), })
unitPriceInAssetProfileCurrency: activity.unitPrice );
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.ROAI, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: exportResponse.user.settings.currency,
userId: userDummyData.id userId: userDummyData.id
}); });

2
apps/api/src/app/portfolio/portfolio.controller.ts

@ -197,7 +197,7 @@ export class PortfolioController {
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'interest', 'interestInBaseCurrency',
'items', 'items',
'liabilities', 'liabilities',
'netPerformance', 'netPerformance',

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

@ -2105,7 +2105,7 @@ export class PortfolioService {
) )
.plus(fees) .plus(fees)
.toNumber(), .toNumber(),
interest: interest.toNumber(), interestInBaseCurrency: interest.toNumber(),
liabilitiesInBaseCurrency: liabilities.toNumber(), liabilitiesInBaseCurrency: liabilities.toNumber(),
totalInvestment: totalInvestment.toNumber(), totalInvestment: totalInvestment.toNumber(),
totalValueInBaseCurrency: netWorth totalValueInBaseCurrency: netWorth

4
apps/api/src/helper/object.helper.spec.ts

@ -1536,7 +1536,7 @@ describe('redactAttributes', () => {
fireWealth: null, fireWealth: null,
grossPerformance: null, grossPerformance: null,
grossPerformanceWithCurrencyEffect: null, grossPerformanceWithCurrencyEffect: null,
interest: null, interestInBaseCurrency: null,
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,
@ -3039,7 +3039,7 @@ describe('redactAttributes', () => {
fireWealth: null, fireWealth: null,
grossPerformance: null, grossPerformance: null,
grossPerformanceWithCurrencyEffect: null, grossPerformanceWithCurrencyEffect: null,
interest: null, interestInBaseCurrency: null,
items: null, items: null,
liabilities: null, liabilities: null,
totalInvestment: null, totalInvestment: null,

21
apps/api/src/services/data-provider/data-provider.service.ts

@ -35,6 +35,8 @@ import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error';
@Injectable() @Injectable()
export class DataProviderService implements OnModuleInit { export class DataProviderService implements OnModuleInit {
private dataProviderMapping: { [dataProviderName: string]: string }; private dataProviderMapping: { [dataProviderName: string]: string };
@ -106,9 +108,9 @@ export class DataProviderService implements OnModuleInit {
); );
promises.push( promises.push(
promise.then((symbolProfile) => { promise.then((assetProfile) => {
if (symbolProfile) { if (isCurrency(assetProfile?.currency)) {
response[symbol] = symbolProfile; response[symbol] = assetProfile;
} }
}) })
); );
@ -117,6 +119,12 @@ export class DataProviderService implements OnModuleInit {
try { try {
await Promise.all(promises); await Promise.all(promises);
if (isEmpty(response)) {
throw new AssetProfileInvalidError(
'No valid asset profiles have been found'
);
}
} catch (error) { } catch (error) {
Logger.error(error, 'DataProviderService'); Logger.error(error, 'DataProviderService');
@ -645,8 +653,11 @@ export class DataProviderService implements OnModuleInit {
const filteredItems = lookupItems const filteredItems = lookupItems
.filter(({ currency }) => { .filter(({ currency }) => {
// Only allow symbols with supported currency if (includeIndices) {
return currency ? true : false; return true;
}
return currency ? isCurrency(currency) : false;
}) })
.map((lookupItem) => { .map((lookupItem) => {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {

7
apps/api/src/services/data-provider/errors/asset-profile-invalid.error.ts

@ -0,0 +1,7 @@
export class AssetProfileInvalidError extends Error {
public constructor(message: string) {
super(message);
this.name = 'AssetProfileInvalidError';
}
}

77
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -102,6 +102,10 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
).then((res) => res.json()); ).then((res) => res.json());
if (!assetProfile) {
throw new Error(`${symbol} not found`);
}
const { assetClass, assetSubClass } = const { assetClass, assetSubClass } =
this.parseAssetClass(assetProfile); this.parseAssetClass(assetProfile);
@ -373,26 +377,42 @@ export class FinancialModelingPrepService implements DataProviderInterface {
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
).then((res) => res.json()) ).then(
(res) => res.json() as unknown as { price: number; symbol: string }[]
)
]); ]);
if (assetProfileResolutions.length === symbols.length) { for (const { currency, symbolTarget } of assetProfileResolutions) {
for (const { currency, symbolTarget } of assetProfileResolutions) { currencyBySymbolMap[symbolTarget] = { currency };
currencyBySymbolMap[symbolTarget] = { currency }; }
const resolvedSymbols = assetProfileResolutions.map(
({ symbolTarget }) => {
return symbolTarget;
} }
} else { );
const symbolsToFetch = quotes
.map(({ symbol }) => {
return symbol;
})
.filter((symbol) => {
return !resolvedSymbols.includes(symbol);
});
if (symbolsToFetch.length > 0) {
await Promise.all( await Promise.all(
quotes.map(({ symbol }) => { symbolsToFetch.map(async (symbol) => {
return this.getAssetProfile({ const assetProfile = await this.getAssetProfile({
requestTimeout, requestTimeout,
symbol symbol
}).then((assetProfile) => {
if (assetProfile?.currency) {
currencyBySymbolMap[symbol] = {
currency: assetProfile.currency
};
}
}); });
if (assetProfile?.currency) {
currencyBySymbolMap[symbol] = {
currency: assetProfile.currency
};
}
}) })
); );
} }
@ -438,6 +458,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
public async search({ public async search({
includeIndices = false,
query, query,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') requestTimeout = this.configurationService.get('REQUEST_TIMEOUT')
}: GetSearchParams): Promise<LookupResponse> { }: GetSearchParams): Promise<LookupResponse> {
@ -484,17 +505,25 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
).then((res) => res.json()); ).then((res) => res.json());
items = result.map(({ currency, name, symbol }) => { items = result
return { .filter(({ symbol }) => {
currency, if (includeIndices === false && symbol.startsWith('^')) {
symbol, return false;
assetClass: undefined, // TODO }
assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(), return true;
dataSource: this.getName(), })
name: this.formatName({ name }) .map(({ currency, name, symbol }) => {
}; return {
}); currency,
symbol,
assetClass: undefined, // TODO
assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: this.formatName({ name })
};
});
} }
} catch (error) { } catch (error) {
let message = error; let message = error;

5
apps/client/src/app/app-routing.module.ts

@ -155,8 +155,9 @@ const routes: Routes = [
// Preload all lazy loaded modules with the attribute preload === true // Preload all lazy loaded modules with the attribute preload === true
{ {
anchorScrolling: 'enabled', anchorScrolling: 'enabled',
preloadingStrategy: ModulePreloadService // enableTracing: true, // <-- debugging purposes only
// enableTracing: true // <-- debugging purposes only preloadingStrategy: ModulePreloadService,
scrollPositionRestoration: 'top'
} }
) )
], ],

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

@ -302,7 +302,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.interest" [value]="isLoading ? undefined : summary?.interestInBaseCurrency"
/> />
</div> </div>
</div> </div>

10
apps/client/src/app/pages/pricing/pricing-page.component.ts

@ -69,6 +69,16 @@ export class GfPricingPageComponent implements OnDestroy, OnInit {
public professionalDataProviderTooltipPremium = translate( public professionalDataProviderTooltipPremium = translate(
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM' 'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
); );
public referralBrokers = [
'DEGIRO',
'finpension',
'frankly',
'Interactive Brokers',
'Mintos',
'Swissquote',
'VIAC',
'Zak'
];
public routerLinkFeatures = publicRoutes.features.routerLink; public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink; public routerLinkRegister = publicRoutes.register.routerLink;
public user: User; public user: User;

43
apps/client/src/app/pages/pricing/pricing-page.html

@ -326,16 +326,43 @@
<div class="row"> <div class="row">
<div class="col mt-3"> <div class="col mt-3">
<p> <p>
If you plan to open an account at <i>DEGIRO</i>, <i>finpension</i>, <ng-container i18n>If you plan to open an account at</ng-container>
<i>frankly</i>, <i>Interactive Brokers</i>, <i>Swissquote</i>, <ng-container>&nbsp;</ng-container>
<i>VIAC</i>, or <i>Zak</i>, please @for (
<a href="mailto:hi@ghostfol.io?Subject=Referral link for..." broker of referralBrokers;
track broker;
let i = $index;
let last = $last
) {
<i>{{ broker }}</i>
@if (last) {
<span>, </span>
} @else {
@if (i === referralBrokers.length - 2) {
<ng-container>&nbsp;</ng-container>
<ng-container i18n>or</ng-container>
<ng-container>&nbsp;</ng-container>
} @else {
<span>, </span>
}
}
}
<ng-container i18n>please</ng-container>
<ng-container>&nbsp;</ng-container>
<a href="mailto:hi@ghostfol.io?Subject=Referral link for..." i18n
>contact us</a >contact us</a
> >
to use our referral link and get a Ghostfolio Premium membership for <ng-container>&nbsp;</ng-container>
one year. Looking for a student discount? Request it <ng-container i18n
<a href="mailto:hi@ghostfol.io?Subject=Student Discount">here</a> >to use our referral link and get a Ghostfolio Premium membership
with your university e-mail address. for one year</ng-container
>. <ng-container i18n>Looking for a student discount?</ng-container>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>Request it</ng-container>
<ng-container>&nbsp;</ng-container>
<a href="mailto:hi@ghostfol.io?Subject=Student Discount" i18n>here</a>
<ng-container>&nbsp;</ng-container>
<ng-container i18n>with your university e-mail address</ng-container>.
</p> </p>
</div> </div>
</div> </div>

17
apps/client/src/app/pages/resources/glossary/resources-glossary.component.html

@ -132,6 +132,23 @@
</div> </div>
</div> </div>
</div> </div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Stealth Wealth</h3>
<div class="mb-1">
Stealth wealth is a lifestyle choice where you don’t openly show
off your wealth, but instead live quietly to maintain privacy and
security.
</div>
<div>
<a
href="https://en.wikipedia.org/wiki/Stealth_wealth"
target="_blank"
>Stealth Wealth →</a
>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>

10
libs/common/src/lib/helper.ts

@ -1,7 +1,7 @@
import * as currencies from '@dinero.js/currencies';
import { NumberParser } from '@internationalized/number'; import { NumberParser } from '@internationalized/number';
import { Type as ActivityType, DataSource, MarketData } from '@prisma/client'; import { Type as ActivityType, DataSource, MarketData } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { isISO4217CurrencyCode } from 'class-validator';
import { import {
getDate, getDate,
getMonth, getMonth,
@ -340,8 +340,12 @@ export function interpolate(template: string, context: any) {
}); });
} }
export function isCurrency(aCurrency = '') { export function isCurrency(aCurrency: string) {
return currencies[aCurrency] || isDerivedCurrency(aCurrency); if (!aCurrency) {
return false;
}
return isISO4217CurrencyCode(aCurrency) || isDerivedCurrency(aCurrency);
} }
export function isDerivedCurrency(aCurrency: string) { export function isDerivedCurrency(aCurrency: string) {

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

@ -20,7 +20,7 @@ export interface PortfolioSummary extends PortfolioPerformance {
fireWealth: FireWealth; fireWealth: FireWealth;
grossPerformance: number; grossPerformance: number;
grossPerformanceWithCurrencyEffect: number; grossPerformanceWithCurrencyEffect: number;
interest: number; interestInBaseCurrency: number;
liabilitiesInBaseCurrency: number; liabilitiesInBaseCurrency: number;
totalBuy: number; totalBuy: number;
totalSell: number; totalSell: number;

12
libs/ui/src/lib/activities-table/activities-table.component.html

@ -361,7 +361,11 @@
<ion-icon name="ellipsis-vertical" /> <ion-icon name="ellipsis-vertical" />
</button> </button>
} }
<mat-menu #activitiesMenu="matMenu" xPosition="before"> <mat-menu
#activitiesMenu="matMenu"
class="no-max-width"
xPosition="before"
>
@if (hasPermissionToCreateActivity) { @if (hasPermissionToCreateActivity) {
<button <button
class="align-items-center d-flex" class="align-items-center d-flex"
@ -425,7 +429,11 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
} }
<mat-menu #activityMenu="matMenu" xPosition="before"> <mat-menu
#activityMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onUpdateActivity(element)"> <button mat-menu-item (click)="onUpdateActivity(element)">
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />

20
libs/ui/src/lib/logo-carousel/logo-carousel.component.scss

@ -194,19 +194,13 @@
); );
} }
.logo { .logo-carousel-track {
&.logo-alternative-to, .logo-carousel-item {
&.logo-dev-community, .logo {
&.logo-hacker-news, &.mask {
&.logo-openalternative, background-color: rgba(var(--light-secondary-text));
&.logo-privacy-tools, }
&.logo-reddit, }
&.logo-sackgeld,
&.logo-selfh-st,
&.logo-sourceforge,
&.logo-umbrel,
&.logo-unraid {
background-color: rgba(var(--light-primary-text));
} }
} }
} }

7
package-lock.json

@ -28,7 +28,6 @@
"@dfinity/candid": "0.15.7", "@dfinity/candid": "0.15.7",
"@dfinity/identity": "0.15.7", "@dfinity/identity": "0.15.7",
"@dfinity/principal": "0.15.7", "@dfinity/principal": "0.15.7",
"@dinero.js/currencies": "2.0.0-alpha.8",
"@internationalized/number": "3.6.3", "@internationalized/number": "3.6.3",
"@ionic/angular": "8.7.3", "@ionic/angular": "8.7.3",
"@keyv/redis": "4.4.0", "@keyv/redis": "4.4.0",
@ -5018,12 +5017,6 @@
"ts-node": "^10.8.2" "ts-node": "^10.8.2"
} }
}, },
"node_modules/@dinero.js/currencies": {
"version": "2.0.0-alpha.8",
"resolved": "https://registry.npmjs.org/@dinero.js/currencies/-/currencies-2.0.0-alpha.8.tgz",
"integrity": "sha512-zApiqtuuPwjiM9LJA5/kNcT48VSHRiz2/mktkXjIpfxrJKzthXybUAgEenExIH6dYhLDgVmsLQZtZFOsdYl0Ag==",
"license": "MIT"
},
"node_modules/@discoveryjs/json-ext": { "node_modules/@discoveryjs/json-ext": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz",

1
package.json

@ -74,7 +74,6 @@
"@dfinity/candid": "0.15.7", "@dfinity/candid": "0.15.7",
"@dfinity/identity": "0.15.7", "@dfinity/identity": "0.15.7",
"@dfinity/principal": "0.15.7", "@dfinity/principal": "0.15.7",
"@dinero.js/currencies": "2.0.0-alpha.8",
"@internationalized/number": "3.6.3", "@internationalized/number": "3.6.3",
"@ionic/angular": "8.7.3", "@ionic/angular": "8.7.3",
"@keyv/redis": "4.4.0", "@keyv/redis": "4.4.0",

7
test/import/ok/novn-buy-and-sell-partially.json

@ -24,5 +24,10 @@
"date": "2022-03-07T00:00:00.000Z", "date": "2022-03-07T00:00:00.000Z",
"symbol": "NOVN.SW" "symbol": "NOVN.SW"
} }
] ],
"user": {
"settings": {
"currency": "CHF"
}
}
} }

7
test/import/ok/novn-buy-and-sell.json

@ -24,5 +24,10 @@
"date": "2022-03-07T00:00:00.000Z", "date": "2022-03-07T00:00:00.000Z",
"symbol": "NOVN.SW" "symbol": "NOVN.SW"
} }
] ],
"user": {
"settings": {
"currency": "CHF"
}
}
} }

Loading…
Cancel
Save