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
- 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 a data gathering frequency (`DAILY` or `HOURLY`) to the asset profile to control the market data gathering interval
### 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 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 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 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`
### Fixed
- 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

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 { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
@ -19,7 +18,6 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ActivitiesModule,
BenchmarkModule,
ConfigurationModule,
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -12,14 +11,12 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { getCurrencyFromSymbol } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketDataDetails,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier,
EnhancedSymbolProfile
AssetProfileIdentifier
} from '@ghostfolio/common/interfaces';
import {
@ -42,7 +39,6 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class AdminService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
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> {
const [user] = await this.getUsersWithAnalytics({
where: { id }
@ -241,7 +182,7 @@ export class AdminService {
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const subscriptions = await this.prismaService.subscription.findMany({
user.subscriptions = await this.prismaService.subscription.findMany({
orderBy: {
expiresAt: 'desc'
},
@ -249,13 +190,6 @@ export class AdminService {
userId: id
}
});
user.subscriptions = subscriptions.map((subscription) => {
return {
...subscription,
price: subscription.price ?? 0
};
});
}
return user;
@ -287,6 +221,7 @@ export class AdminService {
comment,
countries,
currency,
dataGatheringFrequency,
dataSource: newDataSource,
holdings,
isActive,
@ -370,6 +305,7 @@ export class AdminService {
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment,
currency,
dataGatheringFrequency,
dataSource,
isActive,
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 { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import type { AssetResponse } from '@ghostfolio/common/interfaces';
@ -9,7 +9,9 @@ import { pick } from 'lodash';
@Controller('asset')
export class AssetController {
public constructor(private readonly adminService: AdminService) {}
public constructor(
private readonly assetProfilesService: AssetProfilesService
) {}
@Get(':dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@ -19,7 +21,10 @@ export class AssetController {
@Param('symbol') symbol: string
): Promise<AssetResponse> {
const { assetProfile, marketData } =
await this.adminService.getMarketDataBySymbol({ dataSource, symbol });
await this.assetProfilesService.getAssetProfile({
dataSource,
symbol
});
return {
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 { 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({
controllers: [AssetController],
imports: [
AdminModule,
AssetProfilesModule,
TransformDataSourceInRequestModule,
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 { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { AssetProfileResponse } from '@ghostfolio/common/interfaces';
import {
AssetProfilesResponse,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { hasPermission } from '@ghostfolio/common/permissions';
import { permissions } from '@ghostfolio/common/permissions';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
@ -18,7 +24,8 @@ import {
Param,
Patch,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -32,7 +39,8 @@ export class AssetProfilesController {
public constructor(
private readonly apiService: ApiService,
private readonly assetProfilesService: AssetProfilesService,
@Inject(REQUEST) private readonly request: RequestWithUser
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService
) {}
@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)
@Patch(':dataSource/:symbol')
@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 { 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 { 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 { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -12,13 +16,18 @@ import { AssetProfilesService } from './asset-profiles.service';
@Module({
controllers: [AssetProfilesController],
exports: [AssetProfilesService],
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
SymbolProfileModule
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
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 { 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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
@ -11,8 +13,9 @@ import {
isCurrency
} from '@ghostfolio/common/helper';
import {
AssetProfileItem,
AdminMarketDataDetails,
AssetProfileIdentifier,
AssetProfileItem,
AssetProfilesResponse,
EnhancedSymbolProfile,
Filter
@ -28,11 +31,67 @@ export class AssetProfilesService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
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({
filters = [],
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 { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
@ -14,10 +11,7 @@ import {
} from '@ghostfolio/common/config';
import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import {
MarketDataDetailsResponse,
MarketDataOfMarketsResponse
} from '@ghostfolio/common/interfaces';
import { MarketDataOfMarketsResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
@ -30,8 +24,7 @@ import {
Param,
Post,
Query,
UseGuards,
UseInterceptors
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -42,7 +35,6 @@ import { getReasonPhrase, StatusCodes } from 'http-status-codes';
@Controller('market-data')
export class MarketDataController {
public constructor(
private readonly adminService: AdminService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly symbolProfileService: SymbolProfileService,
@ -64,14 +56,16 @@ export class MarketDataController {
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
}
},
useIntradayData: true
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
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')
@UseGuards(AuthGuard('jwt'))
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 { 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 { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
@ -11,13 +8,6 @@ import { MarketDataController } from './market-data.controller';
@Module({
controllers: [MarketDataController],
imports: [
AdminModule,
MarketDataServiceModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
]
imports: [MarketDataServiceModule, SymbolModule, SymbolProfileModule]
})
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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.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 { DataSource, Prisma } from '@prisma/client';
import { Prisma } from '@prisma/client';
@Injectable()
export class WatchlistService {
@ -24,11 +28,7 @@ export class WatchlistService {
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}): Promise<void> {
}: { userId: string } & AssetProfileIdentifier): Promise<void> {
const symbolProfile = await this.prismaService.symbolProfile.findUnique({
where: {
dataSource_symbol: { dataSource, symbol }
@ -72,11 +72,7 @@ export class WatchlistService {
dataSource,
symbol,
userId
}: {
dataSource: DataSource;
symbol: string;
userId: string;
}) {
}: { userId: string } & AssetProfileIdentifier) {
await this.prismaService.user.update({
data: {
watchlist: {
@ -127,7 +123,8 @@ export class WatchlistService {
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketPrice
);
return {

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

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

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

@ -51,13 +51,13 @@ export class CurrentRateService {
const values: GetValueObject[] = [];
if (includesToday) {
const quotesBySymbol = await this.dataProviderService.getQuotes({
const quotes = await this.dataProviderService.getQuotes({
items: dataGatheringItems,
user: this.request?.user
});
for (const { dataSource, symbol } of dataGatheringItems) {
const quote = quotesBySymbol[symbol];
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (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 { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Account, DataSource } from '@prisma/client';
import { Big } from 'big.js';
@ -198,7 +199,7 @@ describe('PortfolioService', () => {
portfolioService as unknown as {
getCashSymbolProfiles: (
aCashDetails: CashDetails
) => { dataSource: DataSource; symbol: string }[];
) => AssetProfileIdentifier[];
}
).getCashSymbolProfiles(cashDetails);

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

@ -46,6 +46,7 @@ import {
import {
AccountsResponse,
Activity,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
@ -776,11 +777,9 @@ export class PortfolioService {
symbol,
userId
}: {
dataSource: DataSource;
impersonationId: string;
symbol: string;
userId: string;
}): Promise<PortfolioHoldingResponse> {
} & AssetProfileIdentifier): Promise<PortfolioHoldingResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user);
@ -1381,12 +1380,10 @@ export class PortfolioService {
tags,
userId
}: {
dataSource: DataSource;
impersonationId: string;
symbol: string;
tags: Tag[];
userId: string;
}) {
} & AssetProfileIdentifier) {
userId = await this.getUserId(impersonationId, userId);
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 { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
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 {
DataProviderHistoricalResponse,
HistoricalDataItem,
@ -24,15 +27,31 @@ export class SymbolService {
public async get({
dataGatheringItem,
includeHistoricalData
includeHistoricalData,
useIntradayData = false
}: {
dataGatheringItem: DataGatheringItem;
includeHistoricalData?: number;
useIntradayData?: boolean;
}): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes({
items: [dataGatheringItem]
});
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
let currency: string;
let marketPrice: number;
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) {
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 { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { DataSource } from '@prisma/client';
import ms from 'ms';
import { AssetProfileChangedEvent } from './asset-profile-changed.event';
@ -64,11 +64,7 @@ export class AssetProfileChangedListener {
currency,
dataSource,
symbol
}: {
currency: string;
dataSource: DataSource;
symbol: string;
}) {
}: { currency: string } & AssetProfileIdentifier) {
this.logger.log(`Asset profile of ${symbol} (${dataSource}) has changed`);
if (

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

@ -8,7 +8,10 @@ import {
CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import { calculateBenchmarkTrend } from '@ghostfolio/common/helper';
import {
calculateBenchmarkTrend,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Benchmark,
@ -266,8 +269,9 @@ export class BenchmarkService {
let storeInCache = true;
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { dataSource, symbol } = benchmarkAssetProfiles[index];
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
quotes[getAssetProfileIdentifier({ dataSource, symbol })] ?? {};
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_OIDC: bool({ default: false }),
ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }),
ENABLE_FEATURE_CRON: bool({ default: true }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }),
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 { UserService } from '@ghostfolio/api/app/user/user.service';
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
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 { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
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 { 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';
@ -20,6 +27,44 @@ import { CronService } from './cron.service';
TwitterBotModule,
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 {}

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

@ -42,6 +42,7 @@ export class CronService {
public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) {
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
});
if (quotes[symbol]?.marketPrice > 0) {
if (
quotes[getAssetProfileIdentifier({ dataSource, symbol })]?.marketPrice > 0
) {
return true;
}
@ -318,12 +320,10 @@ export class DataProviderService implements OnModuleInit {
symbol,
to
}: {
dataSource: DataSource;
from: Date;
granularity: Granularity;
symbol: string;
to: Date;
}) {
} & AssetProfileIdentifier) {
return this.getDataProvider(DataSource[dataSource]).getDividends({
from,
granularity,
@ -514,7 +514,6 @@ export class DataProviderService implements OnModuleInit {
return result;
}
// TODO: Change symbol in response to assetProfileIdentifier
public async getQuotes({
items,
requestTimeout,
@ -526,10 +525,12 @@ export class DataProviderService implements OnModuleInit {
useCache?: boolean;
user?: UserWithSettings;
}): Promise<{
[symbol: string]: DataProviderResponse;
[assetProfileIdentifier: string]: DataProviderResponse;
}> {
const response: {
[symbol: string]: DataProviderResponse;
[assetProfileIdentifier: string]: DataProviderResponse & {
symbol: string;
};
} = {};
const startTimeTotal = performance.now();
@ -538,11 +539,17 @@ export class DataProviderService implements OnModuleInit {
return symbol === `${DEFAULT_CURRENCY}USX`;
})
) {
response[`${DEFAULT_CURRENCY}USX`] = {
response[
getAssetProfileIdentifier({
dataSource: this.getDataSourceForExchangeRates(),
symbol: `${DEFAULT_CURRENCY}USX`
})
] = {
currency: 'USX',
dataSource: this.getDataSourceForExchangeRates(),
marketPrice: 100,
marketState: 'open'
marketState: 'open',
symbol: `${DEFAULT_CURRENCY}USX`
};
}
@ -557,8 +564,13 @@ export class DataProviderService implements OnModuleInit {
if (quoteString) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
const cachedDataProviderResponse = JSON.parse(
quoteString
) as DataProviderResponse;
response[getAssetProfileIdentifier({ dataSource, symbol })] = {
...cachedDataProviderResponse,
symbol
};
continue;
} catch {}
}
@ -646,14 +658,19 @@ export class DataProviderService implements OnModuleInit {
continue;
}
response[symbol] = dataProviderResponse;
response[
getAssetProfileIdentifier({
symbol,
dataSource: DataSource[dataSource]
})
] = { ...dataProviderResponse, symbol };
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
symbol,
dataSource: DataSource[dataSource]
}),
JSON.stringify(response[symbol]),
JSON.stringify(dataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL')
);
@ -663,7 +680,7 @@ export class DataProviderService implements OnModuleInit {
rootCurrency
} of DERIVED_CURRENCIES) {
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
response[`${DEFAULT_CURRENCY}${currency}`] = {
const derivedDataProviderResponse: DataProviderResponse = {
...dataProviderResponse,
currency,
marketPrice: new Big(
@ -674,12 +691,22 @@ export class DataProviderService implements OnModuleInit {
marketState: 'open'
};
response[
getAssetProfileIdentifier({
dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}`
})
] = {
...derivedDataProviderResponse,
symbol: `${DEFAULT_CURRENCY}${currency}`
};
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}`
}),
JSON.stringify(response[`${DEFAULT_CURRENCY}${currency}`]),
JSON.stringify(derivedDataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL')
);
}
@ -697,21 +724,21 @@ export class DataProviderService implements OnModuleInit {
try {
await this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
data: Object.values(response)
.filter(({ marketPrice, marketState }) => {
return (
isNumber(response[symbol].marketPrice) &&
response[symbol].marketPrice > 0 &&
response[symbol].marketState === 'open'
isNumber(marketPrice) &&
marketPrice > 0 &&
marketState === 'open'
);
})
.map((symbol) => {
.map((dataProviderResponse) => {
return {
symbol,
dataSource: response[symbol].dataSource,
dataSource: dataProviderResponse.dataSource,
date: getStartOfUtcDate(new Date()),
marketPrice: response[symbol].marketPrice,
state: 'INTRADAY'
marketPrice: dataProviderResponse.marketPrice,
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';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getYesterday,
resetHours
} from '@ghostfolio/common/helper';
@ -176,11 +177,13 @@ export class ExchangeRateDataService {
requestTimeout: ms('30 seconds')
});
for (const symbol of Object.keys(quotes)) {
if (isNumber(quotes[symbol].marketPrice)) {
for (const { dataSource, symbol } of this.currencyPairs) {
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (isNumber(quote?.marketPrice)) {
result[symbol] = {
[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_OIDC: boolean;
ENABLE_FEATURE_AUTH_TOKEN: boolean;
ENABLE_FEATURE_CRON: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: 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) {
return this.prismaService.marketData.findFirst({
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 { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -17,6 +18,7 @@ import {
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getStartOfUtcDate,
resetHours
} from '@ghostfolio/common/helper';
import {
@ -26,7 +28,7 @@ import {
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { JobOptions, Queue } from 'bull';
import { format, min, subDays, subMilliseconds, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
@ -43,6 +45,7 @@ export class DataGatheringService {
private readonly dataGatheringQueue: Queue,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService
@ -119,11 +122,7 @@ export class DataGatheringService {
dataSource,
date,
symbol
}: {
dataSource: DataSource;
date: Date;
symbol: string;
}) {
}: { date: Date } & AssetProfileIdentifier) {
try {
const historicalData = await this.dataProviderService.getHistoricalRaw({
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({
dataGatheringItems,
force = false,
@ -389,6 +428,36 @@ export class DataGatheringService {
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({
withUserSubscription = false
}: {
@ -469,14 +538,12 @@ export class DataGatheringService {
}
})
)
.filter((symbolProfile) => {
.filter(({ dataSource, scraperConfiguration }) => {
const manualDataSourceWithScraperConfiguration =
symbolProfile.dataSource === 'MANUAL' &&
!isEmpty(symbolProfile.scraperConfiguration);
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
symbolProfile.dataSource !== 'MANUAL' ||
manualDataSourceWithScraperConfiguration
dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration
);
})
.map((symbolProfile) => {

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

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

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

@ -1,5 +1,9 @@
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 { internalRoutes, publicRoutes } from '@ghostfolio/common/routes/routes';
import { ColorScheme } from '@ghostfolio/common/types';
@ -27,7 +31,6 @@ import {
RouterLink,
RouterOutlet
} from '@angular/router';
import { DataSource } from '@prisma/client';
import { Chart } from 'chart.js';
import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons';
@ -269,10 +272,7 @@ export class GfAppComponent implements OnInit {
private openHoldingDetailDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
}: AssetProfileIdentifier) {
this.userService
.get()
.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({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
}: AssetProfileIdentifier) {
this.userService
.get()
.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 {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
MarketData,
Prisma,
SymbolProfile
@ -155,6 +156,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
comment: '',
countries: ['', jsonValidator()],
currency: '',
dataGatheringFrequency: new FormControl<DataGatheringFrequency>('DAILY'),
historicalData: this.formBuilder.group({
csvString: ''
}),
@ -199,6 +201,20 @@ export class GfAssetProfileDialogComponent implements OnInit {
protected currencies: string[] = [];
protected readonly dataGatheringFrequencyValues: {
value: DataGatheringFrequency;
viewValue: string;
}[] = [
{
value: 'DAILY',
viewValue: $localize`Daily`
},
{
value: 'HOURLY',
viewValue: $localize`Hourly`
}
];
protected readonly dateRangeOptions = [
{
label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -401,6 +417,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
}) ?? []
),
currency: this.assetProfile?.currency ?? null,
dataGatheringFrequency:
this.assetProfile?.dataGatheringFrequency ?? 'DAILY',
historicalData: {
csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE
},
@ -583,6 +601,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value || undefined,
currency: this.assetProfileForm.controls.currency.value ?? undefined,
dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ??
undefined,
isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
? this.assetProfileForm.controls.isActive.value
: 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" />
<div class="d-none d-sm-block ml-2" i18n>Overview</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="container px-0 py-3">
<div class="row w-100">
@if (isEditAssetProfileIdentifierMode) {
<div class="col-12 mb-4">
@ -444,6 +444,21 @@
></textarea>
</mat-form-field>
</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>
</div>
</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() {
return getSum(
this.subscriptionsDataSource.data.map(({ price }) => {
return new Big(price);
})
this.subscriptionsDataSource.data
.filter(({ price }) => {
return price !== null;
})
.map(({ price }) => {
return new Big(price);
})
).toNumber();
}

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

@ -128,7 +128,7 @@
@if (subscriptionsDataSource.data.length > 0) {
<div class="row">
<div class="col">
<h2 class="h6" i18n>Subscriptions</h2>
<h2 class="h6" i18n>Subscription History</h2>
<div class="overflow-x-auto">
<table
class="gf-table w-100"
@ -137,7 +137,7 @@
>
<ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Created</ng-container>
<ng-container i18n>Creation</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
@ -170,13 +170,17 @@
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"
/>
@if (element.price === null) {
<span></span>
} @else {
<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
@ -191,7 +195,7 @@
<ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Expires</ng-container>
<ng-container i18n>Expiration</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<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 { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource,
Prisma
} from '@prisma/client';
import {
IsArray,
IsBoolean,
@ -32,6 +38,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
currency?: string;
@IsEnum(DataGatheringFrequency)
@IsOptional()
dataGatheringFrequency?: DataGatheringFrequency;
@IsEnum(DataSource)
@IsOptional()
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 { DataProviderInfo } from './data-provider-info.interface';
@ -15,6 +20,7 @@ export interface EnhancedSymbolProfile {
createdAt: Date;
currency?: string;
cusip?: string;
dataGatheringFrequency?: DataGatheringFrequency;
dataProviderInfo?: DataProviderInfo;
dataSource: DataSource;
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 { AiServiceHealthResponse } from './responses/ai-service-health-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 { AssetResponse } from './responses/asset-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 { InfoResponse } from './responses/info-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 { OAuthResponse } from './responses/oauth-response.interface';
import type { PlatformsResponse } from './responses/platforms-response.interface';
@ -123,6 +123,7 @@ export {
AssetClassSelectorOption,
AssetProfileIdentifier,
AssetProfileItem,
AssetProfileResponse,
AssetProfilesResponse,
AssetResponse,
AttestationCredentialJSON,
@ -158,7 +159,6 @@ export {
LookupItem,
LookupResponse,
MarketData,
MarketDataDetailsResponse,
MarketDataOfMarketsResponse,
NullableLineChartItem,
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';
export interface MarketDataDetailsResponse {
export interface AssetProfileResponse {
assetProfile: Partial<EnhancedSymbolProfile>;
marketData: MarketData[];
}

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

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

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

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

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

@ -6,6 +6,10 @@
height: 100%;
width: 100%;
.external-link-icon {
font-size: 0.75rem;
}
@include mat.tabs-overrides(
(
active-indicator-height: 0,
@ -65,6 +69,11 @@
background-color: rgba(var(--palette-foreground-base), 0.05);
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 { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { TabConfiguration } from './interfaces/interfaces';
@ -27,5 +29,7 @@ export class GfPageTabsComponent {
public constructor() {
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 { Injectable, inject } from '@angular/core';
import { DataSource, MarketData, Platform } from '@prisma/client';
import { MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull';
import { isNumber } from 'lodash';
@ -171,11 +171,7 @@ export class AdminService {
dataSource,
dateString,
symbol
}: {
dataSource: DataSource;
dateString: string;
symbol: string;
}) {
}: { dateString: string } & AssetProfileIdentifier) {
const url = `/api/v1/symbol/${dataSource}/${symbol}/${dateString}`;
return this.http.get<DataProviderHistoricalResponse>(url);
@ -189,6 +185,7 @@ export class AdminService {
comment,
countries,
currency,
dataGatheringFrequency,
dataSource: newDataSource,
isActive,
name,
@ -207,6 +204,7 @@ export class AdminService {
comment,
countries,
currency,
dataGatheringFrequency,
dataSource: newDataSource,
isActive,
name,

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

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

4
package-lock.json

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

2
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "3.12.0",
"version": "3.13.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"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())
currency String
cusip String?
dataGatheringFrequency DataGatheringFrequency @default(DAILY)
dataSource DataSource
figi String?
figiComposite String?
@ -215,6 +216,7 @@ model SymbolProfile {
@@index([assetClass])
@@index([currency])
@@index([cusip])
@@index([dataGatheringFrequency])
@@index([dataSource])
@@index([isActive])
@@index([isin])
@ -316,6 +318,11 @@ enum AssetSubClass {
STOCK
}
enum DataGatheringFrequency {
DAILY
HOURLY
}
enum DataSource {
ALPHA_VANTAGE
COINGECKO

Loading…
Cancel
Save