Browse Source

Merge branch 'ghostfolio:main' into Support-CSV-Yahoo-Finance-#2288

pull/4056/head
Brandon 9 months ago
committed by GitHub
parent
commit
a22ce9d822
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 42
      CHANGELOG.md
  2. 10
      apps/api/src/app/admin/admin.controller.ts
  3. 48
      apps/api/src/app/admin/admin.service.ts
  4. 2
      apps/api/src/app/app.module.ts
  5. 15
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts
  6. 15
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts
  7. 10
      apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts
  8. 196
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  9. 83
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts
  10. 304
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  11. 3
      apps/api/src/app/import/import.service.ts
  12. 3
      apps/api/src/app/info/info.service.ts
  13. 5
      apps/api/src/app/user/user.service.ts
  14. 2317
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  15. 4
      apps/api/src/assets/sitemap.xml
  16. 4
      apps/api/src/middlewares/html-template.middleware.ts
  17. 5
      apps/api/src/services/configuration/configuration.service.ts
  18. 12
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  19. 5
      apps/api/src/services/data-provider/data-provider.module.ts
  20. 29
      apps/api/src/services/data-provider/data-provider.service.ts
  21. 14
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  22. 6
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  23. 284
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  24. 8
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  25. 6
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  26. 1
      apps/api/src/services/interfaces/environment.interface.ts
  27. 9
      apps/client/src/app/app-routing.module.ts
  28. 42
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  29. 83
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  30. 2
      apps/client/src/app/components/admin-settings/admin-settings.module.ts
  31. 20
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts
  32. 14
      apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html
  33. 1
      apps/client/src/app/components/admin-users/admin-users.component.ts
  34. 21
      apps/client/src/app/components/admin-users/admin-users.html
  35. 2
      apps/client/src/app/components/home-holdings/home-holdings.html
  36. 52
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html
  37. 3
      apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss
  38. 11
      apps/client/src/app/core/auth.interceptor.ts
  39. 2
      apps/client/src/app/core/http-response.interceptor.ts
  40. 131
      apps/client/src/app/pages/api/api-page.component.ts
  41. 62
      apps/client/src/app/pages/api/api-page.html
  42. 3
      apps/client/src/app/pages/api/api-page.scss
  43. 17
      apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.component.ts
  44. 180
      apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.html
  45. 27
      apps/client/src/app/pages/blog/blog-page-routing.module.ts
  46. 26
      apps/client/src/app/pages/blog/blog-page.html
  47. 34
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  48. 1
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  49. 31
      apps/client/src/app/services/admin.service.ts
  50. BIN
      apps/client/src/assets/images/blog/black-weeks-2024.jpg
  51. 9
      apps/client/src/assets/oss-friends.json
  52. 436
      apps/client/src/locales/messages.ca.xlf
  53. 438
      apps/client/src/locales/messages.de.xlf
  54. 436
      apps/client/src/locales/messages.es.xlf
  55. 436
      apps/client/src/locales/messages.fr.xlf
  56. 456
      apps/client/src/locales/messages.it.xlf
  57. 436
      apps/client/src/locales/messages.nl.xlf
  58. 436
      apps/client/src/locales/messages.pl.xlf
  59. 436
      apps/client/src/locales/messages.pt.xlf
  60. 436
      apps/client/src/locales/messages.tr.xlf
  61. 420
      apps/client/src/locales/messages.xlf
  62. 436
      apps/client/src/locales/messages.zh.xlf
  63. 4
      libs/common/src/lib/config.ts
  64. 2
      libs/common/src/lib/interfaces/admin-users.interface.ts
  65. 5
      libs/common/src/lib/interfaces/holding-with-parents.interface.ts
  66. 10
      libs/common/src/lib/interfaces/index.ts
  67. 7
      libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts
  68. 7
      libs/common/src/lib/interfaces/responses/dividends-response.interface.ts
  69. 7
      libs/common/src/lib/interfaces/responses/historical-response.interface.ts
  70. 5
      libs/common/src/lib/interfaces/responses/quotes-response.interface.ts
  71. 1
      libs/common/src/lib/permissions.ts
  72. 1
      libs/common/src/lib/types/user-with-settings.type.ts
  73. 5
      libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html
  74. 129
      libs/ui/src/lib/top-holdings/top-holdings.component.html
  75. 32
      libs/ui/src/lib/top-holdings/top-holdings.component.scss
  76. 51
      libs/ui/src/lib/top-holdings/top-holdings.component.ts
  77. 824
      package-lock.json
  78. 32
      package.json
  79. 2
      prisma/migrations/20241103110114_added_ghostfolio_to_data_source/migration.sql
  80. 5
      prisma/migrations/20241130164334_upgraded_to_prisma_6/migration.sql
  81. 19
      prisma/migrations/20241130164335_added_api_keys_to_user copy/migration.sql
  82. 14
      prisma/schema.prisma

42
CHANGELOG.md

@ -5,14 +5,54 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
## 2.125.0 - 2024-11-30
### Changed
- Improved the style of the symbol search component
- Extended the users table in the admin control panel
- Refreshed the cryptocurrencies list
- Increased the default request timeout (`REQUEST_TIMEOUT`)
- Upgraded `cheerio` from version `1.0.0-rc.12` to `1.0.0`
- Upgraded `prisma` from version `5.22.0` to `6.0.0`
## 2.124.1 - 2024-11-25
### Fixed
- Fixed the tables style related to sticky columns
## 2.124.0 - 2024-11-24
### Added
- Added pagination parameters (`skip`, `take`) to the endpoint `GET api/v1/admin/user`
- Added pagination response (`count`) to the endpoint `GET api/v1/admin/user`
- Added `GHOSTFOLIO` as a new data source type
### Changed
- Extended the allocations by ETF holding on the allocations page by the parent ETFs (experimental)
- Improved the language localization for German (`de`)
- Upgraded `countries-and-timezones` from version `3.4.1` to `3.7.2`
- Upgraded `Nx` from version `20.0.6` to `20.1.2`
## 2.123.0 - 2024-11-16
### Added
- Added a blog post: _Black Weeks 2024_
### Changed
- Moved the chart of the holdings tab on the home page from experimental to general availability
- Extended the assistant by a holding selector
- Separated the _FIRE_ / _X-ray_ page
- Improved the usability to customize the rule thresholds in the _X-ray_ page by introducing range sliders (experimental)
- Improved the language localization for German (`de`)
- Improved the language localization for Italian (`it`)
- Upgraded `ngx-skeleton-loader` from version `7.0.0` to `9.0.0`
- Upgraded `prisma` from version `5.21.1` to `5.22.0`
- Upgraded `uuid` from version `9.0.1` to `11.0.2`
## 2.122.0 - 2024-11-07

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

