Browse Source

Merge remote-tracking branch 'origin/main' into task/allocations-page-type-safety

pull/7076/head
KenTandrian 4 weeks ago
parent
commit
764ceed7c6
  1. 16
      CHANGELOG.md
  2. 2
      apps/api/src/app/admin/admin.module.ts
  3. 74
      apps/api/src/app/admin/admin.service.ts
  4. 11
      apps/api/src/app/asset/asset.controller.ts
  5. 4
      apps/api/src/app/asset/asset.module.ts
  6. 58
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  7. 11
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  8. 61
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  9. 61
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  10. 12
      apps/api/src/app/endpoints/market-data/market-data.module.ts
  11. 23
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  12. 2
      apps/api/src/app/import/import.service.ts
  13. 4
      apps/api/src/app/portfolio/current-rate.service.ts
  14. 3
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  15. 9
      apps/api/src/app/portfolio/portfolio.service.ts
  16. 31
      apps/api/src/app/symbol/symbol.service.ts
  17. 8
      apps/api/src/events/asset-profile-changed.listener.ts
  18. 8
      apps/api/src/services/benchmark/benchmark.service.ts
  19. 1
      apps/api/src/services/configuration/configuration.service.ts
  20. 49
      apps/api/src/services/cron/cron.module.ts
  21. 1
      apps/api/src/services/cron/cron.service.ts
  22. 77
      apps/api/src/services/data-provider/data-provider.service.ts
  23. 9
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  24. 1
      apps/api/src/services/interfaces/environment.interface.ts
  25. 13
      apps/api/src/services/market-data/market-data.service.ts
  26. 89
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  27. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  28. 12
      apps/client/src/app/app.component.ts
  29. 5
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  30. 21
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  31. 17
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  32. 10
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  33. 24
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  34. 520
      apps/client/src/locales/messages.ca.xlf
  35. 526
      apps/client/src/locales/messages.de.xlf
  36. 526
      apps/client/src/locales/messages.es.xlf
  37. 526
      apps/client/src/locales/messages.fr.xlf
  38. 526
      apps/client/src/locales/messages.it.xlf
  39. 526
      apps/client/src/locales/messages.ko.xlf
  40. 526
      apps/client/src/locales/messages.nl.xlf
  41. 526
      apps/client/src/locales/messages.pl.xlf
  42. 526
      apps/client/src/locales/messages.pt.xlf
  43. 526
      apps/client/src/locales/messages.tr.xlf
  44. 512
      apps/client/src/locales/messages.uk.xlf
  45. 506
      apps/client/src/locales/messages.xlf
  46. 526
      apps/client/src/locales/messages.zh.xlf
  47. 12
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  48. 8
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  49. 4
      libs/common/src/lib/interfaces/index.ts
  50. 2
      libs/common/src/lib/interfaces/responses/asset-profile-response.interface.ts
  51. 13
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  52. 4
      libs/ui/src/lib/page-tabs/page-tabs.component.html
  53. 9
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  54. 4
      libs/ui/src/lib/page-tabs/page-tabs.component.ts
  55. 10
      libs/ui/src/lib/services/admin.service.ts
  56. 21
      libs/ui/src/lib/services/data.service.ts
  57. 4
      package-lock.json
  58. 2
      package.json
  59. 8
      prisma/migrations/20260620163851_added_data_gathering_frequency_to_symbol_profile/migration.sql
  60. 7
      prisma/schema.prisma

16
CHANGELOG.md

@ -9,20 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Exposed the `ENABLE_FEATURE_CRON` environment variable to control scheduled cron job execution
### Changed
- Improved the language localization for German (`de`)
## 3.13.0 - 2026-06-20
### Added
- Added an icon to indicate external links in the page tabs component
- Added the Korean (`ko`) language to the footer - 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
- 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
apps/api/src/app/admin/admin.module.ts

