Browse Source

Merge branch 'main' into task/add-korean-language-to-footer

pull/7033/head
Thomas Kaul 4 weeks ago
committed by GitHub
parent
commit
56959f0786
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 9
      CHANGELOG.md
  2. 38
      apps/api/src/app/admin/admin.controller.ts
  3. 2
      apps/api/src/app/admin/admin.module.ts
  4. 386
      apps/api/src/app/admin/admin.service.ts
  5. 42
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.controller.ts
  6. 14
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.module.ts
  7. 396
      apps/api/src/app/endpoints/asset-profiles/asset-profiles.service.ts
  8. 6
      apps/api/src/app/user/user.service.ts
  9. 26
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  10. 13
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  11. 12
      apps/client/src/app/components/admin-market-data/admin-market-data.service.ts
  12. 5
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  13. 5
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  14. 4
      apps/client/src/app/components/admin-users/admin-users.component.ts
  15. 4
      apps/client/src/app/components/home-watchlist/home-watchlist.component.ts
  16. 5
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  17. 3
      libs/common/src/lib/config.ts
  18. 12
      libs/common/src/lib/helper.ts
  19. 9
      libs/common/src/lib/interfaces/asset-profile-item.interface.ts
  20. 10
      libs/common/src/lib/interfaces/index.ts
  21. 6
      libs/common/src/lib/interfaces/responses/asset-profiles-response.interface.ts
  22. 79
      libs/common/src/lib/personal-finance-tools.ts
  23. 11
      libs/ui/src/lib/assistant/assistant.component.ts
  24. 4
      libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts
  25. 44
      libs/ui/src/lib/services/admin.service.ts
  26. 37
      libs/ui/src/lib/services/data.service.ts
  27. 32
      package-lock.json
  28. 2
      package.json

9
CHANGELOG.md