@ -352,7 +352,13 @@ export class AdminController {
@Get('user')
@HasPermission(permissions.accessAdminControl)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getUsers(): Promise<AdminUsers> {
return this.adminService.getUsers();
public async getUsers(
@Query('skip') skip?: number,
@Query('take') take?: number
): Promise<AdminUsers> {
return this.adminService.getUsers({
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
}

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

@ -140,7 +140,7 @@ export class AdminService {
const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(),
this.prismaService.order.count(),
this.prismaService.user.count()
this.countUsersWithAnalytics()
]);
return {
@ -429,8 +429,19 @@ export class AdminService {
};
}
public async getUsers(): Promise<AdminUsers> {
return { users: await this.getUsersWithAnalytics() };
public async getUsers({
skip,
take = Number.MAX_SAFE_INTEGER
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers> {
const [count, users] = await Promise.all([
this.countUsersWithAnalytics(),
this.getUsersWithAnalytics({ skip, take })
]);
return { count, users };
}
public async patchAssetProfileData({
@ -508,6 +519,22 @@ export class AdminService {
return response;
}
private async countUsersWithAnalytics() {
let where: Prisma.UserWhereInput;
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
where = {
NOT: {
Analytics: null
}
};
}
return this.prismaService.user.count({
where
});
}
private getExtendedPrismaClient() {
Logger.debug('Connect extended prisma client', 'AdminService');
@ -640,7 +667,13 @@ export class AdminService {
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics(): Promise<AdminUsers['users']> {
private async getUsersWithAnalytics({
skip,
take
}: {
skip?: number;
take?: number;
}): Promise<AdminUsers['users']> {
let orderBy: Prisma.UserOrderByWithRelationInput = {
createdAt: 'desc'
};
@ -661,6 +694,8 @@ export class AdminService {
const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy,
skip,
take,
where,
select: {
_count: {
@ -670,6 +705,7 @@ export class AdminService {
select: {
activityCount: true,
country: true,
dataProviderGhostfolioDailyRequests: true,
updatedAt: true
}
},
@ -677,8 +713,7 @@ export class AdminService {
id: true,
role: true,
Subscription: true
},
take: 30
}
});
return usersWithAnalytics.map(
@ -706,6 +741,7 @@ export class AdminService {
subscription,
accountCount: _count.Account || 0,
country: Analytics?.country,
dailyApiRequests: Analytics?.dataProviderGhostfolioDailyRequests || 0,
lastActivity: Analytics?.updatedAt,
transactionCount: _count.Order || 0
};

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

@ -31,6 +31,7 @@ import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { PublicModule } from './endpoints/public/public.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module';
@ -76,6 +77,7 @@ import { UserModule } from './user/user.module';
ExchangeRateModule,
ExchangeRateDataModule,
ExportModule,
GhostfolioModule,
HealthModule,
ImportModule,
InfoModule,

15
apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts

@ -0,0 +1,15 @@
import { Granularity } from '@ghostfolio/common/types';
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
export class GetDividendsDto {
@IsISO8601()
from: string;
@IsIn(['day', 'month'] as Granularity[])
@IsOptional()
granularity: Granularity;
@IsISO8601()
to: string;
}

15
apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts

@ -0,0 +1,15 @@
import { Granularity } from '@ghostfolio/common/types';
import { IsIn, IsISO8601, IsOptional } from 'class-validator';
export class GetHistoricalDto {
@IsISO8601()
from: string;
@IsIn(['day', 'month'] as Granularity[])
@IsOptional()
granularity: Granularity;
@IsISO8601()
to: string;
}

10
apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts

@ -0,0 +1,10 @@
import { Transform } from 'class-transformer';
import { IsString } from 'class-validator';
export class GetQuotesDto {
@IsString({ each: true })
@Transform(({ value }) =>
typeof value === 'string' ? value.split(',') : value
)
symbols: string[];
}

196
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts

@ -0,0 +1,196 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { parseDate } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
HttpException,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { getReasonPhrase, StatusCodes } from 'http-status-codes';
import { GetDividendsDto } from './get-dividends.dto';
import { GetHistoricalDto } from './get-historical.dto';
import { GetQuotesDto } from './get-quotes.dto';
import { GhostfolioService } from './ghostfolio.service';
@Controller('data-providers/ghostfolio')
export class GhostfolioController {
public constructor(
private readonly ghostfolioService: GhostfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get('dividends/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getDividends(
@Param('symbol') symbol: string,
@Query() query: GetDividendsDto
): Promise<DividendsResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const dividends = await this.ghostfolioService.getDividends({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return dividends;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('historical/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getHistorical(
@Param('symbol') symbol: string,
@Query() query: GetHistoricalDto
): Promise<HistoricalResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const historicalData = await this.ghostfolioService.getHistorical({
symbol,
from: parseDate(query.from),
granularity: query.granularity,
to: parseDate(query.to)
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return historicalData;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('lookup')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async lookupSymbol(
@Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = ''
): Promise<LookupResponse> {
const includeIndices = includeIndicesParam === 'true';
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const result = await this.ghostfolioService.lookup({
includeIndices,
query: query.toLowerCase()
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return result;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('quotes')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getQuotes(
@Query() query: GetQuotesDto
): Promise<QuotesResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const quotes = await this.ghostfolioService.getQuotes({
symbols: query.symbols
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return quotes;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
@Get('status')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getStatus(): Promise<DataProviderGhostfolioStatusResponse> {
return this.ghostfolioService.getStatus({ user: this.request.user });
}
}

83
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts

@ -0,0 +1,83 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { GhostfolioController } from './ghostfolio.controller';
import { GhostfolioService } from './ghostfolio.service';
@Module({
controllers: [GhostfolioController],
imports: [
CryptocurrencyModule,
DataProviderModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [
AlphaVantageService,
CoinGeckoService,
ConfigurationService,
DataProviderService,
EodHistoricalDataService,
FinancialModelingPrepService,
GhostfolioService,
GoogleSheetsService,
ManualService,
RapidApiService,
YahooFinanceService,
YahooFinanceDataEnhancerService,
{
inject: [
AlphaVantageService,
CoinGeckoService,
EodHistoricalDataService,
FinancialModelingPrepService,
GoogleSheetsService,
ManualService,
RapidApiService,
YahooFinanceService
],
provide: 'DataProviderInterfaces',
useFactory: (
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
) => [
alphaVantageService,
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
googleSheetsService,
manualService,
rapidApiService,
yahooFinanceService
]
}
]
})
export class GhostfolioModule {}

304
apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts

@ -0,0 +1,304 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
DERIVED_CURRENCIES
} from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import {
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
LookupItem,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
@Injectable()
export class GhostfolioService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) {}
public async getDividends({
from,
granularity,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetDividendsParams) {
const result: DividendsResponse = { dividends: {} };
try {
const promises: Promise<{
[date: string]: IDataProviderHistoricalResponse;
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getDividends({
from,
granularity,
requestTimeout,
symbol,
to
})
.then((dividends) => {
result.dividends = dividends;
return dividends;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getHistorical({
from,
granularity,
requestTimeout,
to,
symbol
}: GetHistoricalParams) {
const result: HistoricalResponse = { historicalData: {} };
try {
const promises: Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getHistorical({
from,
granularity,
requestTimeout,
symbol,
to
})
.then((historicalData) => {
result.historicalData = historicalData[symbol];
return historicalData;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getMaxDailyRequests() {
return parseInt(
((await this.propertyService.getByKey(
PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS
)) as string) || '0',
10
);
}
public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) {
const results: QuotesResponse = { quotes: {} };
try {
const promises: Promise<any>[] = [];
for (const dataProvider of this.getDataProviderServices()) {
const maximumNumberOfSymbolsPerRequest =
dataProvider.getMaxNumberOfSymbolsPerRequest?.() ??
Number.MAX_SAFE_INTEGER;
for (
let i = 0;
i < symbols.length;
i += maximumNumberOfSymbolsPerRequest
) {
const symbolsChunk = symbols.slice(
i,
i + maximumNumberOfSymbolsPerRequest
);
const promise = Promise.resolve(
dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk })
);
promises.push(
promise.then(async (result) => {
for (const [symbol, dataProviderResponse] of Object.entries(
result
)) {
dataProviderResponse.dataSource = 'GHOSTFOLIO';
if (
[
...DERIVED_CURRENCIES.map(({ currency }) => {
return `${DEFAULT_CURRENCY}${currency}`;
}),
`${DEFAULT_CURRENCY}USX`
].includes(symbol)
) {
continue;
}
results.quotes[symbol] = dataProviderResponse;
for (const {
currency,
factor,
rootCurrency
} of DERIVED_CURRENCIES) {
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
results.quotes[`${DEFAULT_CURRENCY}${currency}`] = {
...dataProviderResponse,
currency,
marketPrice: new Big(
result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice
)
.mul(factor)
.toNumber(),
marketState: 'open'
};
}
}
}
})
);
}
await Promise.all(promises);
}
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getStatus({ user }: { user: UserWithSettings }) {
return {
dailyRequests: user.dataProviderGhostfolioDailyRequests,
dailyRequestsMax: await this.getMaxDailyRequests(),
subscription: user.subscription
};
}
public async incrementDailyRequests({ userId }: { userId: string }) {
await this.prismaService.analytics.update({
data: {
dataProviderGhostfolioDailyRequests: { increment: 1 },
lastRequestAt: new Date()
},
where: { userId }
});
}
public async lookup({
includeIndices = false,
query
}: GetSearchParams): Promise<LookupResponse> {
const results: LookupResponse = { items: [] };
if (!query) {
return results;
}
try {
let lookupItems: LookupItem[] = [];
const promises: Promise<{ items: LookupItem[] }>[] = [];
if (query?.length < 2) {
return { items: lookupItems };
}
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService.search({
includeIndices,
query
})
);
}
const searchResults = await Promise.all(promises);
for (const { items } of searchResults) {
if (items?.length > 0) {
lookupItems = lookupItems.concat(items);
}
}
const filteredItems = lookupItems
.filter(({ currency }) => {
// Only allow symbols with supported currency
return currency ? true : false;
})
.sort(({ name: name1 }, { name: name2 }) => {
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
})
.map((lookupItem) => {
lookupItem.dataProviderInfo = this.getDataProviderInfo();
lookupItem.dataSource = 'GHOSTFOLIO';
return lookupItem;
});
results.items = filteredItems;
return results;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
private getDataProviderInfo(): DataProviderInfo {
return {
isPremium: false,
name: 'Ghostfolio Premium',
url: 'https://ghostfol.io'
};
}
private getDataProviderServices() {
return this.configurationService
.get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER')
.map((dataSource) => {
return this.dataProviderService.getDataProvider(DataSource[dataSource]);
});
}
}

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

@ -582,12 +582,13 @@ export class ImportService {
const assetProfiles: {
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const dataSources = await this.dataProviderService.getDataSources();
for (const [
index,
{ currency, dataSource, symbol, type }
] of activitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
if (!dataSources.includes(dataSource)) {
throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid`
);

3
apps/api/src/app/info/info.service.ts

@ -7,6 +7,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
DEFAULT_CURRENCY,
HEADER_KEY_TOKEN,
PROPERTY_BETTER_UPTIME_MONITOR_ID,
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
PROPERTY_DEMO_USER_ID,
@ -347,7 +348,7 @@ export class InfoService {
)}&to${format(new Date(), DATE_FORMAT)}`,
{
headers: {
Authorization: `Bearer ${this.configurationService.get(
[HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get(
'API_KEY_BETTER_UPTIME'
)}`
},

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

@ -183,7 +183,9 @@ export class UserService {
Settings: Settings as UserWithSettings['Settings'],
thirdPartyId,
updatedAt,
activityCount: Analytics?.activityCount
activityCount: Analytics?.activityCount,
dataProviderGhostfolioDailyRequests:
Analytics?.dataProviderGhostfolioDailyRequests
};
if (user?.Settings) {
@ -307,6 +309,7 @@ export class UserService {
// Reset holdings view mode
user.Settings.settings.holdingsViewMode = undefined;
} else if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.enableDataProviderGhostfolio);
currentPermissions.push(permissions.reportDataGlitch);
currentPermissions = without(

2317
apps/api/src/assets/cryptocurrencies/cryptocurrencies.json

File diff suppressed because it is too large

4
apps/api/src/assets/sitemap.xml

@ -188,6 +188,10 @@
<loc>https://ghostfol.io/en/blog/2024/09/hacktoberfest-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2024/11/black-weeks-2024</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>

4
apps/api/src/middlewares/html-template.middleware.ts

@ -87,6 +87,10 @@ const locales = {
'/en/blog/2024/09/hacktoberfest-2024': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png',
title: `Hacktoberfest 2024 - ${title}`
},
'/en/blog/2024/11/black-weeks-2024': {
featureGraphicPath: 'assets/images/blog/black-weeks-2024.jpg',
title: `Black Weeks 2024 - ${title}`
}
};

5
apps/api/src/services/configuration/configuration.service.ts

@ -35,6 +35,9 @@ export class ConfigurationService {
DATA_SOURCES: json({
default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO]
}),
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({
default: []
}),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
@ -67,7 +70,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: 2000 }),
REQUEST_TIMEOUT: num({ default: ms('3 seconds') }),
ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),

12
apps/api/src/services/data-provider/coingecko/coingecko.service.ts

@ -86,9 +86,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'CoinGeckoService');
@ -255,9 +255,9 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'CoinGeckoService');

5
apps/api/src/services/data-provider/data-provider.module.ts

@ -5,6 +5,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service';
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service';
import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
@ -37,6 +38,7 @@ import { DataProviderService } from './data-provider.service';
DataProviderService,
EodHistoricalDataService,
FinancialModelingPrepService,
GhostfolioService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -47,6 +49,7 @@ import { DataProviderService } from './data-provider.service';
CoinGeckoService,
EodHistoricalDataService,
FinancialModelingPrepService,
GhostfolioService,
GoogleSheetsService,
ManualService,
RapidApiService,
@ -58,6 +61,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
ghostfolioService,
googleSheetsService,
manualService,
rapidApiService,
@ -67,6 +71,7 @@ import { DataProviderService } from './data-provider.service';
coinGeckoService,
eodHistoricalDataService,
financialModelingPrepService,
ghostfolioService,
googleSheetsService,
manualService,
rapidApiService,

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

@ -11,6 +11,7 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
import {
DEFAULT_CURRENCY,
DERIVED_CURRENCIES,
PROPERTY_API_KEY_GHOSTFOLIO,
PROPERTY_DATA_SOURCE_MAPPING
} from '@ghostfolio/common/config';
import {
@ -153,6 +154,24 @@ export class DataProviderService {
return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')];
}
public async getDataSources(): Promise<DataSource[]> {
const dataSources: DataSource[] = this.configurationService
.get('DATA_SOURCES')
.map((dataSource) => {
return DataSource[dataSource];
});
const ghostfolioApiKey = (await this.propertyService.getByKey(
PROPERTY_API_KEY_GHOSTFOLIO
)) as string;
if (ghostfolioApiKey) {
dataSources.push('GHOSTFOLIO');
}
return dataSources.sort();
}
public async getDividends({
dataSource,
from,
@ -589,9 +608,9 @@ export class DataProviderService {
return { items: lookupItems };
}
const dataProviderServices = this.configurationService
.get('DATA_SOURCES')
.map((dataSource) => {
const dataSources = await this.getDataSources();
const dataProviderServices = dataSources.map((dataSource) => {
return this.getDataProvider(DataSource[dataSource]);
});
@ -606,11 +625,11 @@ export class DataProviderService {
const searchResults = await Promise.all(promises);
searchResults.forEach(({ items }) => {
for (const { items } of searchResults) {
if (items?.length > 0) {
lookupItems = lookupItems.concat(items);
}
});
}
const filteredItems = lookupItems
.filter(({ currency }) => {

14
apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts

@ -410,14 +410,12 @@ export class EodHistoricalDataService implements DataProviderInterface {
return name;
}
private async getSearchResult(aQuery: string): Promise<
(LookupItem & {
private async getSearchResult(aQuery: string) {
let searchResult: (LookupItem & {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
isin: string;
})[]
> {
let searchResult = [];
})[] = [];
try {
const abortController = new AbortController();
@ -456,9 +454,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation to search for ${aQuery} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'EodHistoricalDataService');

6
apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts

@ -204,9 +204,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'FinancialModelingPrepService');

284
apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts

@ -0,0 +1,284 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import {
DataProviderInterface,
GetDividendsParams,
GetHistoricalParams,
GetQuotesParams,
GetSearchParams
} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
HEADER_KEY_TOKEN,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderInfo,
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns';
import got from 'got';
import { StatusCodes } from 'http-status-codes';
@Injectable()
export class GhostfolioService implements DataProviderInterface {
private readonly URL = environment.production
? 'https://ghostfol.io/api'
: `${this.configurationService.get('ROOT_URL')}/api`;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService
) {}
public canHandle() {
return true;
}
public async getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const { items } = await this.search({ query: symbol });
const searchResult = items?.[0];
return {
symbol,
assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass,
currency: searchResult?.currency,
dataSource: this.getName(),
name: searchResult?.name
};
}
public getDataProviderInfo(): DataProviderInfo {
return {
isPremium: true,
name: 'Ghostfolio',
url: 'https://ghostfo.io'
};
}
public async getDividends({
from,
granularity = 'day',
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetDividendsParams): Promise<{
[date: string]: IDataProviderHistoricalResponse;
}> {
let response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, requestTimeout);
const { dividends } = await got(
`${this.URL}/v1/data-providers/ghostfolio/dividends/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to,
DATE_FORMAT
)}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<DividendsResponse>();
response = dividends;
} catch (error) {
let message = error;
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
}
return response;
}
public async getHistorical({
from,
granularity = 'day',
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, requestTimeout);
const { historicalData } = await got(
`${this.URL}/v1/data-providers/ghostfolio/historical/${symbol}?from=${format(from, DATE_FORMAT)}&granularity=${granularity}&to=${format(
to,
DATE_FORMAT
)}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<HistoricalResponse>();
return {
[symbol]: historicalData
};
} catch (error) {
let message = error;
if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
throw new Error(
`Could not get historical market data for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`
);
}
}
public getMaxNumberOfSymbolsPerRequest() {
return 20;
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
public async getQuotes({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbols
}: GetQuotesParams): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
let response: { [symbol: string]: IDataProviderResponse } = {};
if (symbols.length <= 0) {
return response;
}
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, requestTimeout);
const { quotes } = await got(
`${this.URL}/v1/data-providers/ghostfolio/quotes?symbols=${symbols.join(',')}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<QuotesResponse>();
response = quotes;
} catch (error) {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
}
return response;
}
public getTestSymbol() {
return 'AAPL.US';
}
public async search({ query }: GetSearchParams): Promise<LookupResponse> {
let searchResult: LookupResponse = { items: [] };
try {
const abortController = new AbortController();
setTimeout(() => {
abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT'));
searchResult = await got(
`${this.URL}/v1/data-providers/ghostfolio/lookup?query=${query}`,
{
headers: await this.getRequestHeaders(),
// @ts-ignore
signal: abortController.signal
}
).json<LookupResponse>();
} catch (error) {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
}
Logger.error(message, 'GhostfolioService');
}
return searchResult;
}
private async getRequestHeaders() {
const apiKey = (await this.propertyService.getByKey(
PROPERTY_API_KEY_GHOSTFOLIO
)) as string;
return {
[HEADER_KEY_TOKEN]: `Bearer ${apiKey}`
};
}
}

8
apps/api/src/services/data-provider/interfaces/data-provider.interface.ts

@ -21,7 +21,13 @@ export interface DataProviderInterface {
getDataProviderInfo(): DataProviderInfo;
getDividends({ from, granularity, symbol, to }: GetDividendsParams): Promise<{
getDividends({
from,
granularity,
requestTimeout,
symbol,
to
}: GetDividendsParams): Promise<{
[date: string]: IDataProviderHistoricalResponse;
}>;

6
apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts

@ -159,9 +159,9 @@ export class RapidApiService implements DataProviderInterface {
let message = error;
if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT'
)}ms`;
message = `RequestError: The operation was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'RapidApiService');

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

@ -15,6 +15,7 @@ export interface Environment extends CleanedEnvAccessors {
DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string;
DATA_SOURCES: string[];
DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[];
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_READ_ONLY_MODE: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;

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

@ -32,6 +32,15 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/admin/admin-page.module').then((m) => m.AdminPageModule)
},
{
canActivate: [AuthGuard],
loadComponent: () =>
import('./pages/api/api-page.component').then(
(c) => c.GfApiPageComponent
),
path: 'api',
title: 'Ghostfolio API'
},
{
path: 'auth',
loadChildren: () =>

42
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -11,23 +11,63 @@
target="_blank"
[href]="pricingUrl"
>
@if (isGhostfolioApiKeyValid === false) {
<span class="badge badge-warning mr-1" i18n>NEW</span>
}
Ghostfolio Premium
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/>
</a>
@if (isGhostfolioApiKeyValid === true) {
<div class="line-height-1">
<small class="text-muted">
<ng-container i18n>Valid until</ng-container>
{{
ghostfolioApiStatus?.subscription?.expiresAt
| date: defaultDateFormat
}}</small
>
</div>
}
</div>
<div class="w-50">
@if (isGhostfolioApiKeyValid === true) {
<div class="align-items-center d-flex flex-wrap">
<div class="flex-grow-1 mr-3">
{{ ghostfolioApiStatus.dailyRequests }}
<ng-container i18n>of</ng-container>
{{ ghostfolioApiStatus.dailyRequestsMax }}
<ng-container i18n>daily requests</ng-container>
</div>
<button
class="mx-1 no-min-width px-2"
mat-button
[matMenuTriggerFor]="ghostfolioApiMenu"
(click)="$event.stopPropagation()"
>
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #ghostfolioApiMenu="matMenu" xPosition="before">
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Remove API key</span>
</span>
</button>
</mat-menu>
</div>
} @else if (isGhostfolioApiKeyValid === false) {
<button
color="accent"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ion-icon class="mr-1" name="key-outline" />
<span i18n>Set API Key</span>
<span i18n>Set API key</span>
</button>
}
</div>
</div>
</mat-card-content>

83
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -1,5 +1,17 @@
import { ConfirmationDialogType } from '@ghostfolio/client/core/notification/confirmation-dialog/confirmation-dialog.type';
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import {
DEFAULT_LANGUAGE_CODE,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
User
} from '@ghostfolio/common/interfaces';
import {
ChangeDetectionStrategy,
@ -10,7 +22,7 @@ import {
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
import { catchError, filter, of, Subject, takeUntil } from 'rxjs';
import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component';
@ -21,6 +33,9 @@ import { GfGhostfolioPremiumApiDialogComponent } from './ghostfolio-premium-api-
templateUrl: './admin-settings.component.html'
})
export class AdminSettingsComponent implements OnDestroy, OnInit {
public defaultDateFormat: string;
public ghostfolioApiStatus: DataProviderGhostfolioStatusResponse;
public isGhostfolioApiKeyValid: boolean;
public pricingUrl: string;
private deviceType: string;
@ -28,9 +43,12 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
private user: User;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private matDialog: MatDialog,
private notificationService: NotificationService,
private userService: UserService
) {}
@ -43,17 +61,42 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user?.settings?.locale
);
const languageCode =
this.user?.settings?.language ?? DEFAULT_LANGUAGE_CODE;
this.pricingUrl =
`https://ghostfol.io/${this.user.settings.language}/` +
`https://ghostfol.io/${languageCode}/` +
$localize`:snake-case:pricing`;
this.changeDetectorRef.markForCheck();
}
});
this.initialize();
}
public onRemoveGhostfolioApiKey() {
this.notificationService.confirm({
confirmFn: () => {
this.dataService
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, { value: undefined })
.subscribe(() => {
this.initialize();
});
},
confirmType: ConfirmationDialogType.Warn,
title: $localize`Do you really want to delete the API key?`
});
}
public onSetGhostfolioApiKey() {
this.matDialog.open(GfGhostfolioPremiumApiDialogComponent, {
const dialogRef = this.matDialog.open(
GfGhostfolioPremiumApiDialogComponent,
{
autoFocus: false,
data: {
deviceType: this.deviceType,
@ -61,6 +104,14 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
},
height: this.deviceType === 'mobile' ? '98vh' : undefined,
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
}
);
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
}
@ -68,4 +119,28 @@ export class AdminSettingsComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initialize() {
this.adminService
.fetchGhostfolioDataProviderStatus()
.pipe(
catchError(() => {
this.isGhostfolioApiKeyValid = false;
this.changeDetectorRef.markForCheck();
return of(null);
}),
filter((status) => {
return status !== null;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((status) => {
this.ghostfolioApiStatus = status;
this.isGhostfolioApiKeyValid = true;
this.changeDetectorRef.markForCheck();
});
}
}

2
apps/client/src/app/components/admin-settings/admin-settings.module.ts

@ -6,6 +6,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatMenuModule } from '@angular/material/menu';
import { RouterModule } from '@angular/router';
import { AdminSettingsComponent } from './admin-settings.component';
@ -19,6 +20,7 @@ import { AdminSettingsComponent } from './admin-settings.component';
GfPremiumIndicatorComponent,
MatButtonModule,
MatCardModule,
MatMenuModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

20
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.component.ts

@ -1,3 +1,5 @@
import { DataService } from '@ghostfolio/client/services/data.service';
import { PROPERTY_API_KEY_GHOSTFOLIO } from '@ghostfolio/common/config';
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { CommonModule } from '@angular/common';
@ -30,10 +32,28 @@ import { GhostfolioPremiumApiDialogParams } from './interfaces/interfaces';
export class GfGhostfolioPremiumApiDialogComponent {
public constructor(
@Inject(MAT_DIALOG_DATA) public data: GhostfolioPremiumApiDialogParams,
private dataService: DataService,
public dialogRef: MatDialogRef<GfGhostfolioPremiumApiDialogComponent>
) {}
public onCancel() {
this.dialogRef.close();
}
public onSetGhostfolioApiKey() {
let ghostfolioApiKey = prompt(
$localize`Please enter your Ghostfolio API key:`
);
ghostfolioApiKey = ghostfolioApiKey?.trim();
if (ghostfolioApiKey) {
this.dataService
.putAdminSetting(PROPERTY_API_KEY_GHOSTFOLIO, {
value: ghostfolioApiKey
})
.subscribe(() => {
this.dialogRef.close();
});
}
}
}

14
apps/client/src/app/components/admin-settings/ghostfolio-premium-api-dialog/ghostfolio-premium-api-dialog.html

@ -29,9 +29,19 @@
href="mailto:hi@ghostfol.io?Subject=Ghostfolio Premium Data Provider&body=Hello%0D%0DPlease notify me as soon as the Ghostfolio Premium Data Provider is available.%0D%0DKind regards"
i18n
mat-flat-button
>Notify me</a
>
Notify me
</a>
<div>
<small class="text-muted" i18n>or</small>
</div>
<button
color="accent"
i18n
mat-stroked-button
(click)="onSetGhostfolioApiKey()"
>
I have an API key
</button>
</div>
</div>

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

@ -60,6 +60,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
'accounts',
'activities',
'engagementPerDay',
'dailyApiRequests',
'lastRequest',
'actions'
];

21
apps/client/src/app/components/admin-users/admin-users.html

@ -169,6 +169,27 @@
/>
</td>
</ng-container>
<ng-container matColumnDef="dailyApiRequests">
<th
*matHeaderCellDef
class="mat-mdc-header-cell px-1 py-2 text-right"
mat-header-cell
>
<ng-container i18n>API Requests Today</ng-container>
</th>
<td
*matCellDef="let element"
class="mat-mdc-cell px-1 py-2 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="element.dailyApiRequests"
/>
</td>
</ng-container>
}
@if (hasPermissionForSubscription) {

2
apps/client/src/app/components/home-holdings/home-holdings.html

@ -7,7 +7,6 @@
<div class="row">
<div class="col-lg">
<div class="d-flex">
@if (user?.settings?.isExperimentalFeatures) {
<div class="d-flex">
<div class="d-none d-lg-block">
<mat-button-toggle-group
@ -23,7 +22,6 @@
</mat-button-toggle-group>
</div>
</div>
}
<div class="align-items-center d-flex flex-grow-1 justify-content-end">
<gf-toggle
class="d-none d-lg-block"

52
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.html

@ -1,6 +1,51 @@
<div mat-dialog-title>{{ data.rule.name }}</div>
<div class="py-3" mat-dialog-content>
@if (
data.rule.configuration.thresholdMin && data.rule.configuration.thresholdMax
) {
<div class="w-100">
<h6 class="mb-0">
<ng-container i18n>Threshold range</ng-container>:
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMin | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMin }}
}
-
@if (data.rule.configuration.threshold.unit === '%') {
{{ data.settings.thresholdMax | percent: '1.2-2' }}
} @else {
{{ data.settings.thresholdMax }}
}
</h6>
<div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
class="flex-grow-1"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
[step]="data.rule.configuration.threshold.step"
>
<input matSliderStartThumb [(ngModel)]="data.settings.thresholdMin" />
<input matSliderEndThumb [(ngModel)]="data.settings.thresholdMax" />
</mat-slider>
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.max | percent: '1.2-2'
}}</label>
} @else {
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
</div>
} @else {
<div
class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMin }"
@ -13,6 +58,7 @@
{{ data.settings.thresholdMin }}
}
</h6>
<div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
@ -21,6 +67,7 @@
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
class="flex-grow-1"
name="thresholdMin"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
@ -36,6 +83,7 @@
<label>{{ data.rule.configuration.threshold.max }}</label>
}
</div>
</div>
<div
class="w-100"
[ngClass]="{ 'd-none': !data.rule.configuration.thresholdMax }"
@ -48,6 +96,7 @@
{{ data.settings.thresholdMax }}
}
</h6>
<div class="align-items-center d-flex w-100">
@if (data.rule.configuration.threshold.unit === '%') {
<label>{{
data.rule.configuration.threshold.min | percent: '1.2-2'
@ -56,6 +105,7 @@
<label>{{ data.rule.configuration.threshold.min }}</label>
}
<mat-slider
class="flex-grow-1"
name="thresholdMax"
[max]="data.rule.configuration.threshold.max"
[min]="data.rule.configuration.threshold.min"
@ -72,6 +122,8 @@
}
</div>
</div>
}
</div>
<div align="end" mat-dialog-actions>
<button i18n mat-button (click)="dialogRef.close()">Close</button>

3
apps/client/src/app/components/rule/rule-settings-dialog/rule-settings-dialog.scss

@ -1,2 +1,5 @@
:host {
label {
margin-bottom: 0;
}
}

11
apps/client/src/app/core/auth.interceptor.ts

@ -2,6 +2,7 @@ import { ImpersonationStorageService } from '@ghostfolio/client/services/imperso
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import {
HEADER_KEY_IMPERSONATION,
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TIMEZONE,
HEADER_KEY_TOKEN
} from '@ghostfolio/common/config';
@ -27,6 +28,16 @@ export class AuthInterceptor implements HttpInterceptor {
next: HttpHandler
): Observable<HttpEvent<any>> {
let request = req;
if (request.headers.has(HEADER_KEY_SKIP_INTERCEPTOR)) {
// Bypass the interceptor
request = request.clone({
headers: req.headers.delete(HEADER_KEY_SKIP_INTERCEPTOR)
});
return next.handle(request);
}
let headers = request.headers.set(
HEADER_KEY_TIMEZONE,
Intl?.DateTimeFormat().resolvedOptions().timeZone

2
apps/client/src/app/core/http-response.interceptor.ts

@ -103,7 +103,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else if (error.status === StatusCodes.UNAUTHORIZED) {
if (this.webAuthnService.isEnabled()) {
this.router.navigate(['/webauthn']);
} else {
} else if (!error.url.includes('/data-providers/ghostfolio/status')) {
this.tokenStorageService.signOut();
}
}

131
apps/client/src/app/pages/api/api-page.component.ts

@ -0,0 +1,131 @@
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DataProviderGhostfolioStatusResponse,
DividendsResponse,
HistoricalResponse,
LookupResponse,
QuotesResponse
} from '@ghostfolio/common/interfaces';
import { CommonModule } from '@angular/common';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Component, OnInit } from '@angular/core';
import { format, startOfYear } from 'date-fns';
import { map, Observable, Subject, takeUntil } from 'rxjs';
@Component({
host: { class: 'page' },
imports: [CommonModule],
selector: 'gf-api-page',
standalone: true,
styleUrls: ['./api-page.scss'],
templateUrl: './api-page.html'
})
export class GfApiPageComponent implements OnInit {
public dividends$: Observable<DividendsResponse['dividends']>;
public historicalData$: Observable<HistoricalResponse['historicalData']>;
public quotes$: Observable<QuotesResponse['quotes']>;
public status$: Observable<DataProviderGhostfolioStatusResponse>;
public symbols$: Observable<LookupResponse['items']>;
private unsubscribeSubject = new Subject<void>();
public constructor(private http: HttpClient) {}
public ngOnInit() {
this.dividends$ = this.fetchDividends({ symbol: 'KO' });
this.historicalData$ = this.fetchHistoricalData({ symbol: 'AAPL.US' });
this.quotes$ = this.fetchQuotes({ symbols: ['AAPL.US', 'VOO.US'] });
this.status$ = this.fetchStatus();
this.symbols$ = this.fetchSymbols({ query: 'apple' });
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private fetchDividends({ symbol }: { symbol: string }) {
const params = new HttpParams()
.set('from', format(startOfYear(new Date()), DATE_FORMAT))
.set('to', format(new Date(), DATE_FORMAT));
return this.http
.get<DividendsResponse>(
`/api/v1/data-providers/ghostfolio/dividends/${symbol}`,
{ params }
)
.pipe(
map(({ dividends }) => {
return dividends;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchHistoricalData({ symbol }: { symbol: string }) {
const params = new HttpParams()
.set('from', format(startOfYear(new Date()), DATE_FORMAT))
.set('to', format(new Date(), DATE_FORMAT));
return this.http
.get<HistoricalResponse>(
`/api/v1/data-providers/ghostfolio/historical/${symbol}`,
{ params }
)
.pipe(
map(({ historicalData }) => {
return historicalData;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchQuotes({ symbols }: { symbols: string[] }) {
const params = new HttpParams().set('symbols', symbols.join(','));
return this.http
.get<QuotesResponse>('/api/v1/data-providers/ghostfolio/quotes', {
params
})
.pipe(
map(({ quotes }) => {
return quotes;
}),
takeUntil(this.unsubscribeSubject)
);
}
private fetchStatus() {
return this.http
.get<DataProviderGhostfolioStatusResponse>(
'/api/v1/data-providers/ghostfolio/status'
)
.pipe(takeUntil(this.unsubscribeSubject));
}
private fetchSymbols({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}) {
let params = new HttpParams().set('query', query);
if (includeIndices) {
params = params.append('includeIndices', includeIndices);
}
return this.http
.get<LookupResponse>('/api/v1/data-providers/ghostfolio/lookup', {
params
})
.pipe(
map(({ items }) => {
return items;
}),
takeUntil(this.unsubscribeSubject)
);
}
}

62
apps/client/src/app/pages/api/api-page.html

@ -0,0 +1,62 @@
<div class="container">
<div class="mb-3">
<h2 class="text-center">Status</h2>
<div>{{ status$ | async | json }}</div>
</div>
<div class="mb-3">
<h2 class="text-center">Lookup</h2>
@if (symbols$) {
@let symbols = symbols$ | async;
<ul>
@for (item of symbols; track item.symbol) {
<li>{{ item.name }} ({{ item.symbol }})</li>
}
</ul>
}
</div>
<div>
<h2 class="text-center">Quotes</h2>
@if (quotes$) {
@let quotes = quotes$ | async;
<ul>
@for (quote of quotes | keyvalue; track quote) {
<li>
{{ quote.key }}: {{ quote.value.marketPrice }}
{{ quote.value.currency }}
</li>
}
</ul>
}
</div>
<div>
<h2 class="text-center">Historical</h2>
@if (historicalData$) {
@let historicalData = historicalData$ | async;
<ul>
@for (
historicalDataItem of historicalData | keyvalue;
track historicalDataItem
) {
<li>
{{ historicalDataItem.key }}:
{{ historicalDataItem.value.marketPrice }}
</li>
}
</ul>
}
</div>
<div>
<h2 class="text-center">Dividends</h2>
@if (dividends$) {
@let dividends = dividends$ | async;
<ul>
@for (dividend of dividends | keyvalue; track dividend) {
<li>
{{ dividend.key }}:
{{ dividend.value.marketPrice }}
</li>
}
</ul>
}
</div>
</div>

3
apps/client/src/app/pages/api/api-page.scss

@ -0,0 +1,3 @@
:host {
display: block;
}

17
apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.component.ts

@ -0,0 +1,17 @@
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator';
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [GfPremiumIndicatorComponent, MatButtonModule, RouterModule],
selector: 'gf-black-weeks-2024-page',
standalone: true,
templateUrl: './black-weeks-2024-page.html'
})
export class BlackWeeks2024PageComponent {
public routerLinkFeatures = ['/' + $localize`:snake-case:features`];
public routerLinkPricing = ['/' + $localize`:snake-case:pricing`];
}

180
apps/client/src/app/pages/blog/2024/11/black-weeks-2024/black-weeks-2024-page.html

@ -0,0 +1,180 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">Black Weeks 2024</h1>
<div class="mb-3 text-muted"><small>2024-11-16</small></div>
<img
alt="Black Week 2024 Teaser"
class="rounded w-100"
src="../assets/images/blog/black-weeks-2024.jpg"
title="Black Weeks 2024"
/>
</div>
<section class="mb-4">
<p>
Take advantage of our exclusive <strong>Black Weeks</strong> offer
and save <strong>25%</strong> on your annual
<span class="align-items-center d-inline-flex"
>Ghostfolio Premium
<gf-premium-indicator
class="d-inline-block ml-1"
[enableLink]="false"
/>
</span>
subscription, plus get <strong>3 months extra</strong> for free!
</p>
</section>
<section class="mb-4">
<p>
<a
href="https://ghostfol.io"
title="Open Source Wealth Management Software"
>Ghostfolio</a
>
is a powerful personal finance dashboard, designed to simplify your
investment journey. With this Open Source Software (OSS) platform,
you can:
</p>
<ul class="list-unstyled">
<li>
<strong>Unify your assets</strong>: Track your financial
portfolio, including stocks, ETFs, cryptocurrencies, etc.
</li>
<li>
<strong>Gain deeper insights</strong>: Access real-time analytics
and data-driven insights.
</li>
<li>
<strong>Make informed decisions</strong>: Empower yourself with
actionable information.
</li>
</ul>
</section>
<section class="mb-4">
<p>
Don’t miss this limited-time offer to optimize your financial
future.
</p>
<p class="text-center">
<a color="primary" mat-flat-button [routerLink]="routerLinkPricing"
>Get the Deal</a
>
</p>
<p class="mt-5">
For more information, visit our
<a [routerLink]="routerLinkPricing">pricing page</a>.
</p>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">2024</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Black Friday</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Black Weeks</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Cryptocurrency</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Dashboard</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Deal</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">DeFi</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">ETF</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Fintech</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio Premium</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Hosting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">OSS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio Tracker</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Pricing</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Promotion</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">SaaS</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Sale</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Stock</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Subscription</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web3</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Web 3.0</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Black Weeks 2024
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

27
apps/client/src/app/pages/blog/blog-page-routing.module.ts

@ -165,15 +165,6 @@ const routes: Routes = [
).then((c) => c.Hacktoberfest2023PageComponent),
title: 'Hacktoberfest 2023'
},
{
canActivate: [AuthGuard],
path: '2023/11/hacktoberfest-2023-debriefing',
loadComponent: () =>
import(
'./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component'
).then((c) => c.Hacktoberfest2023DebriefingPageComponent),
title: 'Hacktoberfest 2023 Debriefing'
},
{
canActivate: [AuthGuard],
path: '2023/11/black-week-2023',
@ -183,6 +174,15 @@ const routes: Routes = [
),
title: 'Black Week 2023'
},
{
canActivate: [AuthGuard],
path: '2023/11/hacktoberfest-2023-debriefing',
loadComponent: () =>
import(
'./2023/11/hacktoberfest-2023-debriefing/hacktoberfest-2023-debriefing-page.component'
).then((c) => c.Hacktoberfest2023DebriefingPageComponent),
title: 'Hacktoberfest 2023 Debriefing'
},
{
canActivate: [AuthGuard],
path: '2024/09/hacktoberfest-2024',
@ -191,6 +191,15 @@ const routes: Routes = [
'./2024/09/hacktoberfest-2024/hacktoberfest-2024-page.component'
).then((c) => c.Hacktoberfest2024PageComponent),
title: 'Hacktoberfest 2024'
},
{
canActivate: [AuthGuard],
path: '2024/11/black-weeks-2024',
loadComponent: () =>
import('./2024/11/black-weeks-2024/black-weeks-2024-page.component').then(
(c) => c.BlackWeeks2024PageComponent
),
title: 'Black Weeks 2024'
}
];

26
apps/client/src/app/pages/blog/blog-page.html

@ -8,6 +8,32 @@
finance</small
>
</h1>
@if (hasPermissionForSubscription) {
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden p-3 w-100"
href="../en/blog/2024/11/black-weeks-2024"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">Black Weeks 2024</div>
<div class="d-flex text-muted">2024-11-16</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
/>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
}
<mat-card appearance="outlined" class="mb-3">
<mat-card-content class="p-0">
<div class="container p-0">

34
apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts

@ -7,7 +7,7 @@ import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Holding,
HoldingWithParents,
PortfolioDetails,
PortfolioPosition,
User
@ -86,7 +86,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number;
};
};
public topHoldings: Holding[];
public topHoldings: HoldingWithParents[];
public topHoldingsMap: {
[name: string]: { name: string; value: number };
};
@ -490,6 +490,36 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
name,
allocationInPercentage:
this.totalValueInEtf > 0 ? value / this.totalValueInEtf : 0,
parents: Object.entries(this.portfolioDetails.holdings)
.map(([symbol, holding]) => {
if (holding.holdings.length > 0) {
const currentParentHolding = holding.holdings.find(
(parentHolding) => {
return parentHolding.name === name;
}
);
return currentParentHolding
? {
allocationInPercentage:
currentParentHolding.valueInBaseCurrency / value,
name: holding.name,
position: holding,
symbol: prettifySymbol(symbol),
valueInBaseCurrency:
currentParentHolding.valueInBaseCurrency
}
: null;
}
return null;
})
.filter((item) => {
return item !== null;
})
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
}),
valueInBaseCurrency: value
};
})

1
apps/client/src/app/pages/portfolio/allocations/allocations-page.html

@ -347,6 +347,7 @@
[locale]="user?.settings?.locale"
[pageSize]="10"
[topHoldings]="topHoldings"
(holdingClicked)="onSymbolChartClicked($event)"
/>
</mat-card-content>
</mat-card>

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

@ -5,6 +5,11 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.
import { CreateTagDto } from '@ghostfolio/api/app/tag/create-tag.dto';
import { UpdateTagDto } from '@ghostfolio/api/app/tag/update-tag.dto';
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import {
HEADER_KEY_SKIP_INTERCEPTOR,
HEADER_KEY_TOKEN,
PROPERTY_API_KEY_GHOSTFOLIO
} from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
@ -13,6 +18,7 @@ import {
AdminMarketData,
AdminMarketDataDetails,
AdminUsers,
DataProviderGhostfolioStatusResponse,
EnhancedSymbolProfile,
Filter
} from '@ghostfolio/common/interfaces';
@ -23,8 +29,9 @@ import { SortDirection } from '@angular/material/sort';
import { DataSource, MarketData, Platform, Tag } from '@prisma/client';
import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs';
import { Observable, map, switchMap } from 'rxjs';
import { environment } from '../../environments/environment';
import { DataService } from './data.service';
@Injectable({
@ -136,6 +143,22 @@ export class AdminService {
);
}
public fetchGhostfolioDataProviderStatus() {
return this.fetchAdminData().pipe(
switchMap(({ settings }) => {
return this.http.get<DataProviderGhostfolioStatusResponse>(
`${environment.production ? 'https://ghostfol.io' : ''}/api/v1/data-providers/ghostfolio/status`,
{
headers: {
[HEADER_KEY_SKIP_INTERCEPTOR]: 'true',
[HEADER_KEY_TOKEN]: `Bearer ${settings[PROPERTY_API_KEY_GHOSTFOLIO]}`
}
}
);
})
);
}
public fetchJobs({ status }: { status?: JobStatus[] }) {
let params = new HttpParams();
@ -157,7 +180,11 @@ export class AdminService {
}
public fetchUsers() {
return this.http.get<AdminUsers>('/api/v1/admin/user');
let params = new HttpParams();
params = params.append('take', 30);
return this.http.get<AdminUsers>('/api/v1/admin/user', { params });
}
public gather7Days() {

BIN
apps/client/src/assets/images/blog/black-weeks-2024.jpg

Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

9
apps/client/src/assets/oss-friends.json

@ -1,5 +1,5 @@
{
"createdAt": "2024-08-31T00:00:00.000Z",
"createdAt": "2024-11-27T00:00:00.000Z",
"data": [
{
"name": "Aptabase",
@ -53,7 +53,7 @@
},
{
"name": "Formbricks",
"description": "Survey granular user segments at any point in the user journey. Gather up to 6x more insights with targeted micro-surveys. All open-source.",
"description": "Open source survey software and Experience Management Platform. Understand your customers, keep full control over your data.",
"href": "https://formbricks.com"
},
{
@ -81,6 +81,11 @@
"description": "Open source, end-to-end encrypted platform that lets you securely manage secrets and configs across your team, devices, and infrastructure.",
"href": "https://infisical.com"
},
{
"name": "KeepHQ",
"description": "Keep is an open-source AIOps (AI for IT operations) platform",
"href": "https://www.keephq.dev"
},
{
"name": "Langfuse",
"description": "Open source LLM engineering platform. Debug, analyze and iterate together.",

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

File diff suppressed because it is too large

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

@ -106,17 +106,21 @@ export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS: JobOptions = {
export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id';
export const HEADER_KEY_TIMEZONE = 'Timezone';
export const HEADER_KEY_TOKEN = 'Authorization';
export const HEADER_KEY_SKIP_INTERCEPTOR = 'X-Skip-Interceptor';
export const MAX_TOP_HOLDINGS = 50;
export const NUMERICAL_PRECISION_THRESHOLD = 100000;
export const PROPERTY_API_KEY_GHOSTFOLIO = 'API_KEY_GHOSTFOLIO';
export const PROPERTY_BENCHMARKS = 'BENCHMARKS';
export const PROPERTY_BETTER_UPTIME_MONITOR_ID = 'BETTER_UPTIME_MONITOR_ID';
export const PROPERTY_COUNTRIES_OF_SUBSCRIBERS = 'COUNTRIES_OF_SUBSCRIBERS';
export const PROPERTY_COUPONS = 'COUPONS';
export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_DATA_SOURCE_MAPPING = 'DATA_SOURCE_MAPPING';
export const PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS =
'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS';
export const PROPERTY_DEMO_USER_ID = 'DEMO_USER_ID';
export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';

2
libs/common/src/lib/interfaces/admin-users.interface.ts

@ -1,10 +1,12 @@
import { Role } from '@prisma/client';
export interface AdminUsers {
count: number;
users: {
accountCount: number;
country: string;
createdAt: Date;
dailyApiRequests: number;
engagement: number;
id: string;
lastActivity: Date;

5
libs/common/src/lib/interfaces/holding-with-parents.interface.ts

@ -0,0 +1,5 @@
import { Holding } from './holding.interface';
export interface HoldingWithParents extends Holding {
parents?: Holding[];
}

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

@ -19,6 +19,7 @@ import type { Export } from './export.interface';
import type { FilterGroup } from './filter-group.interface';
import type { Filter } from './filter.interface';
import type { HistoricalDataItem } from './historical-data-item.interface';
import type { HoldingWithParents } from './holding-with-parents.interface';
import type { Holding } from './holding.interface';
import type { InfoItem } from './info-item.interface';
import type { InvestmentItem } from './investment-item.interface';
@ -39,13 +40,17 @@ import type { Position } from './position.interface';
import type { Product } from './product';
import type { AccountBalancesResponse } from './responses/account-balances-response.interface';
import type { BenchmarkResponse } from './responses/benchmark-response.interface';
import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface';
import type { DividendsResponse } from './responses/dividends-response.interface';
import type { ResponseError } from './responses/errors.interface';
import type { HistoricalResponse } from './responses/historical-response.interface';
import type { ImportResponse } from './responses/import-response.interface';
import type { LookupResponse } from './responses/lookup-response.interface';
import type { OAuthResponse } from './responses/oauth-response.interface';
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface';
import type { QuotesResponse } from './responses/quotes-response.interface';
import type { ScraperConfiguration } from './scraper-configuration.interface';
import type { Statistics } from './statistics.interface';
import type { SubscriptionOffer } from './subscription-offer.interface';
@ -73,13 +78,17 @@ export {
BenchmarkProperty,
BenchmarkResponse,
Coupon,
DataProviderGhostfolioStatusResponse,
DataProviderInfo,
DividendsResponse,
EnhancedSymbolProfile,
Export,
Filter,
FilterGroup,
HistoricalDataItem,
HistoricalResponse,
Holding,
HoldingWithParents,
ImportResponse,
InfoItem,
InvestmentItem,
@ -103,6 +112,7 @@ export {
Position,
Product,
PublicPortfolioResponse,
QuotesResponse,
ResponseError,
ScraperConfiguration,
Statistics,

7
libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts

@ -0,0 +1,7 @@
import { UserWithSettings } from '@ghostfolio/common/types';
export interface DataProviderGhostfolioStatusResponse {
dailyRequests: number;
dailyRequestsMax: number;
subscription: UserWithSettings['subscription'];
}

7
libs/common/src/lib/interfaces/responses/dividends-response.interface.ts

@ -0,0 +1,7 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
export interface DividendsResponse {
dividends: {
[date: string]: IDataProviderHistoricalResponse;
};
}

7
libs/common/src/lib/interfaces/responses/historical-response.interface.ts

@ -0,0 +1,7 @@
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
export interface HistoricalResponse {
historicalData: {
[date: string]: IDataProviderHistoricalResponse;
};
}

5
libs/common/src/lib/interfaces/responses/quotes-response.interface.ts

@ -0,0 +1,5 @@
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
export interface QuotesResponse {
quotes: { [symbol: string]: IDataProviderResponse };
}

1
libs/common/src/lib/permissions.ts

@ -22,6 +22,7 @@ export const permissions = {
deletePlatform: 'deletePlatform',
deleteTag: 'deleteTag',
deleteUser: 'deleteUser',
enableDataProviderGhostfolio: 'enableDataProviderGhostfolio',
enableFearAndGreedIndex: 'enableFearAndGreedIndex',
enableImport: 'enableImport',
enableBlog: 'enableBlog',

1
libs/common/src/lib/types/user-with-settings.type.ts

@ -9,6 +9,7 @@ export type UserWithSettings = User & {
Access: Access[];
Account: Account[];
activityCount: number;
dataProviderGhostfolioDailyRequests: number;
permissions?: string[];
Settings: Settings & { settings: UserSettings };
subscription?: {

5
libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html

@ -19,7 +19,7 @@
[value]="lookupItem"
>
<span class="align-items-center d-flex line-height-1"
><b>{{ lookupItem.name }}</b>
><span>{{ lookupItem.name }}</span>
@if (lookupItem.dataProviderInfo.isPremium) {
<gf-premium-indicator class="ml-1" [enableLink]="false" />
}
@ -29,6 +29,9 @@
@if (lookupItem.assetSubClass) {
· {{ lookupItem.assetSubClassString }}
}
@if (lookupItem.dataProviderInfo.name) {
· {{ lookupItem.dataProviderInfo.name }}
}
</small>
</mat-option>
} @empty {

129
libs/ui/src/lib/top-holdings/top-holdings.component.html

@ -1,14 +1,18 @@
<div class="overflow-x-auto">
<table
class="gf-table w-100"
class="gf-table holdings-table w-100"
mat-table
matSort
matSortActive="allocationInPercentage"
matSortDirection="desc"
multiTemplateDataRows
[dataSource]="dataSource"
>
<colgroup>
<col class="w-100" />
<col />
<col />
</colgroup>
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-2" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-2" mat-header-cell>
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
@ -17,12 +21,7 @@
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="justify-content-end px-2"
mat-header-cell
mat-sort-header
>
<th *matHeaderCellDef class="px-2 text-right" mat-header-cell>
<ng-container i18n>Value</ng-container>
</th>
<td *matCellDef="let element" class="px-2" mat-cell>
@ -37,12 +36,7 @@
</ng-container>
<ng-container matColumnDef="allocationInPercentage" stickyEnd>
<th
*matHeaderCellDef
class="justify-content-end px-2"
mat-header-cell
mat-sort-header
>
<th *matHeaderCellDef class="justify-content-end px-2" mat-header-cell>
<span class="d-none d-sm-block" i18n>Allocation</span>
<span class="d-block d-sm-none" title="Allocation">%</span>
</th>
@ -57,8 +51,107 @@
</td>
</ng-container>
<ng-container matColumnDef="expandedDetail">
<td
*matCellDef="let element"
class="p-0"
mat-cell
[attr.colspan]="displayedColumns.length"
>
<div [@detailExpand]="element.expand ? 'expanded' : 'collapsed'">
<div class="holding-parents-table">
<table
class="gf-table w-100"
mat-table
[dataSource]="element.parents"
>
<colgroup>
<col class="w-100" />
<col />
<col />
</colgroup>
<ng-container matColumnDef="name">
<td *matCellDef="let parentHolding" class="px-2" mat-cell>
<div
class="align-items-center d-flex line-height-1 text-nowrap"
>
<div>{{ parentHolding?.name }}</div>
</div>
<div>
<small class="text-muted">{{
parentHolding?.symbol | gfSymbol
}}</small>
</div>
</td>
<td *matFooterCellDef class="px-2" mat-footer-cell>
<ng-container i18n>Name</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<td *matCellDef="let parentHolding" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="parentHolding?.valueInBaseCurrency"
/>
</div>
</td>
<td *matFooterCellDef class="px-2" mat-footer-cell>
<ng-container i18n>Value</ng-container>
</td>
</ng-container>
<ng-container matColumnDef="allocationInPercentage" stickyEnd>
<td *matCellDef="let parentHolding" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isPercent]="true"
[locale]="locale"
[value]="parentHolding?.allocationInPercentage"
/>
</div>
</td>
<td *matFooterCellDef class="px-2" mat-footer-cell>
<span class="d-none d-sm-block" i18n>Allocation</span>
<span class="d-block d-sm-none">%</span>
</td>
</ng-container>
<tr
*matRowDef="let row; columns: displayedColumns"
mat-row
[ngClass]="{ 'cursor-pointer': row.position }"
(click)="onClickHolding(row.position)"
></tr>
<tr
*matFooterRowDef="displayedColumns"
class="hidden"
mat-footer-row
></tr>
</table>
</div>
</div>
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
<tr
*matRowDef="let element; columns: displayedColumns"
mat-row
[ngClass]="{
'cursor-pointer': element.parents?.length > 0,
expanded: element.expand ?? false
}"
(click)="
element.expand ? (element.expand = false) : (element.expand = true)
"
></tr>
<tr
*matRowDef="let row; columns: ['expandedDetail']"
class="holding-detail"
mat-row
[ngClass]="{ 'd-none': !row.parents?.length }"
></tr>
</table>
</div>

32
libs/ui/src/lib/top-holdings/top-holdings.component.scss

@ -1,11 +1,33 @@
:host {
display: block;
.gf-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
.holdings-table {
table-layout: auto;
tr {
&:not(.expanded) + tr.holding-detail td {
border-bottom: 0;
}
&.expanded {
> td {
font-weight: bold;
}
}
&.holding-detail {
height: 0;
}
.holding-parents-table {
--table-padding: 0.5em;
tr {
height: auto;
td {
padding: var(--table-padding);
}
}
}
}

51
libs/ui/src/lib/top-holdings/top-holdings.component.ts

@ -1,33 +1,56 @@
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { getLocale } from '@ghostfolio/common/helper';
import { Holding } from '@ghostfolio/common/interfaces';
import {
AssetProfileIdentifier,
HoldingWithParents,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
animate,
state,
style,
transition,
trigger
} from '@angular/animations';
import { CommonModule } from '@angular/common';
import {
CUSTOM_ELEMENTS_SCHEMA,
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator';
import { MatSort, MatSortModule } from '@angular/material/sort';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { get } from 'lodash';
import { DataSource } from '@prisma/client';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { Subject } from 'rxjs';
@Component({
animations: [
trigger('detailExpand', [
state('collapsed,void', style({ height: '0px', minHeight: '0' })),
state('expanded', style({ height: '*' })),
transition(
'expanded <=> collapsed',
animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')
)
])
],
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
GfSymbolModule,
GfValueComponent,
MatButtonModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule
],
@ -41,12 +64,20 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy {
@Input() baseCurrency: string;
@Input() locale = getLocale();
@Input() pageSize = Number.MAX_SAFE_INTEGER;
@Input() topHoldings: Holding[];
@Input() topHoldings: HoldingWithParents[];
@Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & {
dataSource?: DataSource;
name: string;
value: number;
};
} = {};
@Output() holdingClicked = new EventEmitter<AssetProfileIdentifier>();
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public dataSource = new MatTableDataSource<Holding>();
public dataSource = new MatTableDataSource<HoldingWithParents>();
public displayedColumns: string[] = [
'name',
'valueInBaseCurrency',
@ -61,14 +92,16 @@ export class GfTopHoldingsComponent implements OnChanges, OnDestroy {
this.dataSource = new MatTableDataSource(this.topHoldings);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
this.dataSource.sortingDataAccessor = get;
if (this.topHoldings) {
this.isLoading = false;
}
}
public onClickHolding(assetProfileIdentifier: AssetProfileIdentifier) {
this.holdingClicked.emit(assetProfileIdentifier);
}
public onShowAllHoldings() {
this.pageSize = Number.MAX_SAFE_INTEGER;

824
package-lock.json

File diff suppressed because it is too large

32
package.json

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "2.122.0",
"version": "2.125.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio",
@ -86,7 +86,7 @@
"@nestjs/platform-express": "10.1.3",
"@nestjs/schedule": "3.0.2",
"@nestjs/serve-static": "4.0.0",
"@prisma/client": "5.21.1",
"@prisma/client": "6.0.0",
"@simplewebauthn/browser": "9.0.1",
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "4.9.0",
@ -102,11 +102,11 @@
"chartjs-chart-treemap": "2.3.1",
"chartjs-plugin-annotation": "2.1.2",
"chartjs-plugin-datalabels": "2.2.0",
"cheerio": "1.0.0-rc.12",
"cheerio": "1.0.0",
"class-transformer": "0.5.1",
"class-validator": "0.14.1",
"color": "4.2.3",
"countries-and-timezones": "3.4.1",
"countries-and-timezones": "3.7.2",
"countries-list": "3.1.1",
"countup.js": "2.8.0",
"date-fns": "3.6.0",
@ -153,16 +153,16 @@
"@angular/pwa": "18.2.9",
"@nestjs/schematics": "10.0.1",
"@nestjs/testing": "10.1.3",
"@nx/angular": "20.0.6",
"@nx/cypress": "20.0.6",
"@nx/eslint-plugin": "20.0.6",
"@nx/jest": "20.0.6",
"@nx/js": "20.0.6",
"@nx/nest": "20.0.6",
"@nx/node": "20.0.6",
"@nx/storybook": "20.0.6",
"@nx/web": "20.0.6",
"@nx/workspace": "20.0.6",
"@nx/angular": "20.1.2",
"@nx/cypress": "20.1.2",
"@nx/eslint-plugin": "20.1.2",
"@nx/jest": "20.1.2",
"@nx/js": "20.1.2",
"@nx/nest": "20.1.2",
"@nx/node": "20.1.2",
"@nx/storybook": "20.1.2",
"@nx/web": "20.1.2",
"@nx/workspace": "20.1.2",
"@schematics/angular": "18.2.9",
"@simplewebauthn/types": "9.0.1",
"@storybook/addon-essentials": "8.3.6",
@ -193,10 +193,10 @@
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest-preset-angular": "14.1.0",
"nx": "20.0.6",
"nx": "20.1.2",
"prettier": "3.3.3",
"prettier-plugin-organize-attributes": "1.0.0",
"prisma": "5.21.1",
"prisma": "6.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"replace-in-file": "7.0.1",

2
prisma/migrations/20241103110114_added_ghostfolio_to_data_source/migration.sql

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DataSource" ADD VALUE 'GHOSTFOLIO';

5
prisma/migrations/20241130164334_upgraded_to_prisma_6/migration.sql

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "_OrderToTag" ADD CONSTRAINT "_OrderToTag_AB_pkey" PRIMARY KEY ("A", "B");
-- DropIndex
DROP INDEX "_OrderToTag_AB_unique";

19
prisma/migrations/20241130164335_added_api_keys_to_user copy/migration.sql

@ -0,0 +1,19 @@
-- CreateTable
CREATE TABLE "ApiKey" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"hashedKey" TEXT NOT NULL,
"id" TEXT NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "ApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ApiKey_hashedKey_idx" ON "ApiKey"("hashedKey");
-- CreateIndex
CREATE INDEX "ApiKey_userId_idx" ON "ApiKey"("userId");
-- AddForeignKey
ALTER TABLE "ApiKey" ADD CONSTRAINT "ApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

14
prisma/schema.prisma

@ -77,6 +77,18 @@ model Analytics {
@@index([updatedAt])
}
model ApiKey {
createdAt DateTime @default(now())
hashedKey String
id String @id @default(uuid())
updatedAt DateTime @updatedAt
userId String
user User @relation(fields: [userId], onDelete: Cascade, references: [id])
@@index([hashedKey])
@@index([userId])
}
model AuthDevice {
createdAt DateTime @default(now())
credentialId Bytes
@ -238,6 +250,7 @@ model User {
AccessGive Access[] @relation("accessGive")
Account Account[]
Analytics Analytics?
ApiKey ApiKey[]
AuthDevice AuthDevice[]
Order Order[]
Settings Settings?
@ -281,6 +294,7 @@ enum DataSource {
COINGECKO
EOD_HISTORICAL_DATA
FINANCIAL_MODELING_PREP
GHOSTFOLIO
GOOGLE_SHEETS
MANUAL
RAPID_API

Loading…
Cancel
Save