Browse Source

Merge branch 'main' into main

pull/7079/head
Thomas Kaul 4 weeks ago
committed by GitHub
parent
commit
1889b40268
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 2
      README.md
  3. 20
      apps/api/src/app/admin/admin.service.ts
  4. 6
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  5. 4
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  6. 2
      apps/api/src/app/import/import.service.ts
  7. 4
      apps/api/src/app/portfolio/current-rate.service.ts
  8. 31
      apps/api/src/app/symbol/symbol.service.ts
  9. 8
      apps/api/src/services/benchmark/benchmark.service.ts
  10. 1
      apps/api/src/services/cron/cron.service.ts
  11. 73
      apps/api/src/services/data-provider/data-provider.service.ts
  12. 9
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  13. 13
      apps/api/src/services/market-data/market-data.service.ts
  14. 83
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  15. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  16. 21
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  17. 17
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  18. 12
      apps/client/src/app/components/footer/footer.component.html
  19. 41
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  20. 96
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  21. 172
      apps/client/src/app/services/import-activities.service.ts
  22. 440
      apps/client/src/locales/messages.ca.xlf
  23. 446
      apps/client/src/locales/messages.de.xlf
  24. 446
      apps/client/src/locales/messages.es.xlf
  25. 446
      apps/client/src/locales/messages.fr.xlf
  26. 446
      apps/client/src/locales/messages.it.xlf
  27. 446
      apps/client/src/locales/messages.ko.xlf
  28. 446
      apps/client/src/locales/messages.nl.xlf
  29. 446
      apps/client/src/locales/messages.pl.xlf
  30. 446
      apps/client/src/locales/messages.pt.xlf
  31. 446
      apps/client/src/locales/messages.tr.xlf
  32. 432
      apps/client/src/locales/messages.uk.xlf
  33. 430
      apps/client/src/locales/messages.xlf
  34. 446
      apps/client/src/locales/messages.zh.xlf
  35. 12
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  36. 1
      libs/common/src/lib/interfaces/admin-user.interface.ts
  37. 8
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  38. 1
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  39. 4
      libs/ui/src/lib/page-tabs/page-tabs.component.html
  40. 9
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  41. 4
      libs/ui/src/lib/page-tabs/page-tabs.component.ts
  42. 2
      libs/ui/src/lib/services/admin.service.ts
  43. 8
      prisma/migrations/20260620163851_added_data_gathering_frequency_to_symbol_profile/migration.sql
  44. 7
      prisma/schema.prisma

11
CHANGELOG.md

