Browse Source

Merge branch 'ghostfolio:main' into main

pull/2302/head
Nicola Elia 2 years ago
committed by GitHub
parent
commit
81d95ec3ef
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 2
      .github/workflows/build-code.yml
  2. 1
      .prettierrc
  3. 241
      CHANGELOG.md
  4. 4
      Dockerfile
  5. 5
      README.md
  6. 2
      apps/api/project.json
  7. 2
      apps/api/src/app/account/account.module.ts
  8. 76
      apps/api/src/app/account/account.service.ts
  9. 20
      apps/api/src/app/admin/admin.controller.ts
  10. 134
      apps/api/src/app/admin/admin.service.ts
  11. 48
      apps/api/src/app/app.module.ts
  12. 5
      apps/api/src/app/auth/auth.controller.ts
  13. 6
      apps/api/src/app/benchmark/benchmark.service.ts
  14. 3
      apps/api/src/app/exchange-rate/exchange-rate.controller.ts
  15. 6
      apps/api/src/app/export/export.module.ts
  16. 68
      apps/api/src/app/export/export.service.ts
  17. 237
      apps/api/src/app/frontend.middleware.ts
  18. 18
      apps/api/src/app/health/health.controller.ts
  19. 3
      apps/api/src/app/health/health.module.ts
  20. 6
      apps/api/src/app/health/health.service.ts
  21. 199
      apps/api/src/app/import/import.service.ts
  22. 50
      apps/api/src/app/info/info.service.ts
  23. 12
      apps/api/src/app/logo/logo.service.ts
  24. 17
      apps/api/src/app/order/order.controller.ts
  25. 3
      apps/api/src/app/order/order.module.ts
  26. 29
      apps/api/src/app/order/order.service.ts
  27. 2
      apps/api/src/app/portfolio/current-rate.service.ts
  28. 3
      apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts
  29. 3
      apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts
  30. 3
      apps/api/src/app/portfolio/portfolio-calculator.ts
  31. 12
      apps/api/src/app/portfolio/portfolio.controller.ts
  32. 2
      apps/api/src/app/portfolio/portfolio.module.ts
  33. 216
      apps/api/src/app/portfolio/portfolio.service.ts
  34. 7
      apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts
  35. 8
      apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts
  36. 6
      apps/api/src/app/redis-cache/redis-cache.module.ts
  37. 27
      apps/api/src/app/redis-cache/redis-cache.service.ts
  38. 36
      apps/api/src/app/sitemap/sitemap.controller.ts
  39. 24
      apps/api/src/app/sitemap/sitemap.module.ts
  40. 5
      apps/api/src/app/subscription/subscription.service.ts
  41. 3
      apps/api/src/app/symbol/symbol.controller.ts
  42. 6
      apps/api/src/app/symbol/symbol.service.ts
  43. 42
      apps/api/src/app/user/user.service.ts
  44. 1
      apps/api/src/assets/countries/asia-pacific-markets.json
  45. 19
      apps/api/src/assets/countries/europe-markets.json
  46. 158
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  47. 1
      apps/api/src/assets/cryptocurrencies/custom.json
  48. 655
      apps/api/src/assets/sitemap.xml
  49. 6
      apps/api/src/main.ts
  50. 132
      apps/api/src/middlewares/html-template.middleware.ts
  51. 10
      apps/api/src/services/account-balance/account-balance.module.ts
  52. 16
      apps/api/src/services/account-balance/account-balance.service.ts
  53. 5
      apps/api/src/services/configuration/configuration.service.ts
  54. 3
      apps/api/src/services/cron.service.ts
  55. 2
      apps/api/src/services/data-gathering/data-gathering.module.ts
  56. 51
      apps/api/src/services/data-gathering/data-gathering.service.ts
  57. 30
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  58. 8
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts
  59. 44
      apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts
  60. 54
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  61. 11
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  62. 68
      apps/api/src/services/data-provider/data-provider.service.ts
  63. 45
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  64. 32
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  65. 2
      apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts
  66. 7
      apps/api/src/services/data-provider/manual/manual.service.ts
  67. 18
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  68. 25
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  69. 5
      apps/api/src/services/twitter-bot/twitter-bot.service.ts
  70. 2
      apps/api/tsconfig.app.json
  71. 96
      apps/client/project.json
  72. 122
      apps/client/src/app/app-routing.module.ts
  73. 38
      apps/client/src/app/app.component.html
  74. 27
      apps/client/src/app/app.component.ts
  75. 2
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
  76. 2
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  77. 42
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  78. 2
      apps/client/src/app/components/admin-overview/admin-overview.html
  79. 4
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  80. 125
      apps/client/src/app/components/header/header.component.html
  81. 8
      apps/client/src/app/components/header/header.component.scss
  82. 11
      apps/client/src/app/components/header/header.component.ts
  83. 2
      apps/client/src/app/components/home-market/home-market.html
  84. 145
      apps/client/src/app/components/home-overview/home-overview.html
  85. 2
      apps/client/src/app/components/home-overview/home-overview.module.ts
  86. 4
      apps/client/src/app/components/home-overview/home-overview.scss
  87. 4
      apps/client/src/app/components/home-summary/home-summary.component.ts
  88. 2
      apps/client/src/app/components/home-summary/home-summary.html
  89. 8
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts
  90. 30
      apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html
  91. 28
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  92. 2
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  93. 2
      apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts
  94. 2
      apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html
  95. 24
      apps/client/src/app/core/auth.guard.ts
  96. 4
      apps/client/src/app/core/http-response.interceptor.ts
  97. 40
      apps/client/src/app/pages/about/about-page-routing.module.ts
  98. 53
      apps/client/src/app/pages/about/about-page.component.ts
  99. 9
      apps/client/src/app/pages/about/about-page.scss
  100. 2
      apps/client/src/app/pages/about/changelog/changelog-page.html

2
.github/workflows/build-code.yml

@ -33,4 +33,4 @@ jobs:
run: yarn test run: yarn test
- name: Build application - name: Build application
run: yarn build:all run: yarn build:production

1
.prettierrc

@ -9,6 +9,7 @@
], ],
"attributeSort": "ASC", "attributeSort": "ASC",
"endOfLine": "auto", "endOfLine": "auto",
"plugins": ["prettier-plugin-organize-attributes"],
"printWidth": 80, "printWidth": 80,
"singleQuote": true, "singleQuote": true,
"tabWidth": 2, "tabWidth": 2,

241
CHANGELOG.md

@ -5,6 +5,233 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
### Added
- Added support for the cryptocurrency _CyberConnect_
### Changed
- Refreshed the cryptocurrencies list
## 1.305.0 - 2023-09-03
### Added
- Added _Hacker News_ to the _As seen in_ section on the landing page
### Changed
- Shortened the page titles
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `4.16.2` to `5.2.0`
- Upgraded `replace-in-file` from version `6.3.5` to `7.0.1`
- Upgraded `yahoo-finance2` from version `2.4.3` to `2.4.4`
### Fixed
- Fixed the alignment in the header navigation
- Fixed the alignment in the menu of the impersonation mode
## 1.304.0 - 2023-08-27
### Added
- Added health check endpoints for data enhancers
### Changed
- Upgraded `Nx` from version `16.7.2` to `16.7.4`
- Upgraded `prettier` from version `2.8.4` to `3.0.2`
## 1.303.0 - 2023-08-23
### Added
- Added a blog post: _Ghostfolio joins OSS Friends_
### Changed
- Refreshed the cryptocurrencies list
- Improved the _OSS Friends_ page
### Fixed
- Fixed an issue with the _Trackinsight_ data enhancer for asset profile data
## 1.302.0 - 2023-08-20
### Changed
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `16.1.8` to `16.2.1`
- Upgraded `Nx` from version `16.6.0` to `16.7.2`
## 1.301.1 - 2023-08-19
### Added
- Added the data export feature to the user account page
- Added a currencies preset to the historical market data table of the admin control panel
- Added the _OSS Friends_ page
### Changed
- Improved the localized meta data in `html` files
### Fixed
- Fixed the rows with cash positions in the holdings table
- Fixed an issue with the date parsing in the historical market data editor of the admin control panel
## 1.300.0 - 2023-08-11
### Added
- Added more durations in the coupon system
### Changed
- Migrated the remaining requests from `bent` to `got`
## 1.299.1 - 2023-08-10
### Changed
- Optimized the activities import by allowing a different currency than the asset's official one
- Added a timeout to the _EOD Historical Data_ requests
- Migrated the requests from `bent` to `got` in the _EOD Historical Data_ service
### Fixed
- Fixed the editing of the emergency fund
- Fixed the historical data gathering interval for asset profiles used as benchmarks having activities
## 1.298.0 - 2023-08-06
### Changed
- Improved the language localization for German (`de`)
- Upgraded `ng-extract-i18n-merge` from version `2.6.0` to `2.7.0`
- Upgraded `Nx` from version `16.5.5` to `16.6.0`
### Fixed
- Fixed the styles of various components (card, progress, tab) after the upgrade to `@angular/material` `16`
## 1.297.4 - 2023-08-05
### Added
- Added the footer to the public page
- Added a `copy-assets` `Nx` target to the client build
### Changed
- Improved the alignment of the region percentages on the allocations page
- Improved the alignment of the region percentages on the public page
- Improved the redirection of the home page to the localized home page
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `15.2.5` to `16.1.8`
- Upgraded `nestjs` from version `9.1.4` to `10.1.3`
- Upgraded `Nx` from version `16.0.3` to `16.5.5`
## 1.296.0 - 2023-08-01
### Changed
- Optimized the validation in the activities import by reducing the list to unique asset profiles
- Optimized the data gathering in the activities import
## 1.295.0 - 2023-07-30
### Added
- Added a step by step introduction for new users
### Fixed
- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation
## 1.294.0 - 2023-07-29
### Changed
- Extended the allocations by market chart on the allocations page by unavailable data
### Fixed
- Considered liabilities in the total account value calculation
## 1.293.0 - 2023-07-26
### Added
- Added error handling for the _Redis_ connections to keep the app running if the connection fails
### Changed
- Set the `lastmod` dates of `sitemap.xml` dynamically
### Fixed
- Fixed the missing values in the holdings table
- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file
## 1.292.0 - 2023-07-24
### Added
- Introduced the allocations by market chart on the allocations page
### Changed
- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3`
### Fixed
- Fixed an issue in the public page
## 1.291.0 - 2023-07-23
### Added
- Broken down the emergency fund by cash and assets
- Added support for account balance time series
### Changed
- Renamed queries to presets in the historical market data table of the admin control panel
## 1.290.0 - 2023-07-16
### Added
- Added hints to the activity types in the create or edit activity dialog
- Added queries to the historical market data table of the admin control panel
### Changed
- Improved the usability of the login dialog
- Disabled the caching in the health check endpoints for data providers
- Improved the content of the Frequently Asked Questions (FAQ) page
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
## 1.289.0 - 2023-07-14
### Changed
- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2`
## 1.288.0 - 2023-07-12
### Changed
- Improved the loading state during filtering on the allocations page
- Beautified the names with ampersand (`&`) in the asset profile
- Improved the language localization for German (`de`)
## 1.287.0 - 2023-07-09 ## 1.287.0 - 2023-07-09
### Changed ### Changed
@ -464,7 +691,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Changed the slide toggles to checkboxes on the account page - Changed the slide toggles to checkboxes on the user account page
- Changed the slide toggles to checkboxes in the admin control panel - Changed the slide toggles to checkboxes in the admin control panel
- Increased the density of the theme - Increased the density of the theme
- Migrated the style of various components to `@angular/material` `15` (mdc) - Migrated the style of various components to `@angular/material` `15` (mdc)
@ -1026,7 +1253,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Improved the language selector on the account page - Improved the language selector on the user account page
- Improved the wording in the _X-ray_ section (net worth instead of investment) - Improved the wording in the _X-ray_ section (net worth instead of investment)
- Extended the asset profile details dialog in the admin control panel - Extended the asset profile details dialog in the admin control panel
- Updated the browserslist database - Updated the browserslist database
@ -1444,7 +1671,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a language selector to the account page - Added a language selector to the user account page
- Added support for translated labels in the value component - Added support for translated labels in the value component
### Changed ### Changed
@ -1773,7 +2000,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added the user id to the account page - Added the user id to the user account page
- Added a new view with jobs of the queue to the admin control panel - Added a new view with jobs of the queue to the admin control panel
### Changed ### Changed
@ -3428,7 +3655,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Respected the cash balance on the analysis page - Respected the cash balance on the analysis page
- Improved the settings selectors on the account page - Improved the settings selectors on the user account page
- Harmonized the slogan to "Open Source Wealth Management Software" - Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed ### Fixed
@ -3894,7 +4121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added ### Added
- Added a gradient to the line charts - Added a gradient to the line charts
- Added a selector to set the base currency on the account page - Added a selector to set the base currency on the user account page
## 0.81.0 - 06.04.2021 ## 0.81.0 - 06.04.2021
@ -4208,7 +4435,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Added the membership status to the account page - Added the membership status to the user account page
### Fixed ### Fixed

4
Dockerfile

@ -33,7 +33,7 @@ COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs COPY ./libs libs
COPY ./apps apps COPY ./apps apps
RUN yarn build:all RUN yarn build:production
# Prepare the dist image with additional node_modules # Prepare the dist image with additional node_modules
WORKDIR /ghostfolio/dist/apps/api WORKDIR /ghostfolio/dist/apps/api
@ -58,4 +58,4 @@ RUN apt update && apt install -y \
COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps COPY --from=builder /ghostfolio/dist/apps /ghostfolio/apps
WORKDIR /ghostfolio/apps/api WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333} EXPOSE ${PORT:-3333}
CMD [ "yarn", "start:prod" ] CMD [ "yarn", "start:production" ]

5
README.md