@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added the Korean (`ko`) language to the footer
### Changed
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- 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
- Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3`
### Fixed
- Fixed an issue with the localization of the country names

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

@ -1,7 +1,6 @@
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { DemoService } from '@ghostfolio/api/services/demo/demo.service';
@ -24,18 +23,13 @@ import {
} from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminUserResponse,
AdminUsersResponse,
EnhancedSymbolProfile,
ScraperConfiguration
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import type {
DateRange,
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
@ -67,7 +61,6 @@ export class AdminController {
public constructor(
private readonly adminService: AdminService,
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService,
private readonly dataGatheringService: DataGatheringService,
private readonly demoService: DemoService,
@ -218,35 +211,6 @@ export class AdminController {
});
}
@Get('market-data')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery
});
return this.adminService.getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@HasPermission(permissions.accessAdminControl)
@Post('market-data/:dataSource/:symbol/test')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -1,6 +1,5 @@
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 { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -21,7 +20,6 @@ import { QueueModule } from './queue/queue.module';
@Module({
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ConfigurationModule,
DataGatheringQueueModule,

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

@ -1,6 +1,5 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.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';
@ -13,24 +12,15 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED
} from '@ghostfolio/common/config';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier,
EnhancedSymbolProfile,
Filter
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import {
BadRequestException,
@ -48,13 +38,11 @@ import {
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { groupBy } from 'lodash';
@Injectable()
export class AdminService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -188,244 +176,6 @@ export class AdminService {
};
}
public async getMarketData({
filters,
presetId,
sortColumn,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const {
ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: filtersByDataSource
} = groupBy(filters, ({ type }) => {
return type;
});
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = [
{
activities: {
_count: sortDirection
}
}
];
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const assetProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: assetProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: assetProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let marketData: AdminMarketDataItem[] = await Promise.all(
assetProfiles.map(async (assetProfile) => {
const {
_count,
activities,
comment,
currency,
dataSource,
id,
isActive,
isUsedByUsersWithSubscription,
symbol
} = assetProfile;
const { assetClass, assetSubClass, countries, name, sectors } =
applyAssetProfileOverrides(
assetProfile,
assetProfile.SymbolProfileOverrides
);
const countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
currency,
dataSource,
id,
isActive,
lastMarketPrice,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
})
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = marketData.length;
}
return {
count,
marketData
};
}
public async getMarketDataBySymbol({
dataSource,
symbol
@ -667,138 +417,6 @@ export class AdminService {
});
}
private getExtendedPrismaClient() {
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
activities: {
where: {
user: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.activities > 0;
}
}
}
}
});
});
return this.prismaService.$extends(symbolProfileExtension);
}
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const marketDataPromise: Promise<AdminMarketDataItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0,
watchedByCount: 0
};
}
);
const marketData = await Promise.all(marketDataPromise);
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics({
skip,
take,

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

@ -1,22 +1,28 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { UpdateAssetProfileDataDto } from '@ghostfolio/common/dtos';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import {
AssetProfilesResponse,
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { MarketDataPreset, RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Get,
HttpException,
Inject,
Param,
Patch,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { DataSource, Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AssetProfilesService } from './asset-profiles.service';
@ -24,10 +30,40 @@ import { AssetProfilesService } from './asset-profiles.service';
@Controller('asset-profiles')
export class AssetProfilesController {
public constructor(
private readonly apiService: ApiService,
private readonly assetProfilesService: AssetProfilesService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getAssetProfiles(
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('query') filterBySearchQuery?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AssetProfilesResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAssetSubClasses,
filterByDataSource,
filterBySearchQuery
});
return this.assetProfilesService.getAssetProfiles({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@HasPermission(permissions.accessAdminControl)
@Patch(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)

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

@ -1,3 +1,8 @@
import { ActivitiesModule } from '@ghostfolio/api/app/activities/activities.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
@ -7,7 +12,14 @@ import { AssetProfilesService } from './asset-profiles.service';
@Module({
controllers: [AssetProfilesController],
imports: [SymbolProfileModule],
imports: [
ActivitiesModule,
ApiModule,
BenchmarkModule,
ExchangeRateDataModule,
PrismaModule,
SymbolProfileModule
],
providers: [AssetProfilesService]
})
export class AssetProfilesModule {}

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

@ -1,19 +1,279 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-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';
import {
applyAssetProfileOverrides,
getAssetProfileIdentifier,
getCurrencyFromSymbol,
isCurrency
} from '@ghostfolio/common/helper';
import {
AssetProfileItem,
AssetProfileIdentifier,
EnhancedSymbolProfile
AssetProfilesResponse,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { groupBy } from 'lodash';
@Injectable()
export class AssetProfilesService {
public constructor(
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getAssetProfiles({
filters = [],
presetId,
sortColumn,
sortDirection = 'asc',
skip,
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AssetProfilesResponse> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'BENCHMARKS') {
const benchmarkAssetProfiles =
await this.benchmarkService.getBenchmarkAssetProfiles();
where.id = {
in: benchmarkAssetProfiles.map(({ id }) => {
return id;
})
};
} else if (presetId === 'CURRENCIES') {
return this.getAssetProfilesForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
} else if (presetId === 'NO_ACTIVITIES') {
where.activities = {
none: {}
};
}
const searchQuery = filters.find(({ type }) => {
return type === 'SEARCH_QUERY';
})?.id;
const {
ASSET_SUB_CLASS: filtersByAssetSubClass,
DATA_SOURCE: filtersByDataSource
} = groupBy(filters, ({ type }) => {
return type;
});
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
}
if (filtersByDataSource) {
where.dataSource = DataSource[filtersByDataSource[0].id];
}
if (searchQuery) {
where.OR = [
{ id: { mode: 'insensitive', startsWith: searchQuery } },
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
{ name: { mode: 'insensitive', startsWith: searchQuery } },
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
];
}
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = [
{
activities: {
_count: sortDirection
}
}
];
}
}
const extendedPrismaClient = this.getExtendedPrismaClient();
const symbolProfileResult = await Promise.all([
extendedPrismaClient.symbolProfile.findMany({
skip,
take,
where,
orderBy: [...orderBy, { id: sortDirection }],
select: {
_count: {
select: {
activities: true,
watchedBy: true
}
},
activities: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
assetClass: true,
assetSubClass: true,
comment: true,
countries: true,
currency: true,
dataSource: true,
id: true,
isin: true,
isActive: true,
isUsedByUsersWithSubscription: true,
name: true,
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
}
}),
this.prismaService.symbolProfile.count({ where })
]);
const symbolProfiles = symbolProfileResult[0];
let count = symbolProfileResult[1];
const lastMarketPrices = await this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: symbolProfiles.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: symbolProfiles.map(({ symbol }) => {
return symbol;
})
}
}
});
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
let assetProfiles: AssetProfileItem[] = await Promise.all(
symbolProfiles.map(async (assetProfile) => {
const {
_count,
activities,
comment,
currency,
dataSource,
id,
isin,
isActive,
isUsedByUsersWithSubscription,
symbol
} = assetProfile;
const { assetClass, assetSubClass, countries, name, sectors } =
applyAssetProfileOverrides(
assetProfile,
assetProfile.SymbolProfileOverrides
);
const countriesCount = countries ? Object.keys(countries).length : 0;
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
currency,
dataSource,
id,
isActive,
isin,
lastMarketPrice,
marketDataItemCount,
name,
sectorsCount,
symbol,
activitiesCount: _count.activities,
date: activities?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
};
})
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
assetProfiles = assetProfiles.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
assetProfiles = assetProfiles.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = assetProfiles.length;
}
return {
assetProfiles,
count
};
}
public async updateAssetProfileData(
{ dataSource, symbol }: AssetProfileIdentifier,
assetProfileData: UpdateAssetProfileDataDto
@ -87,4 +347,136 @@ export class AssetProfilesService {
return data;
}
private async getAssetProfilesForCurrencies(): Promise<AssetProfilesResponse> {
const currencyPairs = this.exchangeRateDataService.getCurrencyPairs();
const [lastMarketPrices, marketDataItems] = await Promise.all([
this.prismaService.marketData.findMany({
distinct: ['dataSource', 'symbol'],
orderBy: { date: 'desc' },
select: {
dataSource: true,
marketPrice: true,
symbol: true
},
where: {
dataSource: {
in: currencyPairs.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: currencyPairs.map(({ symbol }) => {
return symbol;
})
}
}
}),
this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
})
]);
const lastMarketPriceMap = new Map<string, number>();
for (const { dataSource, marketPrice, symbol } of lastMarketPrices) {
lastMarketPriceMap.set(
getAssetProfileIdentifier({ dataSource, symbol }),
marketPrice
);
}
const assetProfilePromises: Promise<AssetProfileItem>[] = currencyPairs.map(
async ({ dataSource, symbol }) => {
let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0;
let currency: EnhancedSymbolProfile['currency'] = '-';
let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity'];
if (isCurrency(getCurrencyFromSymbol(symbol))) {
currency = getCurrencyFromSymbol(symbol);
({ activitiesCount, dateOfFirstActivity } =
await this.activitiesService.getStatisticsByCurrency(currency));
}
const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol })
);
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
activitiesCount,
currency,
dataSource,
lastMarketPrice,
marketDataItemCount,
symbol,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countriesCount: 0,
date: dateOfFirstActivity,
id: undefined,
isActive: true,
name: symbol,
sectorsCount: 0,
watchedByCount: 0
};
}
);
const assetProfiles = await Promise.all(assetProfilePromises);
return { assetProfiles, count: assetProfiles.length };
}
private getExtendedPrismaClient() {
const symbolProfileExtension = Prisma.defineExtension((client) => {
return client.$extends({
result: {
symbolProfile: {
isUsedByUsersWithSubscription: {
compute: async ({ id }) => {
const { _count } =
await this.prismaService.symbolProfile.findUnique({
select: {
_count: {
select: {
activities: {
where: {
user: {
subscriptions: {
some: {
expiresAt: {
gt: new Date()
}
}
}
}
}
}
}
}
},
where: {
id
}
});
return _count.activities > 0;
}
}
}
}
});
});
return this.prismaService.$extends(symbolProfileExtension);
}
}

6
apps/api/src/app/user/user.service.ts

@ -28,11 +28,11 @@ import {
DEFAULT_CURRENCY,
DEFAULT_DATE_RANGE,
DEFAULT_LANGUAGE_CODE,
DEFAULT_LOCALE,
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_REFERRAL_PARTNERS,
PROPERTY_SYSTEM_MESSAGE,
TAG_ID_EXCLUDE_FROM_ANALYSIS,
locale as defaultLocale
TAG_ID_EXCLUDE_FROM_ANALYSIS
} from '@ghostfolio/common/config';
import { SubscriptionType } from '@ghostfolio/common/enums';
import {
@ -102,7 +102,7 @@ export class UserService {
public async getUser({
impersonationUserId,
locale = defaultLocale,
locale = DEFAULT_LOCALE,
user
}: {
impersonationUserId: string;

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

@ -1,8 +1,8 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_COLOR_SCHEME,
DEFAULT_PAGE_SIZE,
locale
DEFAULT_LOCALE,
DEFAULT_PAGE_SIZE
} from '@ghostfolio/common/config';
import {
canDeleteAssetProfile,
@ -10,11 +10,11 @@ import {
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
AssetProfileItem,
Filter,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { GfSymbolPipe } from '@ghostfolio/common/pipes';
import { GfActivitiesFilterComponent } from '@ghostfolio/ui/activities-filter';
@ -152,7 +152,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
}
];
protected readonly canDeleteAssetProfile = canDeleteAssetProfile;
protected dataSource = new MatTableDataSource<AdminMarketDataItem>();
protected dataSource = new MatTableDataSource<AssetProfileItem>();
protected defaultDateFormat: string;
protected readonly displayedColumns: string[] = [];
protected readonly filters$ = new Subject<Filter[]>();
@ -160,7 +160,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
protected readonly isUUID = isUUID;
protected pageSize = DEFAULT_PAGE_SIZE;
protected placeholder = '';
protected readonly selection = new SelectionModel<AdminMarketDataItem>(true);
protected readonly selection = new SelectionModel<AssetProfileItem>(true);
protected totalItems = 0;
protected user: User;
@ -375,8 +375,8 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
this.selection.clear();
this.adminService
.fetchAdminMarketData({
this.dataService
.fetchAssetProfiles({
sortColumn,
sortDirection,
filters: this.activeFilters,
@ -384,15 +384,15 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
take: this.pageSize
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(({ count, marketData }) => {
.subscribe(({ assetProfiles, count }) => {
this.totalItems = count;
this.dataSource = new MatTableDataSource(
marketData.map((marketDataItem) => {
assetProfiles.map((assetProfile) => {
return {
...marketDataItem,
...assetProfile,
isBenchmark: this.benchmarks.some(({ id }) => {
return id === marketDataItem.id;
return id === assetProfile.id;
})
};
})
@ -429,7 +429,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
colorScheme:
this.user?.settings.colorScheme ?? DEFAULT_COLOR_SCHEME,
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies AssetProfileDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '80vh',
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
@ -464,7 +464,7 @@ export class GfAdminMarketDataComponent implements AfterViewInit, OnInit {
autoFocus: false,
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? locale
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
} satisfies CreateAssetProfileDialogParams,
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});

13
apps/client/src/app/components/admin-market-data/admin-market-data.html

@ -239,7 +239,18 @@
[disabled]="!selection.hasValue()"
(click)="onDeleteAssetProfiles()"
>
<ng-container i18n>Delete Profiles</ng-container>
<ng-container i18n
>Delete
{{
selection.selected.length > 1
? selection.selected.length
: ''
}}
{selection.selected.length, plural,
=1 {Profile}
other {Profiles}
}</ng-container
>
</button>
</mat-menu>
</th>

12
apps/client/src/app/components/admin-market-data/admin-market-data.service.ts

@ -32,6 +32,8 @@ export class AdminMarketDataService {
public deleteAssetProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
) {
const assetProfileCount = aAssetProfileIdentifiers.length;
this.notificationService.confirm({
confirmFn: () => {
const deleteRequests = aAssetProfileIdentifiers.map(
@ -44,7 +46,10 @@ export class AdminMarketDataService {
.pipe(
catchError(() => {
this.notificationService.alert({
title: $localize`Oops! Could not delete profiles.`
title:
assetProfileCount === 1
? $localize`Oops! Could not delete the asset profile.`
: $localize`Oops! Could not delete the asset profiles.`
});
return EMPTY;
@ -56,7 +61,10 @@ export class AdminMarketDataService {
.subscribe();
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete these profiles?`
title:
assetProfileCount === 1
? $localize`Do you really want to delete this asset profile?`
: $localize`Do you really want to delete these ${assetProfileCount}:count: asset profiles?`
});
}
}

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