@ -7,19 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added an icon to indicate external links in the page tabs component
- Added the Korean (`ko`) language to the footer
- Added a data gathering frequency (`DAILY` or `HOURLY`) to the asset profile to control the market data gathering interval
### Changed ### Changed
- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol` - Changed the _Fear & Greed Index_ (market mood) in the markets overview to use the stored market data instead of a live quote
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles` - Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol`
- Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel - Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel
- Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel - Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel
- Improved the sorting to be case-insensitive in the platform management of the admin control panel - Improved the sorting to be case-insensitive in the platform management of the admin control panel
- Improved the sorting to be case-insensitive in the tag management of the admin control panel - Improved the sorting to be case-insensitive in the tag management of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3` - Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3`
### Fixed ### Fixed
- Fixed an issue with the localization of the country names - Fixed an issue with the localization of the country names
- Fixed an issue in the data provider service where quotes could be missing for symbols that exist in multiple data sources by keying the quotes response by the asset profile identifier
## 3.12.0 - 2026-06-17 ## 3.12.0 - 2026-06-17

2
README.md

@ -362,7 +362,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you. Ghostfolio is **100% free** and **open source**. We support an active and healthy community and welcome contributions from everyone, including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you. Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.

20
apps/api/src/app/admin/admin.service.ts

@ -181,6 +181,24 @@ export class AdminService {
throw new NotFoundException(`User with ID ${id} not found`); throw new NotFoundException(`User with ID ${id} not found`);
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const subscriptions = await this.prismaService.subscription.findMany({
orderBy: {
expiresAt: 'desc'
},
where: {
userId: id
}
});
user.subscriptions = subscriptions.map((subscription) => {
return {
...subscription,
price: subscription.price ?? 0
};
});
}
return user; return user;
} }
@ -210,6 +228,7 @@ export class AdminService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
dataSource: newDataSource, dataSource: newDataSource,
holdings, holdings,
isActive, isActive,
@ -293,6 +312,7 @@ export class AdminService {
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment, comment,
currency, currency,
dataGatheringFrequency,
dataSource, dataSource,
isActive, isActive,
scraperConfiguration, scraperConfiguration,

6
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -56,14 +56,16 @@ export class MarketDataController {
dataGatheringItem: { dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
} },
useIntradayData: true
}), }),
this.symbolService.get({ this.symbolService.get({
includeHistoricalData, includeHistoricalData,
dataGatheringItem: { dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks symbol: ghostfolioFearAndGreedIndexSymbolStocks
} },
useIntradayData: true
}) })
]); ]);

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

@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
@ -127,7 +128,8 @@ export class WatchlistService {
const performancePercent = const performancePercent =
this.benchmarkService.calculateChangeInPercentage( this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice, allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketPrice
); );
return { return {

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

@ -536,6 +536,8 @@ export class ImportService {
url, url,
comment: assetProfile.comment, comment: assetProfile.comment,
currency: assetProfile.currency, currency: assetProfile.currency,
dataGatheringFrequency:
assetProfile.dataGatheringFrequency ?? 'DAILY',
userId: dataSource === 'MANUAL' ? user.id : undefined userId: dataSource === 'MANUAL' ? user.id : undefined
}, },
symbolProfileId: undefined, symbolProfileId: undefined,

4
apps/api/src/app/portfolio/current-rate.service.ts

@ -51,13 +51,13 @@ export class CurrentRateService {
const values: GetValueObject[] = []; const values: GetValueObject[] = [];
if (includesToday) { if (includesToday) {
const quotesBySymbol = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: dataGatheringItems, items: dataGatheringItems,
user: this.request?.user user: this.request?.user
}); });
for (const { dataSource, symbol } of dataGatheringItems) { for (const { dataSource, symbol } of dataGatheringItems) {
const quote = quotesBySymbol[symbol]; const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (quote?.dataProviderInfo) { if (quote?.dataProviderInfo) {
dataProviderInfos.push(quote.dataProviderInfo); dataProviderInfos.push(quote.dataProviderInfo);

31
apps/api/src/app/symbol/symbol.service.ts

@ -1,7 +1,10 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import { import {
DataProviderHistoricalResponse, DataProviderHistoricalResponse,
HistoricalDataItem, HistoricalDataItem,
@ -24,15 +27,31 @@ export class SymbolService {
public async get({ public async get({
dataGatheringItem, dataGatheringItem,
includeHistoricalData includeHistoricalData,
useIntradayData = false
}: { }: {
dataGatheringItem: DataGatheringItem; dataGatheringItem: DataGatheringItem;
includeHistoricalData?: number; includeHistoricalData?: number;
useIntradayData?: boolean;
}): Promise<SymbolItem> { }): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes({ let currency: string;
items: [dataGatheringItem] let marketPrice: number;
});
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {}; if (useIntradayData) {
const latestMarketData = await this.marketDataService.getLatest({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
marketPrice = latestMarketData?.marketPrice;
} else {
const quotes = await this.dataProviderService.getQuotes({
items: [dataGatheringItem]
});
({ currency, marketPrice } =
quotes[getAssetProfileIdentifier(dataGatheringItem)] ?? {});
}
if (dataGatheringItem.dataSource && marketPrice >= 0) { if (dataGatheringItem.dataSource && marketPrice >= 0) {
let historicalData: HistoricalDataItem[] = []; let historicalData: HistoricalDataItem[] = [];

8
apps/api/src/services/benchmark/benchmark.service.ts

@ -8,7 +8,10 @@ import {
CACHE_TTL_INFINITE, CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { calculateBenchmarkTrend } from '@ghostfolio/common/helper'; import {
calculateBenchmarkTrend,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
@ -266,8 +269,9 @@ export class BenchmarkService {
let storeInCache = true; let storeInCache = true;
const benchmarks = allTimeHighs.map((allTimeHigh, index) => { const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { dataSource, symbol } = benchmarkAssetProfiles[index];
const { marketPrice } = const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {}; quotes[getAssetProfileIdentifier({ dataSource, symbol })] ?? {};
let performancePercentFromAllTimeHigh = 0; let performancePercentFromAllTimeHigh = 0;

1
apps/api/src/services/cron/cron.service.ts

@ -42,6 +42,7 @@ export class CronService {
public async runEveryHourAtRandomMinute() { public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) { if (await this.isDataGatheringEnabled()) {
await this.dataGatheringService.gather7Days(); await this.dataGatheringService.gather7Days();
await this.dataGatheringService.gatherHourlySymbols();
} }
} }

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

@ -77,7 +77,9 @@ export class DataProviderService implements OnModuleInit {
useCache: false useCache: false
}); });
if (quotes[symbol]?.marketPrice > 0) { if (
quotes[getAssetProfileIdentifier({ dataSource, symbol })]?.marketPrice > 0
) {
return true; return true;
} }
@ -514,7 +516,6 @@ export class DataProviderService implements OnModuleInit {
return result; return result;
} }
// TODO: Change symbol in response to assetProfileIdentifier
public async getQuotes({ public async getQuotes({
items, items,
requestTimeout, requestTimeout,
@ -526,10 +527,12 @@ export class DataProviderService implements OnModuleInit {
useCache?: boolean; useCache?: boolean;
user?: UserWithSettings; user?: UserWithSettings;
}): Promise<{ }): Promise<{
[symbol: string]: DataProviderResponse; [assetProfileIdentifier: string]: DataProviderResponse;
}> { }> {
const response: { const response: {
[symbol: string]: DataProviderResponse; [assetProfileIdentifier: string]: DataProviderResponse & {
symbol: string;
};
} = {}; } = {};
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
@ -538,11 +541,17 @@ export class DataProviderService implements OnModuleInit {
return symbol === `${DEFAULT_CURRENCY}USX`; return symbol === `${DEFAULT_CURRENCY}USX`;
}) })
) { ) {
response[`${DEFAULT_CURRENCY}USX`] = { response[
getAssetProfileIdentifier({
dataSource: this.getDataSourceForExchangeRates(),
symbol: `${DEFAULT_CURRENCY}USX`
})
] = {
currency: 'USX', currency: 'USX',
dataSource: this.getDataSourceForExchangeRates(), dataSource: this.getDataSourceForExchangeRates(),
marketPrice: 100, marketPrice: 100,
marketState: 'open' marketState: 'open',
symbol: `${DEFAULT_CURRENCY}USX`
}; };
} }
@ -557,8 +566,13 @@ export class DataProviderService implements OnModuleInit {
if (quoteString) { if (quoteString) {
try { try {
const cachedDataProviderResponse = JSON.parse(quoteString); const cachedDataProviderResponse = JSON.parse(
response[symbol] = cachedDataProviderResponse; quoteString
) as DataProviderResponse;
response[getAssetProfileIdentifier({ dataSource, symbol })] = {
...cachedDataProviderResponse,
symbol
};
continue; continue;
} catch {} } catch {}
} }
@ -646,14 +660,19 @@ export class DataProviderService implements OnModuleInit {
continue; continue;
} }
response[symbol] = dataProviderResponse; response[
getAssetProfileIdentifier({
symbol,
dataSource: DataSource[dataSource]
})
] = { ...dataProviderResponse, symbol };
this.redisCacheService.set( this.redisCacheService.set(
this.redisCacheService.getQuoteKey({ this.redisCacheService.getQuoteKey({
symbol, symbol,
dataSource: DataSource[dataSource] dataSource: DataSource[dataSource]
}), }),
JSON.stringify(response[symbol]), JSON.stringify(dataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL') this.configurationService.get('CACHE_QUOTES_TTL')
); );
@ -663,7 +682,7 @@ export class DataProviderService implements OnModuleInit {
rootCurrency rootCurrency
} of DERIVED_CURRENCIES) { } of DERIVED_CURRENCIES) {
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
response[`${DEFAULT_CURRENCY}${currency}`] = { const derivedDataProviderResponse: DataProviderResponse = {
...dataProviderResponse, ...dataProviderResponse,
currency, currency,
marketPrice: new Big( marketPrice: new Big(
@ -674,12 +693,22 @@ export class DataProviderService implements OnModuleInit {
marketState: 'open' marketState: 'open'
}; };
response[
getAssetProfileIdentifier({
dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}`
})
] = {
...derivedDataProviderResponse,
symbol: `${DEFAULT_CURRENCY}${currency}`
};
this.redisCacheService.set( this.redisCacheService.set(
this.redisCacheService.getQuoteKey({ this.redisCacheService.getQuoteKey({
dataSource: DataSource[dataSource], dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}` symbol: `${DEFAULT_CURRENCY}${currency}`
}), }),
JSON.stringify(response[`${DEFAULT_CURRENCY}${currency}`]), JSON.stringify(derivedDataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL') this.configurationService.get('CACHE_QUOTES_TTL')
); );
} }
@ -697,21 +726,21 @@ export class DataProviderService implements OnModuleInit {
try { try {
await this.marketDataService.updateMany({ await this.marketDataService.updateMany({
data: Object.keys(response) data: Object.values(response)
.filter((symbol) => { .filter(({ marketPrice, marketState }) => {
return ( return (
isNumber(response[symbol].marketPrice) && isNumber(marketPrice) &&
response[symbol].marketPrice > 0 && marketPrice > 0 &&
response[symbol].marketState === 'open' marketState === 'open'
); );
}) })
.map((symbol) => { .map((dataProviderResponse) => {
return { return {
symbol, dataSource: dataProviderResponse.dataSource,
dataSource: response[symbol].dataSource,
date: getStartOfUtcDate(new Date()), date: getStartOfUtcDate(new Date()),
marketPrice: response[symbol].marketPrice, marketPrice: dataProviderResponse.marketPrice,
state: 'INTRADAY' state: 'INTRADAY',
symbol: dataProviderResponse.symbol
}; };
}) })
}); });

9
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -11,6 +11,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier,
getYesterday, getYesterday,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -176,11 +177,13 @@ export class ExchangeRateDataService {
requestTimeout: ms('30 seconds') requestTimeout: ms('30 seconds')
}); });
for (const symbol of Object.keys(quotes)) { for (const { dataSource, symbol } of this.currencyPairs) {
if (isNumber(quotes[symbol].marketPrice)) { const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (isNumber(quote?.marketPrice)) {
result[symbol] = { result[symbol] = {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: quotes[symbol].marketPrice marketPrice: quote.marketPrice
} }
}; };
} }

13
apps/api/src/services/market-data/market-data.service.ts

@ -40,6 +40,19 @@ export class MarketDataService {
}); });
} }
public async getLatest({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<MarketData> {
return this.prismaService.marketData.findFirst({
orderBy: [{ date: 'desc' }],
where: {
dataSource,
symbol
}
});
}
public async getMax({ dataSource, symbol }: AssetProfileIdentifier) { public async getMax({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.findFirst({ return this.prismaService.marketData.findFirst({
select: { select: {

83
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -17,6 +18,7 @@ import {
import { import {
DATE_FORMAT, DATE_FORMAT,
getAssetProfileIdentifier, getAssetProfileIdentifier,
getStartOfUtcDate,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
@ -26,7 +28,7 @@ import {
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, Prisma } from '@prisma/client';
import { JobOptions, Queue } from 'bull'; import { JobOptions, Queue } from 'bull';
import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; import { format, min, subDays, subMilliseconds, subYears } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
@ -43,6 +45,7 @@ export class DataGatheringService {
private readonly dataGatheringQueue: Queue, private readonly dataGatheringQueue: Queue,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
@ -279,6 +282,46 @@ export class DataGatheringService {
} }
} }
public async gatherHourlySymbols() {
const assetProfileIdentifiers =
await this.getHourlyAssetProfileIdentifiers();
if (assetProfileIdentifiers.length <= 0) {
return;
}
const date = getStartOfUtcDate(new Date());
try {
const quotes = await this.dataProviderService.getQuotes({
items: assetProfileIdentifiers,
useCache: false
});
const data: Prisma.MarketDataUpdateInput[] = [];
for (const { dataSource, symbol } of assetProfileIdentifiers) {
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (!quote?.marketPrice) {
continue;
}
data.push({
dataSource,
date,
symbol,
marketPrice: quote.marketPrice,
state: 'INTRADAY'
});
}
await this.marketDataService.updateMany({ data });
} catch (error) {
this.logger.error('Could not gather hourly market data', error);
}
}
public async gatherSymbols({ public async gatherSymbols({
dataGatheringItems, dataGatheringItems,
force = false, force = false,
@ -389,6 +432,36 @@ export class DataGatheringService {
return min([aStartDate, subYears(new Date(), 10)]); return min([aStartDate, subYears(new Date(), 10)]);
} }
private async getHourlyAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[]
> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }, { dataSource: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
},
where: {
dataGatheringFrequency: 'HOURLY',
isActive: true
}
});
return symbolProfiles
.filter(({ dataSource, scraperConfiguration }) => {
const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration
);
})
.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
});
}
private async getSymbols7D({ private async getSymbols7D({
withUserSubscription = false withUserSubscription = false
}: { }: {
@ -469,14 +542,12 @@ export class DataGatheringService {
} }
}) })
) )
.filter((symbolProfile) => { .filter(({ dataSource, scraperConfiguration }) => {
const manualDataSourceWithScraperConfiguration = const manualDataSourceWithScraperConfiguration =
symbolProfile.dataSource === 'MANUAL' && dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
!isEmpty(symbolProfile.scraperConfiguration);
return ( return (
symbolProfile.dataSource !== 'MANUAL' || dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration
manualDataSourceWithScraperConfiguration
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {

2
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -178,6 +178,7 @@ export class SymbolProfileService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
holdings, holdings,
isActive, isActive,
name, name,
@ -195,6 +196,7 @@ export class SymbolProfileService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
holdings, holdings,
isActive, isActive,
name, name,

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

@ -74,6 +74,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataGatheringFrequency,
MarketData, MarketData,
Prisma, Prisma,
SymbolProfile SymbolProfile
@ -155,6 +156,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
comment: '', comment: '',
countries: ['', jsonValidator()], countries: ['', jsonValidator()],
currency: '', currency: '',
dataGatheringFrequency: new FormControl<DataGatheringFrequency>('DAILY'),
historicalData: this.formBuilder.group({ historicalData: this.formBuilder.group({
csvString: '' csvString: ''
}), }),
@ -199,6 +201,20 @@ export class GfAssetProfileDialogComponent implements OnInit {
protected currencies: string[] = []; protected currencies: string[] = [];
protected readonly dataGatheringFrequencyValues: {
value: DataGatheringFrequency;
viewValue: string;
}[] = [
{
value: 'DAILY',
viewValue: $localize`Daily`
},
{
value: 'HOURLY',
viewValue: $localize`Hourly`
}
];
protected readonly dateRangeOptions = [ protected readonly dateRangeOptions = [
{ {
label: $localize`Current week` + ' (' + $localize`WTD` + ')', label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -401,6 +417,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
}) ?? [] }) ?? []
), ),
currency: this.assetProfile?.currency ?? null, currency: this.assetProfile?.currency ?? null,
dataGatheringFrequency:
this.assetProfile?.dataGatheringFrequency ?? 'DAILY',
historicalData: { historicalData: {
csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE
}, },
@ -583,6 +601,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.assetSubClass.value ?? undefined, this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value || undefined, comment: this.assetProfileForm.controls.comment.value || undefined,
currency: this.assetProfileForm.controls.currency.value ?? undefined, currency: this.assetProfileForm.controls.currency.value ?? undefined,
dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ??
undefined,
isActive: isBoolean(this.assetProfileForm.controls.isActive.value) isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
? this.assetProfileForm.controls.isActive.value ? this.assetProfileForm.controls.isActive.value
: undefined, : undefined,

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

@ -113,7 +113,7 @@
<ion-icon name="reader-outline" /> <ion-icon name="reader-outline" />
<div class="d-none d-sm-block ml-2" i18n>Overview</div> <div class="d-none d-sm-block ml-2" i18n>Overview</div>
</ng-template> </ng-template>
<div class="container mt-3 p-0"> <div class="container px-0 py-3">
<div class="row w-100"> <div class="row w-100">
@if (isEditAssetProfileIdentifierMode) { @if (isEditAssetProfileIdentifierMode) {
<div class="col-12 mb-4"> <div class="col-12 mb-4">
@ -444,6 +444,21 @@
></textarea> ></textarea>
</mat-form-field> </mat-form-field>
</div> </div>
<div>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Data Gathering Frequency</mat-label>
<mat-select formControlName="dataGatheringFrequency">
@for (
dataGatheringFrequencyValue of dataGatheringFrequencyValues;
track dataGatheringFrequencyValue.value
) {
<mat-option [value]="dataGatheringFrequencyValue.value">{{
dataGatheringFrequencyValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</form> </form>
</div> </div>
</mat-tab> </mat-tab>

12
apps/client/src/app/components/footer/footer.component.html

@ -141,13 +141,11 @@
<li> <li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a> <a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li> </li>
<!-- <li>
<li> <a href="../ko" title="Ghostfolio in Korean (한국어)"
<a href="../ko" title="Ghostfolio in Korean (한국어)" >Korean (한국어)</a
>Korean (한국어)</a >
> </li>
</li>
-->
<li> <li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a> <a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li> </li>

41
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -1,5 +1,6 @@
import { getSum } from '@ghostfolio/common/helper';
import { AdminUserResponse } from '@ghostfolio/common/interfaces'; import { AdminUserResponse } from '@ghostfolio/common/interfaces';
import { AdminService } from '@ghostfolio/ui/services'; import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value'; import { GfValueComponent } from '@ghostfolio/ui/value';
import { import {
@ -16,7 +17,11 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { Subscription } from '@prisma/client';
import { Big } from 'big.js';
import { differenceInDays } from 'date-fns';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { ellipsisVertical } from 'ionicons/icons'; import { ellipsisVertical } from 'ionicons/icons';
import { EMPTY } from 'rxjs'; import { EMPTY } from 'rxjs';
@ -35,7 +40,8 @@ import {
IonIcon, IonIcon,
MatButtonModule, MatButtonModule,
MatDialogModule, MatDialogModule,
MatMenuModule MatMenuModule,
MatTableModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA], schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-user-detail-dialog', selector: 'gf-user-detail-dialog',
@ -43,18 +49,29 @@ import {
templateUrl: './user-detail-dialog.html' templateUrl: './user-detail-dialog.html'
}) })
export class GfUserDetailDialogComponent implements OnInit { export class GfUserDetailDialogComponent implements OnInit {
public baseCurrency: string;
public subscriptionsDataSource = new MatTableDataSource<Subscription>();
public subscriptionsDisplayedColumns = [
'createdAt',
'type',
'price',
'expiresAt'
];
public user: AdminUserResponse; public user: AdminUserResponse;
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams, @Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef, private destroyRef: DestroyRef,
public dialogRef: MatDialogRef< public dialogRef: MatDialogRef<
GfUserDetailDialogComponent, GfUserDetailDialogComponent,
UserDetailDialogResult UserDetailDialogResult
> >
) { ) {
this.baseCurrency = this.dataService.fetchInfo().baseCurrency;
addIcons({ addIcons({
ellipsisVertical ellipsisVertical
}); });
@ -74,6 +91,8 @@ export class GfUserDetailDialogComponent implements OnInit {
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.user = user;
this.subscriptionsDataSource.data = this.user.subscriptions ?? [];
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
@ -85,6 +104,24 @@ export class GfUserDetailDialogComponent implements OnInit {
}); });
} }
public getSum() {
return getSum(
this.subscriptionsDataSource.data.map(({ price }) => {
return new Big(price);
})
).toNumber();
}
public getType({ createdAt, expiresAt, price }: Subscription) {
if (price) {
return $localize`Paid`;
}
return differenceInDays(expiresAt, createdAt) <= 90
? $localize`Trial`
: $localize`Coupon`;
}
public onClose() { public onClose() {
this.dialogRef.close(); this.dialogRef.close();
} }

96
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -124,6 +124,102 @@
> >
</div> </div>
</div> </div>
@if (subscriptionsDataSource.data.length > 0) {
<div class="row">
<div class="col">
<h2 class="h6" i18n>Subscriptions</h2>
<div class="overflow-x-auto">
<table
class="gf-table w-100"
mat-table
[dataSource]="subscriptionsDataSource"
>
<ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.createdAt"
/>
</td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>
Total
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ getType(element) }}
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="price">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<ng-container i18n>Price</ng-container>
</th>
<td
*matCellDef="let element"
class="px-1 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="element.price"
/>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="getSum()"
/>
</td>
</ng-container>
<ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Expires</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.expiresAt"
/>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr
*matHeaderRowDef="subscriptionsDisplayedColumns"
mat-header-row
></tr>
<tr
*matRowDef="let row; columns: subscriptionsDisplayedColumns"
mat-row
></tr>
<tr
*matFooterRowDef="subscriptionsDisplayedColumns"
mat-footer-row
></tr>
</table>
</div>
</div>
</div>
}
} }
</div> </div>
</div> </div>

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

@ -8,12 +8,11 @@ import { parseDate as parseDateHelper } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces'; import { Activity } from '@ghostfolio/common/interfaces';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { Account, DataSource, Type as ActivityType } from '@prisma/client'; import { Account, DataSource, Type as ActivityType } from '@prisma/client';
import { isFinite } from 'lodash'; import { isFinite, isNumber, isString } from 'lodash';
import { parse as csvToJson } from 'papaparse'; import { parse as csvToJson } from 'papaparse';
import { EMPTY } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { catchError } from 'rxjs/operators';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -35,7 +34,7 @@ export class ImportActivitiesService {
'value' 'value'
]; ];
public constructor(private http: HttpClient) {} private readonly http = inject(HttpClient);
public async importCsv({ public async importCsv({
fileContent, fileContent,
@ -49,7 +48,7 @@ export class ImportActivitiesService {
activities: Activity[]; activities: Activity[];
assetProfiles: CreateAssetProfileWithMarketDataDto[]; assetProfiles: CreateAssetProfileWithMarketDataDto[];
}> { }> {
const content = csvToJson(fileContent, { const content = csvToJson<Record<string, unknown>>(fileContent, {
dynamicTyping: true, dynamicTyping: true,
header: true, header: true,
skipEmptyLines: true skipEmptyLines: true
@ -83,22 +82,22 @@ export class ImportActivitiesService {
assetProfiles.push({ assetProfiles.push({
currency, currency,
symbol, symbol,
assetClass: null, assetClass: undefined,
assetSubClass: null, assetSubClass: undefined,
comment: null, comment: undefined,
countries: [], countries: [],
cusip: null, cusip: undefined,
dataSource: DataSource.MANUAL, dataSource: DataSource.MANUAL,
figi: null, figi: undefined,
figiComposite: null, figiComposite: undefined,
figiShareClass: null, figiShareClass: undefined,
holdings: [], holdings: [],
isActive: true, isActive: true,
isin: null, isin: undefined,
marketData: [], marketData: [],
name: symbol, name: symbol,
sectors: [], sectors: [],
url: null url: undefined
}); });
} }
} }
@ -126,7 +125,7 @@ export class ImportActivitiesService {
}): Promise<{ }): Promise<{
activities: Activity[]; activities: Activity[];
}> { }> {
return new Promise((resolve, reject) => { return firstValueFrom(
this.postImport( this.postImport(
{ {
accounts, accounts,
@ -136,18 +135,7 @@ export class ImportActivitiesService {
}, },
isDryRun isDryRun
) )
.pipe( );
catchError((error) => {
reject(error);
return EMPTY;
})
)
.subscribe({
next: (data) => {
resolve(data);
}
});
});
} }
public importSelectedActivities({ public importSelectedActivities({
@ -163,11 +151,9 @@ export class ImportActivitiesService {
}): Promise<{ }): Promise<{
activities: Activity[]; activities: Activity[];
}> { }> {
const importData: CreateOrderDto[] = []; const importData = activities.map((activity) =>
this.convertToCreateOrderDto(activity)
for (const activity of activities) { );
importData.push(this.convertToCreateOrderDto(activity));
}
return this.importJson({ return this.importJson({
accounts, accounts,
@ -191,14 +177,14 @@ export class ImportActivitiesService {
updateAccountBalance updateAccountBalance
}: Activity): CreateOrderDto { }: Activity): CreateOrderDto {
return { return {
accountId,
comment,
fee, fee,
quantity, quantity,
type, type,
unitPrice, unitPrice,
updateAccountBalance, updateAccountBalance,
currency: currency ?? SymbolProfile.currency, accountId: accountId ?? undefined,
comment: comment ?? undefined,
currency: currency ?? SymbolProfile.currency ?? '',
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toString(), date: date.toString(),
symbol: SymbolProfile.symbol, symbol: SymbolProfile.symbol,
@ -208,28 +194,32 @@ export class ImportActivitiesService {
}; };
} }
private lowercaseKeys(aObject: any) { private lowercaseKeys(
return Object.keys(aObject).reduce((acc, key) => { aObject: Record<string, unknown>
acc[key.toLowerCase()] = aObject[key]; ): Record<string, unknown> {
return acc; return Object.fromEntries(
}, {}); Object.entries(aObject).map(([key, val]) => {
return [key.toLowerCase(), val];
})
);
} }
private parseAccount({ private parseAccount({
item, item,
userAccounts userAccounts
}: { }: {
item: any; item: Record<string, unknown>;
userAccounts: Account[]; userAccounts: Account[];
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.ACCOUNT_KEYS) { for (const key of ImportActivitiesService.ACCOUNT_KEYS) {
if (item[key]) { const value = item[key];
return userAccounts.find((account) => {
if (isNumber(value) || isString(value)) {
return userAccounts.find(({ id, name }) => {
return ( return (
account.id === item[key] || id === value || name?.toLowerCase() === String(value).toLowerCase()
account.name.toLowerCase() === item[key].toLowerCase()
); );
})?.id; })?.id;
} }
@ -238,12 +228,14 @@ export class ImportActivitiesService {
return undefined; return undefined;
} }
private parseComment({ item }: { item: any }) { private parseComment({ item }: { item: Record<string, unknown> }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.COMMENT_KEYS) { for (const key of ImportActivitiesService.COMMENT_KEYS) {
if (item[key]) { const value = item[key];
return item[key];
if (isNumber(value) || isString(value)) {
return String(value);
} }
} }
@ -255,15 +247,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.CURRENCY_KEYS) { for (const key of ImportActivitiesService.CURRENCY_KEYS) {
if (item[key]) { const value = item[key];
return item[key];
if (isString(value)) {
return value;
} }
} }
@ -273,12 +267,14 @@ export class ImportActivitiesService {
}; };
} }
private parseDataSource({ item }: { item: any }) { private parseDataSource({ item }: { item: Record<string, unknown> }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) { for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) {
if (item[key]) { const value = item[key];
return DataSource[item[key].toUpperCase()];
if (isString(value)) {
return DataSource[value.toUpperCase() as keyof typeof DataSource];
} }
} }
@ -290,16 +286,22 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.DATE_KEYS) { for (const key of ImportActivitiesService.DATE_KEYS) {
if (item[key]) { const value = item[key];
if (isNumber(value) || isString(value)) {
try { try {
return parseDateHelper(item[key].toString()).toISOString(); const parsedDate = parseDateHelper(String(value));
if (parsedDate) {
return parsedDate.toISOString();
}
} catch {} } catch {}
} }
} }
@ -315,15 +317,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.FEE_KEYS) { for (const key of ImportActivitiesService.FEE_KEYS) {
if (isFinite(item[key])) { const value = item[key];
return Math.abs(item[key]);
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
} }
} }
@ -338,15 +342,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.QUANTITY_KEYS) { for (const key of ImportActivitiesService.QUANTITY_KEYS) {
if (isFinite(item[key])) { const value = item[key];
return Math.abs(item[key]);
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
} }
} }
@ -361,15 +367,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.SYMBOL_KEYS) { for (const key of ImportActivitiesService.SYMBOL_KEYS) {
if (item[key]) { const value = item[key];
return item[key];
if (isNumber(value) || isString(value)) {
return String(value);
} }
} }
@ -384,15 +392,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}): ActivityType { }): ActivityType {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.TYPE_KEYS) { for (const key of ImportActivitiesService.TYPE_KEYS) {
if (item[key]) { const value = item[key];
switch (item[key].toLowerCase()) {
if (isString(value)) {
switch (value.toLowerCase()) {
case 'buy': case 'buy':
return 'BUY'; return 'BUY';
case 'dividend': case 'dividend':
@ -422,15 +432,17 @@ export class ImportActivitiesService {
index, index,
item item
}: { }: {
content: any[]; content: Record<string, unknown>[];
index: number; index: number;
item: any; item: Record<string, unknown>;
}) { }) {
item = this.lowercaseKeys(item); item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) { for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
if (isFinite(item[key])) { const value = item[key];
return Math.abs(item[key]);
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
} }
} }

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

446
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

12
libs/common/src/lib/dtos/update-asset-profile.dto.ts

@ -1,6 +1,12 @@
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource,
Prisma
} from '@prisma/client';
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
@ -32,6 +38,10 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
currency?: string; currency?: string;
@IsEnum(DataGatheringFrequency)
@IsOptional()
dataGatheringFrequency?: DataGatheringFrequency;
@IsEnum(DataSource) @IsEnum(DataSource)
@IsOptional() @IsOptional()
dataSource?: DataSource; dataSource?: DataSource;

1
libs/common/src/lib/interfaces/admin-user.interface.ts

@ -12,4 +12,5 @@ export interface AdminUser {
provider: Provider; provider: Provider;
role: Role; role: Role;
subscription?: Subscription; subscription?: Subscription;
subscriptions?: Subscription[];
} }

8
libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts

@ -1,4 +1,9 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource
} from '@prisma/client';
import { Country } from './country.interface'; import { Country } from './country.interface';
import { DataProviderInfo } from './data-provider-info.interface'; import { DataProviderInfo } from './data-provider-info.interface';
@ -15,6 +20,7 @@ export interface EnhancedSymbolProfile {
createdAt: Date; createdAt: Date;
currency?: string; currency?: string;
cusip?: string; cusip?: string;
dataGatheringFrequency?: DataGatheringFrequency;
dataProviderInfo?: DataProviderInfo; dataProviderInfo?: DataProviderInfo;
dataSource: DataSource; dataSource: DataSource;
dateOfFirstActivity?: Date; dateOfFirstActivity?: Date;

1
libs/common/src/lib/interfaces/responses/export-response.interface.ts

@ -28,6 +28,7 @@ export interface ExportResponse {
assetProfiles: (Omit< assetProfiles: (Omit<
SymbolProfile, SymbolProfile,
| 'createdAt' | 'createdAt'
| 'dataGatheringFrequency'
| 'id' | 'id'
| 'scraperConfiguration' | 'scraperConfiguration'
| 'symbolMapping' | 'symbolMapping'

4
libs/ui/src/lib/page-tabs/page-tabs.component.html

@ -20,6 +20,10 @@
<ng-container <ng-container
*ngTemplateOutlet="tabContent; context: { $implicit: tab }" *ngTemplateOutlet="tabContent; context: { $implicit: tab }"
/> />
<ion-icon
class="d-none d-sm-block external-link-icon ml-auto text-muted"
name="open-outline"
/>
</button> </button>
} @else { } @else {
<a <a

9
libs/ui/src/lib/page-tabs/page-tabs.component.scss

@ -6,6 +6,10 @@
height: 100%; height: 100%;
width: 100%; width: 100%;
.external-link-icon {
font-size: 0.75rem;
}
@include mat.tabs-overrides( @include mat.tabs-overrides(
( (
active-indicator-height: 0, active-indicator-height: 0,
@ -65,6 +69,11 @@
background-color: rgba(var(--palette-foreground-base), 0.05); background-color: rgba(var(--palette-foreground-base), 0.05);
font-weight: 500; font-weight: 500;
} }
.mdc-tab__content,
.mdc-tab__text-label {
width: 100%;
}
} }
} }
} }

4
libs/ui/src/lib/page-tabs/page-tabs.component.ts

@ -8,6 +8,8 @@ import {
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone'; import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { TabConfiguration } from './interfaces/interfaces'; import { TabConfiguration } from './interfaces/interfaces';
@ -27,5 +29,7 @@ export class GfPageTabsComponent {
public constructor() { public constructor() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType; this.deviceType = this.deviceService.getDeviceInfo().deviceType;
addIcons({ openOutline });
} }
} }

2
libs/ui/src/lib/services/admin.service.ts

@ -189,6 +189,7 @@ export class AdminService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
dataSource: newDataSource, dataSource: newDataSource,
isActive, isActive,
name, name,
@ -207,6 +208,7 @@ export class AdminService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
dataSource: newDataSource, dataSource: newDataSource,
isActive, isActive,
name, name,

8
prisma/migrations/20260620163851_added_data_gathering_frequency_to_symbol_profile/migration.sql

@ -0,0 +1,8 @@
-- CreateEnum
CREATE TYPE "DataGatheringFrequency" AS ENUM ('DAILY', 'HOURLY');
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "dataGatheringFrequency" "DataGatheringFrequency" NOT NULL DEFAULT 'DAILY';
-- CreateIndex
CREATE INDEX "SymbolProfile_dataGatheringFrequency_idx" ON "SymbolProfile"("dataGatheringFrequency");

7
prisma/schema.prisma

@ -191,6 +191,7 @@ model SymbolProfile {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
currency String currency String
cusip String? cusip String?
dataGatheringFrequency DataGatheringFrequency @default(DAILY)
dataSource DataSource dataSource DataSource
figi String? figi String?
figiComposite String? figiComposite String?
@ -215,6 +216,7 @@ model SymbolProfile {
@@index([assetClass]) @@index([assetClass])
@@index([currency]) @@index([currency])
@@index([cusip]) @@index([cusip])
@@index([dataGatheringFrequency])
@@index([dataSource]) @@index([dataSource])
@@index([isActive]) @@index([isActive])
@@index([isin]) @@index([isin])
@ -316,6 +318,11 @@ enum AssetSubClass {
STOCK STOCK
} }
enum DataGatheringFrequency {
DAILY
HOURLY
}
enum DataSource { enum DataSource {
ALPHA_VANTAGE ALPHA_VANTAGE
COINGECKO COINGECKO

Loading…
Cancel
Save