Browse Source

Merge remote-tracking branch 'upstream/main' into refactor/portfolio-filter-component

pull/5618/head
Germán Martín 2 weeks ago
parent
commit
4a61278e34
  1. 19
      CHANGELOG.md
  2. 2
      apps/api/src/app/admin/admin.controller.ts
  3. 59
      apps/api/src/app/endpoints/ai/ai.service.ts
  4. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  5. 4
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  6. 29
      apps/api/src/app/import/import.service.ts
  7. 6
      apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts
  8. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  9. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts
  10. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  11. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  12. 44
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  13. 10
      apps/api/src/app/portfolio/portfolio.controller.ts
  14. 12
      apps/api/src/app/portfolio/portfolio.service.ts
  15. 4
      apps/api/src/helper/object.helper.spec.ts
  16. 2
      apps/client/src/app/app-routing.module.ts
  17. 37
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  18. 18
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  19. 2
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  20. 13
      apps/client/src/app/pages/blog/blog-page.component.ts
  21. 14
      apps/client/src/app/pages/blog/blog-page.module.ts
  22. 15
      apps/client/src/app/pages/blog/blog-page.routes.ts
  23. 20
      apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts
  24. 4
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  25. 10
      apps/client/src/app/pages/pricing/pricing-page.component.ts
  26. 43
      apps/client/src/app/pages/pricing/pricing-page.html
  27. 18
      apps/client/src/app/services/admin.service.ts
  28. 21
      apps/client/src/app/services/data.service.ts
  29. 53
      apps/client/src/app/services/import-activities.service.ts
  30. 452
      apps/client/src/locales/messages.ca.xlf
  31. 454
      apps/client/src/locales/messages.de.xlf
  32. 452
      apps/client/src/locales/messages.es.xlf
  33. 452
      apps/client/src/locales/messages.fr.xlf
  34. 452
      apps/client/src/locales/messages.it.xlf
  35. 452
      apps/client/src/locales/messages.nl.xlf
  36. 452
      apps/client/src/locales/messages.pl.xlf
  37. 452
      apps/client/src/locales/messages.pt.xlf
  38. 452
      apps/client/src/locales/messages.tr.xlf
  39. 452
      apps/client/src/locales/messages.uk.xlf
  40. 441
      apps/client/src/locales/messages.xlf
  41. 452
      apps/client/src/locales/messages.zh.xlf
  42. 12
      libs/common/src/lib/interfaces/index.ts
  43. 5
      libs/common/src/lib/interfaces/portfolio-dividends.interface.ts
  44. 6
      libs/common/src/lib/interfaces/portfolio-investments.interface.ts
  45. 2
      libs/common/src/lib/interfaces/portfolio-summary.interface.ts
  46. 2
      libs/common/src/lib/interfaces/responses/benchmark-market-data-details-response.interface.ts
  47. 5
      libs/common/src/lib/interfaces/responses/portfolio-dividends-response.interface.ts
  48. 6
      libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts
  49. 12
      libs/ui/src/lib/activities-table/activities-table.component.html
  50. 62
      package-lock.json
  51. 3
      package.json
  52. 7
      test/import/ok/novn-buy-and-sell-partially.json
  53. 7
      test/import/ok/novn-buy-and-sell.json
  54. 2
      test/import/ok/penthouse-apartment.csv
  55. 2
      test/import/ok/penthouse-apartment.json

19
CHANGELOG.md

@ -9,7 +9,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added support for data gathering by date range in the asset profile details dialog of the admin control panel
### Changed
- Formatted the holdings table in the _Copy AI prompt to clipboard for analysis_ action on the analysis page (experimental)
- Formatted the holdings table in the _Copy portfolio data to clipboard for AI prompt_ action of the analysis page (experimental)
- Improved the language localization for German (`de`)
## 2.209.0 - 2025-10-18
### Added
- Extended the glossary of the resources page by _Stealth Wealth_ - 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
@ -19,11 +32,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Improved the currency validation in the search 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
- Refactored the blog page component to standalone
- Improved the portfolio calculator unit tests to load the user currency from the exported file
- Improved the language localization for German (`de`)
### Fixed ### Fixed
- Fixed an issue in the `csv` file import where custom asset profiles failed due to validation errors
- Fixed an issue with the total buy and sell calculation in the summary related to activities in a custom currency
- Respected the include indices flag in the search functionality of the _Financial Modeling Prep_ service - 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 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 - 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

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