@ -1,7 +1,7 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { AdminService, DataService } from '@ghostfolio/ui/services';
@ -33,7 +33,6 @@ import {
ellipsisHorizontal,
trashOutline
} from 'ionicons/icons';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateOrUpdatePlatformDialogComponent } from './create-or-update-platform-dialog/create-or-update-platform-dialog.component';
@ -147,7 +146,7 @@ export class GfAdminPlatformComponent implements OnInit {
this.dataSource = new MatTableDataSource(platforms);
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get;
this.dataSource.sortingDataAccessor = getLowercase;
this.dataService.updateInfo();

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

@ -1,7 +1,7 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import { getLocale } from '@ghostfolio/common/helper';
import { getLocale, getLowercase } from '@ghostfolio/common/helper';
import { NotificationService } from '@ghostfolio/ui/notifications';
import { DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
@ -32,7 +32,6 @@ import {
ellipsisHorizontal,
trashOutline
} from 'ionicons/icons';
import { get } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { GfCreateOrUpdateTagDialogComponent } from './create-or-update-tag-dialog/create-or-update-tag-dialog.component';
@ -149,7 +148,7 @@ export class GfAdminTagComponent implements OnInit {
this.dataSource = new MatTableDataSource(this.tags);
this.dataSource.sort = this.sort();
this.dataSource.sortingDataAccessor = get;
this.dataSource.sortingDataAccessor = getLowercase;
this.dataService.updateInfo();

4
apps/client/src/app/components/admin-users/admin-users.component.ts

@ -5,7 +5,7 @@ import {
import { GfUserDetailDialogComponent } from '@ghostfolio/client/components/user-detail-dialog/user-detail-dialog.component';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE, locale } from '@ghostfolio/common/config';
import { DEFAULT_LOCALE, DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { ConfirmationDialogType } from '@ghostfolio/common/enums';
import {
getDateFnsLocale,
@ -317,7 +317,7 @@ export class GfAdminUsersComponent implements OnInit {
currentUserId: this.user?.id,
deviceType: this.deviceType(),
hasPermissionForSubscription: this.hasPermissionForSubscription,
locale: this.user?.settings?.locale ?? locale,
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE,
userId: aUserId
} satisfies UserDetailDialogParams,
height: this.deviceType() === 'mobile' ? '98vh' : '60vh',

4
apps/client/src/app/components/home-watchlist/home-watchlist.component.ts

@ -1,6 +1,6 @@
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import {
AssetProfileIdentifier,
Benchmark,
@ -149,7 +149,7 @@ export class GfHomeWatchlistComponent implements OnInit {
autoFocus: false,
data: {
deviceType: this.deviceType(),
locale: this.user?.settings?.locale ?? defaultLocale
locale: this.user?.settings?.locale ?? DEFAULT_LOCALE
},
width: this.deviceType() === 'mobile' ? '100vw' : '50rem'
});

5
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts

@ -1,6 +1,5 @@
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ASSET_CLASS_MAPPING } from '@ghostfolio/common/config';
import { locale as defaultLocale } from '@ghostfolio/common/config';
import { ASSET_CLASS_MAPPING, DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
@ -120,7 +119,7 @@ export class GfCreateOrUpdateActivityDialogComponent {
this.hasPermissionToCreateOwnTag =
this.data.user?.settings?.isExperimentalFeatures &&
hasPermission(this.data.user?.permissions, permissions.createOwnTag);
this.locale = this.data.user.settings.locale ?? defaultLocale;
this.locale = this.data.user.settings.locale ?? DEFAULT_LOCALE;
this.mode = this.data.activity?.id ? 'update' : 'create';
this.dateAdapter.setLocale(this.locale);

3
libs/common/src/lib/config.ts

@ -13,8 +13,6 @@ export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPr
export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`;
export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`;
export const locale = 'en-US';
export const primaryColorHex = '#36cfcc';
export const primaryColorRgb = {
r: 54,
@ -85,6 +83,7 @@ export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const DEFAULT_DATE_RANGE: DateRange = 'max';
export const DEFAULT_HOST = '0.0.0.0';
export const DEFAULT_LANGUAGE_CODE = 'en';
export const DEFAULT_LOCALE = 'en-US';
export const DEFAULT_PAGE_SIZE = 50;
export const DEFAULT_PORT = 3333;
export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1;

12
libs/common/src/lib/helper.ts

@ -36,16 +36,16 @@ import { get, isNil, isString } from 'lodash';
import {
DEFAULT_CURRENCY,
DEFAULT_LOCALE,
DERIVED_CURRENCIES,
ghostfolioFearAndGreedIndexSymbol,
ghostfolioFearAndGreedIndexSymbolCryptocurrencies,
ghostfolioFearAndGreedIndexSymbolStocks,
ghostfolioScraperApiSymbolPrefix,
locale
ghostfolioScraperApiSymbolPrefix
} from './config';
import {
AdminMarketDataItem,
AssetProfileIdentifier,
AssetProfileItem,
Benchmark
} from './interfaces';
import { BenchmarkTrend, ColorScheme } from './types';
@ -149,7 +149,7 @@ export function canDeleteAssetProfile({
symbol,
watchedByCount
}: Pick<
AdminMarketDataItem,
AssetProfileItem,
'activitiesCount' | 'isBenchmark' | 'symbol' | 'watchedByCount'
>): boolean {
return (
@ -261,7 +261,7 @@ export function getCurrencyFromSymbol(aSymbol = '') {
export function getCountryName({ code }: { code: string }): string {
try {
return (
new Intl.DisplayNames([document.documentElement.lang || locale], {
new Intl.DisplayNames([document.documentElement.lang || DEFAULT_LOCALE], {
type: 'region'
}).of(code) ?? code
);
@ -338,7 +338,7 @@ export function getEmojiFlag(aCountryCode: string) {
}
export function getLocale() {
return navigator.language ?? locale;
return navigator.language ?? DEFAULT_LOCALE;
}
export function getLowercase(object: object, path: string) {

9
libs/common/src/lib/interfaces/admin-market-data.interface.ts → libs/common/src/lib/interfaces/asset-profile-item.interface.ts

@ -1,14 +1,10 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface AdminMarketData {
count: number;
marketData: AdminMarketDataItem[];
}
export interface AdminMarketDataItem {
export interface AssetProfileItem {
activitiesCount: number;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
comment?: string;
countriesCount: number;
currency: string;
dataSource: DataSource;
@ -16,6 +12,7 @@ export interface AdminMarketDataItem {
id: string;
isActive: boolean;
isBenchmark?: boolean;
isin?: string;
isUsedByUsersWithSubscription?: boolean;
lastMarketPrice: number;
marketDataItemCount: number;

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

@ -4,13 +4,10 @@ import type { Activity, ActivityError } from './activities.interface';
import type { AdminData } from './admin-data.interface';
import type { AdminJobs } from './admin-jobs.interface';
import type { AdminMarketDataDetails } from './admin-market-data-details.interface';
import type {
AdminMarketData,
AdminMarketDataItem
} from './admin-market-data.interface';
import type { AdminUser } from './admin-user.interface';
import type { AssetClassSelectorOption } from './asset-class-selector-option.interface';
import type { AssetProfileIdentifier } from './asset-profile-identifier.interface';
import type { AssetProfileItem } from './asset-profile-item.interface';
import type { BenchmarkProperty } from './benchmark-property.interface';
import type { Benchmark } from './benchmark.interface';
import type { Coupon } from './coupon.interface';
@ -50,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 { 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';
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
@ -114,9 +112,7 @@ export {
ActivityResponse,
AdminData,
AdminJobs,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
AdminUser,
AdminUserResponse,
AdminUsersResponse,
@ -126,6 +122,8 @@ export {
AssertionCredentialJSON,
AssetClassSelectorOption,
AssetProfileIdentifier,
AssetProfileItem,
AssetProfilesResponse,
AssetResponse,
AttestationCredentialJSON,
Benchmark,

6
libs/common/src/lib/interfaces/responses/asset-profiles-response.interface.ts

@ -0,0 +1,6 @@
import { AssetProfileItem } from '../asset-profile-item.interface';
export interface AssetProfilesResponse {
assetProfiles: AssetProfileItem[];
count: number;
}

79
libs/common/src/lib/personal-finance-tools.ts

@ -1,6 +1,17 @@
import { Product } from '@ghostfolio/common/interfaces';
export const personalFinanceTools: Product[] = [
{
hasFreePlan: true,
hasSelfHostingAbility: true,
key: 'acemoney',
name: 'AceMoney',
note: 'License is a perpetual license',
origin: 'US',
pricingPerYear: '$44.95',
slogan: 'Lifetime Personal Finance Control, One Single Payment.',
url: 'https://www.mechcad.net'
},
{
founded: 2023,
hasSelfHostingAbility: false,
@ -361,6 +372,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'Your financial superpower',
url: 'https://www.etops.com'
},
{
founded: 2015,
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'everydollar',
name: 'EveryDollar',
origin: 'US',
slogan: 'Plan, track, find more margin',
url: 'https://www.ramseysolutions.com/money/everydollar'
},
{
founded: 2020,
hasFreePlan: true,
@ -497,6 +518,17 @@ export const personalFinanceTools: Product[] = [
slogan: 'Take control over your investments',
url: 'https://www.folishare.com'
},
{
founded: 1993,
hasSelfHostingAbility: true,
key: 'fund-manager',
name: 'Fund Manager',
note: 'License is a perpetual license',
origin: 'US',
pricingPerYear: '$99',
slogan: 'Powerful portfolio management software',
url: 'https://www.fundmanagersoftware.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
@ -528,6 +560,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Portfolio Tracker, Analysis & Community',
url: 'https://www.getquin.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
key: 'goodbudget',
name: 'Goodbudget',
origin: 'US',
slogan: 'Budget with a why',
url: 'https://goodbudget.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: false,
@ -665,6 +706,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'Sustainability insights for wealth managers',
url: 'https://leafs.ch'
},
{
founded: 2020,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'lunch-money',
name: 'Lunch Money',
origin: 'CA',
slogan: 'Delightfully simple personal finance and budgeting',
url: 'https://lunchmoney.app'
},
{
founded: 2018,
hasFreePlan: false,
@ -725,6 +776,16 @@ export const personalFinanceTools: Product[] = [
slogan: 'The smartest way to track your crypto',
url: 'https://www.merlincrypto.com'
},
{
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'mezzi',
name: 'Mezzi',
origin: 'US',
pricingPerYear: '$48',
slogan: 'Self-manage your wealth. Get fiduciary advice.',
url: 'https://www.mezzi.com'
},
{
founded: 1991,
hasSelfHostingAbility: true,
@ -776,6 +837,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Have total control of your financial life',
url: 'https://www.moneyspire.com'
},
{
hasFreePlan: true,
hasSelfHostingAbility: true,
key: 'moneywell',
name: 'MoneyWell',
origin: 'US',
slogan: 'Personal Budgeting Software for Mac and iOS',
url: 'https://moneywell.app'
},
{
key: 'moneywiz',
name: 'MoneyWiz',
@ -1109,6 +1179,15 @@ export const personalFinanceTools: Product[] = [
slogan: 'Stock Portfolio Tracker',
url: 'https://simpleportfolio.app'
},
{
founded: 2020,
hasFreePlan: false,
hasSelfHostingAbility: false,
key: 'simplifi',
name: 'Simplifi by Quicken',
origin: 'US',
url: 'https://www.quicken.com/products/simplifi'
},
{
founded: 2014,
hasFreePlan: true,

11
libs/ui/src/lib/assistant/assistant.component.ts

@ -3,7 +3,7 @@ import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces';
import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface';
import { internalRoutes } from '@ghostfolio/common/routes/routes';
import { AccountWithPlatform, DateRange } from '@ghostfolio/common/types';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { DataService } from '@ghostfolio/ui/services';
import { FocusKeyManager } from '@angular/cdk/a11y';
import {
@ -155,7 +155,6 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
private preselectionTimeout: ReturnType<typeof setTimeout>;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private destroyRef: DestroyRef
@ -674,8 +673,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
private searchAssetProfiles(
aSearchTerm: string
): Observable<SearchResultItem[]> {
return this.adminService
.fetchAdminMarketData({
return this.dataService
.fetchAssetProfiles({
filters: [
{
id: aSearchTerm,
@ -688,8 +687,8 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
catchError(() => {
return EMPTY;
}),
map(({ marketData }) => {
return marketData.map(
map(({ assetProfiles }) => {
return assetProfiles.map(
({ assetSubClass, currency, dataSource, name, symbol }) => {
return {
currency,

4
libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts

@ -1,4 +1,4 @@
import { locale } from '@ghostfolio/common/config';
import { DEFAULT_LOCALE } from '@ghostfolio/common/config';
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -47,7 +47,7 @@ export const Simple: Story = {
annualInterestRate: 5,
currency: 'USD',
fireWealth: 50000,
locale: locale,
locale: DEFAULT_LOCALE,
savingsRate: 1000
}
};

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

@ -11,32 +11,26 @@ import {
import {
AdminData,
AdminJobs,
AdminMarketData,
AdminUserResponse,
AdminUsersResponse,
AssetProfileIdentifier,
DataProviderGhostfolioStatusResponse,
DataProviderHistoricalResponse,
EnhancedSymbolProfile,
Filter
EnhancedSymbolProfile
} from '@ghostfolio/common/interfaces';
import { DateRange } from '@ghostfolio/common/types';
import { GF_ENVIRONMENT } from '@ghostfolio/ui/environment';
import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform } from '@prisma/client';
import { JobStatus } from 'bull';
import { isNumber } from 'lodash';
import { DataService } from './data.service';
@Injectable({
providedIn: 'root'
})
export class AdminService {
private readonly dataService = inject(DataService);
private readonly environment = inject(GF_ENVIRONMENT);
private readonly http = inject(HttpClient);
@ -81,42 +75,6 @@ export class AdminService {
return this.http.get<AdminData>('/api/v1/admin');
}
public fetchAdminMarketData({
filters,
skip,
sortColumn,
sortDirection,
take
}: {
filters?: Filter[];
skip?: number;
sortColumn?: string;
sortDirection?: SortDirection;
take: number;
}) {
let params = this.dataService.buildFiltersAsQueryParams({ filters });
if (skip) {
params = params.append('skip', skip);
}
if (sortColumn) {
params = params.append('sortColumn', sortColumn);
}
if (sortDirection) {
params = params.append('sortDirection', sortDirection);
}
if (take) {
params = params.append('take', take);
}
return this.http.get<AdminMarketData>('/api/v1/admin/market-data', {
params
});
}
public fetchGhostfolioDataProviderStatus(aApiKey: string) {
const headers = new HttpHeaders({
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',

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

@ -28,6 +28,7 @@ import {
AiPromptResponse,
ApiKeyResponse,
AssetProfileIdentifier,
AssetProfilesResponse,
AssetResponse,
BenchmarkMarketDataDetailsResponse,
BenchmarkResponse,
@ -378,6 +379,42 @@ export class DataService {
);
}
public fetchAssetProfiles({
filters,
skip,
sortColumn,
sortDirection,
take
}: {
filters?: Filter[];
skip?: number;
sortColumn?: string;
sortDirection?: SortDirection;
take: number;
}) {
let params = this.buildFiltersAsQueryParams({ filters });
if (skip) {
params = params.append('skip', skip);
}
if (sortColumn) {
params = params.append('sortColumn', sortColumn);
}
if (sortDirection) {
params = params.append('sortDirection', sortDirection);
}
if (take) {
params = params.append('take', take);
}
return this.http.get<AssetProfilesResponse>('/api/v1/asset-profiles', {
params
});
}
public fetchBenchmarkForUser({
dataSource,
filters,

32
package-lock.json

@ -95,7 +95,7 @@
"tablemark": "4.1.0",
"twitter-api-v2": "1.29.0",
"undici": "7.24.4",
"yahoo-finance2": "3.14.2",
"yahoo-finance2": "3.15.3",
"zone.js": "0.16.1"
},
"devDependencies": {
@ -4556,7 +4556,6 @@
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
"integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@ -7344,7 +7343,6 @@
"version": "1.26.0",
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz",
"integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@hono/node-server": "^1.19.9",
@ -18893,7 +18891,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@ -22011,7 +22008,6 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eventsource-parser": "^3.0.1"
@ -22155,7 +22151,6 @@
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz",
"integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==",
"dev": true,
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
@ -23811,7 +23806,6 @@
"version": "4.12.14",
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.14.tgz",
"integrity": "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=16.9.0"
@ -24553,7 +24547,6 @@
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 12"
@ -25222,7 +25215,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"devOptional": true,
"license": "ISC"
},
"node_modules/isobject": {
@ -28477,7 +28469,6 @@
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
"integrity": "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
@ -28652,7 +28643,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/json-stable-stringify-without-jsonify": {
@ -32293,7 +32283,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -32531,7 +32520,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16.20.0"
@ -35418,7 +35406,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@ -35431,7 +35418,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@ -39554,7 +39540,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@ -39879,19 +39864,23 @@
}
},
"node_modules/yahoo-finance2": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.14.2.tgz",
"integrity": "sha512-s+F7TWQT7zAtjhfC7rFHEX16Xfq36u3wceysINP7V+esF3mAYyk9slxZU+fEdkxaTuCT0+PnikHdekMX4UPMrg==",
"version": "3.15.3",
"resolved": "https://registry.npmjs.org/yahoo-finance2/-/yahoo-finance2-3.15.3.tgz",
"integrity": "sha512-AFmVZ4ACg3QFT2a/hyJv4Scp2J45gm/6xetVgvUvXzZypqsqbgreXJad4i+qm0onj6yeCf2fPhvow7Eh0CwwzA==",
"license": "MIT",
"dependencies": {
"@deno/shim-deno": "~0.18.0",
"@modelcontextprotocol/sdk": "npm:@modelcontextprotocol/sdk@^1.26.0",
"fetch-mock-cache": "npm:fetch-mock-cache@^2.1.3",
"json-schema": "^0.4.0",
"tough-cookie": "npm:tough-cookie@^5.1.1",
"tough-cookie-file-store": "npm:tough-cookie-file-store@^2.0.3"
"tough-cookie-file-store": "npm:tough-cookie-file-store@^2.0.3",
"zod": "npm:zod@^3.25.0"
},
"bin": {
"yahoo-finance": "esm/bin/yahoo-finance.js"
"yahoo-finance": "esm/bin/yahoo-finance.js",
"yahoo-finance-mcp": "esm/bin/yahoo-finance-mcp.js",
"yahoo-finance2": "esm/bin/yahoo-finance.js"
},
"engines": {
"node": ">=20.0.0"
@ -40149,7 +40138,6 @@
"version": "3.25.2",
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
"dev": true,
"license": "ISC",
"peerDependencies": {
"zod": "^3.25.28 || ^4"

2
package.json

@ -139,7 +139,7 @@
"tablemark": "4.1.0",
"twitter-api-v2": "1.29.0",
"undici": "7.24.4",
"yahoo-finance2": "3.14.2",
"yahoo-finance2": "3.15.3",
"zone.js": "0.16.1"
},
"devDependencies": {

Loading…
Cancel
Save