@ -1,4 +1,3 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -19,7 +18,6 @@ import { QueueModule } from './queue/queue.module';
@Module({ @Module({
imports: [ imports: [
ActivitiesModule,
BenchmarkModule, BenchmarkModule,
ConfigurationModule, ConfigurationModule,
DataGatheringQueueModule, DataGatheringQueueModule,

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

@ -1,4 +1,3 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -12,14 +11,12 @@ import {
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketDataDetails,
AdminUserResponse, AdminUserResponse,
AdminUsersResponse, AdminUsersResponse,
AssetProfileIdentifier, AssetProfileIdentifier
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { import {
@ -42,7 +39,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
@ -176,61 +172,6 @@ export class AdminService {
}; };
} }
public async getMarketDataBySymbol({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol
}
})
]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return {
marketData,
assetProfile: assetProfile ?? {
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true
}
};
}
public async getUser(id: string): Promise<AdminUserResponse> { public async getUser(id: string): Promise<AdminUserResponse> {
const [user] = await this.getUsersWithAnalytics({ const [user] = await this.getUsersWithAnalytics({
where: { id } where: { id }
@ -241,7 +182,7 @@ export class AdminService {
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const subscriptions = await this.prismaService.subscription.findMany({ user.subscriptions = await this.prismaService.subscription.findMany({
orderBy: { orderBy: {
expiresAt: 'desc' expiresAt: 'desc'
}, },
@ -249,13 +190,6 @@ export class AdminService {
userId: id userId: id
} }
}); });
user.subscriptions = subscriptions.map((subscription) => {
return {
...subscription,
price: subscription.price ?? 0
};
});
} }
return user; return user;
@ -287,6 +221,7 @@ export class AdminService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
dataSource: newDataSource, dataSource: newDataSource,
holdings, holdings,
isActive, isActive,
@ -370,6 +305,7 @@ export class AdminService {
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment, comment,
currency, currency,
dataGatheringFrequency,
dataSource, dataSource,
isActive, isActive,
scraperConfiguration, scraperConfiguration,

11
apps/api/src/app/asset/asset.controller.ts

@ -1,4 +1,4 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { AssetProfilesService } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.service';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AssetResponse } from '@ghostfolio/common/interfaces'; import type { AssetResponse } from '@ghostfolio/common/interfaces';
@ -9,7 +9,9 @@ import { pick } from 'lodash';
@Controller('asset') @Controller('asset')
export class AssetController { export class AssetController {
public constructor(private readonly adminService: AdminService) {} public constructor(
private readonly assetProfilesService: AssetProfilesService
) {}
@Get(':dataSource/:symbol') @Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -19,7 +21,10 @@ export class AssetController {
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<AssetResponse> { ): Promise<AssetResponse> {
const { assetProfile, marketData } = const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); await this.assetProfilesService.getAssetProfile({
dataSource,
symbol
});
return { return {
marketData, marketData,

4
apps/api/src/app/asset/asset.module.ts

@ -1,4 +1,4 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; import { AssetProfilesModule } from '@ghostfolio/api/app/endpoints/asset-profiles/asset-profiles.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
@ -9,7 +9,7 @@ import { AssetController } from './asset.controller';
@Module({ @Module({
controllers: [AssetController], controllers: [AssetController],
imports: [ imports: [
AdminModule, AssetProfilesModule,
TransformDataSourceInRequestModule, TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule TransformDataSourceInResponseModule
] ]

58
apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts

@ -1,11 +1,17 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import { import {
AssetProfilesResponse, AssetProfilesResponse,
EnhancedSymbolProfile EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types'; import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
@ -18,7 +24,8 @@ import {
Param, Param,
Patch, Patch,
Query, Query,
UseGuards UseGuards,
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -32,7 +39,8 @@ export class AssetProfilesController {
public constructor( public constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly assetProfilesService: AssetProfilesService, private readonly assetProfilesService: AssetProfilesService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@Get() @Get()
@ -64,6 +72,52 @@ export class AssetProfilesController {
}); });
} }
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAssetProfile(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AssetProfileResponse> {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canReadAllAssetProfiles = hasPermission(
this.request.user.permissions,
permissions.readMarketData
);
const canReadOwnAssetProfile =
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile
);
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException(
assetProfile?.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return this.assetProfilesService.getAssetProfile({
dataSource,
symbol
});
}
@HasPermission(permissions.accessAdminControl) @HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol') @Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)

11
apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts

@ -1,7 +1,11 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module'; import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -12,13 +16,18 @@ import { AssetProfilesService } from './asset-profiles.service';
@Module({ @Module({
controllers: [AssetProfilesController], controllers: [AssetProfilesController],
exports: [AssetProfilesService],
imports: [ imports: [
ActivitiesModule, ActivitiesModule,
ApiModule, ApiModule,
BenchmarkModule, BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule,
PrismaModule, PrismaModule,
SymbolProfileModule SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
], ],
providers: [AssetProfilesService] providers: [AssetProfilesService]
}) })

61
apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts

@ -1,6 +1,8 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service'; import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos'; import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
@ -11,8 +13,9 @@ import {
isCurrency isCurrency
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { import {
AssetProfileItem, AdminMarketDataDetails,
AssetProfileIdentifier, AssetProfileIdentifier,
AssetProfileItem,
AssetProfilesResponse, AssetProfilesResponse,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
@ -28,11 +31,67 @@ export class AssetProfilesService {
public constructor( public constructor(
private readonly activitiesService: ActivitiesService, private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
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 symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async getAssetProfile({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<AdminMarketDataDetails> {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
const isCurrencyAssetProfile = isCurrency(getCurrencyFromSymbol(symbol));
if (isCurrencyAssetProfile) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const [[assetProfile], marketData] = await Promise.all([
this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol
}
})
]);
if (assetProfile) {
assetProfile.dataProviderInfo = this.dataProviderService
.getDataProvider(assetProfile.dataSource)
.getDataProviderInfo();
}
return {
marketData,
assetProfile: assetProfile ?? {
activitiesCount,
currency,
dataSource,
dateOfFirstActivity,
symbol,
assetClass: isCurrencyAssetProfile ? AssetClass.LIQUIDITY : undefined,
assetSubClass: isCurrencyAssetProfile ? AssetSubClass.CASH : undefined,
isActive: true
}
};
}
public async getAssetProfiles({ public async getAssetProfiles({
filters = [], filters = [],
presetId, presetId,

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

@ -1,9 +1,6 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
@ -14,10 +11,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -30,8 +24,7 @@ import {
Param, Param,
Post, Post,
Query, Query,
UseGuards, UseGuards
UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -42,7 +35,6 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('market-data') @Controller('market-data')
export class MarketDataController { export class MarketDataController {
public constructor( public constructor(
private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService,
@ -64,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
}) })
]); ]);
@ -87,49 +81,6 @@ export class MarketDataController {
}; };
} }
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<MarketDataDetailsResponse> {
const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource, symbol }
]);
if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const canReadAllAssetProfiles = hasPermission(
this.request.user.permissions,
permissions.readMarketData
);
const canReadOwnAssetProfile =
assetProfile?.userId === this.request.user.id &&
hasPermission(
this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile
);
if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) {
throw new HttpException(
assetProfile?.userId
? getReasonPhrase(StatusCodes.NOT_FOUND)
: getReasonPhrase(StatusCodes.FORBIDDEN),
assetProfile?.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN
);
}
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Post(':dataSource/:symbol') @Post(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateMarketData( public async updateMarketData(

12
apps/api/src/app/endpoints/market-data/market-data.module.ts

@ -1,7 +1,4 @@
import { AdminModule } from '@ghostfolio/api/app/admin/admin.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -11,13 +8,6 @@ import { MarketDataController } from './market-data.controller';
@Module({ @Module({
controllers: [MarketDataController], controllers: [MarketDataController],
imports: [ imports: [MarketDataServiceModule, SymbolModule, SymbolProfileModule]
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
}) })
export class MarketDataModule {} export class MarketDataModule {}

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

@ -4,10 +4,14 @@ 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 { WatchlistResponse } from '@ghostfolio/common/interfaces'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
WatchlistResponse
} from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { DataSource, Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class WatchlistService { export class WatchlistService {
@ -24,11 +28,7 @@ export class WatchlistService {
dataSource, dataSource,
symbol, symbol,
userId userId
}: { }: { userId: string } & AssetProfileIdentifier): Promise<void> {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: { where: {
dataSource_symbol: { dataSource, symbol } dataSource_symbol: { dataSource, symbol }
@ -72,11 +72,7 @@ export class WatchlistService {
dataSource, dataSource,
symbol, symbol,
userId userId
}: { }: { userId: string } & AssetProfileIdentifier) {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
await this.prismaService.user.update({ await this.prismaService.user.update({
data: { data: {
watchlist: { watchlist: {
@ -127,7 +123,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);

3
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -11,6 +11,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Account, DataSource } from '@prisma/client'; import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
@ -198,7 +199,7 @@ describe('PortfolioService', () => {
portfolioService as unknown as { portfolioService as unknown as {
getCashSymbolProfiles: ( getCashSymbolProfiles: (
aCashDetails: CashDetails aCashDetails: CashDetails
) => { dataSource: DataSource; symbol: string }[]; ) => AssetProfileIdentifier[];
} }
).getCashSymbolProfiles(cashDetails); ).getCashSymbolProfiles(cashDetails);

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

@ -46,6 +46,7 @@ import {
import { import {
AccountsResponse, AccountsResponse,
Activity, Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter, Filter,
HistoricalDataItem, HistoricalDataItem,
@ -776,11 +777,9 @@ export class PortfolioService {
symbol, symbol,
userId userId
}: { }: {
dataSource: DataSource;
impersonationId: string; impersonationId: string;
symbol: string;
userId: string; userId: string;
}): Promise<PortfolioHoldingResponse> { } & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
@ -1381,12 +1380,10 @@ export class PortfolioService {
tags, tags,
userId userId
}: { }: {
dataSource: DataSource;
impersonationId: string; impersonationId: string;
symbol: string;
tags: Tag[]; tags: Tag[];
userId: string; userId: string;
}) { } & AssetProfileIdentifier) {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
await this.activitiesService.assignTags({ await this.activitiesService.assignTags({

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/events/asset-profile-changed.listener.ts

@ -5,10 +5,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
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 { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter'; import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms'; import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event'; import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@ -64,11 +64,7 @@ export class AssetProfileChangedListener {
currency, currency,
dataSource, dataSource,
symbol symbol
}: { }: { currency: string } & AssetProfileIdentifier) {
currency: string;
dataSource: DataSource;
symbol: string;
}) {
this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`); this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`);
if ( if (

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/configuration/configuration.service.ts

@ -43,6 +43,7 @@ export class ConfigurationService {
ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }),
ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),

49
apps/api/src/services/cron/cron.module.ts

@ -1,12 +1,19 @@
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; import { DataGatheringQueueModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module'; import { StatisticsGatheringQueueModule } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.module';
import { StatisticsGatheringService } from '@ghostfolio/api/services/queues/statistics-gathering/statistics-gathering.service';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common'; import { Logger, Module } from '@nestjs/common';
import { CronService } from './cron.service'; import { CronService } from './cron.service';
@ -20,6 +27,44 @@ import { CronService } from './cron.service';
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],
providers: [CronService] providers: [
{
inject: [
ConfigurationService,
DataGatheringService,
ExchangeRateDataService,
PropertyService,
StatisticsGatheringService,
TwitterBotService,
UserService
],
provide: CronService,
useFactory: (
configurationService: ConfigurationService,
dataGatheringService: DataGatheringService,
exchangeRateDataService: ExchangeRateDataService,
propertyService: PropertyService,
statisticsGatheringService: StatisticsGatheringService,
twitterBotService: TwitterBotService,
userService: UserService
) => {
if (!configurationService.get('ENABLE_FEATURE_CRON')) {
Logger.log('Scheduled cron jobs are disabled', 'CronService');
return null;
}
return new CronService(
configurationService,
dataGatheringService,
exchangeRateDataService,
propertyService,
statisticsGatheringService,
twitterBotService,
userService
);
}
}
]
}) })
export class CronModule {} export class CronModule {}

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

77
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;
} }
@ -318,12 +320,10 @@ export class DataProviderService implements OnModuleInit {
symbol, symbol,
to to
}: { }: {
dataSource: DataSource;
from: Date; from: Date;
granularity: Granularity; granularity: Granularity;
symbol: string;
to: Date; to: Date;
}) { } & AssetProfileIdentifier) {
return this.getDataProvider(DataSource[dataSource]).getDividends({ return this.getDataProvider(DataSource[dataSource]).getDividends({
from, from,
granularity, granularity,
@ -514,7 +514,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 +525,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 +539,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 +564,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 +658,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 +680,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 +691,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 +724,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
} }
}; };
} }

1
apps/api/src/services/interfaces/environment.interface.ts

@ -19,6 +19,7 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_AUTH_GOOGLE: boolean; ENABLE_FEATURE_AUTH_GOOGLE: boolean;
ENABLE_FEATURE_AUTH_OIDC: boolean; ENABLE_FEATURE_AUTH_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean; ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_CRON: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean;

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: {

89
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 { 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
@ -119,11 +122,7 @@ export class DataGatheringService {
dataSource, dataSource,
date, date,
symbol symbol
}: { }: { date: Date } & AssetProfileIdentifier) {
dataSource: DataSource;
date: Date;
symbol: string;
}) {
try { try {
const historicalData = await this.dataProviderService.getHistoricalRaw({ const historicalData = await this.dataProviderService.getHistoricalRaw({
assetProfileIdentifiers: [{ dataSource, symbol }], assetProfileIdentifiers: [{ dataSource, symbol }],
@ -279,6 +278,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 +428,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 +538,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,

12
apps/client/src/app/app.component.ts

@ -1,5 +1,9 @@
import { getCssVariable } from '@ghostfolio/common/helper'; import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import {
AssetProfileIdentifier,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes'; import { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
@ -27,7 +31,6 @@ import {
RouterLink, RouterLink,
RouterOutlet RouterOutlet
} from '@angular/router'; } from '@angular/router';
import { DataSource } from '@prisma/client';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons'; import { openOutline } from 'ionicons/icons';
@ -269,10 +272,7 @@ export class GfAppComponent implements OnInit {
private openHoldingDetailDialog({ private openHoldingDetailDialog({
dataSource, dataSource,
symbol symbol
}: { }: AssetProfileIdentifier) {
dataSource: DataSource;
symbol: string;
}) {
this.userService this.userService
.get() .get()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))

5
apps/client/src/app/components/admin-market-data/admin-market-data.component.ts

@ -408,10 +408,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
private openAssetProfileDialog({ private openAssetProfileDialog({
dataSource, dataSource,
symbol symbol
}: { }: AssetProfileIdentifier) {
dataSource: DataSource;
symbol: string;
}) {
this.userService this.userService
.get() .get()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))

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>

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

@ -106,9 +106,13 @@ export class GfUserDetailDialogComponent implements OnInit {
public getSum() { public getSum() {
return getSum( return getSum(
this.subscriptionsDataSource.data.map(({ price }) => { this.subscriptionsDataSource.data
return new Big(price); .filter(({ price }) => {
}) return price !== null;
})
.map(({ price }) => {
return new Big(price);
})
).toNumber(); ).toNumber();
} }

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

@ -128,7 +128,7 @@
@if (subscriptionsDataSource.data.length > 0) { @if (subscriptionsDataSource.data.length > 0) {
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h2 class="h6" i18n>Subscriptions</h2> <h2 class="h6" i18n>Subscription History</h2>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
<table <table
class="gf-table w-100" class="gf-table w-100"
@ -137,7 +137,7 @@
> >
<ng-container matColumnDef="createdAt"> <ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1" mat-header-cell> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Created</ng-container> <ng-container i18n>Creation</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<gf-value <gf-value
@ -170,13 +170,17 @@
class="px-1 text-right" class="px-1 text-right"
mat-cell mat-cell
> >
<gf-value @if (element.price === null) {
class="d-inline-block justify-content-end" <span></span>
[isCurrency]="true" } @else {
[locale]="data.locale" <gf-value
[unit]="baseCurrency" class="d-inline-block justify-content-end"
[value]="element.price" [isCurrency]="true"
/> [locale]="data.locale"
[unit]="baseCurrency"
[value]="element.price"
/>
}
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value <gf-value
@ -191,7 +195,7 @@
<ng-container matColumnDef="expiresAt"> <ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" mat-header-cell> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Expires</ng-container> <ng-container i18n>Expiration</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<gf-value <gf-value

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

526
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;

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;

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

@ -47,6 +47,7 @@ import type { AdminUsersResponse } from './responses/admin-users-response.interf
import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; import type { AiPromptResponse } from './responses/ai-prompt-response.interface';
import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface'; import type { AiServiceHealthResponse } from './responses/ai-service-health-response.interface';
import type { ApiKeyResponse } from './responses/api-key-response.interface'; import type { ApiKeyResponse } from './responses/api-key-response.interface';
import type { AssetProfileResponse } from './responses/asset-profile-response.interface';
import type { AssetProfilesResponse } from './responses/asset-profiles-response.interface'; import type { AssetProfilesResponse } from './responses/asset-profiles-response.interface';
import type { AssetResponse } from './responses/asset-response.interface'; import type { AssetResponse } from './responses/asset-response.interface';
import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface';
@ -67,7 +68,6 @@ import type { HistoricalResponse } from './responses/historical-response.interfa
import type { ImportResponse } from './responses/import-response.interface'; import type { ImportResponse } from './responses/import-response.interface';
import type { InfoResponse } from './responses/info-response.interface'; import type { InfoResponse } from './responses/info-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface'; 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 { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface'; import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PlatformsResponse } from './responses/platforms-response.interface'; import type { PlatformsResponse } from './responses/platforms-response.interface';
@ -123,6 +123,7 @@ export {
AssetClassSelectorOption, AssetClassSelectorOption,
AssetProfileIdentifier, AssetProfileIdentifier,
AssetProfileItem, AssetProfileItem,
AssetProfileResponse,
AssetProfilesResponse, AssetProfilesResponse,
AssetResponse, AssetResponse,
AttestationCredentialJSON, AttestationCredentialJSON,
@ -158,7 +159,6 @@ export {
LookupItem, LookupItem,
LookupResponse, LookupResponse,
MarketData, MarketData,
MarketDataDetailsResponse,
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
NullableLineChartItem, NullableLineChartItem,
OAuthResponse, OAuthResponse,

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

@ -2,7 +2,7 @@ import { MarketData } from '@prisma/client';
import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface'; import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface';
export interface MarketDataDetailsResponse { export interface AssetProfileResponse {
assetProfile: Partial<EnhancedSymbolProfile>; assetProfile: Partial<EnhancedSymbolProfile>;
marketData: MarketData[]; marketData: MarketData[];
} }

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

@ -1,13 +1,7 @@
import { import { Account, Order, Platform, SymbolProfile, Tag } from '@prisma/client';
Account,
DataSource,
Order,
Platform,
SymbolProfile,
Tag
} from '@prisma/client';
import { AccountBalance } from '../account-balance.interface'; import { AccountBalance } from '../account-balance.interface';
import { AssetProfileIdentifier } from '../asset-profile-identifier.interface';
import { MarketData } from '../market-data.interface'; import { MarketData } from '../market-data.interface';
import { UserSettings } from '../user-settings.interface'; import { UserSettings } from '../user-settings.interface';
@ -24,10 +18,11 @@ export interface ExportResponse {
| 'symbolProfileId' | 'symbolProfileId'
| 'updatedAt' | 'updatedAt'
| 'userId' | 'userId'
> & { dataSource: DataSource; date: string; symbol: string })[]; > & { date: string } & AssetProfileIdentifier)[];
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 });
} }
} }

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

@ -23,7 +23,7 @@ import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { DataSource, MarketData, Platform } from '@prisma/client'; import { MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull'; import { JobStatus } from 'bull';
import { isNumber } from 'lodash'; import { isNumber } from 'lodash';
@ -171,11 +171,7 @@ export class AdminService {
dataSource, dataSource,
dateString, dateString,
symbol symbol
}: { }: { dateString: string } & AssetProfileIdentifier) {
dataSource: DataSource;
dateString: string;
symbol: string;
}) {
const url = `/api/v1/symbol/${dataSource}/${symbol}/${dateString}`; const url = `/api/v1/symbol/${dataSource}/${symbol}/${dateString}`;
return this.http.get<DataProviderHistoricalResponse>(url); return this.http.get<DataProviderHistoricalResponse>(url);
@ -189,6 +185,7 @@ export class AdminService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
dataSource: newDataSource, dataSource: newDataSource,
isActive, isActive,
name, name,
@ -207,6 +204,7 @@ export class AdminService {
comment, comment,
countries, countries,
currency, currency,
dataGatheringFrequency,
dataSource: newDataSource, dataSource: newDataSource,
isActive, isActive,
name, name,

21
libs/ui/src/lib/services/data.service.ts

@ -28,6 +28,7 @@ import {
AiPromptResponse, AiPromptResponse,
ApiKeyResponse, ApiKeyResponse,
AssetProfileIdentifier, AssetProfileIdentifier,
AssetProfileResponse,
AssetProfilesResponse, AssetProfilesResponse,
AssetResponse, AssetResponse,
BenchmarkMarketDataDetailsResponse, BenchmarkMarketDataDetailsResponse,
@ -40,7 +41,6 @@ import {
ImportResponse, ImportResponse,
InfoItem, InfoItem,
LookupResponse, LookupResponse,
MarketDataDetailsResponse,
MarketDataOfMarketsResponse, MarketDataOfMarketsResponse,
OAuthResponse, OAuthResponse,
PlatformsResponse, PlatformsResponse,
@ -479,10 +479,7 @@ export class DataService {
public fetchHoldingDetail({ public fetchHoldingDetail({
dataSource, dataSource,
symbol symbol
}: { }: AssetProfileIdentifier): Observable<
dataSource: DataSource;
symbol: string;
}): Observable<
Omit<PortfolioHoldingResponse, 'dateOfFirstActivity'> & { Omit<PortfolioHoldingResponse, 'dateOfFirstActivity'> & {
dateOfFirstActivity: Date | undefined; dateOfFirstActivity: Date | undefined;
} }
@ -541,17 +538,15 @@ export class DataService {
public fetchMarketDataBySymbol({ public fetchMarketDataBySymbol({
dataSource, dataSource,
symbol symbol
}: { }: AssetProfileIdentifier): Observable<AssetProfileResponse> {
dataSource: DataSource;
symbol: string;
}): Observable<MarketDataDetailsResponse> {
return this.http return this.http
.get<any>(`/api/v1/market-data/${dataSource}/${symbol}`) .get<any>(`/api/v1/asset-profiles/${dataSource}/${symbol}`)
.pipe( .pipe(
map((data) => { map((data) => {
for (const item of data.marketData) { for (const item of data.marketData) {
item.date = parseISO(item.date); item.date = parseISO(item.date);
} }
return data; return data;
}) })
); );
@ -855,11 +850,7 @@ export class DataService {
dataSource, dataSource,
marketData, marketData,
symbol symbol
}: { }: { marketData: UpdateBulkMarketDataDto } & AssetProfileIdentifier) {
dataSource: DataSource;
marketData: UpdateBulkMarketDataDto;
symbol: string;
}) {
const url = `/api/v1/market-data/${dataSource}/${symbol}`; const url = `/api/v1/market-data/${dataSource}/${symbol}`;
return this.http.post<MarketData>(url, marketData); return this.http.post<MarketData>(url, marketData);

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.12.0", "version": "3.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.12.0", "version": "3.13.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.12.0", "version": "3.13.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

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