@ -169,7 +169,7 @@ export class AdminController {
let date: Date; let date: Date;
if (dateRange) { if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange, new Date()); const { startDate } = getIntervalFromDateRange(dateRange);
date = startDate; date = startDate;
} }

59
apps/api/src/app/endpoints/ai/ai.service.ts

@ -10,6 +10,7 @@ import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createOpenRouter } from '@openrouter/ai-sdk-provider'; import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai'; import { generateText } from 'ai';
import tablemark, { ColumnDescriptor } from 'tablemark';
@Injectable() @Injectable()
export class AiService { export class AiService {
@ -58,34 +59,50 @@ export class AiService {
userId userId
}); });
const holdingsTable = [ const holdingsTableColumns: ColumnDescriptor[] = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |', { name: 'Name' },
'| --- | --- | --- | --- | --- | --- |', { name: 'Symbol' },
...Object.values(holdings) { name: 'Currency' },
.sort((a, b) => { { name: 'Asset Class' },
return b.allocationInPercentage - a.allocationInPercentage; { name: 'Asset Sub Class' },
}) { align: 'right', name: 'Allocation in Percentage' }
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name,
symbol
}) => {
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
}
)
]; ];
const holdingsTableRows = Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name,
symbol
}) => {
return {
Name: name,
Symbol: symbol,
Currency: currency,
'Asset Class': assetClass ?? '',
'Asset Sub Class': assetSubClass ?? '',
'Allocation in Percentage': `${(allocationInPercentage * 100).toFixed(3)}%`
};
}
);
const holdingsTableString = tablemark(holdingsTableRows, {
columns: holdingsTableColumns
});
if (mode === 'portfolio') { if (mode === 'portfolio') {
return holdingsTable.join('\n'); return holdingsTableString;
} }
return [ return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, `You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable, holdingsTableString,
'Structure your answer with these sections:', 'Structure your answer with these sections:',
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.', 'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.', 'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.',

4
apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -8,7 +8,7 @@ import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -125,7 +125,7 @@ export class BenchmarksController {
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false' @Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) new Date(startDateString)

4
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetailsResponse,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
@ -43,7 +43,7 @@ export class BenchmarksService {
startDate: Date; startDate: Date;
user: UserWithSettings; user: UserWithSettings;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = []; const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency; const userCurrency = user.settings.settings.baseCurrency;
const userId = user.id; const userId = user.id;

29
apps/api/src/app/import/import.service.ts

@ -743,14 +743,27 @@ export class ImportService {
} }
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) { if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
const assetProfile = { if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
currency, // Skip asset profile validation for FEE, INTEREST, and LIABILITY
...( // as these activity types don't require asset profiles
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = {
currency,
dataSource,
symbol
};
continue;
}
let assetProfile: Partial<SymbolProfile> = { currency };
try {
assetProfile = (
await this.dataProviderService.getAssetProfiles([ await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol } { dataSource, symbol }
]) ])
)?.[symbol] )?.[symbol];
}; } catch {}
if (!assetProfile?.name) { if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find( const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
@ -787,11 +800,7 @@ export class ImportService {
} }
} }
if ( if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`

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
}); });

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

