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 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 content of the pricing page
- Added a _Storybook_ story for the holdings table component
### 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
- Optimized the get quotes functionality by utilizing the asset profile resolutions in the _Financial Modeling Prep_ service
- 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 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
- 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
apps/api/src/app/admin/admin.controller.ts

@ -169,7 +169,7 @@ export class AdminController {
let date: Date;
if (dateRange) {
const { startDate } = getIntervalFromDateRange(dateRange, new Date());
const { startDate } = getIntervalFromDateRange(dateRange);
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 { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { generateText } from 'ai';
import tablemark, { ColumnDescriptor } from 'tablemark';
@Injectable()
export class AiService {
@ -58,34 +59,50 @@ export class AiService {
userId
});
const holdingsTable = [
'| Name | Symbol | Currency | Asset Class | Asset Sub Class | Allocation in Percentage |',
'| --- | --- | --- | --- | --- | --- |',
...Object.values(holdings)
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
})
.map(
({
allocationInPercentage,
assetClass,
assetSubClass,
currency,
name,
symbol
}) => {
return `| ${name} | ${symbol} | ${currency} | ${assetClass} | ${assetSubClass} | ${(allocationInPercentage * 100).toFixed(3)}% |`;
}
)
const holdingsTableColumns: ColumnDescriptor[] = [
{ name: 'Name' },
{ name: 'Symbol' },
{ name: 'Currency' },
{ name: 'Asset Class' },
{ name: 'Asset Sub Class' },
{ align: 'right', name: 'Allocation in Percentage' }
];
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') {
return holdingsTable.join('\n');
return holdingsTableString;
}
return [
`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:',
'Overview: Briefly summarize the portfolio’s composition and allocation rationale.',
'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 type {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkMarketDataDetailsResponse,
BenchmarkResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -125,7 +125,7 @@ export class BenchmarksController {
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> {
): Promise<BenchmarkMarketDataDetailsResponse> {
const { endDate, startDate } = getIntervalFromDateRange(
dateRange,
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 {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
BenchmarkMarketDataDetailsResponse,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
@ -43,7 +43,7 @@ export class BenchmarksService {
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetailsResponse> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.settings.settings.baseCurrency;
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 })]) {
const assetProfile = {
currency,
...(
if (['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
// 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([
{ dataSource, symbol }
])
)?.[symbol]
};
)?.[symbol];
} catch {}
if (!assetProfile?.name) {
const assetProfileInImport = assetProfilesWithMarketDataDto?.find(
@ -787,11 +800,7 @@ export class ImportService {
}
}
if (
(dataSource !== 'MANUAL' && type === 'BUY') ||
type === 'DIVIDEND' ||
type === 'SELL'
) {
if (!['FEE', 'INTEREST', 'LIABILITY'].includes(type)) {
if (!assetProfile?.name) {
throw new Error(
`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';
export const activityDummyData = {
@ -37,6 +39,6 @@ export const userDummyData = {
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
};
export function loadActivityExportFile(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')).activities;
export function loadExportFile(filePath: string): Export {
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 {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btceur.json')
);
});
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in EUR)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
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 {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json')
);
});
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD short sell (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
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 {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(__dirname, '../../../../../../../test/import/ok/btcusd.json')
);
});
@ -97,28 +96,27 @@ describe('PortfolioCalculator', () => {
it.only('with BTCUSD buy (in USD)', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: 44558.42
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: 4.46,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: activity.dataSource,
name: 'Bitcoin',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: 44558.42
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'USD',
currency: exportResponse.user.settings.currency,
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 {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../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 () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
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 {
activityDummyData,
loadActivityExportFile,
loadExportFile,
symbolProfileDummyData,
userDummyData
} 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 { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Tag } from '@prisma/client';
import { Big } from 'big.js';
import { join } from 'node:path';
@ -53,7 +52,7 @@ jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let exportResponse: Export;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
@ -63,7 +62,7 @@ describe('PortfolioCalculator', () => {
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
exportResponse = loadExportFile(
join(
__dirname,
'../../../../../../../test/import/ok/novn-buy-and-sell.json'
@ -100,28 +99,27 @@ describe('PortfolioCalculator', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
tags: activity.tags?.map((id) => {
return { id } as Tag;
}),
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const activities: Activity[] = exportResponse.activities.map(
(activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
})
);
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
currency: exportResponse.user.settings.currency,
userId: userDummyData.id
});

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

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

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

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

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

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

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

@ -48,7 +48,7 @@ const routes: Routes = [
{
path: publicRoutes.blog.path,
loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
import('./pages/blog/blog-page.routes').then((m) => m.routes)
},
{
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,
User
} from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { GfCurrencySelectorComponent } from '@ghostfolio/ui/currency-selector';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { GfHistoricalMarketDataEditorComponent } from '@ghostfolio/ui/historical-market-data-editor';
@ -190,6 +191,32 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
};
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 isBenchmark = false;
public isDataGatheringEnabled: boolean;
@ -405,9 +432,15 @@ export class GfAssetProfileDialogComponent implements OnDestroy, OnInit {
.subscribe();
}
public onGatherSymbol({ dataSource, symbol }: AssetProfileIdentifier) {
public onGatherSymbol({
dataSource,
range,
symbol
}: {
range?: DateRange;
} & AssetProfileIdentifier) {
this.adminService
.gatherSymbol({ dataSource, symbol })
.gatherSymbol({ dataSource, range, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
}

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

@ -26,12 +26,30 @@
[disabled]="
assetProfileForm.dirty || !assetProfileForm.controls.isActive.value
"
[matMenuTriggerFor]="gatherHistoricalMarketDataMenu"
(click)="
onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol })
"
>
<ng-container i18n>Gather Historical Market Data</ng-container>
</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
mat-menu-item
type="button"

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

@ -302,7 +302,7 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.interest"
[value]="isLoading ? undefined : summary?.interestInBaseCurrency"
/>
</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 { 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 { chevronForwardOutline } from 'ionicons/icons';
import { Subject } from 'rxjs';
@Component({
host: { class: 'page' },
imports: [CommonModule, IonIcon, MatCardModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-blog-page',
styleUrls: ['./blog-page.scss'],
templateUrl: './blog-page.html',
standalone: false
templateUrl: './blog-page.html'
})
export class BlogPageComponent implements OnDestroy {
export class GfBlogPageComponent implements OnDestroy {
public hasPermissionForSubscription: boolean;
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 { publicRoutes } from '@ghostfolio/common/routes/routes';
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { 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],
component: BlogPageComponent,
component: GfBlogPageComponent,
path: '',
title: publicRoutes.blog.title
},
@ -212,9 +211,3 @@ const routes: Routes = [
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,
tags: content.tags
});
this.activities = activities;
this.dataSource = new MatTableDataSource(activities.reverse());
this.pageIndex = 0;
@ -360,15 +361,18 @@ export class GfImportActivitiesDialogComponent implements OnDestroy {
const content = fileContent.split('\n').slice(1);
try {
const data = await this.importActivitiesService.importCsv({
fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts
});
this.activities = data.activities;
this.dataSource = new MatTableDataSource(data.activities.reverse());
const { activities, assetProfiles } =
await this.importActivitiesService.importCsv({
fileContent,
isDryRun: true,
userAccounts: this.data.user.accounts
});
this.activities = activities;
this.assetProfiles = assetProfiles;
this.dataSource = new MatTableDataSource(activities.reverse());
this.pageIndex = 0;
this.totalItems = data.activities.length;
this.totalItems = activities.length;
} catch (error) {
console.error(error);
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 {
HistoricalDataItem,
InvestmentItem,
PortfolioInvestments,
PortfolioInvestmentsResponse,
PortfolioPerformance,
PortfolioPosition,
ToggleOption,
@ -94,7 +94,7 @@ export class GfAnalysisPageComponent implements OnDestroy, OnInit {
public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[];
public portfolioEvolutionDataLabel = $localize`Investment`;
public streaks: PortfolioInvestments['streaks'];
public streaks: PortfolioInvestmentsResponse['streaks'];
public top3: PortfolioPosition[];
public unitCurrentStreak: 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(
'PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM'
);
public referralBrokers = [
'DEGIRO',
'finpension',
'frankly',
'Interactive Brokers',
'Mintos',
'Swissquote',
'VIAC',
'Zak'
];
public routerLinkFeatures = publicRoutes.features.routerLink;
public routerLinkRegister = publicRoutes.register.routerLink;
public user: User;

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

@ -326,16 +326,43 @@
<div class="row">
<div class="col mt-3">
<p>
If you plan to open an account at <i>DEGIRO</i>, <i>finpension</i>,
<i>frankly</i>, <i>Interactive Brokers</i>, <i>Swissquote</i>,
<i>VIAC</i>, or <i>Zak</i>, please
<a href="mailto:hi@ghostfol.io?Subject=Referral link for..."
<ng-container i18n>If you plan to open an account at</ng-container>
<ng-container>&nbsp;</ng-container>
@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
>
to use our referral link and get a Ghostfolio Premium membership for
one year. Looking for a student discount? Request it
<a href="mailto:hi@ghostfol.io?Subject=Student Discount">here</a>
with your university e-mail address.
<ng-container>&nbsp;</ng-container>
<ng-container i18n
>to use our referral link and get a Ghostfolio Premium membership
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>
</div>
</div>

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

@ -17,6 +17,7 @@ import {
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
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}`;
return this.http.post<MarketData | void>(url, {});
return this.http.post<MarketData | void>(url, undefined, { params });
}
public fetchSymbolForDate({

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

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

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

@ -45,6 +45,7 @@ export class ImportActivitiesService {
userAccounts: Account[];
}): Promise<{
activities: Activity[];
assetProfiles: CreateAssetProfileWithMarketDataDto[];
}> {
const content = csvToJson(fileContent, {
dynamicTyping: true,
@ -53,23 +54,65 @@ export class ImportActivitiesService {
}).data;
const activities: CreateOrderDto[] = [];
const assetProfiles: CreateAssetProfileWithMarketDataDto[] = [];
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({
currency,
dataSource,
symbol,
type,
accountId: this.parseAccount({ item, userAccounts }),
comment: this.parseComment({ item }),
currency: this.parseCurrency({ content, index, item }),
dataSource: this.parseDataSource({ item }),
date: this.parseDate({ content, index, item }),
fee: this.parseFee({ 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 }),
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({

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 { AssetClassSelectorOption } from './asset-class-selector-option.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 { Benchmark } from './benchmark.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 { PortfolioChart } from './portfolio-chart.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 { PortfolioPosition } from './portfolio-position.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 { AiPromptResponse } from './responses/ai-prompt-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 { DataEnhancerHealthResponse } from './responses/data-enhancer-health-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 { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-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 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 { PortfolioReportResponse } from './responses/portfolio-report.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
@ -91,7 +91,7 @@ export {
AssetClassSelectorOption,
AssetProfileIdentifier,
Benchmark,
BenchmarkMarketDataDetails,
BenchmarkMarketDataDetailsResponse,
BenchmarkProperty,
BenchmarkResponse,
Coupon,
@ -122,10 +122,10 @@ export {
OAuthResponse,
PortfolioChart,
PortfolioDetails,
PortfolioDividends,
PortfolioDividendsResponse,
PortfolioHoldingResponse,
PortfolioHoldingsResponse,
PortfolioInvestments,
PortfolioInvestmentsResponse,
PortfolioPerformance,
PortfolioPerformanceResponse,
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;
grossPerformance: number;
grossPerformanceWithCurrencyEffect: number;
interest: number;
interestInBaseCurrency: number;
liabilitiesInBaseCurrency: number;
totalBuy: 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';
export interface BenchmarkMarketDataDetails {
export interface BenchmarkMarketDataDetailsResponse {
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" />
</button>
}
<mat-menu #activitiesMenu="matMenu" xPosition="before">
<mat-menu
#activitiesMenu="matMenu"
class="no-max-width"
xPosition="before"
>
@if (hasPermissionToCreateActivity) {
<button
class="align-items-center d-flex"
@ -425,7 +429,11 @@
<ion-icon name="ellipsis-horizontal" />
</button>
}
<mat-menu #activityMenu="matMenu" xPosition="before">
<mat-menu
#activityMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onUpdateActivity(element)">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />

62
package-lock.json

@ -1,12 +1,12 @@
{
"name": "ghostfolio",
"version": "2.208.0",
"version": "2.209.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ghostfolio",
"version": "2.208.0",
"version": "2.209.0",
"hasInstallScript": true,
"license": "AGPL-3.0",
"dependencies": {
@ -90,6 +90,7 @@
"rxjs": "7.8.1",
"stripe": "18.5.0",
"svgmap": "2.12.2",
"tablemark": "3.1.0",
"twitter-api-v2": "1.23.0",
"uuid": "11.1.0",
"yahoo-finance2": "3.10.0",
@ -23653,6 +23654,15 @@
"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": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz",
@ -31970,7 +31980,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz",
"integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==",
"dev": true,
"license": "MIT",
"dependencies": {
"tslib": "^2.0.3"
@ -32874,7 +32883,6 @@
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz",
"integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==",
"dev": true,
"license": "MIT",
"dependencies": {
"lower-case": "^2.0.2",
@ -37629,6 +37637,17 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"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": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
@ -38278,6 +38297,19 @@
"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": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz",
@ -39046,6 +39078,19 @@
"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": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz",
@ -40525,6 +40570,15 @@
"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": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

3
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.208.0",
"version": "2.209.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -136,6 +136,7 @@
"rxjs": "7.8.1",
"stripe": "18.5.0",
"svgmap": "2.12.2",
"tablemark": "3.1.0",
"twitter-api-v2": "1.23.0",
"uuid": "11.1.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",
"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",
"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",
"tags": [],
"type": "BUY",
"unitPrice": 500000,
"unitPrice": 500000
}
],
"user": {

Loading…
Cancel
Save