@ -153,7 +153,6 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
### Setup ### Setup
1. Run `yarn install` 1. Run `yarn install`
1. Run `yarn build:dev` to build the source code including the assets
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io) 1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
1. Run `yarn database:setup` to initialize the database schema 1. Run `yarn database:setup` to initialize the database schema
1. Start the server and the client (see [_Development_](#Development)) 1. Start the server and the client (see [_Development_](#Development))
@ -263,7 +262,9 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
## Community Projects ## Community Projects
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
## Contributing ## Contributing

2
apps/api/project.json

@ -33,7 +33,7 @@
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"]
}, },
"serve": { "serve": {
"executor": "@nx/node:node", "executor": "@nx/js:node",
"options": { "options": {
"buildTarget": "api:build" "buildTarget": "api:build"
} }

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

@ -1,6 +1,7 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
controllers: [AccountController], controllers: [AccountController],
exports: [AccountService], exports: [AccountService],
imports: [ imports: [
AccountBalanceModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,

76
apps/api/src/app/account/account.service.ts

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
public constructor( public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async account( public async account({
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput id_userId
): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
return this.prismaService.account.findUnique({ const { id, userId } = id_userId;
where: accountWhereUniqueInput
const [account] = await this.accounts({
where: { id, userId }
}); });
return account;
} }
public async accountWithOrders( public async accountWithOrders(
@ -50,9 +56,11 @@ export class AccountService {
Platform?: Platform; Platform?: Platform;
})[] })[]
> { > {
const { include, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
return this.prismaService.account.findMany({ include.balances = { orderBy: { date: 'desc' }, take: 1 };
const accounts = await this.prismaService.account.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,
@ -60,15 +68,36 @@ export class AccountService {
take, take,
where where
}); });
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances;
return account;
});
} }
public async createAccount( public async createAccount(
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
return this.prismaService.account.create({ const account = await this.prismaService.account.create({
data data
}); });
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: { id: account.id, userId: aUserId }
}
},
value: data.balance
}
});
return account;
} }
public async deleteAccount( public async deleteAccount(
@ -167,6 +196,18 @@ export class AccountService {
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: where.id_userId
}
},
value: <number>data.balance
}
});
return this.prismaService.account.update({ return this.prismaService.account.update({
data, data,
where where
@ -202,16 +243,17 @@ export class AccountService {
); );
if (amountInCurrencyOfAccount) { if (amountInCurrencyOfAccount) {
await this.prismaService.account.update({ await this.accountBalanceService.createAccountBalance({
data: { date,
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber() Account: {
}, connect: {
where: { id_userId: {
id_userId: { userId,
userId, id: accountId
id: accountId }
} }
} },
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
}); });
} }
} }

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

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -15,7 +16,10 @@ import {
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type {
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
@ -34,7 +38,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { isDate } from 'date-fns'; import { isDate, parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@ -113,7 +117,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })
@ -149,7 +153,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })
@ -182,7 +186,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}); });
} }
@ -229,7 +233,7 @@ export class AdminController {
); );
} }
const date = new Date(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
throw new HttpException( throw new HttpException(
@ -249,6 +253,7 @@ export class AdminController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getMarketData( public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string, @Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('skip') skip?: number, @Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string, @Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder, @Query('sortDirection') sortDirection?: Prisma.SortOrder,
@ -279,6 +284,7 @@ export class AdminController {
return this.adminService.getMarketData({ return this.adminService.getMarketData({
filters, filters,
presetId,
sortColumn, sortColumn,
sortDirection, sortDirection,
skip: isNaN(skip) ? undefined : skip, skip: isNaN(skip) ? undefined : skip,
@ -327,7 +333,7 @@ export class AdminController {
); );
} }
const date = new Date(dateString); const date = parseISO(dateString);
return this.marketDataService.updateMarketData({ return this.marketDataService.updateMarketData({
data: { marketPrice: data.marketPrice, state: 'CLOSE' }, data: { marketPrice: data.marketPrice, state: 'CLOSE' },

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

@ -6,17 +6,16 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
DEFAULT_PAGE_SIZE,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem,
Filter, Filter,
UniqueAsset UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client'; import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
@ -103,12 +102,14 @@ export class AdminService {
public async getMarketData({ public async getMarketData({
filters, filters,
presetId,
sortColumn, sortColumn,
sortDirection, sortDirection,
skip, skip,
take = DEFAULT_PAGE_SIZE take = Number.MAX_SAFE_INTEGER
}: { }: {
filters?: Filter[]; filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number; skip?: number;
sortColumn?: string; sortColumn?: string;
sortDirection?: Prisma.SortOrder; sortDirection?: Prisma.SortOrder;
@ -118,6 +119,15 @@ export class AdminService {
[{ symbol: 'asc' }]; [{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {}; const where: Prisma.SymbolProfileWhereInput = {};
if (presetId === 'CURRENCIES') {
return this.getMarketDataForCurrencies();
} else if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
}
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy( const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters, filters,
(filter) => { (filter) => {
@ -146,7 +156,7 @@ export class AdminService {
} }
} }
const [assetProfiles, count] = await Promise.all([ let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({ this.prismaService.symbolProfile.findMany({
orderBy, orderBy,
skip, skip,
@ -174,44 +184,60 @@ export class AdminService {
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
]); ]);
return { let marketData = assetProfiles.map(
count, ({
marketData: assetProfiles.map( _count,
({ assetClass,
_count, assetSubClass,
comment,
countries,
dataSource,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass, assetClass,
assetSubClass, assetSubClass,
comment, comment,
countries, countriesCount,
dataSource, dataSource,
Order, symbol,
sectors, marketDataItemCount,
symbol sectorsCount,
}) => { activitiesCount: _count.Order,
const countriesCount = countries ? Object.keys(countries).length : 0; date: Order?.[0]?.date
const marketDataItemCount = };
marketDataItems.find((marketDataItem) => { }
return ( );
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return { if (presetId) {
assetClass, if (presetId === 'ETF_WITHOUT_COUNTRIES') {
assetSubClass, marketData = marketData.filter(({ countriesCount }) => {
comment, return countriesCount === 0;
countriesCount, });
dataSource, } else if (presetId === 'ETF_WITHOUT_SECTORS') {
symbol, marketData = marketData.filter(({ sectorsCount }) => {
marketDataItemCount, return sectorsCount === 0;
sectorsCount, });
activitiesCount: _count.Order, }
date: Order?.[0]?.date
}; count = marketData.length;
} }
)
return {
count,
marketData
}; };
} }
@ -287,6 +313,36 @@ export class AdminService {
return response; return response;
} }
private async getMarketDataForCurrencies(): Promise<AdminMarketData> {
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const marketData: AdminMarketDataItem[] = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
});
return { marketData, count: marketData.length };
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> { private async getUsersWithAnalytics(): Promise<AdminData['users']> {
let orderBy: any = { let orderBy: any = {
createdAt: 'desc' createdAt: 'desc'

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

@ -7,11 +7,16 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import {
DEFAULT_LANGUAGE_CODE,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
import { MiddlewareConsumer, Module, RequestMethod } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { StatusCodes } from 'http-status-codes';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
@ -23,7 +28,6 @@ import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { FrontendMiddleware } from './frontend.middleware';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
@ -32,6 +36,7 @@ import { OrderModule } from './order/order.module';
import { PlatformModule } from './platform/platform.module'; import { PlatformModule } from './platform/platform.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module'; import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -70,19 +75,30 @@ import { UserModule } from './user/user.module';
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
exclude: ['/api*', '/sitemap.xml'],
rootPath: join(__dirname, '..', 'client'),
serveStaticOptions: { serveStaticOptions: {
/*etag: false // Disable etag header to fix PWA setHeaders: (res) => {
setHeaders: (res, path) => { if (res.req?.path === '/') {
if (path.includes('ngsw.json')) { let languageCode = DEFAULT_LANGUAGE_CODE;
// Disable cache (https://stackoverflow.com/questions/22632593/how-to-disable-webpage-caching-in-expressjs-nodejs/39775595)
// https://gertjans.home.xs4all.nl/javascript/cache-control.html#no-cache try {
res.set('Cache-Control', 'no-cache, no-store, must-revalidate'); const code = res.req.headers['accept-language']
.split(',')[0]
.split('-')[0];
if (SUPPORTED_LANGUAGE_CODES.includes(code)) {
languageCode = code;
}
} catch {}
res.set('Location', `/${languageCode}`);
res.statusCode = StatusCodes.MOVED_PERMANENTLY;
} }
}*/ }
}, }
rootPath: join(__dirname, '..', 'client'),
exclude: ['/api*']
}), }),
SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TwitterBotModule, TwitterBotModule,
@ -91,10 +107,4 @@ import { UserModule } from './user/user.module';
controllers: [AppController], controllers: [AppController],
providers: [CronService] providers: [CronService]
}) })
export class AppModule { export class AppModule {}
configure(consumer: MiddlewareConsumer) {
consumer
.apply(FrontendMiddleware)
.forRoutes({ path: '*', method: RequestMethod.ALL });
}
}

5
apps/api/src/app/auth/auth.controller.ts

@ -41,9 +41,8 @@ export class AuthController {
@Param('accessToken') accessToken: string @Param('accessToken') accessToken: string
): Promise<OAuthResponse> { ): Promise<OAuthResponse> {
try { try {
const authToken = await this.authService.validateAnonymousLogin( const authToken =
accessToken await this.authService.validateAnonymousLogin(accessToken);
);
return { authToken }; return { authToken };
} catch { } catch {
throw new HttpException( throw new HttpException(

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

@ -66,11 +66,11 @@ export class BenchmarkService {
const promises: Promise<number>[] = []; const promises: Promise<number>[] = [];
const quotes = await this.dataProviderService.getQuotes( const quotes = await this.dataProviderService.getQuotes({
benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })
); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promises.push(this.marketDataService.getMax({ dataSource, symbol })); promises.push(this.marketDataService.getMax({ dataSource, symbol }));

3
apps/api/src/app/exchange-rate/exchange-rate.controller.ts

@ -7,6 +7,7 @@ import {
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ExchangeRateService } from './exchange-rate.service'; import { ExchangeRateService } from './exchange-rate.service';
@ -23,7 +24,7 @@ export class ExchangeRateController {
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString); const date = parseISO(dateString);
const exchangeRate = await this.exchangeRateService.getExchangeRate({ const exchangeRate = await this.exchangeRateService.getExchangeRate({
date, date,

6
apps/api/src/app/export/export.module.ts

@ -1,8 +1,9 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExportController } from './export.controller'; import { ExportController } from './export.controller';
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
@Module({ @Module({
imports: [ imports: [
AccountModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
PrismaModule, OrderModule,
RedisCacheModule RedisCacheModule
], ],
controllers: [ExportController], controllers: [ExportController],

68
apps/api/src/app/export/export.service.ts

@ -1,11 +1,15 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(
private readonly accountService: AccountService,
private readonly orderService: OrderService
) {}
public async export({ public async export({
activityIds, activityIds,
@ -14,36 +18,40 @@ export class ExportService {
activityIds?: string[]; activityIds?: string[];
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const accounts = await this.prismaService.account.findMany({ const accounts = (
orderBy: { await this.accountService.accounts({
name: 'asc' orderBy: {
}, name: 'asc'
select: { },
accountType: true, where: { userId }
balance: true, })
comment: true, ).map(
currency: true, ({
id: true, accountType,
isExcluded: true, balance,
name: true, comment,
platformId: true currency,
}, id,
where: { userId } isExcluded,
}); name,
platformId
}) => {
return {
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
let activities = await this.prismaService.order.findMany({ let activities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
quantity: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId } where: { userId }
}); });

237
apps/api/src/app/frontend.middleware.ts

@ -1,237 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
@Injectable()
export class FrontendMiddleware implements NestMiddleware {
public indexHtmlDe = '';
public indexHtmlEn = '';
public indexHtmlEs = '';
public indexHtmlFr = '';
public indexHtmlIt = '';
public indexHtmlNl = '';
public indexHtmlPt = '';
private static readonly DEFAULT_DESCRIPTION =
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
public constructor(
private readonly configurationService: ConfigurationService
) {
try {
this.indexHtmlDe = fs.readFileSync(
this.getPathOfIndexHtmlFile('de'),
'utf8'
);
this.indexHtmlEn = fs.readFileSync(
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
'utf8'
);
this.indexHtmlEs = fs.readFileSync(
this.getPathOfIndexHtmlFile('es'),
'utf8'
);
this.indexHtmlFr = fs.readFileSync(
this.getPathOfIndexHtmlFile('fr'),
'utf8'
);
this.indexHtmlIt = fs.readFileSync(
this.getPathOfIndexHtmlFile('it'),
'utf8'
);
this.indexHtmlNl = fs.readFileSync(
this.getPathOfIndexHtmlFile('nl'),
'utf8'
);
this.indexHtmlPt = fs.readFileSync(
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
} catch {}
}
public use(request: Request, response: Response, next: NextFunction) {
const currentDate = format(new Date(), DATE_FORMAT);
let featureGraphicPath = 'assets/cover.png';
let title = 'Ghostfolio – Open Source Wealth Management Software';
if (request.path.startsWith('/en/blog/2022/08/500-stars-on-github')) {
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
title = `500 Stars - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/10/hacktoberfest-2022')) {
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
title = `Hacktoberfest 2022 - ${title}`;
} else if (request.path.startsWith('/en/blog/2022/11/black-friday-2022')) {
featureGraphicPath = 'assets/images/blog/black-friday-2022.jpg';
title = `Black Friday 2022 - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances'
)
) {
featureGraphicPath = 'assets/images/blog/20221226.jpg';
title = `The importance of tracking your personal finances - ${title}`;
} else if (
request.path.startsWith(
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt'
)
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-sackgeld.png';
title = `Ghostfolio auf Sackgeld.com vorgestellt - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/02/ghostfolio-meets-umbrel')
) {
featureGraphicPath = 'assets/images/blog/ghostfolio-x-umbrel.png';
title = `Ghostfolio meets Umbrel - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github'
)
) {
featureGraphicPath = 'assets/images/blog/1000-stars-on-github.jpg';
title = `Ghostfolio reaches 1’000 Stars on GitHub - ${title}`;
} else if (
request.path.startsWith(
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio'
)
) {
featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
) {
featureGraphicPath = 'assets/images/blog/20230701.jpg';
title = `Exploring the Path to FIRE - ${title}`;
}
if (
request.path.startsWith('/api/') ||
this.isFileRequest(request.url) ||
!environment.production
) {
// Skip
next();
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
response.send(
this.interpolate(this.indexHtmlDe, {
currentDate,
featureGraphicPath,
title,
description:
'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
languageCode: 'de',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/es' || request.path.startsWith('/es/')) {
response.send(
this.interpolate(this.indexHtmlEs, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
languageCode: 'es',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/fr' || request.path.startsWith('/fr/')) {
response.send(
this.interpolate(this.indexHtmlFr, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
languageCode: 'fr',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/it' || request.path.startsWith('/it/')) {
response.send(
this.interpolate(this.indexHtmlIt, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
languageCode: 'it',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/nl' || request.path.startsWith('/nl/')) {
response.send(
this.interpolate(this.indexHtmlNl, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
languageCode: 'nl',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else if (request.path === '/pt' || request.path.startsWith('/pt/')) {
response.send(
this.interpolate(this.indexHtmlPt, {
currentDate,
featureGraphicPath,
title,
description:
'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.',
languageCode: 'pt',
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
} else {
response.send(
this.interpolate(this.indexHtmlEn, {
currentDate,
featureGraphicPath,
title,
description: FrontendMiddleware.DEFAULT_DESCRIPTION,
languageCode: DEFAULT_LANGUAGE_CODE,
path: request.path,
rootUrl: this.configurationService.get('ROOT_URL')
})
);
}
}
private getPathOfIndexHtmlFile(aLocale: string) {
return path.join(__dirname, '..', 'client', aLocale, 'index.html');
}
private interpolate(template: string, context: any) {
return template.replace(/[$]{([^}]+)}/g, (_, objectPath) => {
const properties = objectPath.split('.');
return properties.reduce(
(previous, current) => previous?.[current],
context
);
});
}
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (filename.includes('auth/ey')) {
return false;
}
return filename.split('.').pop() !== filename;
}
}

18
apps/api/src/app/health/health.controller.ts

@ -18,6 +18,19 @@ export class HealthController {
@Get() @Get()
public async getHealth() {} public async getHealth() {}
@Get('data-enhancer/:name')
public async getHealthOfDataEnhancer(@Param('name') name: string) {
const hasResponse =
await this.healthService.hasResponseFromDataEnhancer(name);
if (hasResponse !== true) {
throw new HttpException(
getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE),
StatusCodes.SERVICE_UNAVAILABLE
);
}
}
@Get('data-provider/:dataSource') @Get('data-provider/:dataSource')
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getHealthOfDataProvider( public async getHealthOfDataProvider(
@ -30,9 +43,8 @@ export class HealthController {
); );
} }
const hasResponse = await this.healthService.hasResponseFromDataProvider( const hasResponse =
dataSource await this.healthService.hasResponseFromDataProvider(dataSource);
);
if (hasResponse !== true) { if (hasResponse !== true) {
throw new HttpException( throw new HttpException(

3
apps/api/src/app/health/health.module.ts

@ -1,4 +1,5 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,7 @@ import { HealthService } from './health.service';
@Module({ @Module({
controllers: [HealthController], controllers: [HealthController],
imports: [ConfigurationModule, DataProviderModule], imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
providers: [HealthService] providers: [HealthService]
}) })
export class HealthModule {} export class HealthModule {}

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

@ -1,3 +1,4 @@
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -5,9 +6,14 @@ import { DataSource } from '@prisma/client';
@Injectable() @Injectable()
export class HealthService { export class HealthService {
public constructor( public constructor(
private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService
) {} ) {}
public async hasResponseFromDataEnhancer(aName: string) {
return this.dataEnhancerService.enhance(aName);
}
public async hasResponseFromDataProvider(aDataSource: DataSource) { public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource); return this.dataProviderService.checkQuote(aDataSource);
} }

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

@ -8,10 +8,15 @@ import {
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { import {
AccountWithPlatform, AccountWithPlatform,
@ -20,13 +25,15 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@ -220,8 +227,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({ const assetProfiles = await this.validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
userId
}); });
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
@ -243,17 +249,47 @@ export class ImportService {
const activities: Activity[] = []; const activities: Activity[] = [];
for (const { for (let [
accountId, index,
comment, {
date, accountId,
error, comment,
fee, date,
quantity, error,
SymbolProfile: assetProfile, fee,
type, quantity,
unitPrice SymbolProfile,
} of activitiesExtendedWithErrors) { type,
unitPrice
}
] of activitiesExtendedWithErrors.entries()) {
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
const {
assetClass,
assetSubClass,
countries,
createdAt,
currency,
dataSource,
id,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url,
updatedAt
} = assetProfile;
const validatedAccount = accounts.find(({ id }) => { const validatedAccount = accounts.find(({ id }) => {
return id === accountId; return id === accountId;
}); });
@ -264,6 +300,35 @@ export class ImportService {
Account?: { id: string; name: string }; Account?: { id: string; name: string };
}); });
if (SymbolProfile.currency !== assetProfile.currency) {
// Convert the unit price and fee to the asset currency if the imported
// activity is in a different currency
unitPrice = await this.exchangeRateDataService.toCurrencyAtDate(
unitPrice,
SymbolProfile.currency,
assetProfile.currency,
date
);
if (!unitPrice) {
throw new Error(
`activities.${index} historical exchange rate at ${format(
date,
DATE_FORMAT
)} is not available from "${SymbolProfile.currency}" to "${
assetProfile.currency
}"`
);
}
fee = await this.exchangeRateDataService.toCurrencyAtDate(
fee,
SymbolProfile.currency,
assetProfile.currency,
date
);
}
if (isDryRun) { if (isDryRun) {
order = { order = {
comment, comment,
@ -279,23 +344,22 @@ export class ImportService {
id: uuidv4(), id: uuidv4(),
isDraft: isAfter(date, endOfToday()), isDraft: isAfter(date, endOfToday()),
SymbolProfile: { SymbolProfile: {
assetClass: assetProfile.assetClass, assetClass,
assetSubClass: assetProfile.assetSubClass, assetSubClass,
comment: assetProfile.comment, countries,
countries: assetProfile.countries, createdAt,
createdAt: assetProfile.createdAt, currency,
currency: assetProfile.currency, dataSource,
dataSource: assetProfile.dataSource, id,
id: assetProfile.id, isin,
isin: assetProfile.isin, name,
name: assetProfile.name, scraperConfiguration,
scraperConfiguration: assetProfile.scraperConfiguration, sectors,
sectors: assetProfile.sectors, symbol,
symbol: assetProfile.currency, symbolMapping,
symbolMapping: assetProfile.symbolMapping, updatedAt,
updatedAt: assetProfile.updatedAt, url,
url: assetProfile.url, comment: assetProfile.comment
...assetProfiles[assetProfile.symbol]
}, },
Account: validatedAccount, Account: validatedAccount,
symbolProfileId: undefined, symbolProfileId: undefined,
@ -318,14 +382,14 @@ export class ImportService {
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency: assetProfile.currency, currency,
dataSource: assetProfile.dataSource, dataSource,
symbol: assetProfile.symbol symbol
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: assetProfile.dataSource, dataSource,
symbol: assetProfile.symbol symbol
} }
} }
} }
@ -337,24 +401,49 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber(); const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({ activities.push({
...order, ...order,
error, error,
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee, fee,
assetProfile.currency, currency,
userCurrency userCurrency
), ),
//@ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
assetProfile.currency, currency,
userCurrency userCurrency
) )
}); });
} }
activities.sort((activity1, activity2) => {
return Number(activity1.date) - Number(activity2.date);
});
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
});
});
this.dataGatheringService.gatherSymbols(
uniqueActivities.map(({ date, SymbolProfile }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
})
);
}
return activities; return activities;
} }
@ -446,25 +535,30 @@ export class ImportService {
private async validateActivities({ private async validateActivities({
activitiesDto, activitiesDto,
maxActivitiesToImport, maxActivitiesToImport
userId
}: { }: {
activitiesDto: Partial<CreateOrderDto>[]; activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number; maxActivitiesToImport: number;
userId: string;
}) { }) {
if (activitiesDto?.length > maxActivitiesToImport) { if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
} }
const assetProfiles: { const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [ for (const [
index, index,
{ currency, dataSource, symbol } { currency, dataSource, symbol }
] of activitiesDto.entries()) { ] of uniqueActivitiesDto.entries()) {
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const assetProfile = ( const assetProfile = (
await this.dataProviderService.getAssetProfiles([ await this.dataProviderService.getAssetProfiles([
@ -472,19 +566,26 @@ export class ImportService {
]) ])
)?.[symbol]; )?.[symbol];
if (assetProfile === undefined) { if (!assetProfile) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (assetProfile.currency !== currency) { if (
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error( throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}"` `activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
); );
} }
assetProfiles[symbol] = assetProfile; assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
} }
} }

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

@ -30,9 +30,9 @@ import { permissions } from '@ghostfolio/common/permissions';
import { SubscriptionOffer } from '@ghostfolio/common/types'; import { SubscriptionOffer } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format, subDays } from 'date-fns'; import { format, subDays } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class InfoService { export class InfoService {
@ -172,17 +172,13 @@ export class InfoService {
private async countDockerHubPulls(): Promise<number> { private async countDockerHubPulls(): Promise<number> {
try { try {
const get = bent( const { pull_count } = await got(
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`, `https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
'GET',
'json',
200,
{ {
'User-Agent': 'request' headers: { 'User-Agent': 'request' }
} }
); ).json<any>();
const { pull_count } = await get();
return pull_count; return pull_count;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService');
@ -193,16 +189,9 @@ export class InfoService {
private async countGitHubContributors(): Promise<number> { private async countGitHubContributors(): Promise<number> {
try { try {
const get = bent( const { body } = await got('https://github.com/ghostfolio/ghostfolio');
'https://github.com/ghostfolio/ghostfolio',
'GET',
'string',
200,
{}
);
const html = await get(); const $ = cheerio.load(body);
const $ = cheerio.load(html);
return extractNumberFromString( return extractNumberFromString(
$( $(
@ -218,17 +207,13 @@ export class InfoService {
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const get = bent( const { stargazers_count } = await got(
`https://api.github.com/repos/ghostfolio/ghostfolio`, `https://api.github.com/repos/ghostfolio/ghostfolio`,
'GET',
'json',
200,
{ {
'User-Agent': 'request' headers: { 'User-Agent': 'request' }
} }
); ).json<any>();
const { stargazers_count } = await get();
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService');
@ -346,22 +331,21 @@ export class InfoService {
PROPERTY_BETTER_UPTIME_MONITOR_ID PROPERTY_BETTER_UPTIME_MONITOR_ID
)) as string; )) as string;
const get = bent( const { data } = await got(
`https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format( `https://betteruptime.com/api/v2/monitors/${monitorId}/sla?from=${format(
subDays(new Date(), 90), subDays(new Date(), 90),
DATE_FORMAT DATE_FORMAT
)}&to${format(new Date(), DATE_FORMAT)}`, )}&to${format(new Date(), DATE_FORMAT)}`,
'GET',
'json',
200,
{ {
Authorization: `Bearer ${this.configurationService.get( headers: {
'BETTER_UPTIME_API_KEY' Authorization: `Bearer ${this.configurationService.get(
)}` 'BETTER_UPTIME_API_KEY'
)}`
}
} }
); ).json<any>();
const { data } = await get();
return data.attributes.availability / 100; return data.attributes.availability / 100;
} catch (error) { } catch (error) {
Logger.error(error, 'InfoService'); Logger.error(error, 'InfoService');

12
apps/api/src/app/logo/logo.service.ts

@ -2,7 +2,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/sy
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { HttpException, Injectable } from '@nestjs/common'; import { HttpException, Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import got from 'got';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable() @Injectable()
@ -41,15 +41,11 @@ export class LogoService {
} }
private getBuffer(aUrl: string) { private getBuffer(aUrl: string) {
const get = bent( return got(
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
'GET',
'buffer',
200,
{ {
'User-Agent': 'request' headers: { 'User-Agent': 'request' }
} }
); ).buffer();
return get();
} }
} }

17
apps/api/src/app/order/order.controller.ts

@ -2,6 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,6 +37,7 @@ import { UpdateOrderDto } from './update-order.dto';
export class OrderController { export class OrderController {
public constructor( public constructor(
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
@ -123,7 +125,7 @@ export class OrderController {
); );
} }
return this.orderService.createOrder({ const order = await this.orderService.createOrder({
...data, ...data,
date: parseISO(data.date), date: parseISO(data.date),
SymbolProfile: { SymbolProfile: {
@ -144,6 +146,19 @@ export class OrderController {
User: { connect: { id: this.request.user.id } }, User: { connect: { id: this.request.user.id } },
userId: this.request.user.id userId: this.request.user.id
}); });
if (!order.isDraft) {
// Gather symbol data in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: order.date,
symbol: data.symbol
}
]);
}
return order;
} }
@Put(':id') @Put(':id')

3
apps/api/src/app/order/order.module.ts

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule
], ],
providers: [AccountService, OrderService] providers: [AccountBalanceService, AccountService, OrderService]
}) })
export class OrderModule {} export class OrderModule {}

29
apps/api/src/app/order/order.service.ts

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -117,7 +118,7 @@ export class OrderService {
}; };
} }
await this.dataGatheringService.addJobToQueue({ this.dataGatheringService.addJobToQueue({
data: { data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -125,25 +126,12 @@ export class OrderService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}` jobId: getAssetProfileIdentifier({
}
});
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
} })
]); }
} });
delete data.accountId; delete data.accountId;
delete data.assetClass; delete data.assetClass;
@ -162,6 +150,11 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
...orderData, ...orderData,

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

@ -38,7 +38,7 @@ export class CurrentRateService {
if (includeToday) { if (includeToday) {
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.getQuotes(dataGatheringItems) .getQuotes({ items: dataGatheringItems })
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result: GetValueObject[] = []; const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {

3
apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts

@ -1,4 +1,4 @@
import { DataSource, Type as TypeOfOrder } from '@prisma/client'; import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface PortfolioOrder { export interface PortfolioOrder {
@ -9,6 +9,7 @@ export interface PortfolioOrder {
name: string; name: string;
quantity: Big; quantity: Big;
symbol: string; symbol: string;
tags?: Tag[];
type: TypeOfOrder; type: TypeOfOrder;
unitPrice: Big; unitPrice: Big;
} }

3
apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts

@ -1,4 +1,4 @@
import { DataSource } from '@prisma/client'; import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
investment: Big; investment: Big;
quantity: Big; quantity: Big;
symbol: string; symbol: string;
tags?: Tag[];
transactionCount: number; transactionCount: number;
} }

3
apps/api/src/app/portfolio/portfolio-calculator.ts

@ -114,6 +114,7 @@ export class PortfolioCalculator {
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
quantity: newQuantity, quantity: newQuantity,
symbol: order.symbol, symbol: order.symbol,
tags: order.tags,
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1
}; };
} else { } else {
@ -125,6 +126,7 @@ export class PortfolioCalculator {
investment: unitPrice.mul(order.quantity).mul(factor), investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor), quantity: order.quantity.mul(factor),
symbol: order.symbol, symbol: order.symbol,
tags: order.tags,
transactionCount: 1 transactionCount: 1
}; };
} }
@ -492,6 +494,7 @@ export class PortfolioCalculator {
: null, : null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });

12
apps/api/src/app/portfolio/portfolio.controller.ts

@ -134,7 +134,7 @@ export class PortfolioController {
portfolioPosition.netPerformance = null; portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null; portfolioPosition.quantity = null;
portfolioPosition.valueInPercentage = portfolioPosition.valueInPercentage =
portfolioPosition.value / totalValue; portfolioPosition.valueInBaseCurrency / totalValue;
} }
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -161,10 +161,12 @@ export class PortfolioController {
'emergencyFund', 'emergencyFund',
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'fireWealth',
'items', 'items',
'liabilities', 'liabilities',
'netWorth', 'netWorth',
'totalBuy', 'totalBuy',
'totalInvestment',
'totalSell' 'totalSell'
]); ]);
} }
@ -177,6 +179,9 @@ export class PortfolioController {
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
: undefined,
sectors: hasDetails ? portfolioPosition.sectors : [] sectors: hasDetails ? portfolioPosition.sectors : []
}; };
} }
@ -445,7 +450,8 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = { portfolioPublicDetails.holdings[symbol] = {
allocationInPercentage: portfolioPosition.value / totalValue, allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined, currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource, dataSource: portfolioPosition.dataSource,
@ -456,7 +462,7 @@ export class PortfolioController {
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol, symbol: portfolioPosition.symbol,
url: portfolioPosition.url, url: portfolioPosition.url,
valueInPercentage: portfolioPosition.value / totalValue valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
}; };
} }

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

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
UserModule UserModule
], ],
providers: [ providers: [
AccountBalanceService,
AccountService, AccountService,
CurrentRateService, CurrentRateService,
PortfolioService, PortfolioService,

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

@ -42,7 +42,6 @@ import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
GroupBy, GroupBy,
Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser, RequestWithUser,
UserWithSettings UserWithSettings
@ -84,8 +83,10 @@ import {
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
const developedMarkets = require('../../assets/countries/developed-markets.json'); const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json'); const emergingMarkets = require('../../assets/countries/emerging-markets.json');
const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
@ -469,9 +470,8 @@ export class PortfolioService {
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT) transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
); );
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
filters, filters,
@ -504,15 +504,17 @@ export class PortfolioService {
); );
} }
const dataGatheringItems = currentPositions.positions.map((position) => { const dataGatheringItems = currentPositions.positions.map(
return { ({ dataSource, symbol }) => {
dataSource: position.dataSource, return {
symbol: position.symbol dataSource,
}; symbol
}); };
}
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems), this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems) this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]); ]);
@ -536,30 +538,79 @@ export class PortfolioService {
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
const markets: { [key in Market]: number } = { const markets: PortfolioPosition['markets'] = {
[UNKNOWN_KEY]: 0,
developedMarkets: 0, developedMarkets: 0,
emergingMarkets: 0, emergingMarkets: 0,
otherMarkets: 0 otherMarkets: 0
}; };
const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = {
[UNKNOWN_KEY]: 0,
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
};
for (const country of symbolProfile.countries) { if (symbolProfile.countries.length > 0) {
if (developedMarkets.includes(country.code)) { for (const country of symbolProfile.countries) {
markets.developedMarkets = new Big(markets.developedMarkets) if (developedMarkets.includes(country.code)) {
.plus(country.weight) markets.developedMarkets = new Big(markets.developedMarkets)
.toNumber(); .plus(country.weight)
} else if (emergingMarkets.includes(country.code)) { .toNumber();
markets.emergingMarkets = new Big(markets.emergingMarkets) } else if (emergingMarkets.includes(country.code)) {
.plus(country.weight) markets.emergingMarkets = new Big(markets.emergingMarkets)
.toNumber(); .plus(country.weight)
} else { .toNumber();
markets.otherMarkets = new Big(markets.otherMarkets) } else {
.plus(country.weight) markets.otherMarkets = new Big(markets.otherMarkets)
.toNumber(); .plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
} }
} else {
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
.plus(value)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
.plus(value)
.toNumber();
} }
holdings[item.symbol] = { holdings[item.symbol] = {
markets, markets,
marketsAdvanced,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: value.div(filteredValueInBaseCurrency).toNumber(), : value.div(filteredValueInBaseCurrency).toNumber(),
@ -581,9 +632,10 @@ export class PortfolioService {
quantity: item.quantity.toNumber(), quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol, symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount, transactionCount: item.transactionCount,
url: symbolProfile.url, url: symbolProfile.url,
value: value.toNumber() valueInBaseCurrency: value.toNumber()
}; };
} }
@ -626,7 +678,7 @@ export class PortfolioService {
const emergencyFundInCash = emergencyFund const emergencyFundInCash = emergencyFund
.minus( .minus(
this.getEmergencyFundPositionsValueInBaseCurrency({ this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders holdings
}) })
) )
.toNumber(); .toNumber();
@ -643,7 +695,7 @@ export class PortfolioService {
holdings[userCurrency] = { holdings[userCurrency] = {
...emergencyFundCashPositions[userCurrency], ...emergencyFundCashPositions[userCurrency],
investment: emergencyFundInCash, investment: emergencyFundInCash,
value: emergencyFundInCash valueInBaseCurrency: emergencyFundInCash
}; };
} }
@ -654,7 +706,7 @@ export class PortfolioService {
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency: emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({ this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders holdings
}) })
}); });
@ -740,6 +792,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.SymbolProfile.symbol, symbol: order.SymbolProfile.symbol,
tags: order.tags,
type: order.type, type: order.type,
unitPrice: new Big(order.unitPrice) unitPrice: new Big(order.unitPrice)
})); }));
@ -756,9 +809,8 @@ export class PortfolioService {
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
portfolioStart await portfolioCalculator.getCurrentPositions(portfolioStart);
);
const position = currentPositions.positions.find( const position = currentPositions.positions.find(
(item) => item.symbol === aSymbol (item) => item.symbol === aSymbol
@ -897,9 +949,9 @@ export class PortfolioService {
) )
}; };
} else { } else {
const currentData = await this.dataProviderService.getQuotes([ const currentData = await this.dataProviderService.getQuotes({
{ dataSource: DataSource.YAHOO, symbol: aSymbol } items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
]); });
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical( let historicalData = await this.dataProviderService.getHistorical(
@ -992,23 +1044,22 @@ export class PortfolioService {
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
startDate await portfolioCalculator.getCurrentPositions(startDate);
);
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
); );
const dataGatheringItem = positions.map((position) => { const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
return { return {
dataSource: position.dataSource, dataSource,
symbol: position.symbol symbol
}; };
}); });
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem), this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles( this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => { positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -1184,9 +1235,8 @@ export class PortfolioService {
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions =
portfolioStart await portfolioCalculator.getCurrentPositions(portfolioStart);
);
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
@ -1276,7 +1326,7 @@ export class PortfolioService {
if (cashPositions[account.currency]) { if (cashPositions[account.currency]) {
cashPositions[account.currency].investment += convertedBalance; cashPositions[account.currency].investment += convertedBalance;
cashPositions[account.currency].value += convertedBalance; cashPositions[account.currency].valueInBaseCurrency += convertedBalance;
} else { } else {
cashPositions[account.currency] = this.getInitialCashPosition({ cashPositions[account.currency] = this.getInitialCashPosition({
balance: convertedBalance, balance: convertedBalance,
@ -1288,7 +1338,9 @@ export class PortfolioService {
for (const symbol of Object.keys(cashPositions)) { for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency // Calculate allocations for each currency
cashPositions[symbol].allocationInPercentage = value.gt(0) cashPositions[symbol].allocationInPercentage = value.gt(0)
? new Big(cashPositions[symbol].value).div(value).toNumber() ? new Big(cashPositions[symbol].valueInBaseCurrency)
.div(value)
.toNumber()
: 0; : 0;
} }
@ -1388,13 +1440,13 @@ export class PortfolioService {
} }
private getEmergencyFundPositionsValueInBaseCurrency({ private getEmergencyFundPositionsValueInBaseCurrency({
activities holdings
}: { }: {
activities: Activity[]; holdings: PortfolioDetails['holdings'];
}) { }) {
const emergencyFundOrders = activities.filter((activity) => { const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return ( return (
activity.tags?.some(({ id }) => { tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID; return id === EMERGENCY_FUND_TAG_ID;
}) ?? false }) ?? false
); );
@ -1402,18 +1454,9 @@ export class PortfolioService {
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0); let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
for (const order of emergencyFundOrders) { for (const { valueInBaseCurrency } of emergencyFundHoldings) {
if (order.type === 'BUY') { valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions = valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency);
valueInBaseCurrencyOfEmergencyFundPositions.plus(
order.valueInBaseCurrency
);
} else if (order.type === 'SELL') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.minus(
order.valueInBaseCurrency
);
}
} }
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber(); return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
@ -1472,8 +1515,9 @@ export class PortfolioService {
quantity: 0, quantity: 0,
sectors: [], sectors: [],
symbol: currency, symbol: currency,
tags: [],
transactionCount: 0, transactionCount: 0,
value: balance valueInBaseCurrency: balance
}; };
} }
@ -1499,7 +1543,13 @@ export class PortfolioService {
); );
} }
private getLiabilities(activities: OrderWithAccount[]) { private getLiabilities({
activities,
userCurrency
}: {
activities: OrderWithAccount[];
userCurrency: string;
}) {
return activities return activities
.filter(({ type }) => { .filter(({ type }) => {
return type === TypeOfOrder.LIABILITY; return type === TypeOfOrder.LIABILITY;
@ -1508,7 +1558,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency, SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency userCurrency
); );
}) })
.reduce( .reduce(
@ -1618,7 +1668,10 @@ export class PortfolioService {
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber(); const items = this.getItems(activities).toNumber();
const liabilities = this.getLiabilities(activities).toNumber(); const liabilities = this.getLiabilities({
activities,
userCurrency
}).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY'); const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL'); const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
@ -1683,7 +1736,16 @@ export class PortfolioService {
totalBuy, totalBuy,
totalSell, totalSell,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(), emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency,
cash: emergencyFund
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
total: emergencyFund.toNumber()
},
fireWealth: new Big(performanceInformation.performance.currentValue)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
ordersCount: activities.filter(({ type }) => { ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL'; return type === 'BUY' || type === 'SELL';
}).length }).length
@ -1735,6 +1797,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.SymbolProfile.symbol, symbol: order.SymbolProfile.symbol,
tags: order.tags,
type: order.type, type: order.type,
unitPrice: new Big( unitPrice: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
@ -1775,12 +1838,12 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}) { }) {
const ordersOfTypeItem = await this.orderService.getOrders({ const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
filters, filters,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts, withExcludedAccounts,
types: ['ITEM'] types: ['ITEM', 'LIABILITY']
}); });
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
@ -1820,13 +1883,14 @@ export class PortfolioService {
return accountId === account.id; return accountId === account.id;
}); });
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter( const ordersOfTypeItemOrLiabilityByAccount =
({ accountId }) => { ordersOfTypeItemOrLiability.filter(({ accountId }) => {
return accountId === account.id; return accountId === account.id;
} });
);
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount); ordersByAccount = ordersByAccount.concat(
ordersOfTypeItemOrLiabilityByAccount
);
accounts[account.id] = { accounts[account.id] = {
balance: account.balance, balance: account.balance,
@ -1866,7 +1930,7 @@ export class PortfolioService {
order.unitPrice ?? order.unitPrice ??
0); 0);
if (order.type === 'SELL') { if (order.type === 'LIABILITY' || order.type === 'SELL') {
currentValueOfSymbolInBaseCurrency *= -1; currentValueOfSymbolInBaseCurrency *= -1;
} }

7
apps/api/src/app/redis-cache/interfaces/redis-cache.interface.ts

@ -0,0 +1,7 @@
import { Cache } from 'cache-manager';
import type { RedisStore } from './redis-store.interface';
export interface RedisCache extends Cache {
store: RedisStore;
}

8
apps/api/src/app/redis-cache/interfaces/redis-store.interface.ts

@ -0,0 +1,8 @@
import { Store } from 'cache-manager';
import { createClient } from 'redis';
export interface RedisStore extends Store {
getClient: () => ReturnType<typeof createClient>;
isCacheableValue: (value: any) => boolean;
name: 'redis';
}

6
apps/api/src/app/redis-cache/redis-cache.module.ts

@ -1,7 +1,9 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CacheManagerOptions, CacheModule, Module } from '@nestjs/common'; import { CacheModule } from '@nestjs/cache-manager';
import { Module } from '@nestjs/common';
import * as redisStore from 'cache-manager-redis-store'; import * as redisStore from 'cache-manager-redis-store';
import type { RedisClientOptions } from 'redis';
import { RedisCacheService } from './redis-cache.service'; import { RedisCacheService } from './redis-cache.service';
@ -11,7 +13,7 @@ import { RedisCacheService } from './redis-cache.service';
imports: [ConfigurationModule], imports: [ConfigurationModule],
inject: [ConfigurationService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => { useFactory: async (configurationService: ConfigurationService) => {
return <CacheManagerOptions>{ return <RedisClientOptions>{
host: configurationService.get('REDIS_HOST'), host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'), max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'), password: configurationService.get('REDIS_PASSWORD'),

27
apps/api/src/app/redis-cache/redis-cache.service.ts

@ -1,21 +1,30 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager'; import { Inject, Injectable, Logger } from '@nestjs/common';
import type { RedisCache } from './interfaces/redis-cache.interface';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
public constructor( public constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache, @Inject(CACHE_MANAGER) private readonly cache: RedisCache,
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
) {} ) {
const client = cache.store.getClient();
client.on('error', (error) => {
Logger.error(error, 'RedisCacheService');
});
}
public async get(key: string): Promise<string> { public async get(key: string): Promise<string> {
return await this.cache.get(key); return await this.cache.get(key);
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }
public async remove(key: string) { public async remove(key: string) {
@ -27,8 +36,10 @@ export class RedisCacheService {
} }
public async set(key: string, value: string, ttlInSeconds?: number) { public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set(key, value, { await this.cache.set(
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL') key,
}); value,
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
);
} }
} }

36
apps/api/src/app/sitemap/sitemap.controller.ts

@ -0,0 +1,36 @@
import * as fs from 'fs';
import * as path from 'path';
import {
DATE_FORMAT,
getYesterday,
interpolate
} from '@ghostfolio/common/helper';
import { Controller, Get, Res, VERSION_NEUTRAL, Version } from '@nestjs/common';
import { format } from 'date-fns';
import { Response } from 'express';
@Controller('sitemap.xml')
export class SitemapController {
public sitemapXml = '';
public constructor() {
try {
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@Get()
@Version(VERSION_NEUTRAL)
public async flushCache(@Res() response: Response): Promise<void> {
response.setHeader('content-type', 'application/xml');
response.send(
interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
})
);
}
}

24
apps/api/src/app/sitemap/sitemap.module.ts

@ -0,0 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { SitemapController } from './sitemap.controller';
@Module({
controllers: [SitemapController],
imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule
]
})
export class SitemapModule {}

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

@ -93,9 +93,8 @@ export class SubscriptionService {
public async createSubscriptionViaStripe(aCheckoutSessionId: string) { public async createSubscriptionViaStripe(aCheckoutSessionId: string) {
try { try {
const session = await this.stripe.checkout.sessions.retrieve( const session =
aCheckoutSessionId await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId);
);
await this.createSubscription({ await this.createSubscription({
price: session.amount_total / 100, price: session.amount_total / 100,

3
apps/api/src/app/symbol/symbol.controller.ts

@ -15,6 +15,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
@ -93,7 +94,7 @@ export class SymbolController {
@Param('dateString') dateString: string, @Param('dateString') dateString: string,
@Param('symbol') symbol: string @Param('symbol') symbol: string
): Promise<IDataProviderHistoricalResponse> { ): Promise<IDataProviderHistoricalResponse> {
const date = new Date(dateString); const date = parseISO(dateString);
if (!isDate(date)) { if (!isDate(date)) {
throw new HttpException( throw new HttpException(

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

@ -27,9 +27,9 @@ export class SymbolService {
dataGatheringItem: IDataGatheringItem; dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: number; includeHistoricalData?: number;
}): Promise<SymbolItem> { }): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes([ const quotes = await this.dataProviderService.getQuotes({
dataGatheringItem items: [dataGatheringItem]
]); });
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {}; const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice >= 0) { if (dataGatheringItem.dataSource && marketPrice >= 0) {

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

@ -4,7 +4,11 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config'; import {
DEFAULT_CURRENCY,
PROPERTY_IS_READ_ONLY_MODE,
locale
} from '@ghostfolio/common/config';
import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces'; import { User as IUser, UserSettings } from '@ghostfolio/common/interfaces';
import { import {
getPermissions, getPermissions,
@ -14,14 +18,13 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client'; import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash'; import { sortBy } from 'lodash';
const crypto = require('crypto'); const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string; private baseCurrency: string;
public constructor( public constructor(
@ -123,7 +126,7 @@ export class UserService {
id, id,
provider, provider,
role, role,
Settings, Settings: Settings as UserWithSettings['Settings'],
thirdPartyId, thirdPartyId,
updatedAt, updatedAt,
activityCount: Analytics?.activityCount activityCount: Analytics?.activityCount
@ -144,8 +147,7 @@ export class UserService {
// Set default value for base currency // Set default value for base currency
if (!(user.Settings.settings as UserSettings)?.baseCurrency) { if (!(user.Settings.settings as UserSettings)?.baseCurrency) {
(user.Settings.settings as UserSettings).baseCurrency = (user.Settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY;
UserService.DEFAULT_CURRENCY;
} }
// Set default value for date range // Set default value for date range
@ -165,11 +167,29 @@ export class UserService {
user.subscription = user.subscription =
this.subscriptionService.getSubscription(Subscription); this.subscriptionService.getSubscription(Subscription);
if ( if (user.subscription?.type === 'Basic') {
Analytics?.activityCount % 10 === 0 && const daysSinceRegistration = differenceInDays(
user.subscription?.type === 'Basic' new Date(),
) { user.createdAt
currentPermissions.push(permissions.enableSubscriptionInterstitial); );
let frequency = 20;
if (daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
frequency = 5;
} else if (daysSinceRegistration > 30) {
frequency = 10;
} else if (daysSinceRegistration > 15) {
frequency = 15;
}
if (Analytics?.activityCount % frequency === 1) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
// Reset benchmark
user.Settings.settings.benchmark = undefined;
} }
if (user.subscription?.type === 'Premium') { if (user.subscription?.type === 'Premium') {

1
apps/api/src/assets/countries/asia-pacific-markets.json

@ -0,0 +1 @@
["AU", "HK", "NZ", "SG"]

19
apps/api/src/assets/countries/europe-markets.json

@ -0,0 +1,19 @@
[
"AT",
"BE",
"CH",
"DE",
"DK",
"ES",
"FI",
"FR",
"GB",
"IE",
"IL",
"IT",
"LU",
"NL",
"NO",
"PT",
"SE"
]

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

@ -51,7 +51,9 @@
"3FT": "ThreeFold Token", "3FT": "ThreeFold Token",
"3ULL": "3ULL Coin", "3ULL": "3ULL Coin",
"3XD": "3DChain", "3XD": "3DChain",
"420CHAN": "420chan",
"4ART": "4ART Coin", "4ART": "4ART Coin",
"4CHAN": "4Chan",
"4JNET": "4JNET", "4JNET": "4JNET",
"77G": "GraphenTech", "77G": "GraphenTech",
"7E": "7ELEVEN", "7E": "7ELEVEN",
@ -60,6 +62,7 @@
"8BT": "8 Circuit Studios", "8BT": "8 Circuit Studios",
"8PAY": "8Pay", "8PAY": "8Pay",
"8X8": "8X8 Protocol", "8X8": "8X8 Protocol",
"9GAG": "9GAG",
"A5T": "Alpha5", "A5T": "Alpha5",
"AAA": "Moon Rabbit", "AAA": "Moon Rabbit",
"AAB": "AAX Token", "AAB": "AAX Token",
@ -101,6 +104,7 @@
"ACN": "AvonCoin", "ACN": "AvonCoin",
"ACOIN": "ACoin", "ACOIN": "ACoin",
"ACP": "Anarchists Prime", "ACP": "Anarchists Prime",
"ACQ": "Acquire.Fi",
"ACS": "Access Protocol", "ACS": "Access Protocol",
"ACT": "Achain", "ACT": "Achain",
"ACTIN": "Actinium", "ACTIN": "Actinium",
@ -180,7 +184,7 @@
"AGX": "Agricoin", "AGX": "Agricoin",
"AHOO": "Ahoolee", "AHOO": "Ahoolee",
"AHT": "AhaToken", "AHT": "AhaToken",
"AI": "Multiverse", "AI": "AiDoge",
"AIB": "AdvancedInternetBlock", "AIB": "AdvancedInternetBlock",
"AIBB": "AiBB", "AIBB": "AiBB",
"AIBK": "AIB Utility Token", "AIBK": "AIB Utility Token",
@ -213,6 +217,7 @@
"AKA": "Akroma", "AKA": "Akroma",
"AKITA": "Akita Inu", "AKITA": "Akita Inu",
"AKN": "Akoin", "AKN": "Akoin",
"AKNC": "Aave KNC v1",
"AKRO": "Akropolis", "AKRO": "Akropolis",
"AKT": "Akash Network", "AKT": "Akash Network",
"AKTIO": "AKTIO Coin", "AKTIO": "AKTIO Coin",
@ -237,12 +242,14 @@
"ALIC": "AliCoin", "ALIC": "AliCoin",
"ALICE": "My Neighbor Alice", "ALICE": "My Neighbor Alice",
"ALIEN": "AlienCoin", "ALIEN": "AlienCoin",
"ALINK": "Aave LINK v1",
"ALIS": "ALISmedia", "ALIS": "ALISmedia",
"ALITA": "Alita Network", "ALITA": "Alita Network",
"ALIX": "AlinX", "ALIX": "AlinX",
"ALKI": "Alkimi", "ALKI": "Alkimi",
"ALLBI": "ALL BEST ICO", "ALLBI": "ALL BEST ICO",
"ALLEY": "NFT Alley", "ALLEY": "NFT Alley",
"ALLIN": "All in",
"ALN": "Aluna", "ALN": "Aluna",
"ALOHA": "Aloha", "ALOHA": "Aloha",
"ALP": "Alphacon", "ALP": "Alphacon",
@ -410,12 +417,14 @@
"ARIX": "Arix", "ARIX": "Arix",
"ARK": "ARK", "ARK": "ARK",
"ARKER": "Arker", "ARKER": "Arker",
"ARKM": "Arkham",
"ARKN": "Ark Rivals", "ARKN": "Ark Rivals",
"ARM": "Armory Coin", "ARM": "Armory Coin",
"ARMOR": "ARMOR", "ARMOR": "ARMOR",
"ARMR": "ARMR", "ARMR": "ARMR",
"ARMS": "2Acoin", "ARMS": "2Acoin",
"ARNA": "ARNA Panacea", "ARNA": "ARNA Panacea",
"ARNM": "Arenum",
"ARNO": "ARNO", "ARNO": "ARNO",
"ARNX": "Aeron", "ARNX": "Aeron",
"ARNXM": "Armor NXM", "ARNXM": "Armor NXM",
@ -472,6 +481,7 @@
"ASTO": "Altered State Token", "ASTO": "Altered State Token",
"ASTON": "Aston", "ASTON": "Aston",
"ASTR": "Astar", "ASTR": "Astar",
"ASTRAFER": "Astrafer",
"ASTRAL": "Astral", "ASTRAL": "Astral",
"ASTRO": "AstroSwap", "ASTRO": "AstroSwap",
"ASTROC": "Astroport Classic", "ASTROC": "Astroport Classic",
@ -531,6 +541,7 @@
"AURY": "Aurory", "AURY": "Aurory",
"AUSCM": "Auric Network", "AUSCM": "Auric Network",
"AUSD": "Appeal dollar", "AUSD": "Appeal dollar",
"AUSDC": "Aave USDC v1",
"AUT": "Autoria", "AUT": "Autoria",
"AUTHORSHIP": "Authorship", "AUTHORSHIP": "Authorship",
"AUTO": "Auto", "AUTO": "Auto",
@ -612,6 +623,7 @@
"BACK": "DollarBack", "BACK": "DollarBack",
"BACOIN": "BACoin", "BACOIN": "BACoin",
"BACON": "BaconDAO (BACON)", "BACON": "BaconDAO (BACON)",
"BAD": "Bad Idea AI",
"BADGER": "Badger DAO", "BADGER": "Badger DAO",
"BAG": "BondAppetit", "BAG": "BondAppetit",
"BAGS": "Basis Gold Share", "BAGS": "Basis Gold Share",
@ -662,6 +674,7 @@
"BBCT": "TraDove B2BCoin", "BBCT": "TraDove B2BCoin",
"BBDT": "BBD Token", "BBDT": "BBD Token",
"BBF": "Bubblefong", "BBF": "Bubblefong",
"BBFT": "Block Busters Tech Token",
"BBG": "BigBang", "BBG": "BigBang",
"BBGC": "BigBang Game", "BBGC": "BigBang Game",
"BBI": "BelugaPay", "BBI": "BelugaPay",
@ -725,6 +738,7 @@
"BDX": "Beldex", "BDX": "Beldex",
"BDY": "Buddy DAO", "BDY": "Buddy DAO",
"BEACH": "BeachCoin", "BEACH": "BeachCoin",
"BEAI": "BeNFT Solutions",
"BEAM": "Beam", "BEAM": "Beam",
"BEAN": "BeanCash", "BEAN": "BeanCash",
"BEAST": "CryptoBeast", "BEAST": "CryptoBeast",
@ -806,6 +820,7 @@
"BIDR": "Binance IDR Stable Coin", "BIDR": "Binance IDR Stable Coin",
"BIFI": "Beefy.Finance", "BIFI": "Beefy.Finance",
"BIFIF": "BiFi", "BIFIF": "BiFi",
"BIG": "Big Eyes",
"BIGHAN": "BighanCoin", "BIGHAN": "BighanCoin",
"BIGSB": "BigShortBets", "BIGSB": "BigShortBets",
"BIGUP": "BigUp", "BIGUP": "BigUp",
@ -1090,6 +1105,7 @@
"BRNK": "Brank", "BRNK": "Brank",
"BRNX": "Bronix", "BRNX": "Bronix",
"BRO": "Bitradio", "BRO": "Bitradio",
"BROCK": "Bitrock",
"BRONZ": "BitBronze", "BRONZ": "BitBronze",
"BRT": "Bikerush", "BRT": "Bikerush",
"BRTR": "Barter", "BRTR": "Barter",
@ -1226,7 +1242,7 @@
"BULL": "Bullieverse", "BULL": "Bullieverse",
"BULLC": "BuySell", "BULLC": "BuySell",
"BULLION": "BullionFX", "BULLION": "BullionFX",
"BULLS": "BullshitCoin", "BULLS": "Bull Coin",
"BULLSH": "Bullshit Inu", "BULLSH": "Bullshit Inu",
"BUMN": "BUMooN", "BUMN": "BUMooN",
"BUMP": "Bumper", "BUMP": "Bumper",
@ -1277,6 +1293,7 @@
"BZKY": "Bizkey", "BZKY": "Bizkey",
"BZL": "BZLCoin", "BZL": "BZLCoin",
"BZNT": "Bezant", "BZNT": "Bezant",
"BZR": "Bazaars",
"BZRX": "bZx Protocol", "BZRX": "bZx Protocol",
"BZX": "Bitcoin Zero", "BZX": "Bitcoin Zero",
"BZZ": "Swarmv", "BZZ": "Swarmv",
@ -1319,8 +1336,10 @@
"CAP": "BottleCaps", "CAP": "BottleCaps",
"CAPD": "Capdax", "CAPD": "Capdax",
"CAPP": "Cappasity", "CAPP": "Cappasity",
"CAPRICOIN": "CapriCoin",
"CAPS": "Ternoa", "CAPS": "Ternoa",
"CAPT": "Bitcoin Captain", "CAPT": "Bitcoin Captain",
"CAPTAINPLANET": "Captain Planet",
"CAR": "CarBlock", "CAR": "CarBlock",
"CARAT": "Carats Token", "CARAT": "Carats Token",
"CARBON": "Carboncoin", "CARBON": "Carboncoin",
@ -1478,6 +1497,7 @@
"CHECKR": "CheckerChain", "CHECKR": "CheckerChain",
"CHECOIN": "CheCoin", "CHECOIN": "CheCoin",
"CHEDDA": "Chedda", "CHEDDA": "Chedda",
"CHEEL": "Cheelee",
"CHEESE": "CHEESE", "CHEESE": "CHEESE",
"CHEESUS": "Cheesus", "CHEESUS": "Cheesus",
"CHEQ": "CHEQD Network", "CHEQ": "CHEQD Network",
@ -1520,7 +1540,8 @@
"CHX": "Own", "CHX": "Own",
"CHY": "Concern Poverty Chain", "CHY": "Concern Poverty Chain",
"CHZ": "Chiliz", "CHZ": "Chiliz",
"CIC": "CIChain", "CIC": "Crazy Internet Coin",
"CICHAIN": "CIChain",
"CIF": "Crypto Improvement Fund", "CIF": "Crypto Improvement Fund",
"CIM": "COINCOME", "CIM": "COINCOME",
"CIN": "CinderCoin", "CIN": "CinderCoin",
@ -1630,7 +1651,6 @@
"COB": "Cobinhood", "COB": "Cobinhood",
"COC": "Coin of the champions", "COC": "Coin of the champions",
"COCK": "Shibacock", "COCK": "Shibacock",
"COCOS": "COCOS BCX",
"CODEO": "Codeo Token", "CODEO": "Codeo Token",
"CODEX": "CODEX Finance", "CODEX": "CODEX Finance",
"CODI": "Codi Finance", "CODI": "Codi Finance",
@ -1659,7 +1679,7 @@
"COLX": "ColossusCoinXT", "COLX": "ColossusCoinXT",
"COM": "Coliseum", "COM": "Coliseum",
"COMB": "Combo", "COMB": "Combo",
"COMBO": "Furucombo", "COMBO": "COMBO",
"COMFI": "CompliFi", "COMFI": "CompliFi",
"COMM": "Community Coin", "COMM": "Community Coin",
"COMMUNITYCOIN": "Community Coin", "COMMUNITYCOIN": "Community Coin",
@ -1672,7 +1692,6 @@
"CONI": "CoinBene", "CONI": "CoinBene",
"CONS": "ConSpiracy Coin", "CONS": "ConSpiracy Coin",
"CONSENTIUM": "Consentium", "CONSENTIUM": "Consentium",
"CONT": "Contentos",
"CONUN": "CONUN", "CONUN": "CONUN",
"CONV": "Convergence", "CONV": "Convergence",
"COOK": "Cook", "COOK": "Cook",
@ -1683,17 +1702,19 @@
"COPS": "Cops Finance", "COPS": "Cops Finance",
"COR": "Corion", "COR": "Corion",
"CORAL": "CoralPay", "CORAL": "CoralPay",
"CORE": "Coreum", "CORE": "Core",
"COREDAO": "coreDAO", "COREDAO": "coreDAO",
"COREG": "Core Group Asset", "COREG": "Core Group Asset",
"COREUM": "Coreum",
"CORGI": "Corgi Inu", "CORGI": "Corgi Inu",
"CORN": "CORN", "CORN": "CORN",
"CORX": "CorionX", "CORX": "CorionX",
"COS": "COS", "COS": "Contentos",
"COSHI": "CoShi Inu", "COSHI": "CoShi Inu",
"COSM": "CosmoChain", "COSM": "CosmoChain",
"COSMIC": "CosmicSwap", "COSMIC": "CosmicSwap",
"COSP": "Cosplay Token", "COSP": "Cosplay Token",
"COSS": "COS",
"COSX": "Cosmecoin", "COSX": "Cosmecoin",
"COT": "CoTrader", "COT": "CoTrader",
"COTI": "COTI", "COTI": "COTI",
@ -1729,7 +1750,7 @@
"CPOOL": "Clearpool", "CPOOL": "Clearpool",
"CPROP": "CPROP", "CPROP": "CPROP",
"CPRX": "Crypto Perx", "CPRX": "Crypto Perx",
"CPS": "CapriCoin", "CPS": "Cryptostone",
"CPT": "Cryptaur", "CPT": "Cryptaur",
"CPU": "CPUcoin", "CPU": "CPUcoin",
"CPX": "Apex Token", "CPX": "Apex Token",
@ -1796,6 +1817,7 @@
"CRTS": "Cratos", "CRTS": "Cratos",
"CRU": "Crust Network", "CRU": "Crust Network",
"CRV": "Curve DAO Token", "CRV": "Curve DAO Token",
"CRVUSD": "crvUSD",
"CRW": "Crown Coin", "CRW": "Crown Coin",
"CRWD": "CRWD Network", "CRWD": "CRWD Network",
"CRWNY": "Crowny Token", "CRWNY": "Crowny Token",
@ -1843,7 +1865,7 @@
"CTLX": "Cash Telex", "CTLX": "Cash Telex",
"CTN": "Continuum Finance", "CTN": "Continuum Finance",
"CTO": "Crypto", "CTO": "Crypto",
"CTP": "Captain Planet", "CTP": "Ctomorrow Platform",
"CTPL": "Cultiplan", "CTPL": "Cultiplan",
"CTPT": "Contents Protocol", "CTPT": "Contents Protocol",
"CTR": "Creator Platform", "CTR": "Creator Platform",
@ -2007,6 +2029,7 @@
"DBC": "DeepBrain Chain", "DBC": "DeepBrain Chain",
"DBCCOIN": "Datablockchain", "DBCCOIN": "Datablockchain",
"DBD": "Day By Day", "DBD": "Day By Day",
"DBEAR": "DBear Coin",
"DBET": "Decent.bet", "DBET": "Decent.bet",
"DBIC": "DubaiCoin", "DBIC": "DubaiCoin",
"DBIX": "DubaiCoin", "DBIX": "DubaiCoin",
@ -2058,6 +2081,7 @@
"DEEP": "DeepCloud AI", "DEEP": "DeepCloud AI",
"DEEPG": "Deep Gold", "DEEPG": "Deep Gold",
"DEEX": "DEEX", "DEEX": "DEEX",
"DEEZ": "DEEZ NUTS",
"DEFI": "Defi", "DEFI": "Defi",
"DEFI5": "DEFI Top 5 Tokens Index", "DEFI5": "DEFI Top 5 Tokens Index",
"DEFIL": "DeFIL", "DEFIL": "DeFIL",
@ -2162,11 +2186,12 @@
"DIEM": "Facebook Diem", "DIEM": "Facebook Diem",
"DIESEL": "Diesel", "DIESEL": "Diesel",
"DIFX": "Digital Financial Exchange", "DIFX": "Digital Financial Exchange",
"DIG": "Dignity", "DIG": "DIEGO",
"DIGG": "DIGG", "DIGG": "DIGG",
"DIGIC": "DigiCube", "DIGIC": "DigiCube",
"DIGIF": "DigiFel", "DIGIF": "DigiFel",
"DIGITAL": "Digital Reserve Currency", "DIGITAL": "Digital Reserve Currency",
"DIGNITY": "Dignity",
"DIGS": "Diggits", "DIGS": "Diggits",
"DIKO": "Arkadiko", "DIKO": "Arkadiko",
"DILI": "D Community", "DILI": "D Community",
@ -2246,6 +2271,7 @@
"DOGBOSS": "Dog Boss", "DOGBOSS": "Dog Boss",
"DOGDEFI": "DogDeFiCoin", "DOGDEFI": "DogDeFiCoin",
"DOGE": "Dogecoin", "DOGE": "Dogecoin",
"DOGE20": "Doge 2.0",
"DOGEBNB": "DogeBNB", "DOGEBNB": "DogeBNB",
"DOGEC": "DogeCash", "DOGEC": "DogeCash",
"DOGECEO": "Doge CEO", "DOGECEO": "Doge CEO",
@ -2539,7 +2565,7 @@
"ELONGT": "Elon GOAT", "ELONGT": "Elon GOAT",
"ELONONE": "AstroElon", "ELONONE": "AstroElon",
"ELP": "Ellerium", "ELP": "Ellerium",
"ELS": "Elysium", "ELS": "Ethlas",
"ELT": "Element Black", "ELT": "Element Black",
"ELTC2": "eLTC", "ELTC2": "eLTC",
"ELTCOIN": "ELTCOIN", "ELTCOIN": "ELTCOIN",
@ -2548,6 +2574,7 @@
"ELVN": "11Minutes", "ELVN": "11Minutes",
"ELX": "Energy Ledger", "ELX": "Energy Ledger",
"ELY": "Elysian", "ELY": "Elysian",
"ELYSIUM": "Elysium",
"EM": "Eminer", "EM": "Eminer",
"EMANATE": "EMANATE", "EMANATE": "EMANATE",
"EMAR": "EmaratCoin", "EMAR": "EmaratCoin",
@ -2559,6 +2586,7 @@
"EMC2": "Einsteinium", "EMC2": "Einsteinium",
"EMD": "Emerald", "EMD": "Emerald",
"EMIGR": "EmiratesGoldCoin", "EMIGR": "EmiratesGoldCoin",
"EML": "EML Protocol",
"EMN.CUR": "Eastman Chemical", "EMN.CUR": "Eastman Chemical",
"EMON": "Ethermon", "EMON": "Ethermon",
"EMOT": "Sentigraph.io", "EMOT": "Sentigraph.io",
@ -2692,6 +2720,7 @@
"ETHD": "Ethereum Dark", "ETHD": "Ethereum Dark",
"ETHER": "Etherparty", "ETHER": "Etherparty",
"ETHERDELTA": "EtherDelta", "ETHERDELTA": "EtherDelta",
"ETHERKING": "Ether Kingdoms Token",
"ETHERNITY": "Ethernity Chain", "ETHERNITY": "Ethernity Chain",
"ETHF": "EthereumFair", "ETHF": "EthereumFair",
"ETHIX": "EthicHub", "ETHIX": "EthicHub",
@ -2709,6 +2738,7 @@
"ETHSHIB": "Eth Shiba", "ETHSHIB": "Eth Shiba",
"ETHV": "Ethverse", "ETHV": "Ethverse",
"ETHW": "Ethereum PoW", "ETHW": "Ethereum PoW",
"ETHX": "Stader ETHx",
"ETHY": "Ethereum Yield", "ETHY": "Ethereum Yield",
"ETI": "EtherInc", "ETI": "EtherInc",
"ETK": "Energi Token", "ETK": "Energi Token",
@ -2722,7 +2752,7 @@
"ETR": "Electric Token", "ETR": "Electric Token",
"ETRNT": "Eternal Trusts", "ETRNT": "Eternal Trusts",
"ETS": "ETH Share", "ETS": "ETH Share",
"ETSC": "Ether star blockchain", "ETSC": "Ether star blockchain",
"ETT": "EncryptoTel", "ETT": "EncryptoTel",
"ETY": "Ethereum Cloud", "ETY": "Ethereum Cloud",
"ETZ": "EtherZero", "ETZ": "EtherZero",
@ -2773,6 +2803,7 @@
"EXB": "ExaByte (EXB)", "EXB": "ExaByte (EXB)",
"EXC": "Eximchain", "EXC": "Eximchain",
"EXCC": "ExchangeCoin", "EXCC": "ExchangeCoin",
"EXCHANGEN": "ExchangeN",
"EXCL": "Exclusive Coin", "EXCL": "Exclusive Coin",
"EXE": "ExeCoin", "EXE": "ExeCoin",
"EXFI": "Flare Finance", "EXFI": "Flare Finance",
@ -2781,7 +2812,7 @@
"EXLT": "ExtraLovers", "EXLT": "ExtraLovers",
"EXM": "EXMO Coin", "EXM": "EXMO Coin",
"EXMR": "EXMR FDN", "EXMR": "EXMR FDN",
"EXN": "ExchangeN", "EXN": "Exeno",
"EXO": "Exosis", "EXO": "Exosis",
"EXP": "Expanse", "EXP": "Expanse",
"EXRD": "Radix", "EXRD": "Radix",
@ -2814,6 +2845,7 @@
"FAIR": "FairCoin", "FAIR": "FairCoin",
"FAIRC": "Faireum Token", "FAIRC": "Faireum Token",
"FAIRG": "FairGame", "FAIRG": "FairGame",
"FAKE": "FAKE COIN",
"FAKT": "Medifakt", "FAKT": "Medifakt",
"FALCONS": "Falcon Swaps", "FALCONS": "Falcon Swaps",
"FAME": "Fame MMA", "FAME": "Fame MMA",
@ -2860,6 +2892,7 @@
"FDO": "Firdaos", "FDO": "Firdaos",
"FDR": "French Digital Reserve", "FDR": "French Digital Reserve",
"FDT": "Frutti Dino", "FDT": "Frutti Dino",
"FDUSD": "First Digital USD",
"FDX": "fidentiaX", "FDX": "fidentiaX",
"FDZ": "Friendz", "FDZ": "Friendz",
"FEAR": "Fear", "FEAR": "Fear",
@ -2870,6 +2903,7 @@
"FEN": "First Ever NFT", "FEN": "First Ever NFT",
"FENOMY": "Fenomy", "FENOMY": "Fenomy",
"FER": "Ferro", "FER": "Ferro",
"FERC": "FairERC20",
"FERMA": "Ferma", "FERMA": "Ferma",
"FESS": "Fesschain", "FESS": "Fesschain",
"FET": "Fetch.AI", "FET": "Fetch.AI",
@ -2931,7 +2965,7 @@
"FLASH": "Flashstake", "FLASH": "Flashstake",
"FLASHC": "FLASH coin", "FLASHC": "FLASH coin",
"FLC": "FlowChainCoin", "FLC": "FlowChainCoin",
"FLD": "FLUID", "FLD": "FluidAI",
"FLDC": "Folding Coin", "FLDC": "Folding Coin",
"FLDT": "FairyLand", "FLDT": "FairyLand",
"FLETA": "FLETA", "FLETA": "FLETA",
@ -3091,6 +3125,7 @@
"FUEL": "Jetfuel Finance", "FUEL": "Jetfuel Finance",
"FUJIN": "Fujinto", "FUJIN": "Fujinto",
"FUKU": "Furukuru", "FUKU": "Furukuru",
"FUMO": "Alien Milady Fumo",
"FUN": "FUN Token", "FUN": "FUN Token",
"FUNC": "FunCoin", "FUNC": "FunCoin",
"FUND": "Unification", "FUND": "Unification",
@ -3101,6 +3136,7 @@
"FUNDZ": "FundFantasy", "FUNDZ": "FundFantasy",
"FUNK": "Cypherfunks Coin", "FUNK": "Cypherfunks Coin",
"FUR": "Furio", "FUR": "Furio",
"FURU": "Furucombo",
"FURY": "Engines of Fury", "FURY": "Engines of Fury",
"FUS": "Fus", "FUS": "Fus",
"FUSE": "Fuse Network Token", "FUSE": "Fuse Network Token",
@ -3118,6 +3154,7 @@
"FXP": "FXPay", "FXP": "FXPay",
"FXS": "Frax Share", "FXS": "Frax Share",
"FXT": "FuzeX", "FXT": "FuzeX",
"FXY": "Floxypay",
"FYN": "Affyn", "FYN": "Affyn",
"FYP": "FlypMe", "FYP": "FlypMe",
"FYZ": "Fyooz", "FYZ": "Fyooz",
@ -3172,6 +3209,7 @@
"GAT": "GATCOIN", "GAT": "GATCOIN",
"GATE": "GATENet", "GATE": "GATENet",
"GATEWAY": "Gateway Protocol", "GATEWAY": "Gateway Protocol",
"GAYPEPE": "Gay Pepe",
"GAZE": "GazeTV", "GAZE": "GazeTV",
"GB": "GoldBlocks", "GB": "GoldBlocks",
"GBA": "Geeba", "GBA": "Geeba",
@ -3222,6 +3260,7 @@
"GEMZ": "Gemz Social", "GEMZ": "Gemz Social",
"GEN": "DAOstack", "GEN": "DAOstack",
"GENE": "Genopets", "GENE": "Genopets",
"GENIE": "The Genie",
"GENIX": "Genix", "GENIX": "Genix",
"GENS": "Genshiro", "GENS": "Genshiro",
"GENSTAKE": "Genstake", "GENSTAKE": "Genstake",
@ -3261,6 +3300,7 @@
"GHCOLD": "Galaxy Heroes Coin", "GHCOLD": "Galaxy Heroes Coin",
"GHD": "Giftedhands", "GHD": "Giftedhands",
"GHNY": "Grizzly Honey", "GHNY": "Grizzly Honey",
"GHO": "GHO",
"GHOST": "GhostbyMcAfee", "GHOST": "GhostbyMcAfee",
"GHOSTCOIN": "GhostCoin", "GHOSTCOIN": "GhostCoin",
"GHOSTM": "GhostMarket", "GHOSTM": "GhostMarket",
@ -3274,6 +3314,7 @@
"GIFT": "GiftNet", "GIFT": "GiftNet",
"GIG": "GigaCoin", "GIG": "GigaCoin",
"GIGA": "GigaSwap", "GIGA": "GigaSwap",
"GIGX": "GigXCoin",
"GIM": "Gimli", "GIM": "Gimli",
"GIMMER": "Gimmer", "GIMMER": "Gimmer",
"GIN": "GINcoin", "GIN": "GINcoin",
@ -3385,6 +3426,7 @@
"GOVT": "The Government Network", "GOVT": "The Government Network",
"GOZ": "Göztepe S.K. Fan Token", "GOZ": "Göztepe S.K. Fan Token",
"GP": "Wizards And Dragons", "GP": "Wizards And Dragons",
"GPBP": "Genius Playboy Billionaire Philanthropist",
"GPKR": "Gold Poker", "GPKR": "Gold Poker",
"GPL": "Gold Pressed Latinum", "GPL": "Gold Pressed Latinum",
"GPPT": "Pluto Project Coin", "GPPT": "Pluto Project Coin",
@ -3501,7 +3543,8 @@
"HALF": "0.5X Long Bitcoin Token", "HALF": "0.5X Long Bitcoin Token",
"HALFSHIT": "0.5X Long Shitcoin Index Token", "HALFSHIT": "0.5X Long Shitcoin Index Token",
"HALLO": "Halloween Coin", "HALLO": "Halloween Coin",
"HALO": "Halo Platform", "HALO": "Halo Coin",
"HALOPLATFORM": "Halo Platform",
"HAM": "Hamster", "HAM": "Hamster",
"HAMS": "HamsterCoin", "HAMS": "HamsterCoin",
"HANA": "Hanacoin", "HANA": "Hanacoin",
@ -3598,6 +3641,7 @@
"HILL": "President Clinton", "HILL": "President Clinton",
"HINA": "Hina Inu", "HINA": "Hina Inu",
"HINT": "Hintchain", "HINT": "Hintchain",
"HIPPO": "HIPPO",
"HIRE": "HireMatch", "HIRE": "HireMatch",
"HIT": "HitChain", "HIT": "HitChain",
"HITBTC": "HitBTC Token", "HITBTC": "HitBTC Token",
@ -3634,6 +3678,7 @@
"HNTR": "Hunter", "HNTR": "Hunter",
"HNY": "Honey", "HNY": "Honey",
"HNZO": "Hanzo Inu", "HNZO": "Hanzo Inu",
"HOBO": "HOBO THE BEAR",
"HOD": "HoDooi.com", "HOD": "HoDooi.com",
"HODL": "HOdlcoin", "HODL": "HOdlcoin",
"HOGE": "Hoge Finance", "HOGE": "Hoge Finance",
@ -3839,7 +3884,7 @@
"IMPCN": "Brain Space", "IMPCN": "Brain Space",
"IMPER": "Impermax", "IMPER": "Impermax",
"IMPS": "Impulse Coin", "IMPS": "Impulse Coin",
"IMPT": "Ether Kingdoms Token", "IMPT": "IMPT",
"IMPULSE": "IMPULSE by FDR", "IMPULSE": "IMPULSE by FDR",
"IMS": "Independent Money System", "IMS": "Independent Money System",
"IMST": "Imsmart", "IMST": "Imsmart",
@ -4001,6 +4046,7 @@
"JAM": "Tune.Fm", "JAM": "Tune.Fm",
"JANE": "JaneCoin", "JANE": "JaneCoin",
"JAR": "Jarvis+", "JAR": "Jarvis+",
"JARED": "Jared From Subway",
"JASMY": "JasmyCoin", "JASMY": "JasmyCoin",
"JBS": "JumBucks Coin", "JBS": "JumBucks Coin",
"JBX": "Juicebox", "JBX": "Juicebox",
@ -4163,9 +4209,10 @@
"KIN": "Kin", "KIN": "Kin",
"KIND": "Kind Ads", "KIND": "Kind Ads",
"KINE": "Kine Protocol", "KINE": "Kine Protocol",
"KING": "King Finance", "KING": "KING",
"KING93": "King93", "KING93": "King93",
"KINGDOMQUEST": "Kingdom Quest", "KINGDOMQUEST": "Kingdom Quest",
"KINGF": "King Finance",
"KINGSHIB": "King Shiba", "KINGSHIB": "King Shiba",
"KINGSWAP": "KingSwap", "KINGSWAP": "KingSwap",
"KINT": "Kintsugi", "KINT": "Kintsugi",
@ -4175,6 +4222,7 @@
"KISC": "Kaiser", "KISC": "Kaiser",
"KISHIMOTO": "Kishimoto Inu", "KISHIMOTO": "Kishimoto Inu",
"KISHU": "Kishu Inu", "KISHU": "Kishu Inu",
"KITA": "KITA INU",
"KITSU": "Kitsune Inu", "KITSU": "Kitsune Inu",
"KITTY": "Kitty Inu", "KITTY": "Kitty Inu",
"KKO": "Kineko", "KKO": "Kineko",
@ -4267,10 +4315,12 @@
"KUBO": "KUBO", "KUBO": "KUBO",
"KUBOS": "KubosCoin", "KUBOS": "KubosCoin",
"KUE": "Kuende", "KUE": "Kuende",
"KUJI": "Kujira",
"KUMA": "Kuma Inu", "KUMA": "Kuma Inu",
"KUNCI": "Kunci Coin", "KUNCI": "Kunci Coin",
"KUR": "Kuro", "KUR": "Kuro",
"KURT": "Kurrent", "KURT": "Kurrent",
"KUSA": "Kusa Inu",
"KUSD": "Kowala", "KUSD": "Kowala",
"KUSH": "KushCoin", "KUSH": "KushCoin",
"KUV": "Kuverit", "KUV": "Kuverit",
@ -4280,6 +4330,7 @@
"KVT": "Kinesis Velocity Token", "KVT": "Kinesis Velocity Token",
"KWATT": "4New", "KWATT": "4New",
"KWD": "KIWI DEFI", "KWD": "KIWI DEFI",
"KWENTA": "Kwenta",
"KWH": "KWHCoin", "KWH": "KWHCoin",
"KWIK": "KwikSwap", "KWIK": "KwikSwap",
"KWS": "Knight War Spirits", "KWS": "Knight War Spirits",
@ -4299,7 +4350,9 @@
"LABX": "Stakinglab", "LABX": "Stakinglab",
"LACCOIN": "LocalAgro", "LACCOIN": "LocalAgro",
"LACE": "Lovelace World", "LACE": "Lovelace World",
"LADYS": "Milady Meme Coin",
"LAEEB": "LaEeb", "LAEEB": "LaEeb",
"LAELAPS": "Laelaps",
"LAIKA": "Laika Protocol", "LAIKA": "Laika Protocol",
"LALA": "LaLa World", "LALA": "LaLa World",
"LAMB": "Lambda", "LAMB": "Lambda",
@ -4455,13 +4508,14 @@
"LLAND": "Lyfe Land", "LLAND": "Lyfe Land",
"LLG": "Loligo", "LLG": "Loligo",
"LLION": "Lydian Lion", "LLION": "Lydian Lion",
"LM": "LM Token", "LM": "LeisureMeta",
"LMAO": "LMAO Finance", "LMAO": "LMAO Finance",
"LMC": "LomoCoin", "LMC": "LomoCoin",
"LMCH": "Latamcash", "LMCH": "Latamcash",
"LMCSWAP": "LimoCoin SWAP", "LMCSWAP": "LimoCoin SWAP",
"LMR": "Lumerin", "LMR": "Lumerin",
"LMT": "Lympo Market Token", "LMT": "Lympo Market Token",
"LMTOKEN": "LM Token",
"LMXC": "LimonX", "LMXC": "LimonX",
"LMY": "Lunch Money", "LMY": "Lunch Money",
"LN": "LINK", "LN": "LINK",
@ -4530,6 +4584,7 @@
"LRG": "Largo Coin", "LRG": "Largo Coin",
"LRN": "Loopring [NEO]", "LRN": "Loopring [NEO]",
"LSD": "LightSpeedCoin", "LSD": "LightSpeedCoin",
"LSETH": "Liquid Staked ETH",
"LSK": "Lisk", "LSK": "Lisk",
"LSP": "Lumenswap", "LSP": "Lumenswap",
"LSS": "Lossless", "LSS": "Lossless",
@ -4626,6 +4681,7 @@
"MAEP": "Maester Protocol", "MAEP": "Maester Protocol",
"MAG": "Magnet", "MAG": "Magnet",
"MAGIC": "Magic", "MAGIC": "Magic",
"MAGICF": "MagicFox",
"MAHA": "MahaDAO", "MAHA": "MahaDAO",
"MAI": "Mindsync", "MAI": "Mindsync",
"MAID": "MaidSafe Coin", "MAID": "MaidSafe Coin",
@ -4639,6 +4695,7 @@
"MANDOX": "MandoX", "MANDOX": "MandoX",
"MANGA": "Manga Token", "MANGA": "Manga Token",
"MANNA": "Manna", "MANNA": "Manna",
"MANTLE": "Mantle",
"MAP": "MAP Protocol", "MAP": "MAP Protocol",
"MAPC": "MapCoin", "MAPC": "MapCoin",
"MAPE": "Mecha Morphing", "MAPE": "Mecha Morphing",
@ -4672,6 +4729,7 @@
"MATIC": "Polygon", "MATIC": "Polygon",
"MATPAD": "MaticPad", "MATPAD": "MaticPad",
"MATTER": "AntiMatter", "MATTER": "AntiMatter",
"MAV": "Maverick Protocol",
"MAX": "MaxCoin", "MAX": "MaxCoin",
"MAXR": "Max Revive", "MAXR": "Max Revive",
"MAY": "Theresa May Coin", "MAY": "Theresa May Coin",
@ -4776,6 +4834,7 @@
"MESA": "MetaVisa", "MESA": "MetaVisa",
"MESG": "MESG", "MESG": "MESG",
"MESH": "MeshBox", "MESH": "MeshBox",
"MESSI": "MESSI COIN",
"MET": "Metronome", "MET": "Metronome",
"META": "Metadium", "META": "Metadium",
"METAC": "Metacoin", "METAC": "Metacoin",
@ -4881,6 +4940,7 @@
"MIODIO": "MIODIOCOIN", "MIODIO": "MIODIOCOIN",
"MIOTA": "IOTA", "MIOTA": "IOTA",
"MIR": "Mirror Protocol", "MIR": "Mirror Protocol",
"MIRACLE": "MIRACLE",
"MIRC": "MIR COIN", "MIRC": "MIR COIN",
"MIS": "Mithril Share", "MIS": "Mithril Share",
"MISA": "Sangkara", "MISA": "Sangkara",
@ -4938,7 +4998,6 @@
"MNRB": "MoneyRebel", "MNRB": "MoneyRebel",
"MNS": "Monnos", "MNS": "Monnos",
"MNST": "MoonStarter", "MNST": "MoonStarter",
"MNT": "microNFT",
"MNTC": "Manet Coin", "MNTC": "Manet Coin",
"MNTG": "Monetas", "MNTG": "Monetas",
"MNTL": "AssetMantle", "MNTL": "AssetMantle",
@ -4967,6 +5026,7 @@
"MOF": "Molecular Future (TRC20)", "MOF": "Molecular Future (TRC20)",
"MOFI": "MobiFi", "MOFI": "MobiFi",
"MOFOLD": "Molecular Future (ERC20)", "MOFOLD": "Molecular Future (ERC20)",
"MOG": "Mog Coin",
"MOGU": "Mogu", "MOGU": "Mogu",
"MOGX": "Mogu", "MOGX": "Mogu",
"MOI": "MyOwnItem", "MOI": "MyOwnItem",
@ -4989,9 +5049,11 @@
"MONEYIMT": "MoneyToken", "MONEYIMT": "MoneyToken",
"MONF": "Monfter", "MONF": "Monfter",
"MONG": "MongCoin", "MONG": "MongCoin",
"MONG20": "Mongoose 2.0",
"MONI": "Monsta Infinite", "MONI": "Monsta Infinite",
"MONK": "Monkey Project", "MONK": "Monkey Project",
"MONKEY": "Monkey", "MONKEY": "Monkey",
"MONKEYS": "Monkeys Token",
"MONO": "MonoX", "MONO": "MonoX",
"MONONOKEINU": "Mononoke Inu", "MONONOKEINU": "Mononoke Inu",
"MONS": "Monsters Clan", "MONS": "Monsters Clan",
@ -5011,11 +5073,13 @@
"MOONSHOT": "Moonshot", "MOONSHOT": "Moonshot",
"MOOO": "Hashtagger", "MOOO": "Hashtagger",
"MOOV": "dotmoovs", "MOOV": "dotmoovs",
"MOOX": "Moox Protocol",
"MOPS": "Mops", "MOPS": "Mops",
"MORA": "Meliora", "MORA": "Meliora",
"MORE": "More Coin", "MORE": "More Coin",
"MOS": "MOS Coin", "MOS": "MOS Coin",
"MOT": "Olympus Labs", "MOT": "Olympus Labs",
"MOTG": "MetaOctagon",
"MOTI": "Motion", "MOTI": "Motion",
"MOTO": "Motocoin", "MOTO": "Motocoin",
"MOV": "MovieCoin", "MOV": "MovieCoin",
@ -5076,6 +5140,7 @@
"MSWAP": "MoneySwap", "MSWAP": "MoneySwap",
"MT": "MyToken", "MT": "MyToken",
"MTA": "Meta", "MTA": "Meta",
"MTB": "MetaBridge",
"MTBC": "Metabolic", "MTBC": "Metabolic",
"MTC": "MEDICAL TOKEN CURRENCY", "MTC": "MEDICAL TOKEN CURRENCY",
"MTCMN": "MTC Mesh", "MTCMN": "MTC Mesh",
@ -5108,6 +5173,7 @@
"MUE": "MonetaryUnit", "MUE": "MonetaryUnit",
"MULTI": "Multichain", "MULTI": "Multichain",
"MULTIBOT": "Multibot", "MULTIBOT": "Multibot",
"MULTIV": "Multiverse",
"MUN": "MUNcoin", "MUN": "MUNcoin",
"MUNCH": "Munch Token", "MUNCH": "Munch Token",
"MUSD": "mStable USD", "MUSD": "mStable USD",
@ -5648,6 +5714,7 @@
"OZP": "OZAPHYRE", "OZP": "OZAPHYRE",
"P202": "Project 202", "P202": "Project 202",
"P2PS": "P2P Solutions Foundation", "P2PS": "P2P Solutions Foundation",
"PAAL": "PAAL AI",
"PAC": "PAC Protocol", "PAC": "PAC Protocol",
"PACOCA": "Pacoca", "PACOCA": "Pacoca",
"PAD": "NearPad", "PAD": "NearPad",
@ -5736,6 +5803,7 @@
"PEARL": "Pearl Finance", "PEARL": "Pearl Finance",
"PEC": "PeaceCoin", "PEC": "PeaceCoin",
"PEEL": "Meta Apes", "PEEL": "Meta Apes",
"PEEPA": "Peepa",
"PEEPS": "The People’s Coin", "PEEPS": "The People’s Coin",
"PEG": "PegNet", "PEG": "PegNet",
"PEGS": "PegShares", "PEGS": "PegShares",
@ -5748,6 +5816,7 @@
"PEOPLE": "ConstitutionDAO", "PEOPLE": "ConstitutionDAO",
"PEOS": "pEOS", "PEOS": "pEOS",
"PEPE": "Pepe", "PEPE": "Pepe",
"PEPE20": "Pepe 2.0",
"PEPECASH": "Pepe Cash", "PEPECASH": "Pepe Cash",
"PEPPER": "Pepper Token", "PEPPER": "Pepper Token",
"PEPS": "PEPS Coin", "PEPS": "PEPS Coin",
@ -5822,6 +5891,7 @@
"PINK": "PinkCoin", "PINK": "PinkCoin",
"PINKX": "PantherCoin", "PINKX": "PantherCoin",
"PINMO": "Pinmo", "PINMO": "Pinmo",
"PINO": "Pinocchu",
"PINU": "Piccolo Inu", "PINU": "Piccolo Inu",
"PIO": "Pioneershares", "PIO": "Pioneershares",
"PIPI": "Pippi Finance", "PIPI": "Pippi Finance",
@ -5885,6 +5955,7 @@
"PLS": "Pulsechain", "PLS": "Pulsechain",
"PLSD": "PulseDogecoin", "PLSD": "PulseDogecoin",
"PLSPAD": "PulsePad", "PLSPAD": "PulsePad",
"PLSX": "PulseX",
"PLT": "Poollotto.finance", "PLT": "Poollotto.finance",
"PLTC": "PlatonCoin", "PLTC": "PlatonCoin",
"PLTX": "PlutusX", "PLTX": "PlutusX",
@ -5911,7 +5982,6 @@
"PNK": "Kleros", "PNK": "Kleros",
"PNL": "True PNL", "PNL": "True PNL",
"PNODE": "Pinknode", "PNODE": "Pinknode",
"PNP": "LogisticsX",
"PNT": "pNetwork Token", "PNT": "pNetwork Token",
"PNX": "PhantomX", "PNX": "PhantomX",
"PNY": "Peony Coin", "PNY": "Peony Coin",
@ -5927,6 +5997,7 @@
"POINTS": "Cryptsy Points", "POINTS": "Cryptsy Points",
"POK": "Pokmonsters", "POK": "Pokmonsters",
"POKEM": "Pokemonio", "POKEM": "Pokemonio",
"POKEMON": "Pokemon",
"POKER": "PokerCoin", "POKER": "PokerCoin",
"POKT": "Pocket Network", "POKT": "Pocket Network",
"POL": "Pool-X", "POL": "Pool-X",
@ -6010,6 +6081,7 @@
"PRIME": "Echelon Prime", "PRIME": "Echelon Prime",
"PRIMECHAIN": "PrimeChain", "PRIMECHAIN": "PrimeChain",
"PRINT": "Printer.Finance", "PRINT": "Printer.Finance",
"PRINTERIUM": "Printerium",
"PRINTS": "FingerprintsDAO", "PRINTS": "FingerprintsDAO",
"PRISM": "Prism", "PRISM": "Prism",
"PRIX": "Privatix", "PRIX": "Privatix",
@ -6033,7 +6105,7 @@
"PROTON": "Proton", "PROTON": "Proton",
"PROUD": "PROUD Money", "PROUD": "PROUD Money",
"PROXI": "PROXI", "PROXI": "PROXI",
"PRP": "Papyrus", "PRP": "Pepe Prime",
"PRPS": "Purpose", "PRPS": "Purpose",
"PRPT": "Purple Token", "PRPT": "Purple Token",
"PRQ": "PARSIQ", "PRQ": "PARSIQ",
@ -6042,7 +6114,7 @@
"PRTG": "Pre-Retogeum", "PRTG": "Pre-Retogeum",
"PRV": "PrivacySwap", "PRV": "PrivacySwap",
"PRVS": "Previse", "PRVS": "Previse",
"PRX": "Printerium", "PRX": "Parex",
"PRXY": "Proxy", "PRXY": "Proxy",
"PRY": "PRIMARY", "PRY": "PRIMARY",
"PSB": "Planet Sandbox", "PSB": "Planet Sandbox",
@ -6120,6 +6192,7 @@
"PYRAM": "Pyram Token", "PYRAM": "Pyram Token",
"PYRK": "Pyrk", "PYRK": "Pyrk",
"PYT": "Payther", "PYT": "Payther",
"PYUSD": "PayPal USD",
"PZM": "Prizm", "PZM": "Prizm",
"Q1S": "Quantum1Net", "Q1S": "Quantum1Net",
"Q2C": "QubitCoin", "Q2C": "QubitCoin",
@ -6178,6 +6251,7 @@
"QUA": "Quantum Tech", "QUA": "Quantum Tech",
"QUACK": "Rich Quack", "QUACK": "Rich Quack",
"QUAM": "Quam Network", "QUAM": "Quam Network",
"QUANT": "Quant Finance",
"QUARASHI": "Quarashi Network", "QUARASHI": "Quarashi Network",
"QUARTZ": "Sandclock", "QUARTZ": "Sandclock",
"QUASA": "Quasacoin", "QUASA": "Quasacoin",
@ -6201,7 +6275,7 @@
"RAC": "RAcoin", "RAC": "RAcoin",
"RACA": "Radio Caca", "RACA": "Radio Caca",
"RACEFI": "RaceFi", "RACEFI": "RaceFi",
"RAD": "Radicle", "RAD": "Radworks",
"RADAR": "DappRadar", "RADAR": "DappRadar",
"RADI": "RadicalCoin", "RADI": "RadicalCoin",
"RADIO": "RadioShack", "RADIO": "RadioShack",
@ -6220,7 +6294,7 @@
"RAM": "Ramifi Protocol", "RAM": "Ramifi Protocol",
"RAMP": "RAMP", "RAMP": "RAMP",
"RANKER": "RankerDao", "RANKER": "RankerDao",
"RAP": "Rapture", "RAP": "Philosoraptor",
"RAPDOGE": "RapDoge", "RAPDOGE": "RapDoge",
"RARE": "SuperRare", "RARE": "SuperRare",
"RARI": "Rarible", "RARI": "Rarible",
@ -6277,6 +6351,7 @@
"REA": "Realisto", "REA": "Realisto",
"REAL": "RealLink", "REAL": "RealLink",
"REALM": "Realm", "REALM": "Realm",
"REALMS": "Realms of Ethernity",
"REALPLATFORM": "REAL", "REALPLATFORM": "REAL",
"REALY": "Realy Metaverse", "REALY": "Realy Metaverse",
"REAP": "ReapChain", "REAP": "ReapChain",
@ -6287,6 +6362,7 @@
"RED": "RED TOKEN", "RED": "RED TOKEN",
"REDC": "RedCab", "REDC": "RedCab",
"REDCO": "Redcoin", "REDCO": "Redcoin",
"REDDIT": "Reddit",
"REDI": "REDi", "REDI": "REDi",
"REDLANG": "RED", "REDLANG": "RED",
"REDLC": "Redlight Chain", "REDLC": "Redlight Chain",
@ -6324,7 +6400,7 @@
"REST": "Restore", "REST": "Restore",
"RET": "RealTract", "RET": "RealTract",
"RETAIL": "Retail.Global", "RETAIL": "Retail.Global",
"RETH": "Realms of Ethernity", "RETH": "Rocket Pool ETH",
"RETH2": "rETH2", "RETH2": "rETH2",
"RETIRE": "Retire Token", "RETIRE": "Retire Token",
"REU": "REUCOIN", "REU": "REUCOIN",
@ -6351,6 +6427,7 @@
"RGP": "Rigel Protocol", "RGP": "Rigel Protocol",
"RGT": "Rari Governance Token", "RGT": "Rari Governance Token",
"RHEA": "Rhea", "RHEA": "Rhea",
"RHINO": "RHINO",
"RHOC": "RChain", "RHOC": "RChain",
"RHP": "Rhypton Club", "RHP": "Rhypton Club",
"RIC": "Riecoin", "RIC": "Riecoin",
@ -6490,6 +6567,7 @@
"RWE": "Real-World Evidence", "RWE": "Real-World Evidence",
"RWN": "Rowan Token", "RWN": "Rowan Token",
"RWS": "Robonomics Web Services", "RWS": "Robonomics Web Services",
"RXD": "Radiant",
"RXT": "RIMAUNANGIS", "RXT": "RIMAUNANGIS",
"RYC": "RoyalCoin", "RYC": "RoyalCoin",
"RYCN": "RoyalCoin 2.0", "RYCN": "RoyalCoin 2.0",
@ -6564,6 +6642,7 @@
"SBTC": "Super Bitcoin", "SBTC": "Super Bitcoin",
"SC": "Siacoin", "SC": "Siacoin",
"SCA": "SiaClassic", "SCA": "SiaClassic",
"SCAM": "Scam Coin",
"SCAP": "SafeCapital", "SCAP": "SafeCapital",
"SCAR": "Velhalla", "SCAR": "Velhalla",
"SCASH": "SpaceCash", "SCASH": "SpaceCash",
@ -6624,6 +6703,7 @@
"SEER": "SEER", "SEER": "SEER",
"SEI": "Sei", "SEI": "Sei",
"SEL": "SelenCoin", "SEL": "SelenCoin",
"SELF": "SELFCrypto",
"SEM": "Semux", "SEM": "Semux",
"SEN": "Sentaro", "SEN": "Sentaro",
"SENATE": "SENATE", "SENATE": "SENATE",
@ -6665,6 +6745,7 @@
"SGE": "Society of Galactic Exploration", "SGE": "Society of Galactic Exploration",
"SGLY": "Singularity", "SGLY": "Singularity",
"SGN": "Signals Network", "SGN": "Signals Network",
"SGO": "SafuuGO",
"SGOLD": "SpaceGold", "SGOLD": "SpaceGold",
"SGP": "SGPay", "SGP": "SGPay",
"SGR": "Sogur Currency", "SGR": "Sogur Currency",
@ -6684,6 +6765,7 @@
"SHEESH": "Sheesh it is bussin bussin", "SHEESH": "Sheesh it is bussin bussin",
"SHEESHA": "Sheesha Finance", "SHEESHA": "Sheesha Finance",
"SHELL": "Shell Token", "SHELL": "Shell Token",
"SHERA": "Shera Tokens",
"SHFL": "SHUFFLE!", "SHFL": "SHUFFLE!",
"SHFT": "Shyft Network", "SHFT": "Shyft Network",
"SHI": "Shirtum", "SHI": "Shirtum",
@ -6719,6 +6801,8 @@
"SHR": "ShareToken", "SHR": "ShareToken",
"SHREK": "ShrekCoin", "SHREK": "ShrekCoin",
"SHROOM": "Shroom.Finance", "SHROOM": "Shroom.Finance",
"SHROOMFOX": "Magic Shroom",
"SHS": "SHEESH",
"SHX": "Stronghold Token", "SHX": "Stronghold Token",
"SI": "Siren", "SI": "Siren",
"SIB": "SibCoin", "SIB": "SibCoin",
@ -7018,9 +7102,11 @@
"STEN": "Steneum Coin", "STEN": "Steneum Coin",
"STEP": "Step Finance", "STEP": "Step Finance",
"STEPH": "Step Hero", "STEPH": "Step Hero",
"STEPR": "Step",
"STEPS": "Steps", "STEPS": "Steps",
"STERLINGCOIN": "SterlingCoin", "STERLINGCOIN": "SterlingCoin",
"STETH": "Staked Ether", "STETH": "Staked Ether",
"STEWIE": "Stewie Coin",
"STEX": "STEX", "STEX": "STEX",
"STF": "Structure Finance", "STF": "Structure Finance",
"STFX": "STFX", "STFX": "STFX",
@ -7055,7 +7141,7 @@
"STR": "Sourceless", "STR": "Sourceless",
"STRAKS": "Straks", "STRAKS": "Straks",
"STRAX": "Stratis", "STRAX": "Stratis",
"STRAY": "Animal Token", "STRAY": "Stray Dog",
"STREAM": "STREAMIT COIN", "STREAM": "STREAMIT COIN",
"STRIP": "Stripto", "STRIP": "Stripto",
"STRK": "Strike", "STRK": "Strike",
@ -7361,6 +7447,7 @@
"TOM": "TOM Finance", "TOM": "TOM Finance",
"TOMAHAWKCOIN": "Tomahawkcoin", "TOMAHAWKCOIN": "Tomahawkcoin",
"TOMB": "Tomb", "TOMB": "Tomb",
"TOMI": "tomiNet",
"TOMO": "TomoChain", "TOMO": "TomoChain",
"TOMOE": "TomoChain ERC20", "TOMOE": "TomoChain ERC20",
"TOMS": "TomTomCoin", "TOMS": "TomTomCoin",
@ -7385,6 +7472,7 @@
"TOTM": "Totem", "TOTM": "Totem",
"TOWER": "Tower", "TOWER": "Tower",
"TOWN": "Town Star", "TOWN": "Town Star",
"TOX": "INTOverse",
"TOZ": "Tozex", "TOZ": "Tozex",
"TP": "Token Swap", "TP": "Token Swap",
"TPAD": "TrustPad", "TPAD": "TrustPad",
@ -7600,6 +7688,7 @@
"UNITY": "SuperNET", "UNITY": "SuperNET",
"UNIVRS": "Universe", "UNIVRS": "Universe",
"UNIX": "UniX", "UNIX": "UniX",
"UNLEASH": "UnleashClub",
"UNN": "UNION Protocol Governance Token", "UNN": "UNION Protocol Governance Token",
"UNO": "Unobtanium", "UNO": "Unobtanium",
"UNORE": "UnoRe", "UNORE": "UnoRe",
@ -7673,6 +7762,7 @@
"UTT": "United Traders Token", "UTT": "United Traders Token",
"UTU": "UTU Protocol", "UTU": "UTU Protocol",
"UUU": "U Network", "UUU": "U Network",
"UWU": "uwu",
"UZUMAKI": "Uzumaki Inu", "UZUMAKI": "Uzumaki Inu",
"VAB": "Vabble", "VAB": "Vabble",
"VADER": "Vader Protocol", "VADER": "Vader Protocol",
@ -7695,6 +7785,7 @@
"VCF": "Valencia CF Fan Token", "VCF": "Valencia CF Fan Token",
"VCG": "VCGamers", "VCG": "VCGamers",
"VCK": "28VCK", "VCK": "28VCK",
"VCORE": "VCORE",
"VDG": "VeriDocGlobal", "VDG": "VeriDocGlobal",
"VDL": "Vidulum", "VDL": "Vidulum",
"VDO": "VidioCoin", "VDO": "VidioCoin",
@ -7710,6 +7801,7 @@
"VEIL": "VEIL", "VEIL": "VEIL",
"VELA": "Vela Token", "VELA": "Vela Token",
"VELO": "Velo", "VELO": "Velo",
"VELOD": "Velodrome Finance",
"VELOX": "Velox", "VELOX": "Velox",
"VELOXPROJECT": "Velox", "VELOXPROJECT": "Velox",
"VEMP": "vEmpire DDAO", "VEMP": "vEmpire DDAO",
@ -7782,6 +7874,7 @@
"VNT": "VNT Chain", "VNT": "VNT Chain",
"VNTW": "Value Network Token", "VNTW": "Value Network Token",
"VNX": "VisionX", "VNX": "VisionX",
"VNXAU": "VNX Gold",
"VNXLU": "VNX Exchange", "VNXLU": "VNX Exchange",
"VOCO": "Provoco", "VOCO": "Provoco",
"VODKA": "Vodka Token", "VODKA": "Vodka Token",
@ -7902,7 +7995,8 @@
"WEC": "Whole Earth Coin", "WEC": "Whole Earth Coin",
"WEGEN": "WeGen Platform", "WEGEN": "WeGen Platform",
"WELD": "Weld", "WELD": "Weld",
"WELL": "Well", "WELL": "Moonwell",
"WELLTOKEN": "Well",
"WELT": "Fabwelt", "WELT": "Fabwelt",
"WELUPS": "Welups Blockchain", "WELUPS": "Welups Blockchain",
"WEMIX": "WEMIX", "WEMIX": "WEMIX",
@ -7958,6 +8052,7 @@
"WIX": "Wixlar", "WIX": "Wixlar",
"WIZ": "WIZ Protocol", "WIZ": "WIZ Protocol",
"WKD": "Wakanda Inu", "WKD": "Wakanda Inu",
"WLD": "Worldcoin",
"WLF": "Wolfs Group", "WLF": "Wolfs Group",
"WLITI": "wLITI", "WLITI": "wLITI",
"WLK": "Wolk", "WLK": "Wolk",
@ -7983,6 +8078,7 @@
"WNZ": "Winerz", "WNZ": "Winerz",
"WOA": "Wrapped Origin Axie", "WOA": "Wrapped Origin Axie",
"WOD": "World of Defish", "WOD": "World of Defish",
"WOID": "WORLD ID",
"WOJ": "Wojak Finance", "WOJ": "Wojak Finance",
"WOLF": "Insanity Coin", "WOLF": "Insanity Coin",
"WOLFILAND": "Wolfiland", "WOLFILAND": "Wolfiland",
@ -8000,6 +8096,7 @@
"WOOFY": "Woofy", "WOOFY": "Woofy",
"WOOL": "Wolf Game Wool", "WOOL": "Wolf Game Wool",
"WOONK": "Woonkly", "WOONK": "Woonkly",
"WOOO": "wooonen",
"WOOP": "Woonkly Power", "WOOP": "Woonkly Power",
"WOP": "WorldPay", "WOP": "WorldPay",
"WORLD": "World Token", "WORLD": "World Token",
@ -8010,6 +8107,7 @@
"WOZX": "Efforce", "WOZX": "Efforce",
"WPC": "WePiggy Coin", "WPC": "WePiggy Coin",
"WPE": "OPES (Wrapped PE)", "WPE": "OPES (Wrapped PE)",
"WPLS": "Wrapped Pulse",
"WPP": "Green Energy Token", "WPP": "Green Energy Token",
"WPR": "WePower", "WPR": "WePower",
"WQT": "Work Quest", "WQT": "Work Quest",
@ -8049,6 +8147,7 @@
"WZEC": "Wrapped Zcash", "WZEC": "Wrapped Zcash",
"WZENIQ": "Wrapped Zeniq (ETH)", "WZENIQ": "Wrapped Zeniq (ETH)",
"WZRD": "Wizardia", "WZRD": "Wizardia",
"X": "AI-X",
"X2": "X2Coin", "X2": "X2Coin",
"X2Y2": "X2Y2", "X2Y2": "X2Y2",
"X42": "X42 Protocol", "X42": "X42 Protocol",
@ -8096,7 +8195,7 @@
"XCI": "Cannabis Industry Coin", "XCI": "Cannabis Industry Coin",
"XCLR": "ClearCoin", "XCLR": "ClearCoin",
"XCM": "CoinMetro", "XCM": "CoinMetro",
"XCN": "Chain", "XCN": "Onyxcoin",
"XCO": "XCoin", "XCO": "XCoin",
"XCONSOL": "X-Consoles", "XCONSOL": "X-Consoles",
"XCP": "CounterParty", "XCP": "CounterParty",
@ -8365,6 +8464,7 @@
"YUANG": "Yuang Coin", "YUANG": "Yuang Coin",
"YUCJ": "Yu Coin", "YUCJ": "Yu Coin",
"YUCT": "Yucreat", "YUCT": "Yucreat",
"YUDI": "Yudi",
"YUM": "Yumerium", "YUM": "Yumerium",
"YUMMY": "Yummy", "YUMMY": "Yummy",
"YUP": "Crowdholding", "YUP": "Crowdholding",

1
apps/api/src/assets/cryptocurrencies/custom.json

@ -1,4 +1,5 @@
{ {
"CYBER24781": "CyberConnect",
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",

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

@ -0,0 +1,655 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/maerkte</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/preise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/registrierung</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/license</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/08/ghostfolio-joins-oss-friends</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>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/markets</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/pricing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/register</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/mercados</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/precios</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/registro</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/enregistrement</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/marches</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/prix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/funzionalita</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/iscrizione</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/mercati</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/prezzi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/kenmerken</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/markten</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/registratie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/mercados</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/precos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/registo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
</urlset>

6
apps/api/src/main.ts

@ -7,6 +7,7 @@ import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
import { HtmlTemplateMiddleware } from './middlewares/html-template.middleware';
async function bootstrap() { async function bootstrap() {
const configApp = await NestFactory.create(AppModule); const configApp = await NestFactory.create(AppModule);
@ -23,7 +24,7 @@ async function bootstrap() {
defaultVersion: '1', defaultVersion: '1',
type: VersioningType.URI type: VersioningType.URI
}); });
app.setGlobalPrefix('api'); app.setGlobalPrefix('api', { exclude: ['sitemap.xml'] });
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,
@ -40,6 +41,7 @@ async function bootstrap() {
helmet({ helmet({
contentSecurityPolicy: { contentSecurityPolicy: {
directives: { directives: {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
@ -51,6 +53,8 @@ async function bootstrap() {
); );
} }
app.use(HtmlTemplateMiddleware);
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY'); const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
const HOST = configService.get<string>('HOST') || '0.0.0.0'; const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333; const PORT = configService.get<number>('PORT') || 3333;

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

@ -0,0 +1,132 @@
import * as fs from 'fs';
import { join } from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import {
DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL,
SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
const descriptions = {
de: 'Mit dem Finanz-Dashboard Ghostfolio können Sie Ihr Vermögen in Form von Aktien, ETFs oder Kryptowährungen verteilt über mehrere Finanzinstitute überwachen.',
en: 'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.',
es: 'Ghostfolio es un dashboard de finanzas personales para hacer un seguimiento de tus activos como acciones, ETFs o criptodivisas a través de múltiples plataformas.',
fr: 'Ghostfolio est un dashboard de finances personnelles qui permet de suivre vos actifs comme les actions, les ETF ou les crypto-monnaies sur plusieurs plateformes.',
it: 'Ghostfolio è un dashboard di finanza personale per tenere traccia delle vostre attività come azioni, ETF o criptovalute su più piattaforme.',
nl: 'Ghostfolio is een persoonlijk financieel dashboard om uw activa zoals aandelen, ETF’s of cryptocurrencies over meerdere platforms bij te houden.',
pt: 'Ghostfolio é um dashboard de finanças pessoais para acompanhar os seus activos como acções, ETFs ou criptomoedas em múltiplas plataformas.'
};
const title = 'Ghostfolio – Open Source Wealth Management Software';
const titleShort = 'Ghostfolio';
let indexHtmlMap: { [languageCode: string]: string } = {};
try {
indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce(
(map, languageCode) => ({
...map,
[languageCode]: fs.readFileSync(
join(__dirname, '..', 'client', languageCode, 'index.html'),
'utf8'
)
}),
{}
);
} catch {}
const locales = {
'/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png',
title: `Ghostfolio auf Sackgeld.com vorgestellt - ${titleShort}`
},
'/en/blog/2022/08/500-stars-on-github': {
featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg',
title: `500 Stars - ${titleShort}`
},
'/en/blog/2022/10/hacktoberfest-2022': {
featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png',
title: `Hacktoberfest 2022 - ${titleShort}`
},
'/en/blog/2022/12/the-importance-of-tracking-your-personal-finances': {
featureGraphicPath: 'assets/images/blog/20221226.jpg',
title: `The importance of tracking your personal finances - ${titleShort}`
},
'/en/blog/2023/02/ghostfolio-meets-umbrel': {
featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png',
title: `Ghostfolio meets Umbrel - ${titleShort}`
},
'/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github': {
featureGraphicPath: 'assets/images/blog/1000-stars-on-github.jpg',
title: `Ghostfolio reaches 1’000 Stars on GitHub - ${titleShort}`
},
'/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': {
featureGraphicPath: 'assets/images/blog/20230520.jpg',
title: `Unlock your Financial Potential with Ghostfolio - ${titleShort}`
},
'/en/blog/2023/07/exploring-the-path-to-fire': {
featureGraphicPath: 'assets/images/blog/20230701.jpg',
title: `Exploring the Path to FIRE - ${titleShort}`
},
'/en/blog/2023/08/ghostfolio-joins-oss-friends': {
featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png',
title: `Ghostfolio joins OSS Friends - ${titleShort}`
}
};
const isFileRequest = (filename: string) => {
if (filename === '/assets/LICENSE') {
return true;
} else if (
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}
return filename.split('.').pop() !== filename;
};
export const HtmlTemplateMiddleware = async (
request: Request,
response: Response,
next: NextFunction
) => {
const path = request.originalUrl.replace(/\/$/, '');
let languageCode = path.substr(1, 2);
if (!SUPPORTED_LANGUAGE_CODES.includes(languageCode)) {
languageCode = DEFAULT_LANGUAGE_CODE;
}
const currentDate = format(new Date(), DATE_FORMAT);
const rootUrl = process.env.ROOT_URL || DEFAULT_ROOT_URL;
if (
path.startsWith('/api/') ||
isFileRequest(path) ||
!environment.production
) {
// Skip
next();
} else {
const indexHtml = interpolate(indexHtmlMap[languageCode], {
currentDate,
languageCode,
path,
rootUrl,
description: descriptions[languageCode],
featureGraphicPath:
locales[path]?.featureGraphicPath ?? 'assets/cover.png',
title: locales[path]?.title ?? title
});
return response.send(indexHtml);
}
};

10
apps/api/src/services/account-balance/account-balance.module.ts

@ -0,0 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@Module({
exports: [AccountBalanceService],
imports: [PrismaModule],
providers: [AccountBalanceService]
})
export class AccountBalanceModule {}

16
apps/api/src/services/account-balance/account-balance.service.ts

@ -0,0 +1,16 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(private readonly prismaService: PrismaService) {}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
}

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

@ -1,4 +1,5 @@
import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface';
import { DEFAULT_CURRENCY, DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
@ -13,7 +14,7 @@ export class ConfigurationService {
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ BASE_CURRENCY: str({
choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'], choices: ['AUD', 'CAD', 'CNY', 'EUR', 'GBP', 'JPY', 'RUB', 'USD'],
default: 'USD' default: DEFAULT_CURRENCY
}), }),
BETTER_UPTIME_API_KEY: str({ default: '' }), BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }), CACHE_QUOTES_TTL: num({ default: 1 }),
@ -46,7 +47,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }), ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),

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

@ -2,6 +2,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS, GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule'; import { Cron, CronExpression } from '@nestjs/schedule';
@ -48,7 +49,7 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS, name: GATHER_ASSET_PROFILE_PROCESS,
opts: { opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS, ...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}` jobId: getAssetProfileIdentifier({ dataSource, symbol })
} }
}; };
}) })

2
apps/api/src/services/data-gathering/data-gathering.module.ts

@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull'; import { BullModule } from '@nestjs/bull';
@ -28,6 +29,7 @@ import { DataGatheringProcessor } from './data-gathering.processor';
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
SymbolProfileModule SymbolProfileModule
], ],
providers: [DataGatheringProcessor, DataGatheringService], providers: [DataGatheringProcessor, DataGatheringService],

51
apps/api/src/services/data-gathering/data-gathering.service.ts

@ -4,14 +4,20 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DATA_GATHERING_QUEUE, DATA_GATHERING_QUEUE,
GATHER_HISTORICAL_MARKET_DATA_PROCESS, GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import {
import { UniqueAsset } from '@ghostfolio/common/interfaces'; DATE_FORMAT,
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { BenchmarkProperty, UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull'; import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -30,6 +36,7 @@ export class DataGatheringService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
@ -120,12 +127,10 @@ export class DataGatheringService {
uniqueAssets = await this.getUniqueAssets(); uniqueAssets = await this.getUniqueAssets();
} }
const assetProfiles = await this.dataProviderService.getAssetProfiles( const assetProfiles =
uniqueAssets await this.dataProviderService.getAssetProfiles(uniqueAssets);
); const symbolProfiles =
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
uniqueAssets
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -221,7 +226,10 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS, name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: { opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS, ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}` jobId: `${getAssetProfileIdentifier({
dataSource,
symbol
})}-${format(date, DATE_FORMAT)}`
} }
}; };
}) })
@ -248,6 +256,10 @@ export class DataGatheringService {
}); });
} }
private getEarliestDate(aStartDate: Date) {
return min([aStartDate, subYears(new Date(), 10)]);
}
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
@ -314,6 +326,14 @@ export class DataGatheringService {
} }
private async getSymbolsMax(): Promise<IDataGatheringItem[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const benchmarkAssetProfileIdMap: { [key: string]: boolean } = {};
(
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as BenchmarkProperty[]) ?? []
).forEach(({ symbolProfileId }) => {
benchmarkAssetProfileIdMap[symbolProfileId] = true;
});
const startDate = const startDate =
( (
await this.prismaService.order.findFirst({ await this.prismaService.order.findFirst({
@ -327,7 +347,7 @@ export class DataGatheringService {
return { return {
dataSource, dataSource,
symbol, symbol,
date: min([startDate, subYears(new Date(), 10)]) date: this.getEarliestDate(startDate)
}; };
}); });
@ -336,6 +356,7 @@ export class DataGatheringService {
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { select: {
dataSource: true, dataSource: true,
id: true,
Order: { Order: {
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { date: true }, select: { date: true },
@ -357,9 +378,15 @@ export class DataGatheringService {
); );
}) })
.map((symbolProfile) => { .map((symbolProfile) => {
let date = symbolProfile.Order?.[0]?.date ?? startDate;
if (benchmarkAssetProfileIdMap[symbolProfile.id]) {
date = this.getEarliestDate(startDate);
}
return { return {
...symbolProfile, ...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate date
}; };
}); });

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

@ -15,8 +15,8 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import bent from 'bent';
import { format, fromUnixTime, getUnixTime } from 'date-fns'; import { format, fromUnixTime, getUnixTime } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class CoinGeckoService implements DataProviderInterface { export class CoinGeckoService implements DataProviderInterface {
@ -45,8 +45,7 @@ export class CoinGeckoService implements DataProviderInterface {
}; };
try { try {
const get = bent(`${this.URL}/coins/${aSymbol}`, 'GET', 'json', 200); const { name } = await got(`${this.URL}/coins/${aSymbol}`).json<any>();
const { name } = await get();
response.name = name; response.name = name;
} catch (error) { } catch (error) {
@ -79,17 +78,13 @@ export class CoinGeckoService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const get = bent( const { prices } = await got(
`${ `${
this.URL this.URL
}/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime( }/coins/${aSymbol}/market_chart/range?vs_currency=${this.baseCurrency.toLowerCase()}&from=${getUnixTime(
from from
)}&to=${getUnixTime(to)}`, )}&to=${getUnixTime(to)}`
'GET', ).json<any>();
'json',
200
);
const { prices } = await get();
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -132,15 +127,11 @@ export class CoinGeckoService implements DataProviderInterface {
} }
try { try {
const get = bent( const response = await got(
`${this.URL}/simple/price?ids=${aSymbols.join( `${this.URL}/simple/price?ids=${aSymbols.join(
',' ','
)}&vs_currencies=${this.baseCurrency.toLowerCase()}`, )}&vs_currencies=${this.baseCurrency.toLowerCase()}`
'GET', ).json<any>();
'json',
200
);
const response = await get();
for (const symbol in response) { for (const symbol in response) {
if (Object.prototype.hasOwnProperty.call(response, symbol)) { if (Object.prototype.hasOwnProperty.call(response, symbol)) {
@ -174,8 +165,9 @@ export class CoinGeckoService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200); const { coins } = await got(
const { coins } = await get(); `${this.URL}/search?query=${query}`
).json<any>();
items = coins.map(({ id: symbol, name }) => { items = coins.map(({ id: symbol, name }) => {
return { return {

8
apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts

@ -4,14 +4,18 @@ import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-p
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DataEnhancerService } from './data-enhancer.service';
@Module({ @Module({
exports: [ exports: [
'DataEnhancers', DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService YahooFinanceDataEnhancerService,
'DataEnhancers'
], ],
imports: [ConfigurationModule, CryptocurrencyModule], imports: [ConfigurationModule, CryptocurrencyModule],
providers: [ providers: [
DataEnhancerService,
TrackinsightDataEnhancerService, TrackinsightDataEnhancerService,
YahooFinanceDataEnhancerService, YahooFinanceDataEnhancerService,
{ {

44
apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts

@ -0,0 +1,44 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { HttpException, Inject, Injectable } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@Injectable()
export class DataEnhancerService {
public constructor(
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[]
) {}
public async enhance(aName: string) {
const dataEnhancer = this.dataEnhancers.find((dataEnhancer) => {
return dataEnhancer.getName() === aName;
});
if (!dataEnhancer) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
try {
const assetProfile = await dataEnhancer.enhance({
response: {
assetClass: 'EQUITY',
assetSubClass: 'ETF'
},
symbol: dataEnhancer.getTestSymbol()
});
if (
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
) {
return true;
}
} catch {}
return false;
}
}

54
apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts

@ -3,13 +3,11 @@ import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import bent from 'bent'; import got from 'got';
const getJSON = bent('json');
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://data.trackinsight.com'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countries = require('countries-list/dist/countries.json'); private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
@ -34,27 +32,43 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
const profile = await getJSON( const profile = await got(
`${TrackinsightDataEnhancerService.baseUrl}/data-api/funds/${symbol}.json` `${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`
).catch(() => { )
return {}; .json<any>()
}); .catch(() => {
return got(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol.split(
'.'
)?.[0]}.json`
)
.json<any>()
.catch(() => {
return {};
});
});
const isin = profile.isin?.split(';')?.[0]; const isin = profile?.isin?.split(';')?.[0];
if (isin) { if (isin) {
response.isin = isin; response.isin = isin;
} }
const holdings = await getJSON( const holdings = await got(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json` `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`
).catch(() => { )
return getJSON( .json<any>()
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${ .catch(() => {
symbol.split('.')?.[0] return got(
}.json` `${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol.split(
); '.'
}); )?.[0]}.json`
)
.json<any>()
.catch(() => {
return {};
});
});
if (holdings?.weight < 0.95) { if (holdings?.weight < 0.95) {
// Skip if data is inaccurate // Skip if data is inaccurate
@ -112,4 +126,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getName() { public getName() {
return 'TRACKINSIGHT'; return 'TRACKINSIGHT';
} }
public getTestSymbol() {
return 'QQQ';
}
} }

11
apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts

@ -99,9 +99,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
yahooSymbol = quotes[0].symbol; yahooSymbol = quotes[0].symbol;
} }
const { countries, sectors, url } = await this.getAssetProfile( const { countries, sectors, url } =
yahooSymbol await this.getAssetProfile(yahooSymbol);
);
if (countries) { if (countries) {
response.countries = countries; response.countries = countries;
@ -135,6 +134,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
let name = longName; let name = longName;
if (name) { if (name) {
name = name.replace('&amp;', '&');
name = name.replace('Amundi Index Solutions - ', ''); name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', ''); name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', ''); name = name.replace('iShares III Public Limited Company - ', '');
@ -232,6 +233,10 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public getTestSymbol() {
return 'AAPL';
}
public parseAssetClass({ public parseAssetClass({
quoteType, quoteType,
shortName shortName

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

@ -3,7 +3,6 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { import {
IDataGatheringItem,
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
@ -12,6 +11,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
@ -45,12 +45,15 @@ export class DataProviderService {
const dataProvider = this.getDataProvider(dataSource); const dataProvider = this.getDataProvider(dataSource);
const symbol = dataProvider.getTestSymbol(); const symbol = dataProvider.getTestSymbol();
const quotes = await this.getQuotes([ const quotes = await this.getQuotes({
{ items: [
dataSource, {
symbol dataSource,
} symbol
]); }
],
useCache: false
});
if (quotes[symbol]?.marketPrice > 0) { if (quotes[symbol]?.marketPrice > 0) {
return true; return true;
@ -59,14 +62,16 @@ export class DataProviderService {
return false; return false;
} }
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{ public async getAssetProfiles(items: UniqueAsset[]): Promise<{
[symbol: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
}> { }> {
const response: { const response: {
[symbol: string]: Partial<SymbolProfile>; [symbol: string]: Partial<SymbolProfile>;
} = {}; } = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource); const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => {
return dataSource;
});
const promises = []; const promises = [];
@ -127,7 +132,7 @@ export class DataProviderService {
} }
public async getHistorical( public async getHistorical(
aItems: IDataGatheringItem[], aItems: UniqueAsset[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
@ -155,11 +160,11 @@ export class DataProviderService {
)}'` )}'`
: ''; : '';
const dataSources = aItems.map((item) => { const dataSources = aItems.map(({ dataSource }) => {
return item.dataSource; return dataSource;
}); });
const symbols = aItems.map((item) => { const symbols = aItems.map(({ symbol }) => {
return item.symbol; return symbol;
}); });
try { try {
@ -192,7 +197,7 @@ export class DataProviderService {
} }
public async getHistoricalRaw( public async getHistoricalRaw(
aDataGatheringItems: IDataGatheringItem[], aDataGatheringItems: UniqueAsset[],
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
@ -229,7 +234,13 @@ export class DataProviderService {
return result; return result;
} }
public async getQuotes(items: IDataGatheringItem[]): Promise<{ public async getQuotes({
items,
useCache = true
}: {
items: UniqueAsset[];
useCache?: boolean;
}): Promise<{
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
}> { }> {
const response: { const response: {
@ -238,23 +249,24 @@ export class DataProviderService {
const startTimeTotal = performance.now(); const startTimeTotal = performance.now();
// Get items from cache // Get items from cache
const itemsToFetch: IDataGatheringItem[] = []; const itemsToFetch: UniqueAsset[] = [];
for (const { dataSource, symbol } of items) { for (const { dataSource, symbol } of items) {
const quoteString = await this.redisCacheService.get( if (useCache) {
this.redisCacheService.getQuoteKey({ dataSource, symbol }) const quoteString = await this.redisCacheService.get(
); this.redisCacheService.getQuoteKey({ dataSource, symbol })
);
if (quoteString) { if (quoteString) {
try { try {
const cachedDataProviderResponse = JSON.parse(quoteString); const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse; response[symbol] = cachedDataProviderResponse;
} catch {} continue;
} catch {}
}
} }
if (!quoteString) { itemsToFetch.push({ dataSource, symbol });
itemsToFetch.push({ dataSource, symbol });
}
} }
const numberOfItemsInCache = Object.keys(response)?.length; const numberOfItemsInCache = Object.keys(response)?.length;

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

@ -5,6 +5,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
@ -14,9 +15,10 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { format, isToday } from 'date-fns'; import { format, isToday } from 'date-fns';
import got from 'got';
import ms from 'ms';
@Injectable() @Injectable()
export class EodHistoricalDataService implements DataProviderInterface { export class EodHistoricalDataService implements DataProviderInterface {
@ -76,19 +78,19 @@ export class EodHistoricalDataService implements DataProviderInterface {
const symbol = this.convertToEodSymbol(aSymbol); const symbol = this.convertToEodSymbol(aSymbol);
try { try {
const get = bent( const response = await got(
`${this.URL}/eod/${symbol}?api_token=${ `${this.URL}/eod/${symbol}?api_token=${
this.apiKey this.apiKey
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format( }&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
to, to,
DATE_FORMAT DATE_FORMAT
)}&period={aGranularity}`, )}&period={aGranularity}`,
'GET', {
'json', timeout: {
200 request: DEFAULT_REQUEST_TIMEOUT
); }
}
const response = await get(); ).json<any>();
return response.reduce( return response.reduce(
(result, historicalItem, index, array) => { (result, historicalItem, index, array) => {
@ -136,16 +138,16 @@ export class EodHistoricalDataService implements DataProviderInterface {
} }
try { try {
const get = bent( const realTimeResponse = await got(
`${this.URL}/real-time/${symbols[0]}?api_token=${ `${this.URL}/real-time/${symbols[0]}?api_token=${
this.apiKey this.apiKey
}&fmt=json&s=${symbols.join(',')}`, }&fmt=json&s=${symbols.join(',')}`,
'GET', {
'json', timeout: {
200 request: DEFAULT_REQUEST_TIMEOUT
); }
}
const realTimeResponse = await get(); ).json<any>();
const quotes = const quotes =
symbols.length === 1 ? [realTimeResponse] : realTimeResponse; symbols.length === 1 ? [realTimeResponse] : realTimeResponse;
@ -329,13 +331,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
let searchResult = []; let searchResult = [];
try { try {
const get = bent( const response = await got(
`${this.URL}/search/${aQuery}?api_token=${this.apiKey}`, `${this.URL}/search/${aQuery}?api_token=${this.apiKey}`,
'GET', {
'json', timeout: {
200 request: DEFAULT_REQUEST_TIMEOUT
); }
const response = await get(); }
).json<any>();
searchResult = response.map( searchResult = response.map(
({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => {

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

@ -10,8 +10,8 @@ import { DataProviderInfo } from '@ghostfolio/common/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format, isAfter, isBefore, isSameDay } from 'date-fns'; import { format, isAfter, isBefore, isSameDay } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService implements DataProviderInterface {
@ -64,13 +64,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const get = bent( const { historical } = await got(
`${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`, `${this.URL}/historical-price-full/${aSymbol}?apikey=${this.apiKey}`
'GET', ).json<any>();
'json',
200
);
const { historical } = await get();
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
@ -115,13 +111,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
try { try {
const get = bent( const response = await got(
`${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`, `${this.URL}/quote/${aSymbols.join(',')}?apikey=${this.apiKey}`
'GET', ).json<any>();
'json',
200
);
const response = await get();
for (const { price, symbol } of response) { for (const { price, symbol } of response) {
results[symbol] = { results[symbol] = {
@ -153,13 +145,9 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const get = bent( const result = await got(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`, `${this.URL}/search?query=${query}&apikey=${this.apiKey}`
'GET', ).json<any>();
'json',
200
);
const result = await get();
items = result.map(({ currency, name, symbol }) => { items = result.map(({ currency, name, symbol }) => {
return { return {

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

@ -10,4 +10,6 @@ export interface DataEnhancerInterface {
}): Promise<Partial<SymbolProfile>>; }): Promise<Partial<SymbolProfile>>;
getName(): string; getName(): string;
getTestSymbol(): string;
} }

7
apps/api/src/services/data-provider/manual/manual.service.ts

@ -14,10 +14,10 @@ import {
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { addDays, format, isBefore } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
@ -95,10 +95,9 @@ export class ManualService implements DataProviderInterface {
return {}; return {};
} }
const get = bent(url, 'GET', 'string', 200, headers); const { body } = await got(url, { headers });
const html = await get(); const $ = cheerio.load(body);
const $ = cheerio.load(html);
const value = extractNumberFromString($(selector).text()); const value = extractNumberFromString($(selector).text());

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

@ -10,8 +10,8 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import bent from 'bent';
import { format } from 'date-fns'; import { format } from 'date-fns';
import got from 'got';
@Injectable() @Injectable()
export class RapidApiService implements DataProviderInterface { export class RapidApiService implements DataProviderInterface {
@ -135,19 +135,17 @@ export class RapidApiService implements DataProviderInterface {
oneYearAgo: { value: number; valueText: string }; oneYearAgo: { value: number; valueText: string };
}> { }> {
try { try {
const get = bent( const { fgi } = await got(
`https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`,
'GET',
'json',
200,
{ {
useQueryString: true, headers: {
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', useQueryString: 'true',
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY') 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
}
} }
); ).json<any>();
const { fgi } = await get();
return fgi; return fgi;
} catch (error) { } catch (error) {
Logger.error(error, 'RapidApiService'); Logger.error(error, 'RapidApiService');

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

@ -33,6 +33,15 @@ export class ExchangeRateDataService {
return this.currencyPairs; return this.currencyPairs;
} }
public hasCurrencyPair(currency1: string, currency2: string) {
return this.currencyPairs.some(({ symbol }) => {
return (
symbol === `${currency1}${currency2}` ||
symbol === `${currency2}${currency1}`
);
});
}
public async initialize() { public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY'); this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies(); this.currencies = await this.prepareCurrencies();
@ -64,11 +73,11 @@ export class ExchangeRateDataService {
if (Object.keys(result).length !== this.currencyPairs.length) { if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback // Load currencies directly from data provider as a fallback
// if historical data is not fully available // if historical data is not fully available
const quotes = await this.dataProviderService.getQuotes( const quotes = await this.dataProviderService.getQuotes({
this.currencyPairs.map(({ dataSource, symbol }) => { items: this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })
); });
for (const symbol of Object.keys(quotes)) { for (const symbol of Object.keys(quotes)) {
if (isNumber(quotes[symbol].marketPrice)) { if (isNumber(quotes[symbol].marketPrice)) {
@ -125,9 +134,11 @@ export class ExchangeRateDataService {
return 0; return 0;
} }
let factor = 1; let factor: number;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else { } else {
@ -171,7 +182,9 @@ export class ExchangeRateDataService {
let factor: number; let factor: number;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
const dataSource = const dataSource =
this.dataProviderService.getDataSourceForExchangeRates(); this.dataProviderService.getDataSourceForExchangeRates();
const symbol = `${aFromCurrency}${aToCurrency}`; const symbol = `${aFromCurrency}${aToCurrency}`;

5
apps/api/src/services/twitter-bot/twitter-bot.service.ts

@ -65,9 +65,8 @@ export class TwitterBotService {
status += benchmarkListing; status += benchmarkListing;
} }
const { data: createdTweet } = await this.twitterClient.v2.tweet( const { data: createdTweet } =
status await this.twitterClient.v2.tweet(status);
);
Logger.log( Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`, `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,

2
apps/api/tsconfig.app.json

@ -4,7 +4,7 @@
"outDir": "../../dist/out-tsc", "outDir": "../../dist/out-tsc",
"types": ["node"], "types": ["node"],
"emitDecoratorMetadata": true, "emitDecoratorMetadata": true,
"target": "es2015" "target": "es2021"
}, },
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"] "include": ["**/*.ts"]

96
apps/client/project.json

@ -11,60 +11,15 @@
"prefix": "gf", "prefix": "gf",
"targets": { "targets": {
"build": { "build": {
"executor": "@angular-devkit/build-angular:browser", "executor": "@nx/angular:webpack-browser",
"options": { "options": {
"localize": true,
"outputPath": "dist/apps/client", "outputPath": "dist/apps/client",
"index": "apps/client/src/index.html", "index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts", "main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"assets": [ "assets": [],
{
"glob": "assetlinks.json",
"input": "apps/client/src/assets",
"output": "./../.well-known"
},
{
"glob": "CHANGELOG.md",
"input": "",
"output": "./../assets"
},
{
"glob": "LICENSE",
"input": "",
"output": "./../assets"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "site.webmanifest",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "**/*",
"input": "node_modules/ionicons/dist/ionicons",
"output": "./../ionicons"
},
{
"glob": "**/*.js",
"input": "node_modules/ionicons/dist/",
"output": "./../"
},
{
"glob": "**/*",
"input": "apps/client/src/assets",
"output": "./../assets/"
}
],
"styles": [ "styles": [
"apps/client/src/styles/theme.scss", "apps/client/src/styles/theme.scss",
"apps/client/src/styles.scss" "apps/client/src/styles.scss"
@ -139,8 +94,51 @@
"outputs": ["{options.outputPath}"], "outputs": ["{options.outputPath}"],
"defaultConfiguration": "" "defaultConfiguration": ""
}, },
"copy-assets": {
"executor": "nx:run-commands",
"options": {
"commands": [
{
"command": "mkdir -p dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets dist/apps/client"
},
{
"command": "cp -r apps/client/src/assets/.well-known dist/apps/client"
},
{
"command": "cp apps/client/src/assets/favicon.ico dist/apps/client"
},
{
"command": "cp apps/client/src/assets/index.html dist/apps/client"
},
{
"command": "cp apps/client/src/assets/robots.txt dist/apps/client"
},
{
"command": "cp apps/client/src/assets/site.webmanifest dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/index.js dist/apps/client"
},
{
"command": "cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
},
{
"command": "cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
},
{
"command": "cp CHANGELOG.md dist/apps/client/assets"
},
{
"command": "cp LICENSE dist/apps/client/assets"
}
]
}
},
"serve": { "serve": {
"executor": "@angular-devkit/build-angular:dev-server", "executor": "@nx/angular:webpack-dev-server",
"options": { "options": {
"browserTarget": "client:build", "browserTarget": "client:build",
"proxyConfig": "apps/client/proxy.conf.json" "proxyConfig": "apps/client/proxy.conf.json"

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

@ -4,25 +4,29 @@ import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strate
import { ModulePreloadService } from './core/module-preload.service'; import { ModulePreloadService } from './core/module-preload.service';
export const paths = {
about: $localize`about`,
faq: $localize`faq`,
features: $localize`features`,
license: $localize`license`,
markets: $localize`markets`,
pricing: $localize`pricing`,
privacyPolicy: $localize`privacy-policy`,
register: $localize`register`,
resources: $localize`resources`
};
const routes: Routes = [ const routes: Routes = [
...[ {
'about', path: paths.about,
/////
'a-propos',
'informazioni-su',
'over',
'sobre',
'ueber-uns'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule) import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
})), },
{ {
path: 'account', path: 'account',
loadChildren: () => loadChildren: () =>
import('./pages/account/account-page.module').then( import('./pages/user-account/user-account-page.module').then(
(m) => m.AccountPageModule (m) => m.UserAccountPageModule
) )
}, },
{ {
@ -42,64 +46,40 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule) import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
}, },
...['blog'].map((path) => ({ {
path, path: 'blog',
loadChildren: () => loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule) import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
})), },
{ {
path: 'demo', path: 'demo',
loadChildren: () => loadChildren: () =>
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule) import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
}, },
...[ {
'faq', path: paths.faq,
/////
'domande-piu-frequenti',
'foire-aux-questions',
'haeufig-gestellte-fragen',
'perguntas-mais-frequentes',
'preguntas-mas-frecuentes',
'vaak-gestelde-vragen'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule) import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
})), },
...[ {
'features', path: paths.features,
/////
'fonctionnalites',
'funcionalidades',
'funzionalita',
'kenmerken'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/features/features-page.module').then( import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule (m) => m.FeaturesPageModule
) )
})), },
{ {
path: 'home', path: 'home',
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
...[ {
'markets', path: paths.markets,
/////
'maerkte',
'marches',
'markten',
'mercados',
'mercati'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/markets/markets-page.module').then( import('./pages/markets/markets-page.module').then(
(m) => m.MarketsPageModule (m) => m.MarketsPageModule
) )
})), },
{ {
path: 'open', path: 'open',
loadChildren: () => loadChildren: () =>
@ -119,53 +99,27 @@ const routes: Routes = [
(m) => m.PortfolioPageModule (m) => m.PortfolioPageModule
) )
}, },
...[ {
'pricing', path: paths.pricing,
/////
'precios',
'precos',
'preise',
'prezzi',
'prijzen',
'prix'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/pricing/pricing-page.module').then( import('./pages/pricing/pricing-page.module').then(
(m) => m.PricingPageModule (m) => m.PricingPageModule
) )
})), },
...[ {
'register', path: paths.register,
/////
'enregistrement',
'iscrizione',
'registo',
'registratie',
'registrierung',
'registro'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/register/register-page.module').then( import('./pages/register/register-page.module').then(
(m) => m.RegisterPageModule (m) => m.RegisterPageModule
) )
})), },
...[ {
'resources', path: paths.resources,
/////
'bronnen',
'recursos',
'ressourcen',
'ressources',
'risorse'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./pages/resources/resources-page.module').then( import('./pages/resources/resources-page.module').then(
(m) => m.ResourcesPageModule (m) => m.ResourcesPageModule
) )
})), },
{ {
path: 'start', path: 'start',
loadChildren: () => loadChildren: () =>

38
apps/client/src/app/app.component.html

@ -19,7 +19,7 @@
<a <a
*ngIf="canCreateAccount" *ngIf="canCreateAccount"
class="text-center" class="text-center"
[routerLink]="['/register']" [routerLink]="routerLinkRegister"
> >
<div <div
class="cursor-pointer d-inline-block info-message px-3 py-2" class="cursor-pointer d-inline-block info-message px-3 py-2"
@ -43,21 +43,7 @@
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
<footer <footer *ngIf="showFooter" class="d-flex justify-content-center py-4 w-100">
*ngIf="
(currentRoute === 'blog' ||
currentRoute === 'faq' ||
currentRoute === 'features' ||
currentRoute === 'markets' ||
currentRoute === 'open' ||
currentRoute === 'pricing' ||
currentRoute === 'resources' ||
currentRoute === 'register' ||
currentRoute === 'start') &&
deviceType !== 'mobile'
"
class="d-flex justify-content-center py-4 w-100"
>
<div class="container"> <div class="container">
<div class="mb-3 row"> <div class="mb-3 row">
<div class="col-sm"> <div class="col-sm">
@ -67,36 +53,38 @@
<div class="h6 mt-2" i18n>Personal Finance</div> <div class="h6 mt-2" i18n>Personal Finance</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li *ngIf="hasPermissionToAccessFearAndGreedIndex"> <li *ngIf="hasPermissionToAccessFearAndGreedIndex">
<a i18n [routerLink]="['/markets']">Markets</a> <a i18n [routerLink]="routerLinkMarkets">Markets</a>
</li> </li>
<li><a i18n [routerLink]="['/resources']">Resources</a></li> <li><a i18n [routerLink]="routerLinkResources">Resources</a></li>
</ul> </ul>
</div> </div>
<div class="col-sm"> <div class="col-sm">
<div class="h6 mt-2">Ghostfolio</div> <div class="h6 mt-2">Ghostfolio</div>
<ul class="list-unstyled"> <ul class="list-unstyled">
<li><a i18n [routerLink]="['/about']">About</a></li> <li><a i18n [routerLink]="routerLinkAbout">About</a></li>
<li *ngIf="hasPermissionForBlog"> <li *ngIf="hasPermissionForBlog">
<a i18n [routerLink]="['/blog']">Blog</a> <a i18n [routerLink]="['/blog']">Blog</a>
</li> </li>
<li> <li>
<a i18n [routerLink]="['/about', 'changelog']">Changelog</a> <a i18n [routerLink]="routerLinkAboutChangelog">Changelog</a>
</li> </li>
<li><a i18n [routerLink]="['/features']">Features</a></li> <li><a i18n [routerLink]="routerLinkFeatures">Features</a></li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/faq']">Frequently Asked Questions (FAQ)</a> <a i18n [routerLink]="routerLinkFaq"
>Frequently Asked Questions (FAQ)</a
>
</li> </li>
<li> <li>
<a i18n [routerLink]="['/about', 'license']">License</a> <a i18n [routerLink]="routerLinkAboutLicense">License</a>
</li> </li>
<li *ngIf="hasPermissionForStatistics"> <li *ngIf="hasPermissionForStatistics">
<a [routerLink]="['/open']">Open Startup</a> <a [routerLink]="['/open']">Open Startup</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/pricing']">Pricing</a> <a i18n [routerLink]="routerLinkPricing">Pricing</a>
</li> </li>
<li *ngIf="hasPermissionForSubscription"> <li *ngIf="hasPermissionForSubscription">
<a i18n [routerLink]="['/about', 'privacy-policy']" <a i18n [routerLink]="routerLinkAboutPrivacyPolicy"
>Privacy Policy</a >Privacy Policy</a
> >
</li> </li>

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

@ -38,6 +38,20 @@ export class AppComponent implements OnDestroy, OnInit {
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public info: InfoItem; public info: InfoItem;
public pageTitle: string; public pageTitle: string;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkAboutChangelog = ['/' + $localize`about`, 'changelog'];
public routerLinkAboutLicense = ['/' + $localize`about`, $localize`license`];
public routerLinkAboutPrivacyPolicy = [
'/' + $localize`about`,
$localize`privacy-policy`
];
public routerLinkFaq = ['/' + $localize`faq`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
public showFooter = false;
public user: User; public user: User;
public version = environment.version; public version = environment.version;
@ -89,6 +103,19 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegments = urlSegmentGroup.segments; const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path; this.currentRoute = urlSegments[0].path;
this.showFooter =
(this.currentRoute === 'blog' ||
this.currentRoute === this.routerLinkFaq[0].slice(1) ||
this.currentRoute === this.routerLinkFeatures[0].slice(1) ||
this.currentRoute === this.routerLinkMarkets[0].slice(1) ||
this.currentRoute === 'open' ||
this.currentRoute === 'p' ||
this.currentRoute === this.routerLinkPricing[0].slice(1) ||
this.currentRoute === this.routerLinkRegister[0].slice(1) ||
this.currentRoute === this.routerLinkResources[0].slice(1) ||
this.currentRoute === 'start') &&
this.deviceType !== 'mobile';
if (this.deviceType === 'mobile') { if (this.deviceType === 'mobile') {
setTimeout(() => { setTimeout(() => {
const index = this.title.getTitle().indexOf('–'); const index = this.title.getTitle().indexOf('–');

2
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html

@ -29,7 +29,7 @@
}" }"
[title]=" [title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1) (itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date : defaultDateFormat) ?? '' | date: defaultDateFormat) ?? ''
" "
(click)=" (click)="
onOpenMarketDataDetail({ onOpenMarketDataDetail({

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

@ -154,7 +154,7 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string; day: string;
yearMonth: string; yearMonth: string;
}) { }) {
const date = new Date(`${yearMonth}-${day}`); const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) { if (isSameDay(date, new Date())) {

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

@ -8,11 +8,13 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
import { MatSort, Sort } from '@angular/material/sort'; import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
@ -26,8 +28,6 @@ import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces'; import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component'; import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces'; import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
@Component({ @Component({
changeDetection: ChangeDetectionStrategy.OnPush, changeDetection: ChangeDetectionStrategy.OnPush,
@ -51,13 +51,31 @@ export class AdminMarketDataComponent
AssetSubClass.PRECIOUS_METAL, AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY, AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK AssetSubClass.STOCK
].map((assetSubClass) => { ]
return { .map((assetSubClass) => {
id: assetSubClass, return {
label: translate(assetSubClass), id: assetSubClass.toString(),
type: 'ASSET_SUB_CLASS' label: translate(assetSubClass),
}; type: <Filter['type']>'ASSET_SUB_CLASS'
}); };
})
.concat([
{
id: 'CURRENCIES',
label: $localize`Currencies`,
type: <Filter['type']>'PRESET_ID'
},
{
id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`,
type: <Filter['type']>'PRESET_ID'
},
{
id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`,
type: <Filter['type']>'PRESET_ID'
}
]);
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> = public dataSource: MatTableDataSource<AdminMarketDataItem> =
@ -237,6 +255,12 @@ export class AdminMarketDataComponent
) { ) {
this.isLoading = true; this.isLoading = true;
this.pageSize =
this.activeFilters.length === 1 &&
this.activeFilters[0].type === 'PRESET_ID'
? undefined
: DEFAULT_PAGE_SIZE;
if (pageIndex === 0 && this.paginator) { if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0; this.paginator.pageIndex = 0;
} }

2
apps/client/src/app/components/admin-overview/admin-overview.html

@ -169,6 +169,8 @@
<mat-option value="7 days">7 Days</mat-option> <mat-option value="7 days">7 Days</mat-option>
<mat-option value="14 days">14 Days</mat-option> <mat-option value="14 days">14 Days</mat-option>
<mat-option value="30 days">30 Days</mat-option> <mat-option value="30 days">30 Days</mat-option>
<mat-option value="90 days">90 Days</mat-option>
<mat-option value="180 days">180 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option> <mat-option value="1 year">1 Year</mat-option>
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>

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

@ -1,14 +1,14 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h3 class="text-center" i18n>Platforms</h3> <h2 class="text-center" i18n>Platforms</h2>
<gf-admin-platform></gf-admin-platform> <gf-admin-platform></gf-admin-platform>
</div> </div>
</div> </div>
<!-- <!--
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="text-center" i18n>Tags</h3> <h2 class="text-center" i18n>Tags</h2>
</div> </div>
</div> </div>
--> -->

125
apps/client/src/app/components/header/header.component.html

@ -26,7 +26,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -39,7 +39,7 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -52,7 +52,7 @@
</li> </li>
<li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item"> <li *ngIf="hasPermissionToAccessAdminControl" class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
@ -65,14 +65,14 @@
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'resources', 'font-weight-bold': currentRoute === routeResources,
'text-decoration-underline': currentRoute === 'resources' 'text-decoration-underline': currentRoute === routeResources
}" }"
[routerLink]="['/resources']" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
</li> </li>
@ -83,27 +83,27 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'pricing', 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === 'pricing' 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'about', 'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === 'about' 'text-decoration-underline': currentRoute === routeAbout
}" }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About</a >About</a
> >
</li> </li>
@ -129,33 +129,37 @@
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<ng-container *ngIf="user?.access?.length > 0"> <ng-container *ngIf="user?.access?.length > 0">
<button mat-menu-item (click)="impersonateAccount(null)"> <button mat-menu-item (click)="impersonateAccount(null)">
<ion-icon <span class="align-items-center d-flex">
*ngIf="user?.access?.length > 0" <ion-icon
class="mr-2" *ngIf="user?.access?.length > 0"
[name]=" class="mr-2"
impersonationId [name]="
? 'radio-button-off-outline' impersonationId
: 'radio-button-on-outline' ? 'radio-button-off-outline'
" : 'radio-button-on-outline'
></ion-icon> "
<span i18n>Me</span> ></ion-icon>
<span i18n>Me</span>
</span>
</button> </button>
<button <button
*ngFor="let accessItem of user?.access" *ngFor="let accessItem of user?.access"
mat-menu-item mat-menu-item
(click)="impersonateAccount(accessItem.id)" (click)="impersonateAccount(accessItem.id)"
> >
<ion-icon <span class="align-items-center d-flex">
class="mr-2" <ion-icon
name="square-outline" class="mr-2"
[name]=" name="square-outline"
accessItem.id === impersonationId [name]="
? 'radio-button-on-outline' accessItem.id === impersonationId
: 'radio-button-off-outline' ? 'radio-button-on-outline'
" : 'radio-button-off-outline'
></ion-icon> "
<span *ngIf="accessItem.alias">{{ accessItem.alias }}</span> ></ion-icon>
<span *ngIf="!accessItem.alias" i18n>User</span> <span *ngIf="accessItem.alias">{{ accessItem.alias }}</span>
<span *ngIf="!accessItem.alias" i18n>User</span>
</span>
</button> </button>
<hr class="m-0" /> <hr class="m-0" />
</ng-container> </ng-container>
@ -210,9 +214,9 @@
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'resources' 'font-weight-bold': currentRoute === routeResources
}" }"
[routerLink]="['/resources']" [routerLink]="routerLinkResources"
>Resources</a >Resources</a
> >
<a <a
@ -223,16 +227,16 @@
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'pricing' }" [ngClass]="{ 'font-weight-bold': currentRoute === routePricing }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
<a <a
class="d-flex d-sm-none" class="d-flex d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ 'font-weight-bold': currentRoute === 'about' }" [ngClass]="{ 'font-weight-bold': currentRoute === routeAbout }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About Ghostfolio</a >About Ghostfolio</a
> >
<hr class="d-flex d-sm-none m-0" /> <hr class="d-flex d-sm-none m-0" />
@ -256,39 +260,40 @@
<ul class="alig-items-center d-flex list-inline m-0"> <ul class="alig-items-center d-flex list-inline m-0">
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'features', 'font-weight-bold': currentRoute === routeFeatures,
'text-decoration-underline': currentRoute === 'features' 'text-decoration-underline': currentRoute === routeFeatuers
}" }"
[routerLink]="['/features']" [routerLink]="routerLinkFeatures"
>Features</a >Features</a
> >
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'about', 'font-weight-bold': currentRoute === routeAbout,
'text-decoration-underline': currentRoute === 'about' 'text-decoration-underline': currentRoute === routeAbout
}" }"
[routerLink]="['/about']" [routerLink]="routerLinkAbout"
>About</a >About</a
> >
</li> </li>
<li *ngIf="hasPermissionForSubscription" class="list-inline-item"> <li *ngIf="hasPermissionForSubscription" class="list-inline-item">
<a <a
class="d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'pricing', 'font-weight-bold': currentRoute === routePricing,
'text-decoration-underline': currentRoute === 'pricing' 'text-decoration-underline': currentRoute === routePricing
}" }"
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
>Pricing</a >Pricing</a
> >
</li> </li>
@ -297,14 +302,14 @@
class="list-inline-item" class="list-inline-item"
> >
<a <a
class="d-none d-sm-block mx-1" class="d-none d-sm-block"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'markets', 'font-weight-bold': currentRoute === routeMarkets,
'text-decoration-underline': currentRoute === 'markets' 'text-decoration-underline': currentRoute === routeMarkets
}" }"
[routerLink]="['/markets']" [routerLink]="routerLinkMarkets"
>Markets</a >Markets</a
> >
</li> </li>
@ -317,19 +322,19 @@
></a> ></a>
</li> </li>
<li class="list-inline-item"> <li class="list-inline-item">
<button class="mx-1" mat-flat-button (click)="openLoginDialog()"> <button class="d-sm-block" mat-flat-button (click)="openLoginDialog()">
<ng-container i18n>Sign in</ng-container> <ng-container i18n>Sign in</ng-container>
</button> </button>
</li> </li>
<li <li
*ngIf="currentRoute !== 'register' && hasPermissionToCreateUser" *ngIf="currentRoute !== 'register' && hasPermissionToCreateUser"
class="list-inline-item" class="list-inline-item ml-1"
> >
<a <a
class="d-none d-sm-block" class="d-none d-sm-block"
color="primary" color="primary"
mat-flat-button mat-flat-button
[routerLink]="['/register']" [routerLink]="routerLinkRegister"
><ng-container i18n>Get started</ng-container> ><ng-container i18n>Get started</ng-container>
</a> </a>
</li> </li>

8
apps/client/src/app/components/header/header.component.scss

@ -7,8 +7,8 @@
.mat-toolbar { .mat-toolbar {
background-color: var(--light-background); background-color: var(--light-background);
.spacer { .list-inline-item {
flex: 1 1 auto; margin: 0;
} }
.mdc-button { .mdc-button {
@ -24,6 +24,10 @@
font-size: 1.5rem; font-size: 1.5rem;
} }
} }
.spacer {
flex: 1 1 auto;
}
} }
} }

11
apps/client/src/app/components/header/header.component.ts

@ -42,6 +42,17 @@ export class HeaderComponent implements OnChanges {
public hasPermissionToCreateUser: boolean; public hasPermissionToCreateUser: boolean;
public impersonationId: string; public impersonationId: string;
public isMenuOpen: boolean; public isMenuOpen: boolean;
public routeAbout = $localize`about`;
public routeFeatures = $localize`features`;
public routeMarkets = $localize`markets`;
public routePricing = $localize`pricing`;
public routeResources = $localize`resources`;
public routerLinkAbout = ['/' + $localize`about`];
public routerLinkFeatures = ['/' + $localize`features`];
public routerLinkMarkets = ['/' + $localize`markets`];
public routerLinkPricing = ['/' + $localize`pricing`];
public routerLinkRegister = ['/' + $localize`register`];
public routerLinkResources = ['/' + $localize`resources`];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();

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

@ -1,5 +1,5 @@
<div class="container"> <div class="container">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Markets</h3> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Markets</h1>
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted"> <div class="mb-2 text-center text-muted">

145
apps/client/src/app/components/home-overview/home-overview.html

@ -1,57 +1,110 @@
<div <div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative" class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
> >
<div class="row w-100"> <div
<div class="col p-0"> *ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0; else isUserActive"
<div class="chart-container mx-auto position-relative"> class="justify-content-center row w-100"
<div >
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0" <div class="col introduction">
class="align-items-center d-flex h-100 justify-content-center w-100" <h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold">
<li
class="mb-2"
[ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
> >
<div class="d-flex justify-content-center"> <a class="d-block" [routerLink]="['/accounts']"
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator> ><span i18n>Setup your accounts</span><br />
</div> <span class="font-weight-normal" i18n
</div> >Get a comprehensive financial overview by adding your bank and
<gf-line-chart brokerage accounts.</span
class="position-absolute" ></a
symbol="Performance" >
unit="%" </li>
[colorScheme]="user?.settings?.colorScheme" <li class="mb-2">
[hidden]="historicalDataItems?.length === 0" <a class="d-block" [routerLink]="['/portfolio', 'activities']">
[historicalDataItems]="historicalDataItems" <span i18n>Capture your activities</span><br />
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true" <span class="font-weight-normal" i18n
[locale]="user?.settings?.locale" >Record your investment activities to keep your portfolio up to
[ngClass]="{ 'pr-3': deviceType === 'mobile' }" date.</span
[showGradient]="true" ></a
[showLoader]="false" >
[showXAxis]="false" </li>
[showYAxis]="false" <li class="mb-2">
></gf-line-chart> <a class="d-block" [routerLink]="['/portfolio']">
<span i18n>Monitor and analyze your portfolio</span><br />
<span class="font-weight-normal" i18n
>Track your progress in real-time with comprehensive analysis and
insights.</span
>
</a>
</li>
</ol>
<div class="d-flex justify-content-center">
<a
*ngIf="user?.accounts?.length === 1"
color="primary"
mat-flat-button
[routerLink]="['/accounts']"
>
<ng-container i18n>Setup accounts</ng-container>
</a>
<a
*ngIf="user?.accounts?.length > 1"
color="primary"
mat-flat-button
[routerLink]="['/portfolio', 'activities']"
>
<ng-container i18n>Add activity</ng-container>
</a>
</div> </div>
</div> </div>
</div> </div>
<div class="overview-container row mt-1"> <ng-template #isUserActive>
<div class="col"> <div class="row w-100">
<gf-portfolio-performance <div class="col p-0">
class="pb-4" <div class="chart-container mx-auto position-relative">
[baseCurrency]="user?.settings?.baseCurrency" <gf-line-chart
[deviceType]="deviceType" class="position-absolute"
[errors]="errors" symbol="Performance"
[isAllTimeHigh]="isAllTimeHigh" unit="%"
[isAllTimeLow]="isAllTimeLow" [colorScheme]="user?.settings?.colorScheme"
[isLoading]="isLoadingPerformance" [hidden]="historicalDataItems?.length === 0"
[locale]="user?.settings?.locale" [historicalDataItems]="historicalDataItems"
[performance]="performance" [isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[showDetails]="showDetails" [locale]="user?.settings?.locale"
></gf-portfolio-performance> [ngClass]="{ 'pr-3': deviceType === 'mobile' }"
<div *ngIf="showDetails" class="text-center"> [showGradient]="true"
<gf-toggle [showLoader]="false"
[defaultValue]="user?.settings?.dateRange" [showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance" [isLoading]="isLoadingPerformance"
[options]="dateRangeOptions" [locale]="user?.settings?.locale"
(change)="onChangeDateRange($event.value)" [performance]="performance"
></gf-toggle> [showDetails]="showDetails"
></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
</div> </div>
</div> </div>
</div> </ng-template>
</div> </div>

2
apps/client/src/app/components/home-overview/home-overview.module.ts

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module'; import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
@ -16,6 +17,7 @@ import { HomeOverviewComponent } from './home-overview.component';
GfNoTransactionsInfoModule, GfNoTransactionsInfoModule,
GfPortfolioPerformanceModule, GfPortfolioPerformanceModule,
GfToggleModule, GfToggleModule,
MatButtonModule,
RouterModule RouterModule
], ],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

4
apps/client/src/app/components/home-overview/home-overview.scss

@ -31,4 +31,8 @@
top: 0; top: 0;
} }
} }
.introduction {
max-width: 50rem;
}
} }

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

@ -101,7 +101,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
this.dataService this.dataService
.fetchPortfolioDetails({}) .fetchPortfolioDetails()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ summary }) => { .subscribe(({ summary }) => {
this.summary = summary; this.summary = summary;
@ -121,7 +121,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef.onAction().subscribe(() => {
this.router.navigate(['/pricing']); this.router.navigate(['/' + $localize`pricing`]);
}); });
} }

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

@ -1,5 +1,5 @@
<div class="container pb-3 px-3"> <div class="container pb-3 px-3">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Summary</h3> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Summary</h1>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<mat-card appearance="outlined"> <mat-card appearance="outlined">

8
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.component.ts

@ -16,6 +16,8 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
templateUrl: 'login-with-access-token-dialog.html' templateUrl: 'login-with-access-token-dialog.html'
}) })
export class LoginWithAccessTokenDialog { export class LoginWithAccessTokenDialog {
public isAccessTokenHidden = true;
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: any, @Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>, public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
@ -38,6 +40,12 @@ export class LoginWithAccessTokenDialog {
this.dialogRef.close(); this.dialogRef.close();
} }
public onLoginWithAccessToken() {
if (this.data.accessToken) {
this.dialogRef.close(this.data);
}
}
public async onLoginWithInternetIdentity() { public async onLoginWithInternetIdentity() {
try { try {
const { authToken } = await this.internetIdentityService.login(); const { authToken } = await this.internetIdentityService.login();

30
apps/client/src/app/components/login-with-access-token-dialog/login-with-access-token-dialog.html

@ -6,15 +6,27 @@
<div class="py-3" mat-dialog-content> <div class="py-3" mat-dialog-content>
<div class="align-items-center d-flex flex-column"> <div class="align-items-center d-flex flex-column">
<mat-form-field appearance="outline" class="without-hint w-100"> <form class="w-100" (ngSubmit)="onLoginWithAccessToken()">
<mat-label i18n>Security Token</mat-label> <mat-form-field appearance="outline" class="without-hint w-100">
<textarea <mat-label i18n>Security Token</mat-label>
cdkTextareaAutosize <input
matInput matInput
type="text" name="password"
[(ngModel)]="data.accessToken" [type]="isAccessTokenHidden ? 'password' : 'text'"
></textarea> [(ngModel)]="data.accessToken"
</mat-form-field> />
<button
mat-button
matSuffix
type="button"
(click)="isAccessTokenHidden = !isAccessTokenHidden"
>
<ion-icon
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
></ion-icon>
</button>
</mat-form-field>
</form>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin"> <ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="my-3 text-center text-muted" i18n>or</div> <div class="my-3 text-center text-muted" i18n>or</div>
<div class="d-flex flex-column"> <div class="d-flex flex-column">

28
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html

@ -163,7 +163,33 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund" [value]="isLoading ? undefined : summary?.emergencyFund?.total"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Cash</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.cash"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Assets</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.assets"
></gf-value> ></gf-value>
</div> </div>
</div> </div>

2
apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts

@ -50,7 +50,7 @@ export class PortfolioSummaryComponent implements OnChanges, OnInit {
public onEditEmergencyFund() { public onEditEmergencyFund() {
const emergencyFundInput = prompt( const emergencyFundInput = prompt(
$localize`Please enter the amount of your emergency fund:`, $localize`Please enter the amount of your emergency fund:`,
this.summary.emergencyFund?.toString() ?? '0' this.summary.emergencyFund?.total?.toString() ?? '0'
); );
const emergencyFund = parseFloat(emergencyFundInput?.trim()); const emergencyFund = parseFloat(emergencyFundInput?.trim());

2
apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.component.ts

@ -11,6 +11,8 @@ import { SubscriptionInterstitialDialogParams } from './interfaces/interfaces';
templateUrl: 'subscription-interstitial-dialog.html' templateUrl: 'subscription-interstitial-dialog.html'
}) })
export class SubscriptionInterstitialDialog { export class SubscriptionInterstitialDialog {
public routerLinkPricing = ['/' + $localize`pricing`];
public constructor( public constructor(
@Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams, @Inject(MAT_DIALOG_DATA) public data: SubscriptionInterstitialDialogParams,
public dialogRef: MatDialogRef<SubscriptionInterstitialDialog> public dialogRef: MatDialogRef<SubscriptionInterstitialDialog>

2
apps/client/src/app/components/subscription-interstitial-dialog/subscription-interstitial-dialog.html

@ -56,7 +56,7 @@
<a <a
color="primary" color="primary"
mat-flat-button mat-flat-button
[routerLink]="['/pricing']" [routerLink]="routerLinkPricing"
(click)="closeDialog()" (click)="closeDialog()"
> >
<span i18n>Upgrade Plan</span> <span i18n>Upgrade Plan</span>

24
apps/client/src/app/core/auth.guard.ts

@ -1,10 +1,10 @@
import { Injectable } from '@angular/core'; import { Injectable } from '@angular/core';
import { import {
ActivatedRouteSnapshot, ActivatedRouteSnapshot,
CanActivate,
Router, Router,
RouterStateSnapshot RouterStateSnapshot
} from '@angular/router'; } from '@angular/router';
import { paths } from '@ghostfolio/client/app-routing.module';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -12,23 +12,19 @@ import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators'; import { catchError } from 'rxjs/operators';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate { export class AuthGuard {
private static PUBLIC_PAGE_ROUTES = [ private static PUBLIC_PAGE_ROUTES = [
'/about', `/${paths.about}`,
'/about/changelog',
'/about/privacy-policy',
'/blog', '/blog',
'/de/blog',
'/demo', '/demo',
'/en/blog', `/${paths.faq}`,
'/faq', `/${paths.features}`,
'/features', `/${paths.markets}`,
'/markets',
'/open', '/open',
'/p', '/p',
'/pricing', `/${paths.pricing}`,
'/register', `/${paths.register}`,
'/resources' `/${paths.resources}`
]; ];
constructor( constructor(
@ -54,7 +50,7 @@ export class AuthGuard implements CanActivate {
this.router.navigate(['/demo']); this.router.navigate(['/demo']);
resolve(false); resolve(false);
} else if (utmSource === 'trusted-web-activity') { } else if (utmSource === 'trusted-web-activity') {
this.router.navigate(['/register']); this.router.navigate(['/' + $localize`register`]);
resolve(false); resolve(false);
} else if ( } else if (
AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) => AuthGuard.PUBLIC_PAGE_ROUTES.filter((publicPageRoute) =>

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

@ -62,7 +62,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
undefined, undefined,
{ duration: 6000 } { duration: 6000 }
); );
} else { } else if (!error.url.endsWith('auth/anonymous')) {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
$localize`This feature requires a subscription.`, $localize`This feature requires a subscription.`,
this.hasPermissionForSubscription this.hasPermissionForSubscription
@ -77,7 +77,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}); });
this.snackBarRef.onAction().subscribe(() => { this.snackBarRef.onAction().subscribe(() => {
this.router.navigate(['/pricing']); this.router.navigate(['/' + $localize`pricing`]);
}); });
} }
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) { } else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {

40
apps/client/src/app/pages/about/about-page-routing.module.ts

@ -1,5 +1,8 @@
import * as path from 'path';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router'; import { RouterModule, Routes } from '@angular/router';
import { paths } from '@ghostfolio/client/app-routing.module';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard'; import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@ -22,38 +25,27 @@ const routes: Routes = [
(m) => m.ChangelogPageModule (m) => m.ChangelogPageModule
) )
}, },
...[ {
'license', path: paths.license,
/////
'licenca',
'licence',
'licencia',
'licentie',
'lizenz',
'licenza'
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./license/license-page.module').then( import('./license/license-page.module').then(
(m) => m.LicensePageModule (m) => m.LicensePageModule
) )
})), },
...[ {
'privacy-policy', path: 'oss-friends',
///// loadChildren: () =>
'datenschutzbestimmungen', import('./oss-friends/oss-friends-page.module').then(
'informativa-sulla-privacy', (m) => m.OpenSourceSoftwareFriendsPageModule
'politique-de-confidentialite', )
'politica-de-privacidad', },
'politica-de-privacidade', {
'privacybeleid' path: paths.privacyPolicy,
].map((path) => ({
path,
loadChildren: () => loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then( import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule (m) => m.PrivacyPolicyPageModule
) )
})) }
], ],
component: AboutPageComponent, component: AboutPageComponent,
path: '', path: '',

53
apps/client/src/app/pages/about/about-page.component.ts

@ -44,30 +44,31 @@ export class AboutPageComponent implements OnDestroy, OnInit {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
this.tabs = [
{
iconName: 'reader-outline',
label: $localize`About`,
path: ['/' + $localize`about`]
},
{
iconName: 'sparkles-outline',
label: $localize`Changelog`,
path: ['/' + $localize`about`, 'changelog']
},
{
iconName: 'ribbon-outline',
label: $localize`License`,
path: ['/' + $localize`about`, $localize`license`]
}
];
if (state?.user) { if (state?.user) {
this.tabs = [ this.tabs.push({
{ iconName: 'shield-checkmark-outline',
iconName: 'reader-outline', label: $localize`Privacy Policy`,
label: $localize`About`, path: ['/' + $localize`about`, $localize`privacy-policy`],
path: ['/about'] showCondition: this.hasPermissionForSubscription
}, });
{
iconName: 'sparkles-outline',
label: $localize`Changelog`,
path: ['/about', 'changelog']
},
{
iconName: 'ribbon-outline',
label: $localize`License`,
path: ['/about', 'license']
},
{
iconName: 'shield-checkmark-outline',
label: $localize`Privacy Policy`,
path: ['/about', 'privacy-policy'],
showCondition: this.hasPermissionForSubscription
}
];
this.user = state.user; this.user = state.user;
this.hasMessage = this.hasMessage =
@ -78,6 +79,12 @@ export class AboutPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
this.tabs.push({
iconName: 'happy-outline',
label: 'OSS Friends',
path: ['/' + $localize`about`, 'oss-friends']
});
}); });
} }

9
apps/client/src/app/pages/about/about-page.scss

@ -11,14 +11,9 @@
padding-bottom: constant(safe-area-inset-bottom); padding-bottom: constant(safe-area-inset-bottom);
::ng-deep { ::ng-deep {
gf-about-page,
gf-changelog-page,
gf-privacy-policy-page {
flex: 1 1 auto;
overflow-y: auto;
}
.mat-mdc-tab-link-container { .mat-mdc-tab-link-container {
--mat-tab-header-active-focus-indicator-color: transparent;
--mat-tab-header-active-hover-indicator-color: transparent;
--mdc-tab-indicator-active-indicator-color: transparent; --mdc-tab-indicator-active-indicator-color: transparent;
.mat-mdc-tab-link { .mat-mdc-tab-link {

2
apps/client/src/app/pages/about/changelog/changelog-page.html

@ -1,7 +1,7 @@
<div class="container"> <div class="container">
<div class="mb-5 row"> <div class="mb-5 row">
<div class="col"> <div class="col">
<h1 class="d-none d-sm-block h3 mb-3 text-center" i18n>Changelog</h1> <h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Changelog</h1>
<div class="changelog"> <div class="changelog">
<markdown [src]="'../assets/CHANGELOG.md'"></markdown> <markdown [src]="'../assets/CHANGELOG.md'"></markdown>
</div> </div>

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save