@ -19,10 +19,10 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividendsResponse,
PortfolioHoldingResponse, PortfolioHoldingResponse,
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestmentsResponse,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReportResponse PortfolioReportResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -197,7 +197,7 @@ export class PortfolioController {
'filteredValueInBaseCurrency', 'filteredValueInBaseCurrency',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect', 'grossPerformanceWithCurrencyEffect',
'interest', 'interestInBaseCurrency',
'items', 'items',
'liabilities', 'liabilities',
'netPerformance', 'netPerformance',
@ -305,7 +305,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioDividends> { ): Promise<PortfolioDividendsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
@ -439,7 +439,7 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string, @Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<PortfolioInvestments> { ): Promise<PortfolioInvestmentsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,

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

@ -46,7 +46,7 @@ import {
InvestmentItem, InvestmentItem,
PortfolioDetails, PortfolioDetails,
PortfolioHoldingResponse, PortfolioHoldingResponse,
PortfolioInvestments, PortfolioInvestmentsResponse,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,
PortfolioReportResponse, PortfolioReportResponse,
@ -397,7 +397,7 @@ export class PortfolioService {
impersonationId: string; impersonationId: string;
savingsRate: number; savingsRate: number;
userId: string; userId: string;
}): Promise<PortfolioInvestments> { }): Promise<PortfolioInvestmentsResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -448,7 +448,7 @@ export class PortfolioService {
}); });
} }
let streaks: PortfolioInvestments['streaks']; let streaks: PortfolioInvestmentsResponse['streaks'];
if (savingsRate) { if (savingsRate) {
streaks = this.getStreaks({ streaks = this.getStreaks({
@ -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
@ -2126,11 +2126,11 @@ export class PortfolioService {
.filter(({ isDraft, type }) => { .filter(({ isDraft, type }) => {
return isDraft === false && type === activityType; return isDraft === false && type === activityType;
}) })
.map(({ quantity, SymbolProfile, unitPrice }) => { .map(({ currency, quantity, SymbolProfile, unitPrice }) => {
return new Big( return new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency, currency ?? SymbolProfile.currency,
userCurrency userCurrency
) )
); );

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,

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

@ -48,7 +48,7 @@ const routes: Routes = [
{ {
path: publicRoutes.blog.path, path: publicRoutes.blog.path,
loadChildren: () => loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.routes').then((m) => m.routes)
}, },
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],

37
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -18,6 +18,7 @@ import {
ScraperConfiguration, ScraperConfiguration,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector'; import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor'; import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
@ -190,6 +191,32 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
}; };
public currencies: string[] = []; public currencies: string[] = [];
public dateRangeOptions = [
{
label: $localize`Current week` + ' (' + $localize`WTD` + ')',
value: 'wtd'
},
{
label: $localize`Current month` + ' (' + $localize`MTD` + ')',
value: 'mtd'
},
{
label: $localize`Current year` + ' (' + $localize`YTD` + ')',
value: 'ytd'
},
{
label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')',
value: '1y'
},
{
label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')',
value: '5y'
},
{
label: $localize`Max`,
value: 'max'
}
];
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isBenchmark = false; public isBenchmark = false;
public isDataGatheringEnabled: boolean; public isDataGatheringEnabled: boolean;
@ -405,9 +432,15 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
.subscribe(); .subscribe();
} }
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { public onGatherSymbol({
dataSource,
range,
symbol
}: {
range?: DateRange;
} & AssetProfileIdentifier) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, range, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(); .subscribe();
} }

18
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -26,12 +26,30 @@
[disabled]=" [disabled]="
assetProfileForm.dirty || !assetProfileForm.controls.isActive.value assetProfileForm.dirty || !assetProfileForm.controls.isActive.value
" "
[matMenuTriggerFor]="gatherHistoricalMarketDataMenu"
(click)=" (click)="
onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol }) onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol })
" "
> >
<ng-container i18n>Gather Historical Market Data</ng-container> <ng-container i18n>Gather Historical Market Data</ng-container>
</button> </button>
<mat-menu #gatherHistoricalMarketDataMenu="matMenu">
@for (dateRange of dateRangeOptions; track dateRange.value) {
<button
mat-menu-item
type="button"
(click)="
onGatherSymbol({
dataSource: data.dataSource,
range: dateRange.value,
symbol: data.symbol
})
"
>
{{ dateRange.label }}
</button>
}
</mat-menu>
<button <button
mat-menu-item mat-menu-item
type="button" type="button"

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>

13
apps/client/src/app/pages/blog/blog-page.component.ts

@ -1,19 +1,24 @@
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Component, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common';
import { Component, CUSTOM_ELEMENTS_SCHEMA, OnDestroy } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { chevronForwardOutline } from 'ionicons/icons'; import { chevronForwardOutline } from 'ionicons/icons';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@Component({ @Component({
host: { class: 'page' }, host: { class: 'page' },
imports: [CommonModule, IonIcon, MatCardModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-blog-page', selector: 'gf-blog-page',
styleUrls: ['./blog-page.scss'], styleUrls: ['./blog-page.scss'],
templateUrl: './blog-page.html', templateUrl: './blog-page.html'
standalone: false
}) })
export class BlogPageComponent implements OnDestroy { export class GfBlogPageComponent implements OnDestroy {
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

14
apps/client/src/app/pages/blog/blog-page.module.ts

@ -1,14 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { IonIcon } from '@ionic/angular/standalone';
import { BlogPageRoutingModule } from './blog-page-routing.module';
import { BlogPageComponent } from './blog-page.component';
@NgModule({
declarations: [BlogPageComponent],
imports: [BlogPageRoutingModule, CommonModule, IonIcon, MatCardModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class BlogPageModule {}

15
apps/client/src/app/pages/blog/blog-page-routing.module.ts → apps/client/src/app/pages/blog/blog-page.routes.ts

@ -1,15 +1,14 @@
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { publicRoutes } from '@ghostfolio/common/routes/routes'; import { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core'; import { Routes } from '@angular/router';
import { RouterModule, Routes } from '@angular/router';
import { BlogPageComponent } from './blog-page.component'; import { GfBlogPageComponent } from './blog-page.component';
const routes: Routes = [ export const routes: Routes = [
{ {
canActivate: [AuthGuard], canActivate: [AuthGuard],
component: BlogPageComponent, component: GfBlogPageComponent,
path: '', path: '',
title: publicRoutes.blog.title title: publicRoutes.blog.title
}, },
@ -212,9 +211,3 @@ const routes: Routes = [
title: 'Hacktoberfest 2025' title: 'Hacktoberfest 2025'
} }
]; ];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class BlogPageRoutingModule {}

20
apps/client/src/app/pages/portfolio/activities/import-activities-dialog/import-activities-dialog.component.ts

@ -346,6 +346,7 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
isDryRun: true, isDryRun: true,
tags: content.tags tags: content.tags
}); });
this.activities = activities; this.activities = activities;
this.dataSource = new MatTableDataSource(activities.reverse()); this.dataSource = new MatTableDataSource(activities.reverse());
this.pageIndex = 0; this.pageIndex = 0;
@ -360,15 +361,18 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
const content = fileContent.split('\n').slice(1); const content = fileContent.split('\n').slice(1);
try { try {
const data = await this.importActivitiesService.importCsv({ const { activities, assetProfiles } =
fileContent, await this.importActivitiesService.importCsv({
isDryRun: true, fileContent,
userAccounts: this.data.user.accounts isDryRun: true,
}); userAccounts: this.data.user.accounts
this.activities = data.activities; });
this.dataSource = new MatTableDataSource(data.activities.reverse());
this.activities = activities;
this.assetProfiles = assetProfiles;
this.dataSource = new MatTableDataSource(activities.reverse());
this.pageIndex = 0; this.pageIndex = 0;
this.totalItems = data.activities.length; this.totalItems = activities.length;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ this.handleImportError({

4
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -6,7 +6,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
HistoricalDataItem, HistoricalDataItem,
InvestmentItem, InvestmentItem,
PortfolioInvestments, PortfolioInvestmentsResponse,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPosition,
ToggleOption, ToggleOption,
@ -94,7 +94,7 @@ export class GfAnalysisPageComponent implements OnDestroy, OnInit {
public performanceDataItems: HistoricalDataItem[]; public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[]; public performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`; public portfolioEvolutionDataLabel = $localize`Investment`;
public streaks: PortfolioInvestments['streaks']; public streaks: PortfolioInvestmentsResponse['streaks'];
public top3: PortfolioPosition[]; public top3: PortfolioPosition[];
public unitCurrentStreak: string; public unitCurrentStreak: string;
public unitLongestStreak: string; public unitLongestStreak: string;

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>

18
apps/client/src/app/services/admin.service.ts

@ -17,6 +17,7 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
@ -178,9 +179,22 @@ export class AdminService {
); );
} }
public gatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) { public gatherSymbol({
dataSource,
range,
symbol
}: {
range?: DateRange;
} & AssetProfileIdentifier) {
let params = new HttpParams();
if (range) {
params = params.append('range', range);
}
const url = `/api/v1/admin/gather/${dataSource}/${symbol}`; const url = `/api/v1/admin/gather/${dataSource}/${symbol}`;
return this.http.post<MarketData | void>(url, {});
return this.http.post<MarketData | void>(url, undefined, { params });
} }
public fetchSymbolForDate({ public fetchSymbolForDate({

21
apps/client/src/app/services/data.service.ts

@ -30,7 +30,7 @@ import {
AiPromptResponse, AiPromptResponse,
ApiKeyResponse, ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetailsResponse,
BenchmarkResponse, BenchmarkResponse,
DataProviderHealthResponse, DataProviderHealthResponse,
Export, Export,
@ -42,10 +42,10 @@ import {
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
OAuthResponse, OAuthResponse,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividendsResponse,
PortfolioHoldingResponse, PortfolioHoldingResponse,
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestmentsResponse,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioReportResponse, PortfolioReportResponse,
PublicPortfolioResponse, PublicPortfolioResponse,
@ -270,9 +270,12 @@ export class DataService {
params = params.append('groupBy', groupBy); params = params.append('groupBy', groupBy);
params = params.append('range', range); params = params.append('range', range);
return this.http.get<PortfolioDividends>('/api/v1/portfolio/dividends', { return this.http.get<PortfolioDividendsResponse>(
params '/api/v1/portfolio/dividends',
}); {
params
}
);
} }
public fetchDividendsImport({ dataSource, symbol }: AssetProfileIdentifier) { public fetchDividendsImport({ dataSource, symbol }: AssetProfileIdentifier) {
@ -365,7 +368,7 @@ export class DataService {
range: DateRange; range: DateRange;
startDate: Date; startDate: Date;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Observable<BenchmarkMarketDataDetails> { } & AssetProfileIdentifier) {
let params = this.buildFiltersAsQueryParams({ filters }); let params = this.buildFiltersAsQueryParams({ filters });
params = params.append('range', range); params = params.append('range', range);
@ -374,7 +377,7 @@ export class DataService {
params = params.append('withExcludedAccounts', withExcludedAccounts); params = params.append('withExcludedAccounts', withExcludedAccounts);
} }
return this.http.get<BenchmarkMarketDataDetails>( return this.http.get<BenchmarkMarketDataDetailsResponse>(
`/api/v1/benchmarks/${dataSource}/${symbol}/${format(startDate, DATE_FORMAT, { in: utc })}`, `/api/v1/benchmarks/${dataSource}/${symbol}/${format(startDate, DATE_FORMAT, { in: utc })}`,
{ params } { params }
); );
@ -460,7 +463,7 @@ export class DataService {
params = params.append('groupBy', groupBy); params = params.append('groupBy', groupBy);
params = params.append('range', range); params = params.append('range', range);
return this.http.get<PortfolioInvestments>( return this.http.get<PortfolioInvestmentsResponse>(
'/api/v1/portfolio/investments', '/api/v1/portfolio/investments',
{ params } { params }
); );

53
apps/client/src/app/services/import-activities.service.ts

@ -45,6 +45,7 @@ export class ImportActivitiesService {
userAccounts: Account[]; userAccounts: Account[];
}): Promise<{ }): Promise<{
activities: Activity[]; activities: Activity[];
assetProfiles: CreateAssetProfileWithMarketDataDto[];
}> { }> {
const content = csvToJson(fileContent, { const content = csvToJson(fileContent, {
dynamicTyping: true, dynamicTyping: true,
@ -53,23 +54,65 @@ export class ImportActivitiesService {
}).data; }).data;
const activities: CreateOrderDto[] = []; const activities: CreateOrderDto[] = [];
const assetProfiles: CreateAssetProfileWithMarketDataDto[] = [];
for (const [index, item] of content.entries()) { for (const [index, item] of content.entries()) {
const currency = this.parseCurrency({ content, index, item });
const dataSource = this.parseDataSource({ item });
const symbol = this.parseSymbol({ content, index, item });
const type = this.parseType({ content, index, item });
activities.push({ activities.push({
currency,
dataSource,
symbol,
type,
accountId: this.parseAccount({ item, userAccounts }), accountId: this.parseAccount({ item, userAccounts }),
comment: this.parseComment({ item }), comment: this.parseComment({ item }),
currency: this.parseCurrency({ content, index, item }),
dataSource: this.parseDataSource({ item }),
date: this.parseDate({ content, index, item }), date: this.parseDate({ content, index, item }),
fee: this.parseFee({ content, index, item }), fee: this.parseFee({ content, index, item }),
quantity: this.parseQuantity({ content, index, item }), quantity: this.parseQuantity({ content, index, item }),
symbol: this.parseSymbol({ content, index, item }),
type: this.parseType({ content, index, item }),
unitPrice: this.parseUnitPrice({ content, index, item }), unitPrice: this.parseUnitPrice({ content, index, item }),
updateAccountBalance: false updateAccountBalance: false
}); });
if (
dataSource === DataSource.MANUAL &&
!['FEE', 'INTEREST', 'LIABILITY'].includes(type)
) {
// Create synthetic asset profile for MANUAL data source
// (except for FEE, INTEREST, and LIABILITY which don't require asset profiles)
assetProfiles.push({
currency,
symbol,
assetClass: null,
assetSubClass: null,
comment: null,
countries: [],
cusip: null,
dataSource: DataSource.MANUAL,
figi: null,
figiComposite: null,
figiShareClass: null,
holdings: [],
isActive: true,
isin: null,
marketData: [],
name: symbol,
scraperConfiguration: null,
sectors: [],
symbolMapping: {},
url: null
});
}
} }
return await this.importJson({ activities, isDryRun }); const result = await this.importJson({
activities,
assetProfiles,
isDryRun
});
return { ...result, assetProfiles };
} }
public importJson({ public importJson({

452
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

454
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

441
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

452
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

12
libs/common/src/lib/interfaces/index.ts

@ -10,7 +10,6 @@ import type {
import type { AdminUsers } from './admin-users.interface'; import type { AdminUsers } from './admin-users.interface';
import type { AssetClassSelectorOption } from './asset-class-selector-option.interface'; import type { AssetClassSelectorOption } from './asset-class-selector-option.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface'; import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { BenchmarkMarketDataDetails } from './benchmark-market-data-details.interface';
import type { BenchmarkProperty } from './benchmark-property.interface'; import type { BenchmarkProperty } from './benchmark-property.interface';
import type { Benchmark } from './benchmark.interface'; import type { Benchmark } from './benchmark.interface';
import type { Coupon } from './coupon.interface'; import type { Coupon } from './coupon.interface';
@ -30,8 +29,6 @@ import type { LookupItem } from './lookup-item.interface';
import type { MarketData } from './market-data.interface'; import type { MarketData } from './market-data.interface';
import type { PortfolioChart } from './portfolio-chart.interface'; import type { PortfolioChart } from './portfolio-chart.interface';
import type { PortfolioDetails } from './portfolio-details.interface'; import type { PortfolioDetails } from './portfolio-details.interface';
import type { PortfolioDividends } from './portfolio-dividends.interface';
import type { PortfolioInvestments } from './portfolio-investments.interface';
import type { PortfolioPerformance } from './portfolio-performance.interface'; import type { PortfolioPerformance } from './portfolio-performance.interface';
import type { PortfolioPosition } from './portfolio-position.interface'; import type { PortfolioPosition } from './portfolio-position.interface';
import type { PortfolioReportRule } from './portfolio-report-rule.interface'; import type { PortfolioReportRule } from './portfolio-report-rule.interface';
@ -43,6 +40,7 @@ import type { AccountBalancesResponse } from './responses/account-balances-respo
import type { AccountsResponse } from './responses/accounts-response.interface'; import type { AccountsResponse } from './responses/accounts-response.interface';
import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; import type { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface'; import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface'; import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface';
import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface'; import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface';
@ -56,8 +54,10 @@ import type { LookupResponse } from './responses/lookup-response.interface';
import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface'; import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface';
import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface'; import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioDividendsResponse } from './responses/portfolio-dividends-response.interface';
import { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface'; import { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioInvestmentsResponse } from './responses/portfolio-investments.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PortfolioReportResponse } from './responses/portfolio-report.interface'; import type { PortfolioReportResponse } from './responses/portfolio-report.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
@ -91,7 +91,7 @@ export {
AssetClassSelectorOption, AssetClassSelectorOption,
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetailsResponse,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse, BenchmarkResponse,
Coupon, Coupon,
@ -122,10 +122,10 @@ export {
OAuthResponse, OAuthResponse,
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividendsResponse,
PortfolioHoldingResponse, PortfolioHoldingResponse,
PortfolioHoldingsResponse, PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestmentsResponse,
PortfolioPerformance, PortfolioPerformance,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPosition, PortfolioPosition,

5
libs/common/src/lib/interfaces/portfolio-dividends.interface.ts

@ -1,5 +0,0 @@
import { InvestmentItem } from './investment-item.interface';
export interface PortfolioDividends {
dividends: InvestmentItem[];
}

6
libs/common/src/lib/interfaces/portfolio-investments.interface.ts

@ -1,6 +0,0 @@
import { InvestmentItem } from './investment-item.interface';
export interface PortfolioInvestments {
investments: InvestmentItem[];
streaks: { currentStreak: number; longestStreak: number };
}

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;

2
libs/common/src/lib/interfaces/benchmark-market-data-details.interface.ts → libs/common/src/lib/interfaces/responses/benchmark-market-data-details-response.interface.ts

@ -1,5 +1,5 @@
import { LineChartItem } from '@ghostfolio/common/interfaces'; import { LineChartItem } from '@ghostfolio/common/interfaces';
export interface BenchmarkMarketDataDetails { export interface BenchmarkMarketDataDetailsResponse {
marketData: LineChartItem[]; marketData: LineChartItem[];
} }

5
libs/common/src/lib/interfaces/responses/portfolio-dividends-response.interface.ts

@ -0,0 +1,5 @@
import { InvestmentItem } from '../investment-item.interface';
export interface PortfolioDividendsResponse {
dividends: InvestmentItem[];
}

6
libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts

@ -0,0 +1,6 @@
import { InvestmentItem } from '../investment-item.interface';
export interface PortfolioInvestmentsResponse {
investments: InvestmentItem[];
streaks: { currentStreak: number; longestStreak: 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" />

62
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.208.0", "version": "2.209.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.208.0", "version": "2.209.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {
@ -90,6 +90,7 @@
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "18.5.0", "stripe": "18.5.0",
"svgmap": "2.12.2", "svgmap": "2.12.2",
"tablemark": "3.1.0",
"twitter-api-v2": "1.23.0", "twitter-api-v2": "1.23.0",
"uuid": "11.1.0", "uuid": "11.1.0",
"yahoo-finance2": "3.10.0", "yahoo-finance2": "3.10.0",
@ -23653,6 +23654,15 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/get-stdin": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-5.0.1.tgz",
"integrity": "sha512-jZV7n6jGE3Gt7fgSTJoz91Ak5MuTLwMwkoYdjxuJ/AmjIsE1UC03y/IWkZCQGEvVNS9qoRNwy5BCqxImv0FVeA==",
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/get-stream": { "node_modules/get-stream": {
"version": "5.2.0", "version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@ -31970,7 +31980,6 @@
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"tslib": "^2.0.3" "tslib": "^2.0.3"
@ -32874,7 +32883,6 @@
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"lower-case": "^2.0.2", "lower-case": "^2.0.2",
@ -37629,6 +37637,17 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sentence-case": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz",
"integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==",
"license": "MIT",
"dependencies": {
"no-case": "^3.0.4",
"tslib": "^2.0.3",
"upper-case-first": "^2.0.2"
}
},
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
@ -38278,6 +38297,19 @@
"wbuf": "^1.7.3" "wbuf": "^1.7.3"
} }
}, },
"node_modules/split-text-to-chunks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/split-text-to-chunks/-/split-text-to-chunks-1.0.0.tgz",
"integrity": "sha512-HLtEwXK/T4l7QZSJ/kOSsZC0o5e2Xg3GzKKFxm0ZexJXw0Bo4CaEl39l7MCSRHk9EOOL5jT8JIDjmhTtcoe6lQ==",
"license": "MIT",
"dependencies": {
"get-stdin": "^5.0.1",
"minimist": "^1.2.0"
},
"bin": {
"wordwrap": "cli.js"
}
},
"node_modules/sprintf-js": { "node_modules/sprintf-js": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@ -39046,6 +39078,19 @@
"url": "https://opencollective.com/synckit" "url": "https://opencollective.com/synckit"
} }
}, },
"node_modules/tablemark": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tablemark/-/tablemark-3.1.0.tgz",
"integrity": "sha512-IwO6f0SEzp1Z+zqz/7ANUmeEac4gaNlknWyj/S9aSg11wZmWYnLeyI/xXvEOU88BYUIf8y30y0wxB58xIKrVlQ==",
"license": "MIT",
"dependencies": {
"sentence-case": "^3.0.4",
"split-text-to-chunks": "^1.0.0"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/tapable": { "node_modules/tapable": {
"version": "2.2.2", "version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
@ -40525,6 +40570,15 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/upper-case-first": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz",
"integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
}
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

3
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "2.208.0", "version": "2.209.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",
@ -136,6 +136,7 @@
"rxjs": "7.8.1", "rxjs": "7.8.1",
"stripe": "18.5.0", "stripe": "18.5.0",
"svgmap": "2.12.2", "svgmap": "2.12.2",
"tablemark": "3.1.0",
"twitter-api-v2": "1.23.0", "twitter-api-v2": "1.23.0",
"uuid": "11.1.0", "uuid": "11.1.0",
"yahoo-finance2": "3.10.0", "yahoo-finance2": "3.10.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"
}
}
} }

2
test/import/ok/penthouse-apartment.csv

@ -0,0 +1,2 @@
Date,Code,DataSource,Currency,Price,Quantity,Action,Fee,Note
01.01.2022,Penthouse Apartment,MANUAL,USD,500000.0,1,buy,0.00,
1 Date Code DataSource Currency Price Quantity Action Fee Note
2 01.01.2022 Penthouse Apartment MANUAL USD 500000.0 1 buy 0.00

2
test/import/ok/penthouse-apartment.json

@ -42,7 +42,7 @@
"symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1", "symbol": "7e91b7d4-1430-4212-8380-289a06c9bbc1",
"tags": [], "tags": [],
"type": "BUY", "type": "BUY",
"unitPrice": 500000, "unitPrice": 500000
} }
], ],
"user": { "user": {

Loading…
Cancel
Save