diff --git a/apps/api/eslint.config.cjs b/apps/api/eslint.config.cjs new file mode 100644 index 000000000..043802a0d --- /dev/null +++ b/apps/api/eslint.config.cjs @@ -0,0 +1,31 @@ +const baseConfig = require('../../eslint.config.cjs'); + +module.exports = [ + { + ignores: ['**/dist'] + }, + ...baseConfig, + { + rules: {} + }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + languageOptions: { + parserOptions: { + project: ['apps/api/tsconfig.*?.json'] + } + } + }, + { + files: ['**/*.ts', '**/*.tsx'], + // Override or add rules here + rules: {} + }, + { + files: ['**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {} + } +]; diff --git a/apps/api/jest.config.ts b/apps/api/jest.config.ts new file mode 100644 index 000000000..b87f91a79 --- /dev/null +++ b/apps/api/jest.config.ts @@ -0,0 +1,18 @@ +/* eslint-disable */ +export default { + displayName: 'api', + + globals: {}, + transform: { + '^.+\\.[tj]s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json' + } + ] + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/apps/api', + testEnvironment: 'node', + preset: '../../jest.preset.js' +}; diff --git a/apps/api/project.json b/apps/api/project.json new file mode 100644 index 000000000..4e1affb13 --- /dev/null +++ b/apps/api/project.json @@ -0,0 +1,78 @@ +{ + "name": "api", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/api/src", + "projectType": "application", + "prefix": "api", + "generators": {}, + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "options": { + "compiler": "tsc", + "deleteOutputPath": false, + "main": "apps/api/src/main.ts", + "outputPath": "dist/apps/api", + "sourceMap": true, + "target": "node", + "tsConfig": "apps/api/tsconfig.app.json", + "webpackConfig": "apps/api/webpack.config.js" + }, + "configurations": { + "production": { + "generatePackageJson": true, + "optimization": true, + "extractLicenses": true, + "inspect": false, + "fileReplacements": [ + { + "replace": "apps/api/src/environments/environment.ts", + "with": "apps/api/src/environments/environment.prod.ts" + } + ] + } + }, + "outputs": ["{options.outputPath}"] + }, + "copy-assets": { + "executor": "nx:run-commands", + "options": { + "commands": [ + { + "command": "shx rm -rf dist/apps/api" + }, + { + "command": "shx mkdir -p dist/apps/api/assets/locales" + }, + { + "command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets" + }, + { + "command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales" + } + ], + "parallel": false + } + }, + "serve": { + "executor": "@nx/js:node", + "options": { + "buildTarget": "api:build" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "lintFilePatterns": ["apps/api/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "options": { + "jestConfig": "apps/api/jest.config.ts" + }, + "outputs": ["{workspaceRoot}/coverage/apps/api"] + } + }, + "tags": [] +} diff --git a/apps/api/src/app/access/access.controller.ts b/apps/api/src/app/access/access.controller.ts new file mode 100644 index 000000000..5056a6d71 --- /dev/null +++ b/apps/api/src/app/access/access.controller.ts @@ -0,0 +1,171 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { CreateAccessDto, UpdateAccessDto } from '@ghostfolio/common/dtos'; +import { Access } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + HttpException, + Inject, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Access as AccessModel } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { AccessService } from './access.service'; + +@Controller('access') +export class AccessController { + public constructor( + private readonly accessService: AccessService, + private readonly configurationService: ConfigurationService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getAllAccesses(): Promise { + const accessesWithGranteeUser = await this.accessService.accesses({ + include: { + granteeUser: true + }, + orderBy: [{ granteeUserId: 'desc' }, { createdAt: 'asc' }], + where: { userId: this.request.user.id } + }); + + return accessesWithGranteeUser.map( + ({ alias, granteeUser, id, permissions }) => { + if (granteeUser) { + return { + alias, + id, + permissions, + grantee: granteeUser?.id, + type: 'PRIVATE' + }; + } + + return { + alias, + id, + permissions, + grantee: 'Public', + type: 'PUBLIC' + }; + } + ); + } + + @HasPermission(permissions.createAccess) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createAccess( + @Body() data: CreateAccessDto + ): Promise { + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + return this.accessService.createAccess({ + alias: data.alias || undefined, + granteeUser: data.granteeUserId + ? { connect: { id: data.granteeUserId } } + : undefined, + permissions: data.permissions, + user: { connect: { id: this.request.user.id } } + }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } + + @Delete(':id') + @HasPermission(permissions.deleteAccess) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteAccess(@Param('id') id: string): Promise { + const originalAccess = await this.accessService.access({ + id, + userId: this.request.user.id + }); + + if (!originalAccess) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accessService.deleteAccess({ + id + }); + } + + @HasPermission(permissions.updateAccess) + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateAccess( + @Body() data: UpdateAccessDto, + @Param('id') id: string + ): Promise { + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const originalAccess = await this.accessService.access({ + id, + userId: this.request.user.id + }); + + if (!originalAccess) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + try { + return this.accessService.updateAccess({ + data: { + alias: data.alias, + granteeUser: data.granteeUserId + ? { connect: { id: data.granteeUserId } } + : { disconnect: true }, + permissions: data.permissions + }, + where: { id } + }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } +} diff --git a/apps/api/src/app/access/access.module.ts b/apps/api/src/app/access/access.module.ts new file mode 100644 index 000000000..44d28a230 --- /dev/null +++ b/apps/api/src/app/access/access.module.ts @@ -0,0 +1,15 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { AccessController } from './access.controller'; +import { AccessService } from './access.service'; + +@Module({ + controllers: [AccessController], + exports: [AccessService], + imports: [ConfigurationModule, PrismaModule], + providers: [AccessService] +}) +export class AccessModule {} diff --git a/apps/api/src/app/access/access.service.ts b/apps/api/src/app/access/access.service.ts new file mode 100644 index 000000000..70e46dc36 --- /dev/null +++ b/apps/api/src/app/access/access.service.ts @@ -0,0 +1,68 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { AccessWithGranteeUser } from '@ghostfolio/common/types'; + +import { Injectable } from '@nestjs/common'; +import { Access, Prisma } from '@prisma/client'; + +@Injectable() +export class AccessService { + public constructor(private readonly prismaService: PrismaService) {} + + public async access( + accessWhereInput: Prisma.AccessWhereInput + ): Promise { + return this.prismaService.access.findFirst({ + include: { + granteeUser: true + }, + where: accessWhereInput + }); + } + + public async accesses(params: { + cursor?: Prisma.AccessWhereUniqueInput; + include?: Prisma.AccessInclude; + orderBy?: Prisma.Enumerable; + skip?: number; + take?: number; + where?: Prisma.AccessWhereInput; + }): Promise { + const { cursor, include, orderBy, skip, take, where } = params; + + return this.prismaService.access.findMany({ + cursor, + include, + orderBy, + skip, + take, + where + }); + } + + public async createAccess(data: Prisma.AccessCreateInput): Promise { + return this.prismaService.access.create({ + data + }); + } + + public async deleteAccess( + where: Prisma.AccessWhereUniqueInput + ): Promise { + return this.prismaService.access.delete({ + where + }); + } + + public async updateAccess({ + data, + where + }: { + data: Prisma.AccessUpdateInput; + where: Prisma.AccessWhereUniqueInput; + }): Promise { + return this.prismaService.access.update({ + data, + where + }); + } +} diff --git a/apps/api/src/app/account-balance/account-balance.controller.ts b/apps/api/src/app/account-balance/account-balance.controller.ts new file mode 100644 index 000000000..baf002bd3 --- /dev/null +++ b/apps/api/src/app/account-balance/account-balance.controller.ts @@ -0,0 +1,84 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Body, + Post, + Delete, + HttpException, + Inject, + Param, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { AccountBalance } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { AccountBalanceService } from './account-balance.service'; + +@Controller('account-balance') +export class AccountBalanceController { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly accountService: AccountService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @HasPermission(permissions.createAccountBalance) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createAccountBalance( + @Body() data: CreateAccountBalanceDto + ): Promise { + const account = await this.accountService.account({ + id_userId: { + id: data.accountId, + userId: this.request.user.id + } + }); + + if (!account) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: account.id, + balance: data.balance, + date: data.date, + userId: account.userId + }); + } + + @HasPermission(permissions.deleteAccountBalance) + @Delete(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteAccountBalance( + @Param('id') id: string + ): Promise { + const accountBalance = await this.accountBalanceService.accountBalance({ + id, + userId: this.request.user.id + }); + + if (!accountBalance) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accountBalanceService.deleteAccountBalance({ + id: accountBalance.id, + userId: accountBalance.userId + }); + } +} diff --git a/apps/api/src/app/account-balance/account-balance.module.ts b/apps/api/src/app/account-balance/account-balance.module.ts new file mode 100644 index 000000000..02323acc9 --- /dev/null +++ b/apps/api/src/app/account-balance/account-balance.module.ts @@ -0,0 +1,16 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { AccountBalanceController } from './account-balance.controller'; +import { AccountBalanceService } from './account-balance.service'; + +@Module({ + controllers: [AccountBalanceController], + exports: [AccountBalanceService], + imports: [ExchangeRateDataModule, PrismaModule], + providers: [AccountBalanceService, AccountService] +}) +export class AccountBalanceModule {} diff --git a/apps/api/src/app/account-balance/account-balance.service.ts b/apps/api/src/app/account-balance/account-balance.service.ts new file mode 100644 index 000000000..321624003 --- /dev/null +++ b/apps/api/src/app/account-balance/account-balance.service.ts @@ -0,0 +1,186 @@ +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; +import { DATE_FORMAT, getSum, resetHours } from '@ghostfolio/common/helper'; +import { + AccountBalancesResponse, + Filter, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { AccountBalance, Prisma } from '@prisma/client'; +import { Big } from 'big.js'; +import { format, parseISO } from 'date-fns'; + +@Injectable() +export class AccountBalanceService { + public constructor( + private readonly eventEmitter: EventEmitter2, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService + ) {} + + public async accountBalance( + accountBalanceWhereInput: Prisma.AccountBalanceWhereInput + ): Promise { + return this.prismaService.accountBalance.findFirst({ + include: { + account: true + }, + where: accountBalanceWhereInput + }); + } + + public async createOrUpdateAccountBalance({ + accountId, + balance, + date, + userId + }: CreateAccountBalanceDto & { + userId: string; + }): Promise { + const accountBalance = await this.prismaService.accountBalance.upsert({ + create: { + account: { + connect: { + id_userId: { + userId, + id: accountId + } + } + }, + date: resetHours(parseISO(date)), + value: balance + }, + update: { + value: balance + }, + where: { + accountId_date: { + accountId, + date: resetHours(parseISO(date)) + } + } + }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + + return accountBalance; + } + + public async deleteAccountBalance( + where: Prisma.AccountBalanceWhereUniqueInput + ): Promise { + const accountBalance = await this.prismaService.accountBalance.delete({ + where + }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: where.userId as string + }) + ); + + return accountBalance; + } + + public async getAccountBalanceItems({ + filters, + userCurrency, + userId + }: { + filters?: Filter[]; + userCurrency: string; + userId: string; + }): Promise { + const { balances } = await this.getAccountBalances({ + filters, + userCurrency, + userId, + withExcludedAccounts: false // TODO + }); + const accumulatedBalancesByDate: { [date: string]: HistoricalDataItem } = + {}; + const lastBalancesByAccount: { [accountId: string]: Big } = {}; + + for (const { accountId, date, valueInBaseCurrency } of balances) { + const formattedDate = format(date, DATE_FORMAT); + + lastBalancesByAccount[accountId] = new Big(valueInBaseCurrency); + + const totalBalance = getSum(Object.values(lastBalancesByAccount)); + + // Add or update the accumulated balance for this date + accumulatedBalancesByDate[formattedDate] = { + date: formattedDate, + value: totalBalance.toNumber() + }; + } + + return Object.values(accumulatedBalancesByDate); + } + + @LogPerformance + public async getAccountBalances({ + filters, + userCurrency, + userId, + withExcludedAccounts + }: { + filters?: Filter[]; + userCurrency: string; + userId: string; + withExcludedAccounts?: boolean; + }): Promise { + const where: Prisma.AccountBalanceWhereInput = { userId }; + + const accountFilter = filters?.find(({ type }) => { + return type === 'ACCOUNT'; + }); + + if (accountFilter) { + where.accountId = accountFilter.id; + } + + if (withExcludedAccounts === false) { + where.account = { isExcluded: false }; + } + + const balances = await this.prismaService.accountBalance.findMany({ + where, + orderBy: { + date: 'asc' + }, + select: { + account: true, + date: true, + id: true, + value: true + } + }); + + return { + balances: balances.map((balance) => { + return { + ...balance, + accountId: balance.account.id, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + balance.value, + balance.account.currency, + userCurrency + ) + }; + }) + }; + } +} diff --git a/apps/api/src/app/account/account.controller.ts b/apps/api/src/app/account/account.controller.ts new file mode 100644 index 000000000..052720176 --- /dev/null +++ b/apps/api/src/app/account/account.controller.ts @@ -0,0 +1,295 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + CreateAccountDto, + TransferBalanceDto, + UpdateAccountDto +} from '@ghostfolio/common/dtos'; +import { + AccountBalancesResponse, + AccountResponse, + AccountsResponse +} from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + Headers, + HttpException, + Inject, + Param, + Post, + Put, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Account as AccountModel } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { AccountService } from './account.service'; + +@Controller('account') +export class AccountController { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly accountService: AccountService, + private readonly apiService: ApiService, + private readonly impersonationService: ImpersonationService, + private readonly portfolioService: PortfolioService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Delete(':id') + @HasPermission(permissions.deleteAccount) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteAccount(@Param('id') id: string): Promise { + const account = await this.accountService.accountWithActivities( + { + id_userId: { + id, + userId: this.request.user.id + } + }, + { activities: true } + ); + + if (!account || account?.activities.length > 0) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.accountService.deleteAccount({ + id_userId: { + id, + userId: this.request.user.id + } + }); + } + + @Get() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getAllAccounts( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('dataSource') filterByDataSource?: string, + @Query('query') filterBySearchQuery?: string, + @Query('symbol') filterBySymbol?: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByDataSource, + filterBySearchQuery, + filterBySymbol + }); + + return this.portfolioService.getAccountsWithAggregations({ + filters, + userId: impersonationUserId || this.request.user.id, + withExcludedAccounts: true + }); + } + + @Get(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + public async getAccountById( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('id') id: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + + const accountsWithAggregations = + await this.portfolioService.getAccountsWithAggregations({ + filters: [{ id, type: 'ACCOUNT' }], + userId: impersonationUserId || this.request.user.id, + withExcludedAccounts: true + }); + + return accountsWithAggregations.accounts[0]; + } + + @Get(':id/balances') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + public async getAccountBalancesById( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('id') id: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + + return this.accountBalanceService.getAccountBalances({ + filters: [{ id, type: 'ACCOUNT' }], + userCurrency: this.request.user.settings.settings.baseCurrency, + userId: impersonationUserId || this.request.user.id + }); + } + + @HasPermission(permissions.createAccount) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createAccount( + @Body() data: CreateAccountDto + ): Promise { + if (data.platformId) { + const platformId = data.platformId; + delete data.platformId; + + return this.accountService.createAccount( + { + ...data, + platform: { connect: { id: platformId } }, + user: { connect: { id: this.request.user.id } } + }, + this.request.user.id + ); + } else { + delete data.platformId; + + return this.accountService.createAccount( + { + ...data, + user: { connect: { id: this.request.user.id } } + }, + this.request.user.id + ); + } + } + + @HasPermission(permissions.updateAccount) + @Post('transfer-balance') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async transferAccountBalance( + @Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto + ) { + const accountsOfUser = await this.accountService.getAccounts( + this.request.user.id + ); + + const accountFrom = accountsOfUser.find(({ id }) => { + return id === accountIdFrom; + }); + + const accountTo = accountsOfUser.find(({ id }) => { + return id === accountIdTo; + }); + + if (!accountFrom || !accountTo) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + if (accountFrom.id === accountTo.id) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + if (accountFrom.balance < balance) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + await this.accountService.updateAccountBalance({ + accountId: accountFrom.id, + amount: -balance, + currency: accountFrom.currency, + userId: this.request.user.id + }); + + await this.accountService.updateAccountBalance({ + accountId: accountTo.id, + amount: balance, + currency: accountFrom.currency, + userId: this.request.user.id + }); + } + + @HasPermission(permissions.updateAccount) + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) { + const originalAccount = await this.accountService.account({ + id_userId: { + id, + userId: this.request.user.id + } + }); + + if (!originalAccount) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + if (data.platformId) { + const platformId = data.platformId; + delete data.platformId; + + return this.accountService.updateAccount( + { + data: { + ...data, + platform: { connect: { id: platformId } }, + user: { connect: { id: this.request.user.id } } + }, + where: { + id_userId: { + id, + userId: this.request.user.id + } + } + }, + this.request.user.id + ); + } else { + // platformId is null, remove it + delete data.platformId; + + return this.accountService.updateAccount( + { + data: { + ...data, + platform: originalAccount.platformId + ? { disconnect: true } + : undefined, + user: { connect: { id: this.request.user.id } } + }, + where: { + id_userId: { + id, + userId: this.request.user.id + } + } + }, + this.request.user.id + ); + } + } +} diff --git a/apps/api/src/app/account/account.module.ts b/apps/api/src/app/account/account.module.ts new file mode 100644 index 000000000..fb89bb2b6 --- /dev/null +++ b/apps/api/src/app/account/account.module.ts @@ -0,0 +1,30 @@ +import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { AccountController } from './account.controller'; +import { AccountService } from './account.service'; + +@Module({ + controllers: [AccountController], + exports: [AccountService], + imports: [ + AccountBalanceModule, + ApiModule, + ConfigurationModule, + ExchangeRateDataModule, + ImpersonationModule, + PortfolioModule, + PrismaModule, + RedactValuesInResponseModule + ], + providers: [AccountService] +}) +export class AccountModule {} diff --git a/apps/api/src/app/account/account.service.ts b/apps/api/src/app/account/account.service.ts new file mode 100644 index 000000000..e1b01a6ed --- /dev/null +++ b/apps/api/src/app/account/account.service.ts @@ -0,0 +1,288 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { Filter } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + Account, + AccountBalance, + Order, + Platform, + Prisma, + SymbolProfile +} from '@prisma/client'; +import { Big } from 'big.js'; +import { format } from 'date-fns'; +import { groupBy } from 'lodash'; + +import { CashDetails } from './interfaces/cash-details.interface'; + +@Injectable() +export class AccountService { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly eventEmitter: EventEmitter2, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService + ) {} + + public async account({ + id_userId + }: Prisma.AccountWhereUniqueInput): Promise { + const [account] = await this.accounts({ + where: id_userId + }); + + return account; + } + + public async accountWithActivities( + accountWhereUniqueInput: Prisma.AccountWhereUniqueInput, + accountInclude: Prisma.AccountInclude + ): Promise< + Account & { + activities?: Order[]; + } + > { + return this.prismaService.account.findUnique({ + include: accountInclude, + where: accountWhereUniqueInput + }); + } + + public async accounts(params: { + include?: Prisma.AccountInclude; + skip?: number; + take?: number; + cursor?: Prisma.AccountWhereUniqueInput; + where?: Prisma.AccountWhereInput; + orderBy?: Prisma.AccountOrderByWithRelationInput; + }): Promise< + (Account & { + activities?: (Order & { SymbolProfile?: SymbolProfile })[]; + balances?: AccountBalance[]; + platform?: Platform; + })[] + > { + const { include = {}, skip, take, cursor, where, orderBy } = params; + + const isBalancesIncluded = !!include.balances; + + include.balances = { + orderBy: { date: 'desc' }, + ...(isBalancesIncluded ? {} : { take: 1 }) + }; + + const accounts = await this.prismaService.account.findMany({ + cursor, + include, + orderBy, + skip, + take, + where + }); + + return accounts.map((account) => { + account = { ...account, balance: account.balances[0]?.value ?? 0 }; + + if (!isBalancesIncluded) { + delete account.balances; + } + + return account; + }); + } + + public async createAccount( + data: Prisma.AccountCreateInput, + aUserId: string + ): Promise { + const account = await this.prismaService.account.create({ + data + }); + + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: account.id, + balance: data.balance, + date: format(new Date(), DATE_FORMAT), + userId: aUserId + }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; + } + + public async deleteAccount( + where: Prisma.AccountWhereUniqueInput + ): Promise { + const account = await this.prismaService.account.delete({ + where + }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; + } + + public async getAccounts(aUserId: string): Promise { + const accounts = await this.accounts({ + include: { + activities: true, + platform: true + }, + orderBy: { name: 'asc' }, + where: { userId: aUserId } + }); + + return accounts.map((account) => { + let activitiesCount = 0; + + for (const { isDraft } of account.activities) { + if (!isDraft) { + activitiesCount += 1; + } + } + + const result = { ...account, activitiesCount }; + + delete result.activities; + + return result; + }); + } + + public async getCashDetails({ + currency, + filters = [], + userId, + withExcludedAccounts = false + }: { + currency: string; + filters?: Filter[]; + userId: string; + withExcludedAccounts?: boolean; + }): Promise { + let totalCashBalanceInBaseCurrency = new Big(0); + + const where: Prisma.AccountWhereInput = { + userId + }; + + if (withExcludedAccounts === false) { + where.isExcluded = false; + } + + const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { + return type; + }); + + if (filtersByAccount?.length > 0) { + where.id = { + in: filtersByAccount.map(({ id }) => { + return id; + }) + }; + } + + const accounts = await this.accounts({ where }); + + for (const account of accounts) { + totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus( + this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + currency + ) + ); + } + + return { + accounts, + balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber() + }; + } + + public async updateAccount( + params: { + where: Prisma.AccountWhereUniqueInput; + data: Prisma.AccountUpdateInput; + }, + aUserId: string + ): Promise { + const { data, where } = params; + + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId: data.id as string, + balance: data.balance as number, + date: format(new Date(), DATE_FORMAT), + userId: aUserId + }); + + const account = await this.prismaService.account.update({ + data, + where + }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: account.userId + }) + ); + + return account; + } + + public async updateAccountBalance({ + accountId, + amount, + currency, + date = new Date(), + userId + }: { + accountId: string; + amount: number; + currency: string; + date?: Date; + userId: string; + }) { + const { balance, currency: currencyOfAccount } = await this.account({ + id_userId: { + userId, + id: accountId + } + }); + + const amountInCurrencyOfAccount = + await this.exchangeRateDataService.toCurrencyAtDate( + amount, + currency, + currencyOfAccount, + date + ); + + if (amountInCurrencyOfAccount) { + await this.accountBalanceService.createOrUpdateAccountBalance({ + accountId, + userId, + balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(), + date: date.toISOString() + }); + } + } +} diff --git a/apps/api/src/app/account/interfaces/cash-details.interface.ts b/apps/api/src/app/account/interfaces/cash-details.interface.ts new file mode 100644 index 000000000..715343766 --- /dev/null +++ b/apps/api/src/app/account/interfaces/cash-details.interface.ts @@ -0,0 +1,6 @@ +import { Account } from '@prisma/client'; + +export interface CashDetails { + accounts: Account[]; + balanceInBaseCurrency: number; +} diff --git a/apps/api/src/app/admin/admin.controller.ts b/apps/api/src/app/admin/admin.controller.ts new file mode 100644 index 000000000..8a202a926 --- /dev/null +++ b/apps/api/src/app/admin/admin.controller.ts @@ -0,0 +1,337 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; +import { DemoService } from '@ghostfolio/api/services/demo/demo.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, + GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS +} from '@ghostfolio/common/config'; +import { + UpdateAssetProfileDto, + UpdatePropertyDto +} from '@ghostfolio/common/dtos'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + AdminData, + AdminMarketData, + AdminUserResponse, + AdminUsersResponse, + EnhancedSymbolProfile, + ScraperConfiguration +} from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { + DateRange, + MarketDataPreset, + RequestWithUser +} from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + HttpException, + Inject, + Logger, + Param, + Patch, + Post, + Put, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client'; +import { isDate, parseISO } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { AdminService } from './admin.service'; + +@Controller('admin') +export class AdminController { + public constructor( + private readonly adminService: AdminService, + private readonly apiService: ApiService, + private readonly dataGatheringService: DataGatheringService, + private readonly demoService: DemoService, + private readonly manualService: ManualService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get() + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getAdminData(): Promise { + return this.adminService.get(); + } + + @Get('demo-user/sync') + @HasPermission(permissions.syncDemoUserAccount) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async syncDemoUserAccount(): Promise { + return this.demoService.syncDemoUserAccount(); + } + + @HasPermission(permissions.accessAdminControl) + @Post('gather') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async gather7Days(): Promise { + this.dataGatheringService.gather7Days(); + } + + @HasPermission(permissions.accessAdminControl) + @Post('gather/max') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async gatherMax(): Promise { + const assetProfileIdentifiers = + await this.dataGatheringService.getActiveAssetProfileIdentifiers(); + + await this.dataGatheringService.addJobsToQueue( + assetProfileIdentifiers.map(({ dataSource, symbol }) => { + return { + data: { + dataSource, + symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + } + }; + }) + ); + + this.dataGatheringService.gatherMax(); + } + + @HasPermission(permissions.accessAdminControl) + @Post('gather/profile-data') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async gatherProfileData(): Promise { + const assetProfileIdentifiers = + await this.dataGatheringService.getActiveAssetProfileIdentifiers(); + + await this.dataGatheringService.addJobsToQueue( + assetProfileIdentifiers.map(({ dataSource, symbol }) => { + return { + data: { + dataSource, + symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + } + }; + }) + ); + } + + @HasPermission(permissions.accessAdminControl) + @Post('gather/profile-data/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async gatherProfileDataForSymbol( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + await this.dataGatheringService.addJobToQueue({ + data: { + dataSource, + symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + } + }); + } + + @Post('gather/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @HasPermission(permissions.accessAdminControl) + public async gatherSymbol( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string, + @Query('range') dateRange: DateRange + ): Promise { + let date: Date; + + if (dateRange) { + const { startDate } = getIntervalFromDateRange(dateRange); + date = startDate; + } + + this.dataGatheringService.gatherSymbol({ + dataSource, + date, + symbol + }); + + return; + } + + @HasPermission(permissions.accessAdminControl) + @Post('gather/:dataSource/:symbol/:dateString') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async gatherSymbolForDate( + @Param('dataSource') dataSource: DataSource, + @Param('dateString') dateString: string, + @Param('symbol') symbol: string + ): Promise { + const date = parseISO(dateString); + + if (!isDate(date)) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + return this.dataGatheringService.gatherSymbolForDate({ + dataSource, + date, + symbol + }); + } + + @Get('market-data') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getMarketData( + @Query('assetSubClasses') filterByAssetSubClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('presetId') presetId?: MarketDataPreset, + @Query('query') filterBySearchQuery?: string, + @Query('skip') skip?: number, + @Query('sortColumn') sortColumn?: string, + @Query('sortDirection') sortDirection?: Prisma.SortOrder, + @Query('take') take?: number + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAssetSubClasses, + filterByDataSource, + filterBySearchQuery + }); + + return this.adminService.getMarketData({ + filters, + presetId, + sortColumn, + sortDirection, + skip: isNaN(skip) ? undefined : skip, + take: isNaN(take) ? undefined : take + }); + } + + @HasPermission(permissions.accessAdminControl) + @Post('market-data/:dataSource/:symbol/test') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async testMarketData( + @Body() data: { scraperConfiguration: ScraperConfiguration }, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise<{ price: number }> { + try { + const price = await this.manualService.test({ + symbol, + scraperConfiguration: data.scraperConfiguration + }); + + if (price) { + return { price }; + } + + throw new Error( + `Could not parse the market price for ${symbol} (${dataSource})` + ); + } catch (error) { + Logger.error(error, 'AdminController'); + + throw new HttpException(error.message, StatusCodes.BAD_REQUEST); + } + } + + @HasPermission(permissions.accessAdminControl) + @Post('profile-data/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async addProfileData( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + return this.adminService.addAssetProfile({ + dataSource, + symbol, + currency: this.request.user.settings.settings.baseCurrency + }); + } + + @Delete('profile-data/:dataSource/:symbol') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteProfileData( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + return this.adminService.deleteProfileData({ dataSource, symbol }); + } + + @HasPermission(permissions.accessAdminControl) + @Patch('profile-data/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async patchAssetProfileData( + @Body() assetProfile: UpdateAssetProfileDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + return this.adminService.patchAssetProfileData( + { dataSource, symbol }, + assetProfile + ); + } + + @HasPermission(permissions.accessAdminControl) + @Put('settings/:key') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateProperty( + @Param('key') key: string, + @Body() data: UpdatePropertyDto + ) { + return this.adminService.putSetting(key, data.value); + } + + @Get('user') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getUsers( + @Query('skip') skip?: number, + @Query('take') take?: number + ): Promise { + return this.adminService.getUsers({ + skip: isNaN(skip) ? undefined : skip, + take: isNaN(take) ? undefined : take + }); + } + + @Get('user/:id') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getUser(@Param('id') id: string): Promise { + return this.adminService.getUser(id); + } +} diff --git a/apps/api/src/app/admin/admin.module.ts b/apps/api/src/app/admin/admin.module.ts new file mode 100644 index 000000000..598b68f17 --- /dev/null +++ b/apps/api/src/app/admin/admin.module.ts @@ -0,0 +1,42 @@ +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { DemoModule } from '@ghostfolio/api/services/demo/demo.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { AdminController } from './admin.controller'; +import { AdminService } from './admin.service'; +import { QueueModule } from './queue/queue.module'; + +@Module({ + imports: [ + ApiModule, + BenchmarkModule, + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + DemoModule, + ExchangeRateDataModule, + MarketDataModule, + OrderModule, + PrismaModule, + PropertyModule, + QueueModule, + SymbolProfileModule, + TransformDataSourceInRequestModule + ], + controllers: [AdminController], + providers: [AdminService], + exports: [AdminService] +}) +export class AdminModule {} diff --git a/apps/api/src/app/admin/admin.service.ts b/apps/api/src/app/admin/admin.service.ts new file mode 100644 index 000000000..d77fde346 --- /dev/null +++ b/apps/api/src/app/admin/admin.service.ts @@ -0,0 +1,938 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { environment } from '@ghostfolio/api/environments/environment'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + PROPERTY_CURRENCIES, + PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_IS_USER_SIGNUP_ENABLED +} from '@ghostfolio/common/config'; +import { + getAssetProfileIdentifier, + getCurrencyFromSymbol, + isCurrency +} from '@ghostfolio/common/helper'; +import { + AdminData, + AdminMarketData, + AdminMarketDataDetails, + AdminMarketDataItem, + AdminUserResponse, + AdminUsersResponse, + AssetProfileIdentifier, + EnhancedSymbolProfile, + Filter +} from '@ghostfolio/common/interfaces'; +import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; +import { MarketDataPreset } from '@ghostfolio/common/types'; + +import { + BadRequestException, + HttpException, + Injectable, + Logger, + NotFoundException +} from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + DataSource, + Prisma, + PrismaClient, + Property, + SymbolProfile +} from '@prisma/client'; +import { differenceInDays } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { groupBy } from 'lodash'; + +@Injectable() +export class AdminService { + public constructor( + private readonly benchmarkService: BenchmarkService, + private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly marketDataService: MarketDataService, + private readonly orderService: OrderService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async addAssetProfile({ + currency, + dataSource, + symbol + }: AssetProfileIdentifier & { currency?: string }): Promise< + SymbolProfile | never + > { + try { + if (dataSource === 'MANUAL') { + return this.symbolProfileService.add({ + currency, + dataSource, + symbol + }); + } + + const assetProfiles = await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfiles[symbol]?.currency) { + throw new BadRequestException( + `Asset profile not found for ${symbol} (${dataSource})` + ); + } + + return this.symbolProfileService.add( + assetProfiles[symbol] as Prisma.SymbolProfileCreateInput + ); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new BadRequestException( + `Asset profile of ${symbol} (${dataSource}) already exists` + ); + } + + throw error; + } + } + + public async deleteProfileData({ + dataSource, + symbol + }: AssetProfileIdentifier) { + await this.marketDataService.deleteMany({ dataSource, symbol }); + + const currency = getCurrencyFromSymbol(symbol); + const customCurrencies = + await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + if (customCurrencies.includes(currency)) { + const updatedCustomCurrencies = customCurrencies.filter( + (customCurrency) => { + return customCurrency !== currency; + } + ); + + await this.putSetting( + PROPERTY_CURRENCIES, + JSON.stringify(updatedCustomCurrencies) + ); + } else { + await this.symbolProfileService.delete({ dataSource, symbol }); + } + } + + public async get(): Promise { + const dataSources = Object.values(DataSource); + + const [activitiesCount, enabledDataSources, settings, userCount] = + await Promise.all([ + this.prismaService.order.count(), + this.dataProviderService.getDataSources(), + this.propertyService.get(), + this.countUsersWithAnalytics() + ]); + + const dataProviders = ( + await Promise.all( + dataSources.map(async (dataSource) => { + const assetProfileCount = + await this.prismaService.symbolProfile.count({ + where: { + dataSource + } + }); + + const isEnabled = enabledDataSources.includes(dataSource); + + if ( + assetProfileCount > 0 || + dataSource === 'GHOSTFOLIO' || + isEnabled + ) { + const dataProviderInfo = this.dataProviderService + .getDataProvider(dataSource) + .getDataProviderInfo(); + + return { + ...dataProviderInfo, + assetProfileCount, + useForExchangeRates: + dataSource === + this.dataProviderService.getDataSourceForExchangeRates() + }; + } + + return null; + }) + ) + ).filter(Boolean); + + return { + activitiesCount, + dataProviders, + settings, + userCount, + version: environment.version + }; + } + + public async getMarketData({ + filters, + presetId, + sortColumn, + sortDirection = 'asc', + skip, + take = Number.MAX_SAFE_INTEGER + }: { + filters?: Filter[]; + presetId?: MarketDataPreset; + skip?: number; + sortColumn?: string; + sortDirection?: Prisma.SortOrder; + take?: number; + }): Promise { + let orderBy: Prisma.Enumerable = + [{ symbol: 'asc' }]; + const where: Prisma.SymbolProfileWhereInput = {}; + + if (presetId === 'BENCHMARKS') { + const benchmarkAssetProfiles = + await this.benchmarkService.getBenchmarkAssetProfiles(); + + where.id = { + in: benchmarkAssetProfiles.map(({ id }) => { + return id; + }) + }; + } else if (presetId === 'CURRENCIES') { + return this.getMarketDataForCurrencies(); + } else if ( + presetId === 'ETF_WITHOUT_COUNTRIES' || + presetId === 'ETF_WITHOUT_SECTORS' + ) { + filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }]; + } else if (presetId === 'NO_ACTIVITIES') { + where.activities = { + none: {} + }; + } + + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + + const { + ASSET_SUB_CLASS: filtersByAssetSubClass, + DATA_SOURCE: filtersByDataSource + } = groupBy(filters, ({ type }) => { + return type; + }); + + const marketDataItems = await this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }); + + if (filtersByAssetSubClass) { + where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id]; + } + + if (filtersByDataSource) { + where.dataSource = DataSource[filtersByDataSource[0].id]; + } + + if (searchQuery) { + where.OR = [ + { id: { mode: 'insensitive', startsWith: searchQuery } }, + { isin: { mode: 'insensitive', startsWith: searchQuery } }, + { name: { mode: 'insensitive', startsWith: searchQuery } }, + { symbol: { mode: 'insensitive', startsWith: searchQuery } } + ]; + } + + if (sortColumn) { + orderBy = [{ [sortColumn]: sortDirection }]; + + if (sortColumn === 'activitiesCount') { + orderBy = [ + { + activities: { + _count: sortDirection + } + } + ]; + } + } + + const extendedPrismaClient = this.getExtendedPrismaClient(); + + try { + const symbolProfileResult = await Promise.all([ + extendedPrismaClient.symbolProfile.findMany({ + skip, + take, + where, + orderBy: [...orderBy, { id: sortDirection }], + select: { + _count: { + select: { + activities: true, + watchedBy: true + } + }, + activities: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + assetClass: true, + assetSubClass: true, + comment: true, + countries: true, + currency: true, + dataSource: true, + id: true, + isActive: true, + isUsedByUsersWithSubscription: true, + name: true, + scraperConfiguration: true, + sectors: true, + symbol: true, + SymbolProfileOverrides: true + } + }), + this.prismaService.symbolProfile.count({ where }) + ]); + const assetProfiles = symbolProfileResult[0]; + let count = symbolProfileResult[1]; + + const lastMarketPrices = await this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: assetProfiles.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: assetProfiles.map(({ symbol }) => { + return symbol; + }) + } + } + }); + + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + let marketData: AdminMarketDataItem[] = await Promise.all( + assetProfiles.map( + async ({ + _count, + activities, + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource, + id, + isActive, + isUsedByUsersWithSubscription, + name, + sectors, + symbol, + SymbolProfileOverrides + }) => { + let countriesCount = countries ? Object.keys(countries).length : 0; + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + let sectorsCount = sectors ? Object.keys(sectors).length : 0; + + if (SymbolProfileOverrides) { + assetClass = SymbolProfileOverrides.assetClass ?? assetClass; + assetSubClass = + SymbolProfileOverrides.assetSubClass ?? assetSubClass; + + if ( + ( + SymbolProfileOverrides.countries as unknown as Prisma.JsonArray + )?.length > 0 + ) { + countriesCount = ( + SymbolProfileOverrides.countries as unknown as Prisma.JsonArray + ).length; + } + + name = SymbolProfileOverrides.name ?? name; + + if ( + (SymbolProfileOverrides.sectors as unknown as Sector[]) + ?.length > 0 + ) { + sectorsCount = ( + SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray + ).length; + } + } + + return { + assetClass, + assetSubClass, + comment, + currency, + countriesCount, + dataSource, + id, + isActive, + lastMarketPrice, + name, + symbol, + marketDataItemCount, + sectorsCount, + activitiesCount: _count.activities, + date: activities?.[0]?.date, + isUsedByUsersWithSubscription: + await isUsedByUsersWithSubscription, + watchedByCount: _count.watchedBy + }; + } + ) + ); + + if (presetId) { + if (presetId === 'ETF_WITHOUT_COUNTRIES') { + marketData = marketData.filter(({ countriesCount }) => { + return countriesCount === 0; + }); + } else if (presetId === 'ETF_WITHOUT_SECTORS') { + marketData = marketData.filter(({ sectorsCount }) => { + return sectorsCount === 0; + }); + } + + count = marketData.length; + } + + return { + count, + marketData + }; + } finally { + await extendedPrismaClient.$disconnect(); + + Logger.debug('Disconnect extended prisma client', 'AdminService'); + } + } + + public async getMarketDataBySymbol({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise { + let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; + let currency: EnhancedSymbolProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + + if (isCurrency(getCurrencyFromSymbol(symbol))) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.orderService.getStatisticsByCurrency(currency)); + } + + const [[assetProfile], marketData] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]), + this.marketDataService.marketDataItems({ + orderBy: { + date: 'asc' + }, + where: { + dataSource, + symbol + } + }) + ]); + + if (assetProfile) { + assetProfile.dataProviderInfo = this.dataProviderService + .getDataProvider(assetProfile.dataSource) + .getDataProviderInfo(); + } + + return { + marketData, + assetProfile: assetProfile ?? { + activitiesCount, + currency, + dataSource, + dateOfFirstActivity, + symbol, + isActive: true + } + }; + } + + public async getUser(id: string): Promise { + const [user] = await this.getUsersWithAnalytics({ + where: { id } + }); + + if (!user) { + throw new NotFoundException(`User with ID ${id} not found`); + } + + return user; + } + + public async getUsers({ + skip, + take = Number.MAX_SAFE_INTEGER + }: { + skip?: number; + take?: number; + }): Promise { + const [count, users] = await Promise.all([ + this.countUsersWithAnalytics(), + this.getUsersWithAnalytics({ + skip, + take + }) + ]); + + return { count, users }; + } + + public async patchAssetProfileData( + { dataSource, symbol }: AssetProfileIdentifier, + { + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource: newDataSource, + holdings, + isActive, + name, + scraperConfiguration, + sectors, + symbol: newSymbol, + symbolMapping, + url + }: Prisma.SymbolProfileUpdateInput + ) { + if ( + newSymbol && + newDataSource && + (newSymbol !== symbol || newDataSource !== dataSource) + ) { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { + dataSource: DataSource[newDataSource.toString()], + symbol: newSymbol as string + } + ]); + + if (assetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.CONFLICT), + StatusCodes.CONFLICT + ); + } + + try { + Promise.all([ + await this.symbolProfileService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + { + dataSource: DataSource[newDataSource.toString()], + symbol: newSymbol as string + } + ), + await this.marketDataService.updateAssetProfileIdentifier( + { + dataSource, + symbol + }, + { + dataSource: DataSource[newDataSource.toString()], + symbol: newSymbol as string + } + ) + ]); + + return this.symbolProfileService.getSymbolProfiles([ + { + dataSource: DataSource[newDataSource.toString()], + symbol: newSymbol as string + } + ])?.[0]; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } else { + const symbolProfileOverrides = { + assetClass: assetClass as AssetClass, + assetSubClass: assetSubClass as AssetSubClass, + name: name as string, + url: url as string + }; + + const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = { + comment, + countries, + currency, + dataSource, + holdings, + isActive, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + ...(dataSource === 'MANUAL' + ? { assetClass, assetSubClass, name, url } + : { + SymbolProfileOverrides: { + upsert: { + create: symbolProfileOverrides, + update: symbolProfileOverrides + } + } + }) + }; + + await this.symbolProfileService.updateSymbolProfile( + { + dataSource, + symbol + }, + updatedSymbolProfile + ); + + return this.symbolProfileService.getSymbolProfiles([ + { + dataSource: dataSource as DataSource, + symbol: symbol as string + } + ])?.[0]; + } + } + + public async putSetting(key: string, value: string) { + let response: Property; + + if (value) { + response = await this.propertyService.put({ key, value }); + } else { + response = await this.propertyService.delete({ key }); + } + + if (key === PROPERTY_IS_READ_ONLY_MODE && value === 'true') { + await this.putSetting(PROPERTY_IS_USER_SIGNUP_ENABLED, 'false'); + } else if (key === PROPERTY_CURRENCIES) { + await this.exchangeRateDataService.initialize(); + } + + return response; + } + + private async countUsersWithAnalytics() { + let where: Prisma.UserWhereInput; + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + where = { + NOT: { + analytics: null + } + }; + } + + return this.prismaService.user.count({ + where + }); + } + + private getExtendedPrismaClient() { + Logger.debug('Connect extended prisma client', 'AdminService'); + + const symbolProfileExtension = Prisma.defineExtension((client) => { + return client.$extends({ + result: { + symbolProfile: { + isUsedByUsersWithSubscription: { + compute: async ({ id }) => { + const { _count } = + await this.prismaService.symbolProfile.findUnique({ + select: { + _count: { + select: { + activities: { + where: { + user: { + subscriptions: { + some: { + expiresAt: { + gt: new Date() + } + } + } + } + } + } + } + } + }, + where: { + id + } + }); + + return _count.activities > 0; + } + } + } + } + }); + }); + + return new PrismaClient().$extends(symbolProfileExtension); + } + + private async getMarketDataForCurrencies(): Promise { + const currencyPairs = this.exchangeRateDataService.getCurrencyPairs(); + + const [lastMarketPrices, marketDataItems] = await Promise.all([ + this.prismaService.marketData.findMany({ + distinct: ['dataSource', 'symbol'], + orderBy: { date: 'desc' }, + select: { + dataSource: true, + marketPrice: true, + symbol: true + }, + where: { + dataSource: { + in: currencyPairs.map(({ dataSource }) => { + return dataSource; + }) + }, + symbol: { + in: currencyPairs.map(({ symbol }) => { + return symbol; + }) + } + } + }), + this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'] + }) + ]); + + const lastMarketPriceMap = new Map(); + + for (const { dataSource, marketPrice, symbol } of lastMarketPrices) { + lastMarketPriceMap.set( + getAssetProfileIdentifier({ dataSource, symbol }), + marketPrice + ); + } + + const marketDataPromise: Promise[] = currencyPairs.map( + async ({ dataSource, symbol }) => { + let activitiesCount: EnhancedSymbolProfile['activitiesCount'] = 0; + let currency: EnhancedSymbolProfile['currency'] = '-'; + let dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + + if (isCurrency(getCurrencyFromSymbol(symbol))) { + currency = getCurrencyFromSymbol(symbol); + ({ activitiesCount, dateOfFirstActivity } = + await this.orderService.getStatisticsByCurrency(currency)); + } + + const lastMarketPrice = lastMarketPriceMap.get( + getAssetProfileIdentifier({ dataSource, symbol }) + ); + + const marketDataItemCount = + marketDataItems.find((marketDataItem) => { + return ( + marketDataItem.dataSource === dataSource && + marketDataItem.symbol === symbol + ); + })?._count ?? 0; + + return { + activitiesCount, + currency, + dataSource, + lastMarketPrice, + marketDataItemCount, + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countriesCount: 0, + date: dateOfFirstActivity, + id: undefined, + isActive: true, + name: symbol, + sectorsCount: 0, + watchedByCount: 0 + }; + } + ); + + const marketData = await Promise.all(marketDataPromise); + return { marketData, count: marketData.length }; + } + + private async getUsersWithAnalytics({ + skip, + take, + where + }: { + skip?: number; + take?: number; + where?: Prisma.UserWhereInput; + }): Promise { + let orderBy: Prisma.Enumerable = [ + { createdAt: 'desc' } + ]; + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + orderBy = [ + { + analytics: { + lastRequestAt: 'desc' + } + } + ]; + + const noAnalyticsCondition: Prisma.UserWhereInput['NOT'] = { + analytics: null + }; + + if (where) { + if (where.NOT) { + where.NOT = { ...where.NOT, ...noAnalyticsCondition }; + } else { + where.NOT = noAnalyticsCondition; + } + } else { + where = { NOT: noAnalyticsCondition }; + } + } + + const usersWithAnalytics = await this.prismaService.user.findMany({ + skip, + take, + where, + orderBy: [...orderBy, { id: 'desc' }], + select: { + _count: { + select: { accounts: true, activities: true } + }, + analytics: { + select: { + activityCount: true, + country: true, + dataProviderGhostfolioDailyRequests: true, + updatedAt: true + } + }, + createdAt: true, + id: true, + provider: true, + role: true, + subscriptions: { + orderBy: { + expiresAt: 'desc' + }, + take: 1, + where: { + expiresAt: { + gt: new Date() + } + } + } + } + }); + + return usersWithAnalytics.map( + ({ _count, analytics, createdAt, id, provider, role, subscriptions }) => { + const daysSinceRegistration = + differenceInDays(new Date(), createdAt) + 1; + const engagement = analytics + ? analytics.activityCount / daysSinceRegistration + : undefined; + + const subscription = + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + subscriptions?.length > 0 + ? subscriptions[0] + : undefined; + + return { + createdAt, + engagement, + id, + provider, + role, + subscription, + accountCount: _count.accounts || 0, + activityCount: _count.activities || 0, + country: analytics?.country, + dailyApiRequests: analytics?.dataProviderGhostfolioDailyRequests || 0, + lastActivity: analytics?.updatedAt + }; + } + ); + } +} diff --git a/apps/api/src/app/admin/queue/queue.controller.ts b/apps/api/src/app/admin/queue/queue.controller.ts new file mode 100644 index 000000000..060abd247 --- /dev/null +++ b/apps/api/src/app/admin/queue/queue.controller.ts @@ -0,0 +1,56 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { AdminJobs } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { + Controller, + Delete, + Get, + Param, + Query, + UseGuards +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { JobStatus } from 'bull'; + +import { QueueService } from './queue.service'; + +@Controller('admin/queue') +export class QueueController { + public constructor(private readonly queueService: QueueService) {} + + @Delete('job') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteJobs( + @Query('status') filterByStatus?: string + ): Promise { + const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined; + return this.queueService.deleteJobs({ status }); + } + + @Get('job') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getJobs( + @Query('status') filterByStatus?: string + ): Promise { + const status = (filterByStatus?.split(',') as JobStatus[]) ?? undefined; + return this.queueService.getJobs({ status }); + } + + @Delete('job/:id') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteJob(@Param('id') id: string): Promise { + return this.queueService.deleteJob(id); + } + + @Get('job/:id/execute') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async executeJob(@Param('id') id: string): Promise { + return this.queueService.executeJob(id); + } +} diff --git a/apps/api/src/app/admin/queue/queue.module.ts b/apps/api/src/app/admin/queue/queue.module.ts new file mode 100644 index 000000000..22d1cefc6 --- /dev/null +++ b/apps/api/src/app/admin/queue/queue.module.ts @@ -0,0 +1,14 @@ +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; + +import { Module } from '@nestjs/common'; + +import { QueueController } from './queue.controller'; +import { QueueService } from './queue.service'; + +@Module({ + controllers: [QueueController], + imports: [DataGatheringModule, PortfolioSnapshotQueueModule], + providers: [QueueService] +}) +export class QueueModule {} diff --git a/apps/api/src/app/admin/queue/queue.service.ts b/apps/api/src/app/admin/queue/queue.service.ts new file mode 100644 index 000000000..747c4d6fb --- /dev/null +++ b/apps/api/src/app/admin/queue/queue.service.ts @@ -0,0 +1,91 @@ +import { + DATA_GATHERING_QUEUE, + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, + QUEUE_JOB_STATUS_LIST +} from '@ghostfolio/common/config'; +import { AdminJobs } from '@ghostfolio/common/interfaces'; + +import { InjectQueue } from '@nestjs/bull'; +import { Injectable } from '@nestjs/common'; +import { JobStatus, Queue } from 'bull'; + +@Injectable() +export class QueueService { + public constructor( + @InjectQueue(DATA_GATHERING_QUEUE) + private readonly dataGatheringQueue: Queue, + @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) + private readonly portfolioSnapshotQueue: Queue + ) {} + + public async deleteJob(aId: string) { + let job = await this.dataGatheringQueue.getJob(aId); + + if (!job) { + job = await this.portfolioSnapshotQueue.getJob(aId); + } + + return job?.remove(); + } + + public async deleteJobs({ + status = QUEUE_JOB_STATUS_LIST + }: { + status?: JobStatus[]; + }) { + for (const statusItem of status) { + const queueStatus = statusItem === 'waiting' ? 'wait' : statusItem; + + await this.dataGatheringQueue.clean(300, queueStatus); + await this.portfolioSnapshotQueue.clean(300, queueStatus); + } + } + + public async executeJob(aId: string) { + let job = await this.dataGatheringQueue.getJob(aId); + + if (!job) { + job = await this.portfolioSnapshotQueue.getJob(aId); + } + + return job?.promote(); + } + + public async getJobs({ + limit = 1000, + status = QUEUE_JOB_STATUS_LIST + }: { + limit?: number; + status?: JobStatus[]; + }): Promise { + const [dataGatheringJobs, portfolioSnapshotJobs] = await Promise.all([ + this.dataGatheringQueue.getJobs(status), + this.portfolioSnapshotQueue.getJobs(status) + ]); + + const jobsWithState = await Promise.all( + [...dataGatheringJobs, ...portfolioSnapshotJobs] + .filter((job) => { + return job; + }) + .slice(0, limit) + .map(async (job) => { + return { + attemptsMade: job.attemptsMade, + data: job.data, + finishedOn: job.finishedOn, + id: job.id, + name: job.name, + opts: job.opts, + stacktrace: job.stacktrace, + state: await job.getState(), + timestamp: job.timestamp + }; + }) + ); + + return { + jobs: jobsWithState + }; + } +} diff --git a/apps/api/src/app/app.controller.ts b/apps/api/src/app/app.controller.ts new file mode 100644 index 000000000..28437391f --- /dev/null +++ b/apps/api/src/app/app.controller.ts @@ -0,0 +1,18 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; + +import { Controller } from '@nestjs/common'; + +@Controller() +export class AppController { + public constructor( + private readonly exchangeRateDataService: ExchangeRateDataService + ) { + this.initialize(); + } + + private async initialize() { + try { + await this.exchangeRateDataService.initialize(); + } catch {} + } +} diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts new file mode 100644 index 000000000..89f52e1ea --- /dev/null +++ b/apps/api/src/app/app.module.ts @@ -0,0 +1,148 @@ +import { EventsModule } from '@ghostfolio/api/events/events.module'; +import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { CronModule } from '@ghostfolio/api/services/cron/cron.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 { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; +import { + DEFAULT_LANGUAGE_CODE, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; + +import { BullModule } from '@nestjs/bull'; +import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { EventEmitterModule } from '@nestjs/event-emitter'; +import { ScheduleModule } from '@nestjs/schedule'; +import { ServeStaticModule } from '@nestjs/serve-static'; +import { StatusCodes } from 'http-status-codes'; +import { join } from 'node:path'; + +import { AccessModule } from './access/access.module'; +import { AccountModule } from './account/account.module'; +import { AdminModule } from './admin/admin.module'; +import { AppController } from './app.controller'; +import { AssetModule } from './asset/asset.module'; +import { AuthDeviceModule } from './auth-device/auth-device.module'; +import { AuthModule } from './auth/auth.module'; +import { CacheModule } from './cache/cache.module'; +import { AiModule } from './endpoints/ai/ai.module'; +import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; +import { AssetsModule } from './endpoints/assets/assets.module'; +import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module'; +import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; +import { MarketDataModule } from './endpoints/market-data/market-data.module'; +import { PlatformsModule } from './endpoints/platforms/platforms.module'; +import { PublicModule } from './endpoints/public/public.module'; +import { SitemapModule } from './endpoints/sitemap/sitemap.module'; +import { TagsModule } from './endpoints/tags/tags.module'; +import { WatchlistModule } from './endpoints/watchlist/watchlist.module'; +import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; +import { ExportModule } from './export/export.module'; +import { HealthModule } from './health/health.module'; +import { ImportModule } from './import/import.module'; +import { InfoModule } from './info/info.module'; +import { LogoModule } from './logo/logo.module'; +import { OrderModule } from './order/order.module'; +import { PlatformModule } from './platform/platform.module'; +import { PortfolioModule } from './portfolio/portfolio.module'; +import { RedisCacheModule } from './redis-cache/redis-cache.module'; +import { SubscriptionModule } from './subscription/subscription.module'; +import { SymbolModule } from './symbol/symbol.module'; +import { UserModule } from './user/user.module'; + +@Module({ + controllers: [AppController], + imports: [ + AdminModule, + AccessModule, + AccountModule, + AiModule, + ApiKeysModule, + AssetModule, + AssetsModule, + AuthDeviceModule, + AuthModule, + BenchmarksModule, + BullModule.forRoot({ + redis: { + db: parseInt(process.env.REDIS_DB ?? '0', 10), + host: process.env.REDIS_HOST, + password: process.env.REDIS_PASSWORD, + port: parseInt(process.env.REDIS_PORT ?? '6379', 10) + } + }), + CacheModule, + ConfigModule.forRoot(), + ConfigurationModule, + CronModule, + DataGatheringModule, + DataProviderModule, + EventEmitterModule.forRoot(), + EventsModule, + ExchangeRateModule, + ExchangeRateDataModule, + ExportModule, + GhostfolioModule, + HealthModule, + ImportModule, + InfoModule, + LogoModule, + MarketDataModule, + OrderModule, + PlatformModule, + PlatformsModule, + PortfolioModule, + PortfolioSnapshotQueueModule, + PrismaModule, + PropertyModule, + PublicModule, + RedisCacheModule, + ScheduleModule.forRoot(), + ServeStaticModule.forRoot({ + exclude: ['/.well-known/*wildcard', '/api/*wildcard', '/sitemap.xml'], + rootPath: join(__dirname, '..', 'client'), + serveStaticOptions: { + setHeaders: (res) => { + if (res.req?.path === '/') { + let languageCode = DEFAULT_LANGUAGE_CODE; + + try { + 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; + } + } + } + }), + ServeStaticModule.forRoot({ + rootPath: join(__dirname, '..', 'client', '.well-known'), + serveRoot: '/.well-known' + }), + SitemapModule, + SubscriptionModule, + SymbolModule, + TagsModule, + UserModule, + WatchlistModule + ], + providers: [I18nService] +}) +export class AppModule implements NestModule { + public configure(consumer: MiddlewareConsumer) { + consumer.apply(HtmlTemplateMiddleware).forRoutes('*wildcard'); + } +} diff --git a/apps/api/src/app/asset/asset.controller.ts b/apps/api/src/app/asset/asset.controller.ts new file mode 100644 index 000000000..3b2031084 --- /dev/null +++ b/apps/api/src/app/asset/asset.controller.ts @@ -0,0 +1,29 @@ +import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import type { AssetResponse } from '@ghostfolio/common/interfaces'; + +import { Controller, Get, Param, UseInterceptors } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { pick } from 'lodash'; + +@Controller('asset') +export class AssetController { + public constructor(private readonly adminService: AdminService) {} + + @Get(':dataSource/:symbol') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getAsset( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const { assetProfile, marketData } = + await this.adminService.getMarketDataBySymbol({ dataSource, symbol }); + + return { + marketData, + assetProfile: pick(assetProfile, ['dataSource', 'name', 'symbol']) + }; + } +} diff --git a/apps/api/src/app/asset/asset.module.ts b/apps/api/src/app/asset/asset.module.ts new file mode 100644 index 000000000..168585ed8 --- /dev/null +++ b/apps/api/src/app/asset/asset.module.ts @@ -0,0 +1,17 @@ +import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; + +import { Module } from '@nestjs/common'; + +import { AssetController } from './asset.controller'; + +@Module({ + controllers: [AssetController], + imports: [ + AdminModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ] +}) +export class AssetModule {} diff --git a/apps/api/src/app/auth-device/auth-device.controller.ts b/apps/api/src/app/auth-device/auth-device.controller.ts new file mode 100644 index 000000000..15e853465 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.controller.ts @@ -0,0 +1,19 @@ +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { Controller, Delete, Param, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('auth-device') +export class AuthDeviceController { + public constructor(private readonly authDeviceService: AuthDeviceService) {} + + @Delete(':id') + @HasPermission(permissions.deleteAuthDevice) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteAuthDevice(@Param('id') id: string): Promise { + await this.authDeviceService.deleteAuthDevice({ id }); + } +} diff --git a/apps/api/src/app/auth-device/auth-device.module.ts b/apps/api/src/app/auth-device/auth-device.module.ts new file mode 100644 index 000000000..515efa155 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.module.ts @@ -0,0 +1,19 @@ +import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; + +@Module({ + controllers: [AuthDeviceController], + imports: [ + JwtModule.register({ + secret: process.env.JWT_SECRET_KEY, + signOptions: { expiresIn: '180 days' } + }), + PrismaModule + ], + providers: [AuthDeviceService] +}) +export class AuthDeviceModule {} diff --git a/apps/api/src/app/auth-device/auth-device.service.ts b/apps/api/src/app/auth-device/auth-device.service.ts new file mode 100644 index 000000000..59208a1f3 --- /dev/null +++ b/apps/api/src/app/auth-device/auth-device.service.ts @@ -0,0 +1,62 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Injectable } from '@nestjs/common'; +import { AuthDevice, Prisma } from '@prisma/client'; + +@Injectable() +export class AuthDeviceService { + public constructor(private readonly prismaService: PrismaService) {} + + public async authDevice( + where: Prisma.AuthDeviceWhereUniqueInput + ): Promise { + return this.prismaService.authDevice.findUnique({ + where + }); + } + + public async authDevices(params: { + skip?: number; + take?: number; + cursor?: Prisma.AuthDeviceWhereUniqueInput; + where?: Prisma.AuthDeviceWhereInput; + orderBy?: Prisma.AuthDeviceOrderByWithRelationInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prismaService.authDevice.findMany({ + skip, + take, + cursor, + where, + orderBy + }); + } + + public async createAuthDevice( + data: Prisma.AuthDeviceCreateInput + ): Promise { + return this.prismaService.authDevice.create({ + data + }); + } + + public async updateAuthDevice(params: { + data: Prisma.AuthDeviceUpdateInput; + where: Prisma.AuthDeviceWhereUniqueInput; + }): Promise { + const { data, where } = params; + + return this.prismaService.authDevice.update({ + data, + where + }); + } + + public async deleteAuthDevice( + where: Prisma.AuthDeviceWhereUniqueInput + ): Promise { + return this.prismaService.authDevice.delete({ + where + }); + } +} diff --git a/apps/api/src/app/auth/api-key.strategy.ts b/apps/api/src/app/auth/api-key.strategy.ts new file mode 100644 index 000000000..f9937aaa7 --- /dev/null +++ b/apps/api/src/app/auth/api-key.strategy.ts @@ -0,0 +1,70 @@ +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { HEADER_KEY_TOKEN } from '@ghostfolio/common/config'; +import { hasRole } from '@ghostfolio/common/permissions'; + +import { HttpException, Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { HeaderAPIKeyStrategy } from 'passport-headerapikey'; + +@Injectable() +export class ApiKeyStrategy extends PassportStrategy( + HeaderAPIKeyStrategy, + 'api-key' +) { + public constructor( + private readonly apiKeyService: ApiKeyService, + private readonly configurationService: ConfigurationService, + private readonly prismaService: PrismaService, + private readonly userService: UserService + ) { + super({ header: HEADER_KEY_TOKEN, prefix: 'Api-Key ' }, false); + } + + public async validate(apiKey: string) { + const user = await this.validateApiKey(apiKey); + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (hasRole(user, 'INACTIVE')) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + await this.prismaService.analytics.upsert({ + create: { user: { connect: { id: user.id } } }, + update: { + activityCount: { increment: 1 }, + lastRequestAt: new Date() + }, + where: { userId: user.id } + }); + } + + return user; + } + + private async validateApiKey(apiKey: string) { + if (!apiKey) { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + + try { + const { id } = await this.apiKeyService.getUserByApiKey(apiKey); + + return this.userService.user({ id }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + } +} diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts new file mode 100644 index 000000000..388f1dbd3 --- /dev/null +++ b/apps/api/src/app/auth/auth.controller.ts @@ -0,0 +1,175 @@ +import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; +import { + AssertionCredentialJSON, + AttestationCredentialJSON, + OAuthResponse +} from '@ghostfolio/common/interfaces'; + +import { + Body, + Controller, + Get, + HttpException, + Param, + Post, + Req, + Res, + UseGuards, + Version, + VERSION_NEUTRAL +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Request, Response } from 'express'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +import { AuthService } from './auth.service'; + +@Controller('auth') +export class AuthController { + public constructor( + private readonly authService: AuthService, + private readonly configurationService: ConfigurationService, + private readonly webAuthService: WebAuthService + ) {} + + /** + * @deprecated + */ + @Get('anonymous/:accessToken') + public async accessTokenLoginGet( + @Param('accessToken') accessToken: string + ): Promise { + try { + const authToken = + await this.authService.validateAnonymousLogin(accessToken); + return { authToken }; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } + + @Post('anonymous') + public async accessTokenLogin( + @Body() body: { accessToken: string } + ): Promise { + try { + const authToken = await this.authService.validateAnonymousLogin( + body.accessToken + ); + return { authToken }; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } + + @Get('google') + @UseGuards(AuthGuard('google')) + public googleLogin() { + // Initiates the Google OAuth2 login flow + } + + @Get('google/callback') + @UseGuards(AuthGuard('google')) + @Version(VERSION_NEUTRAL) + public googleLoginCallback( + @Req() request: Request, + @Res() response: Response + ) { + const jwt: string = (request.user as any).jwt; + + if (jwt) { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` + ); + } else { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth` + ); + } + } + + @Get('oidc') + @UseGuards(AuthGuard('oidc')) + @Version(VERSION_NEUTRAL) + public oidcLogin() { + if (!this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } + + @Get('oidc/callback') + @UseGuards(AuthGuard('oidc')) + @Version(VERSION_NEUTRAL) + public oidcLoginCallback(@Req() request: Request, @Res() response: Response) { + const jwt: string = (request.user as any).jwt; + + if (jwt) { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth/${jwt}` + ); + } else { + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/auth` + ); + } + } + + @Post('webauthn/generate-authentication-options') + public async generateAuthenticationOptions( + @Body() body: { deviceId: string } + ) { + return this.webAuthService.generateAuthenticationOptions(body.deviceId); + } + + @Get('webauthn/generate-registration-options') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async generateRegistrationOptions() { + return this.webAuthService.generateRegistrationOptions(); + } + + @Post('webauthn/verify-attestation') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async verifyAttestation( + @Body() body: { deviceName: string; credential: AttestationCredentialJSON } + ) { + return this.webAuthService.verifyAttestation(body.credential); + } + + @Post('webauthn/verify-authentication') + public async verifyAuthentication( + @Body() body: { deviceId: string; credential: AssertionCredentialJSON } + ) { + try { + const authToken = await this.webAuthService.verifyAuthentication( + body.deviceId, + body.credential + ); + return { authToken }; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + } +} diff --git a/apps/api/src/app/auth/auth.module.ts b/apps/api/src/app/auth/auth.module.ts new file mode 100644 index 000000000..9fc5d0925 --- /dev/null +++ b/apps/api/src/app/auth/auth.module.ts @@ -0,0 +1,122 @@ +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; +import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Logger, Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; +import type { StrategyOptions } from 'passport-openidconnect'; + +import { ApiKeyStrategy } from './api-key.strategy'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; +import { GoogleStrategy } from './google.strategy'; +import { JwtStrategy } from './jwt.strategy'; +import { OidcStrategy } from './oidc.strategy'; + +@Module({ + controllers: [AuthController], + imports: [ + ConfigurationModule, + JwtModule.register({ + secret: process.env.JWT_SECRET_KEY, + signOptions: { expiresIn: '180 days' } + }), + PrismaModule, + PropertyModule, + SubscriptionModule, + UserModule + ], + providers: [ + ApiKeyService, + ApiKeyStrategy, + AuthDeviceService, + AuthService, + GoogleStrategy, + JwtStrategy, + { + inject: [AuthService, ConfigurationService], + provide: OidcStrategy, + useFactory: async ( + authService: AuthService, + configurationService: ConfigurationService + ) => { + const isOidcEnabled = configurationService.get( + 'ENABLE_FEATURE_AUTH_OIDC' + ); + + if (!isOidcEnabled) { + return null; + } + + const issuer = configurationService.get('OIDC_ISSUER'); + const scope = configurationService.get('OIDC_SCOPE'); + + const callbackUrl = + configurationService.get('OIDC_CALLBACK_URL') || + `${configurationService.get('ROOT_URL')}/api/auth/oidc/callback`; + + // Check for manual URL overrides + const manualAuthorizationUrl = configurationService.get( + 'OIDC_AUTHORIZATION_URL' + ); + const manualTokenUrl = configurationService.get('OIDC_TOKEN_URL'); + const manualUserInfoUrl = + configurationService.get('OIDC_USER_INFO_URL'); + + let authorizationURL: string; + let tokenURL: string; + let userInfoURL: string; + + if (manualAuthorizationUrl && manualTokenUrl && manualUserInfoUrl) { + // Use manual URLs + authorizationURL = manualAuthorizationUrl; + tokenURL = manualTokenUrl; + userInfoURL = manualUserInfoUrl; + } else { + // Fetch OIDC configuration from discovery endpoint + try { + const response = await fetch( + `${issuer}/.well-known/openid-configuration` + ); + + const config = (await response.json()) as { + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + }; + + // Manual URLs take priority over discovered ones + authorizationURL = + manualAuthorizationUrl || config.authorization_endpoint; + tokenURL = manualTokenUrl || config.token_endpoint; + userInfoURL = manualUserInfoUrl || config.userinfo_endpoint; + } catch (error) { + Logger.error(error, 'OidcStrategy'); + throw new Error('Failed to fetch OIDC configuration from issuer'); + } + } + + const options: StrategyOptions = { + authorizationURL, + issuer, + scope, + tokenURL, + userInfoURL, + callbackURL: callbackUrl, + clientID: configurationService.get('OIDC_CLIENT_ID'), + clientSecret: configurationService.get('OIDC_CLIENT_SECRET') + }; + + return new OidcStrategy(authService, options); + } + }, + WebAuthService + ] +}) +export class AuthModule {} diff --git a/apps/api/src/app/auth/auth.service.ts b/apps/api/src/app/auth/auth.service.ts new file mode 100644 index 000000000..6fe50dce0 --- /dev/null +++ b/apps/api/src/app/auth/auth.service.ts @@ -0,0 +1,74 @@ +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; + +import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; + +import { ValidateOAuthLoginParams } from './interfaces/interfaces'; + +@Injectable() +export class AuthService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly jwtService: JwtService, + private readonly propertyService: PropertyService, + private readonly userService: UserService + ) {} + + public async validateAnonymousLogin(accessToken: string): Promise { + const hashedAccessToken = this.userService.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); + + const [user] = await this.userService.users({ + where: { accessToken: hashedAccessToken } + }); + + if (user) { + return this.jwtService.sign({ + id: user.id + }); + } + + throw new Error(); + } + + public async validateOAuthLogin({ + provider, + thirdPartyId + }: ValidateOAuthLoginParams): Promise { + try { + let [user] = await this.userService.users({ + where: { provider, thirdPartyId } + }); + + if (!user) { + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); + + if (!isUserSignupEnabled) { + throw new Error('Sign up forbidden'); + } + + // Create new user if not found + user = await this.userService.createUser({ + data: { + provider, + thirdPartyId + } + }); + } + + return this.jwtService.sign({ + id: user.id + }); + } catch (error) { + throw new InternalServerErrorException( + 'validateOAuthLogin', + error instanceof Error ? error.message : 'Unknown error' + ); + } + } +} diff --git a/apps/api/src/app/auth/google.strategy.ts b/apps/api/src/app/auth/google.strategy.ts new file mode 100644 index 000000000..3e4b4ca0d --- /dev/null +++ b/apps/api/src/app/auth/google.strategy.ts @@ -0,0 +1,47 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Provider } from '@prisma/client'; +import { DoneCallback } from 'passport'; +import { Profile, Strategy } from 'passport-google-oauth20'; + +import { AuthService } from './auth.service'; + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + public constructor( + private readonly authService: AuthService, + configurationService: ConfigurationService + ) { + super({ + callbackURL: `${configurationService.get( + 'ROOT_URL' + )}/api/auth/google/callback`, + clientID: configurationService.get('GOOGLE_CLIENT_ID'), + clientSecret: configurationService.get('GOOGLE_SECRET'), + passReqToCallback: true, + scope: ['profile'] + }); + } + + public async validate( + _request: any, + _token: string, + _refreshToken: string, + profile: Profile, + done: DoneCallback + ) { + try { + const jwt = await this.authService.validateOAuthLogin({ + provider: Provider.GOOGLE, + thirdPartyId: profile.id + }); + + done(null, { jwt }); + } catch (error) { + Logger.error(error, 'GoogleStrategy'); + done(error, false); + } + } +} diff --git a/apps/api/src/app/auth/interfaces/interfaces.ts b/apps/api/src/app/auth/interfaces/interfaces.ts new file mode 100644 index 000000000..7ddfe41d2 --- /dev/null +++ b/apps/api/src/app/auth/interfaces/interfaces.ts @@ -0,0 +1,31 @@ +import { AuthDeviceDto } from '@ghostfolio/common/dtos'; + +import { Provider } from '@prisma/client'; + +export interface AuthDeviceDialogParams { + authDevice: AuthDeviceDto; +} + +export interface OidcContext { + claims?: { + sub?: string; + }; +} + +export interface OidcIdToken { + sub?: string; +} + +export interface OidcParams { + sub?: string; +} + +export interface OidcProfile { + id?: string; + sub?: string; +} + +export interface ValidateOAuthLoginParams { + provider: Provider; + thirdPartyId: string; +} diff --git a/apps/api/src/app/auth/jwt.strategy.ts b/apps/api/src/app/auth/jwt.strategy.ts new file mode 100644 index 000000000..c70e8fb60 --- /dev/null +++ b/apps/api/src/app/auth/jwt.strategy.ts @@ -0,0 +1,85 @@ +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { + DEFAULT_CURRENCY, + DEFAULT_LANGUAGE_CODE, + HEADER_KEY_TIMEZONE +} from '@ghostfolio/common/config'; +import { hasRole } from '@ghostfolio/common/permissions'; + +import { HttpException, Injectable } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import * as countriesAndTimezones from 'countries-and-timezones'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { ExtractJwt, Strategy } from 'passport-jwt'; + +@Injectable() +export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly prismaService: PrismaService, + private readonly userService: UserService + ) { + super({ + jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), + passReqToCallback: true, + secretOrKey: configurationService.get('JWT_SECRET_KEY') + }); + } + + public async validate(request: Request, { id }: { id: string }) { + try { + const timezone = request.headers[HEADER_KEY_TIMEZONE.toLowerCase()]; + const user = await this.userService.user({ id }); + + if (user) { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (hasRole(user, 'INACTIVE')) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + const country = + countriesAndTimezones.getCountryForTimezone(timezone)?.id; + + await this.prismaService.analytics.upsert({ + create: { country, user: { connect: { id: user.id } } }, + update: { + country, + activityCount: { increment: 1 }, + lastRequestAt: new Date() + }, + where: { userId: user.id } + }); + } + + if (!user.settings.settings.baseCurrency) { + user.settings.settings.baseCurrency = DEFAULT_CURRENCY; + } + + if (!user.settings.settings.language) { + user.settings.settings.language = DEFAULT_LANGUAGE_CODE; + } + + return user; + } else { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + } catch (error) { + if (error?.getStatus?.() === StatusCodes.TOO_MANY_REQUESTS) { + throw error; + } else { + throw new HttpException( + getReasonPhrase(StatusCodes.UNAUTHORIZED), + StatusCodes.UNAUTHORIZED + ); + } + } + } +} diff --git a/apps/api/src/app/auth/oidc-state.store.ts b/apps/api/src/app/auth/oidc-state.store.ts new file mode 100644 index 000000000..653451166 --- /dev/null +++ b/apps/api/src/app/auth/oidc-state.store.ts @@ -0,0 +1,114 @@ +import ms from 'ms'; + +/** + * Custom state store for OIDC authentication that doesn't rely on express-session. + * This store manages OAuth2 state parameters in memory with automatic cleanup. + */ +export class OidcStateStore { + private readonly STATE_EXPIRY_MS = ms('10 minutes'); + + private stateMap = new Map< + string, + { + appState?: unknown; + ctx: { issued?: Date; maxAge?: number; nonce?: string }; + meta?: unknown; + timestamp: number; + } + >(); + + /** + * Store request state. + * Signature matches passport-openidconnect SessionStore + */ + public store( + _req: unknown, + _meta: unknown, + appState: unknown, + ctx: { maxAge?: number; nonce?: string; issued?: Date }, + callback: (err: Error | null, handle?: string) => void + ) { + try { + // Generate a unique handle for this state + const handle = this.generateHandle(); + + this.stateMap.set(handle, { + appState, + ctx, + meta: _meta, + timestamp: Date.now() + }); + + // Clean up expired states + this.cleanup(); + + callback(null, handle); + } catch (error) { + callback(error as Error); + } + } + + /** + * Verify request state. + * Signature matches passport-openidconnect SessionStore + */ + public verify( + _req: unknown, + handle: string, + callback: ( + err: Error | null, + appState?: unknown, + ctx?: { maxAge?: number; nonce?: string; issued?: Date } + ) => void + ) { + try { + const data = this.stateMap.get(handle); + + if (!data) { + return callback(null, undefined, undefined); + } + + if (Date.now() - data.timestamp > this.STATE_EXPIRY_MS) { + // State has expired + this.stateMap.delete(handle); + return callback(null, undefined, undefined); + } + + // Remove state after verification (one-time use) + this.stateMap.delete(handle); + + callback(null, data.ctx, data.appState); + } catch (error) { + callback(error as Error); + } + } + + /** + * Clean up expired states + */ + private cleanup() { + const now = Date.now(); + const expiredKeys: string[] = []; + + for (const [key, value] of this.stateMap.entries()) { + if (now - value.timestamp > this.STATE_EXPIRY_MS) { + expiredKeys.push(key); + } + } + + for (const key of expiredKeys) { + this.stateMap.delete(key); + } + } + + /** + * Generate a cryptographically secure random handle + */ + private generateHandle() { + return ( + Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) + + Date.now().toString(36) + ); + } +} diff --git a/apps/api/src/app/auth/oidc.strategy.ts b/apps/api/src/app/auth/oidc.strategy.ts new file mode 100644 index 000000000..96b284121 --- /dev/null +++ b/apps/api/src/app/auth/oidc.strategy.ts @@ -0,0 +1,69 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Provider } from '@prisma/client'; +import { Request } from 'express'; +import { Strategy, type StrategyOptions } from 'passport-openidconnect'; + +import { AuthService } from './auth.service'; +import { + OidcContext, + OidcIdToken, + OidcParams, + OidcProfile +} from './interfaces/interfaces'; +import { OidcStateStore } from './oidc-state.store'; + +@Injectable() +export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { + private static readonly stateStore = new OidcStateStore(); + + public constructor( + private readonly authService: AuthService, + options: StrategyOptions + ) { + super({ + ...options, + passReqToCallback: true, + store: OidcStrategy.stateStore + }); + } + + public async validate( + _request: Request, + issuer: string, + profile: OidcProfile, + context: OidcContext, + idToken: OidcIdToken, + _accessToken: string, + _refreshToken: string, + params: OidcParams + ) { + try { + const thirdPartyId = + profile?.id ?? + profile?.sub ?? + idToken?.sub ?? + params?.sub ?? + context?.claims?.sub; + + const jwt = await this.authService.validateOAuthLogin({ + thirdPartyId, + provider: Provider.OIDC + }); + + if (!thirdPartyId) { + Logger.error( + `Missing subject identifier in OIDC response from ${issuer}`, + 'OidcStrategy' + ); + + throw new Error('Missing subject identifier in OIDC response'); + } + + return { jwt }; + } catch (error) { + Logger.error(error, 'OidcStrategy'); + throw error; + } + } +} diff --git a/apps/api/src/app/auth/web-auth.service.ts b/apps/api/src/app/auth/web-auth.service.ts new file mode 100644 index 000000000..6cffcd244 --- /dev/null +++ b/apps/api/src/app/auth/web-auth.service.ts @@ -0,0 +1,234 @@ +import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { AuthDeviceDto } from '@ghostfolio/common/dtos'; +import { + AssertionCredentialJSON, + AttestationCredentialJSON +} from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Inject, + Injectable, + InternalServerErrorException, + Logger +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { + generateAuthenticationOptions, + GenerateAuthenticationOptionsOpts, + generateRegistrationOptions, + GenerateRegistrationOptionsOpts, + VerifiedAuthenticationResponse, + VerifiedRegistrationResponse, + verifyAuthenticationResponse, + VerifyAuthenticationResponseOpts, + verifyRegistrationResponse, + VerifyRegistrationResponseOpts +} from '@simplewebauthn/server'; +import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers'; +import ms from 'ms'; + +@Injectable() +export class WebAuthService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly deviceService: AuthDeviceService, + private readonly jwtService: JwtService, + private readonly userService: UserService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + private get expectedOrigin() { + return this.configurationService.get('ROOT_URL'); + } + + private get rpID() { + return new URL(this.configurationService.get('ROOT_URL')).hostname; + } + + public async generateRegistrationOptions() { + const user = this.request.user; + + const opts: GenerateRegistrationOptionsOpts = { + authenticatorSelection: { + authenticatorAttachment: 'platform', + residentKey: 'required', + userVerification: 'preferred' + }, + rpID: this.rpID, + rpName: 'Ghostfolio', + timeout: ms('60 seconds'), + userID: isoUint8Array.fromUTF8String(user.id), + userName: '' + }; + + const registrationOptions = await generateRegistrationOptions(opts); + + await this.userService.updateUser({ + data: { + authChallenge: registrationOptions.challenge + }, + where: { + id: user.id + } + }); + + return registrationOptions; + } + + public async verifyAttestation( + credential: AttestationCredentialJSON + ): Promise { + const user = this.request.user; + const expectedChallenge = user.authChallenge; + let verification: VerifiedRegistrationResponse; + + try { + const opts: VerifyRegistrationResponseOpts = { + expectedChallenge, + expectedOrigin: this.expectedOrigin, + expectedRPID: this.rpID, + requireUserVerification: false, + response: { + clientExtensionResults: credential.clientExtensionResults, + id: credential.id, + rawId: credential.rawId, + response: credential.response, + type: 'public-key' + } + }; + + verification = await verifyRegistrationResponse(opts); + } catch (error) { + Logger.error(error, 'WebAuthService'); + throw new InternalServerErrorException(error.message); + } + + const { registrationInfo, verified } = verification; + + const devices = await this.deviceService.authDevices({ + where: { userId: user.id } + }); + if (registrationInfo && verified) { + const { + credential: { + counter, + id: credentialId, + publicKey: credentialPublicKey + } + } = registrationInfo; + + let existingDevice = devices.find((device) => { + return isoBase64URL.fromBuffer(device.credentialId) === credentialId; + }); + + if (!existingDevice) { + /** + * Add the returned device to the user's list of devices + */ + existingDevice = await this.deviceService.createAuthDevice({ + counter, + credentialId: Buffer.from(credentialId), + credentialPublicKey: Buffer.from(credentialPublicKey), + user: { connect: { id: user.id } } + }); + } + + return { + createdAt: existingDevice.createdAt.toISOString(), + id: existingDevice.id + }; + } + + throw new InternalServerErrorException('An unknown error occurred'); + } + + public async generateAuthenticationOptions(deviceId: string) { + const device = await this.deviceService.authDevice({ id: deviceId }); + + if (!device) { + throw new Error('Device not found'); + } + + const opts: GenerateAuthenticationOptionsOpts = { + allowCredentials: [], + rpID: this.rpID, + timeout: ms('60 seconds'), + userVerification: 'preferred' + }; + + const authenticationOptions = await generateAuthenticationOptions(opts); + + await this.userService.updateUser({ + data: { + authChallenge: authenticationOptions.challenge + }, + where: { + id: device.userId + } + }); + + return authenticationOptions; + } + + public async verifyAuthentication( + deviceId: string, + credential: AssertionCredentialJSON + ) { + const device = await this.deviceService.authDevice({ id: deviceId }); + + if (!device) { + throw new Error('Device not found'); + } + + const user = await this.userService.user({ id: device.userId }); + + let verification: VerifiedAuthenticationResponse; + + try { + const opts: VerifyAuthenticationResponseOpts = { + credential: { + counter: device.counter, + id: isoBase64URL.fromBuffer(device.credentialId), + publicKey: device.credentialPublicKey + }, + expectedChallenge: `${user.authChallenge}`, + expectedOrigin: this.expectedOrigin, + expectedRPID: this.rpID, + requireUserVerification: false, + response: { + clientExtensionResults: credential.clientExtensionResults, + id: credential.id, + rawId: credential.rawId, + response: credential.response, + type: 'public-key' + } + }; + + verification = await verifyAuthenticationResponse(opts); + } catch (error) { + Logger.error(error, 'WebAuthService'); + throw new InternalServerErrorException({ error: error.message }); + } + + const { authenticationInfo, verified } = verification; + + if (verified) { + device.counter = authenticationInfo.newCounter; + + await this.deviceService.updateAuthDevice({ + data: device, + where: { id: device.id } + }); + + return this.jwtService.sign({ + id: user.id + }); + } + + throw new Error(); + } +} diff --git a/apps/api/src/app/cache/cache.controller.ts b/apps/api/src/app/cache/cache.controller.ts new file mode 100644 index 000000000..4d34a2eff --- /dev/null +++ b/apps/api/src/app/cache/cache.controller.ts @@ -0,0 +1,19 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { Controller, Post, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('cache') +export class CacheController { + public constructor(private readonly redisCacheService: RedisCacheService) {} + + @HasPermission(permissions.accessAdminControl) + @Post('flush') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async flushCache(): Promise { + await this.redisCacheService.reset(); + } +} diff --git a/apps/api/src/app/cache/cache.module.ts b/apps/api/src/app/cache/cache.module.ts new file mode 100644 index 000000000..d435c72a6 --- /dev/null +++ b/apps/api/src/app/cache/cache.module.ts @@ -0,0 +1,11 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; + +import { Module } from '@nestjs/common'; + +import { CacheController } from './cache.controller'; + +@Module({ + controllers: [CacheController], + imports: [RedisCacheModule] +}) +export class CacheModule {} diff --git a/apps/api/src/app/endpoints/ai/ai.controller.ts b/apps/api/src/app/endpoints/ai/ai.controller.ts new file mode 100644 index 000000000..b1607b53b --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.controller.ts @@ -0,0 +1,59 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { AiPromptResponse } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Get, + Inject, + Param, + Query, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; + +import { AiService } from './ai.service'; + +@Controller('ai') +export class AiController { + public constructor( + private readonly aiService: AiService, + private readonly apiService: ApiService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get('prompt/:mode') + @HasPermission(permissions.readAiPrompt) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getPrompt( + @Param('mode') mode: AiPromptMode, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + const prompt = await this.aiService.getPrompt({ + filters, + mode, + impersonationId: undefined, + languageCode: this.request.user.settings.settings.language, + userCurrency: this.request.user.settings.settings.baseCurrency, + userId: this.request.user.id + }); + + return { prompt }; + } +} diff --git a/apps/api/src/app/endpoints/ai/ai.module.ts b/apps/api/src/app/endpoints/ai/ai.module.ts new file mode 100644 index 000000000..8a441fde7 --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.module.ts @@ -0,0 +1,59 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { AiController } from './ai.controller'; +import { AiService } from './ai.service'; + +@Module({ + controllers: [AiController], + imports: [ + ApiModule, + BenchmarkModule, + ConfigurationModule, + DataProviderModule, + ExchangeRateDataModule, + I18nModule, + ImpersonationModule, + MarketDataModule, + OrderModule, + PortfolioSnapshotQueueModule, + PrismaModule, + PropertyModule, + RedisCacheModule, + SymbolProfileModule, + UserModule + ], + providers: [ + AccountBalanceService, + AccountService, + AiService, + CurrentRateService, + MarketDataService, + PortfolioCalculatorFactory, + PortfolioService, + RulesService + ] +}) +export class AiModule {} diff --git a/apps/api/src/app/endpoints/ai/ai.service.ts b/apps/api/src/app/endpoints/ai/ai.service.ts new file mode 100644 index 000000000..d07768d69 --- /dev/null +++ b/apps/api/src/app/endpoints/ai/ai.service.ts @@ -0,0 +1,169 @@ +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_API_KEY_OPENROUTER, + PROPERTY_OPENROUTER_MODEL +} from '@ghostfolio/common/config'; +import { Filter } from '@ghostfolio/common/interfaces'; +import type { AiPromptMode } from '@ghostfolio/common/types'; + +import { Injectable } from '@nestjs/common'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; +import type { ColumnDescriptor } from 'tablemark'; + +@Injectable() +export class AiService { + private static readonly HOLDINGS_TABLE_COLUMN_DEFINITIONS: ({ + key: + | 'ALLOCATION_PERCENTAGE' + | 'ASSET_CLASS' + | 'ASSET_SUB_CLASS' + | 'CURRENCY' + | 'NAME' + | 'SYMBOL'; + } & ColumnDescriptor)[] = [ + { key: 'NAME', name: 'Name' }, + { key: 'SYMBOL', name: 'Symbol' }, + { key: 'CURRENCY', name: 'Currency' }, + { key: 'ASSET_CLASS', name: 'Asset Class' }, + { key: 'ASSET_SUB_CLASS', name: 'Asset Sub Class' }, + { + align: 'right', + key: 'ALLOCATION_PERCENTAGE', + name: 'Allocation in Percentage' + } + ]; + + public constructor( + private readonly portfolioService: PortfolioService, + private readonly propertyService: PropertyService + ) {} + + public async generateText({ prompt }: { prompt: string }) { + const openRouterApiKey = await this.propertyService.getByKey( + PROPERTY_API_KEY_OPENROUTER + ); + + const openRouterModel = await this.propertyService.getByKey( + PROPERTY_OPENROUTER_MODEL + ); + + const openRouterService = createOpenRouter({ + apiKey: openRouterApiKey + }); + + return generateText({ + prompt, + model: openRouterService.chat(openRouterModel) + }); + } + + public async getPrompt({ + filters, + impersonationId, + languageCode, + mode, + userCurrency, + userId + }: { + filters?: Filter[]; + impersonationId: string; + languageCode: string; + mode: AiPromptMode; + userCurrency: string; + userId: string; + }) { + const { holdings } = await this.portfolioService.getDetails({ + filters, + impersonationId, + userId + }); + + const holdingsTableColumns: ColumnDescriptor[] = + AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.map(({ align, name }) => { + return { name, align: align ?? 'left' }; + }); + + const holdingsTableRows = Object.values(holdings) + .sort((a, b) => { + return b.allocationInPercentage - a.allocationInPercentage; + }) + .map( + ({ + allocationInPercentage, + assetClass, + assetSubClass, + currency, + name: label, + symbol + }) => { + return AiService.HOLDINGS_TABLE_COLUMN_DEFINITIONS.reduce( + (row, { key, name }) => { + switch (key) { + case 'ALLOCATION_PERCENTAGE': + row[name] = `${(allocationInPercentage * 100).toFixed(3)}%`; + break; + + case 'ASSET_CLASS': + row[name] = assetClass ?? ''; + break; + + case 'ASSET_SUB_CLASS': + row[name] = assetSubClass ?? ''; + break; + + case 'CURRENCY': + row[name] = currency; + break; + + case 'NAME': + row[name] = label; + break; + + case 'SYMBOL': + row[name] = symbol; + break; + + default: + row[name] = ''; + break; + } + + return row; + }, + {} as Record + ); + } + ); + + // Dynamic import to load ESM module from CommonJS context + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const dynamicImport = new Function('s', 'return import(s)') as ( + s: string + ) => Promise; + const { tablemark } = await dynamicImport('tablemark'); + + const holdingsTableString = tablemark(holdingsTableRows, { + columns: holdingsTableColumns + }); + + if (mode === 'portfolio') { + return holdingsTableString; + } + + return [ + `You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, + holdingsTableString, + 'Structure your answer with these sections:', + 'Overview: Briefly summarize the portfolio’s composition and allocation rationale.', + 'Risk Assessment: Identify potential risks, including market volatility, concentration, and sectoral imbalances.', + 'Advantages: Highlight strengths, focusing on growth potential, diversification, or other benefits.', + 'Disadvantages: Point out weaknesses, such as overexposure or lack of defensive assets.', + 'Target Group: Discuss who this portfolio might suit (e.g., risk tolerance, investment goals, life stages, and experience levels).', + 'Optimization Ideas: Offer ideas to complement the portfolio, ensuring they are constructive and neutral in tone.', + 'Conclusion: Provide a concise summary highlighting key insights.', + `Provide your answer in the following language: ${languageCode}.` + ].join('\n'); + } +} diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts new file mode 100644 index 000000000..cbc68df93 --- /dev/null +++ b/apps/api/src/app/endpoints/api-keys/api-keys.controller.ts @@ -0,0 +1,25 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ApiKeyService } from '@ghostfolio/api/services/api-key/api-key.service'; +import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('api-keys') +export class ApiKeysController { + public constructor( + private readonly apiKeyService: ApiKeyService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @HasPermission(permissions.createApiKey) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createApiKey(): Promise { + return this.apiKeyService.create({ userId: this.request.user.id }); + } +} diff --git a/apps/api/src/app/endpoints/api-keys/api-keys.module.ts b/apps/api/src/app/endpoints/api-keys/api-keys.module.ts new file mode 100644 index 000000000..123f11854 --- /dev/null +++ b/apps/api/src/app/endpoints/api-keys/api-keys.module.ts @@ -0,0 +1,11 @@ +import { ApiKeyModule } from '@ghostfolio/api/services/api-key/api-key.module'; + +import { Module } from '@nestjs/common'; + +import { ApiKeysController } from './api-keys.controller'; + +@Module({ + controllers: [ApiKeysController], + imports: [ApiKeyModule] +}) +export class ApiKeysModule {} diff --git a/apps/api/src/app/endpoints/assets/assets.controller.ts b/apps/api/src/app/endpoints/assets/assets.controller.ts new file mode 100644 index 000000000..a314b3f19 --- /dev/null +++ b/apps/api/src/app/endpoints/assets/assets.controller.ts @@ -0,0 +1,46 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { interpolate } from '@ghostfolio/common/helper'; + +import { + Controller, + Get, + Param, + Res, + Version, + VERSION_NEUTRAL +} from '@nestjs/common'; +import { Response } from 'express'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +@Controller('assets') +export class AssetsController { + private webManifest = ''; + + public constructor( + public readonly configurationService: ConfigurationService + ) { + try { + this.webManifest = readFileSync( + join(__dirname, 'assets', 'site.webmanifest'), + 'utf8' + ); + } catch {} + } + + @Get('/:languageCode/site.webmanifest') + @Version(VERSION_NEUTRAL) + public getWebManifest( + @Param('languageCode') languageCode: string, + @Res() response: Response + ): void { + const rootUrl = this.configurationService.get('ROOT_URL'); + const webManifest = interpolate(this.webManifest, { + languageCode, + rootUrl + }); + + response.setHeader('Content-Type', 'application/json'); + response.send(webManifest); + } +} diff --git a/apps/api/src/app/endpoints/assets/assets.module.ts b/apps/api/src/app/endpoints/assets/assets.module.ts new file mode 100644 index 000000000..51d330e50 --- /dev/null +++ b/apps/api/src/app/endpoints/assets/assets.module.ts @@ -0,0 +1,11 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Module } from '@nestjs/common'; + +import { AssetsController } from './assets.controller'; + +@Module({ + controllers: [AssetsController], + providers: [ConfigurationService] +}) +export class AssetsModule {} diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts new file mode 100644 index 000000000..629d90928 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts @@ -0,0 +1,156 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import type { + AssetProfileIdentifier, + BenchmarkMarketDataDetailsResponse, + BenchmarkResponse +} from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + Headers, + HttpException, + Inject, + Param, + Post, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { BenchmarksService } from './benchmarks.service'; + +@Controller('benchmarks') +export class BenchmarksController { + public constructor( + private readonly apiService: ApiService, + private readonly benchmarkService: BenchmarkService, + private readonly benchmarksService: BenchmarksService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @HasPermission(permissions.accessAdminControl) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async addBenchmark( + @Body() { dataSource, symbol }: AssetProfileIdentifier + ) { + try { + const benchmark = await this.benchmarkService.addBenchmark({ + dataSource, + symbol + }); + + if (!benchmark) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return benchmark; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Delete(':dataSource/:symbol') + @HasPermission(permissions.accessAdminControl) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteBenchmark( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ) { + try { + const benchmark = await this.benchmarkService.deleteBenchmark({ + dataSource, + symbol + }); + + if (!benchmark) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return benchmark; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get() + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getBenchmark(): Promise { + return { + benchmarks: await this.benchmarkService.getBenchmarks() + }; + } + + @Get(':dataSource/:symbol/:startDateString') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getBenchmarkMarketDataForUser( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('startDateString') startDateString: string, + @Param('symbol') symbol: string, + @Query('range') dateRange: DateRange = 'max', + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string, + @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' + ): Promise { + const { endDate, startDate } = getIntervalFromDateRange( + dateRange, + new Date(startDateString) + ); + + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + const withExcludedAccounts = withExcludedAccountsParam === 'true'; + + return this.benchmarksService.getMarketDataForUser({ + dataSource, + dateRange, + endDate, + filters, + impersonationId, + startDate, + symbol, + withExcludedAccounts, + user: this.request.user + }); + } +} diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts new file mode 100644 index 000000000..8bdf79035 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts @@ -0,0 +1,65 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { BenchmarksController } from './benchmarks.controller'; +import { BenchmarksService } from './benchmarks.service'; + +@Module({ + controllers: [BenchmarksController], + imports: [ + ApiModule, + ConfigurationModule, + DataProviderModule, + ExchangeRateDataModule, + I18nModule, + ImpersonationModule, + MarketDataModule, + OrderModule, + PortfolioSnapshotQueueModule, + PrismaModule, + PropertyModule, + RedisCacheModule, + SymbolModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule, + UserModule + ], + providers: [ + AccountBalanceService, + AccountService, + BenchmarkService, + BenchmarksService, + CurrentRateService, + MarketDataService, + PortfolioCalculatorFactory, + PortfolioService, + RulesService + ] +}) +export class BenchmarksModule {} diff --git a/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts new file mode 100644 index 000000000..03ff32c21 --- /dev/null +++ b/apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts @@ -0,0 +1,163 @@ +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + BenchmarkMarketDataDetailsResponse, + Filter +} from '@ghostfolio/common/interfaces'; +import { DateRange, UserWithSettings } from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; +import { format, isSameDay } from 'date-fns'; +import { isNumber } from 'lodash'; + +@Injectable() +export class BenchmarksService { + public constructor( + private readonly benchmarkService: BenchmarkService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly marketDataService: MarketDataService, + private readonly portfolioService: PortfolioService, + private readonly symbolService: SymbolService + ) {} + + public async getMarketDataForUser({ + dataSource, + dateRange, + endDate = new Date(), + filters, + impersonationId, + startDate, + symbol, + user, + withExcludedAccounts + }: { + dateRange: DateRange; + endDate?: Date; + filters?: Filter[]; + impersonationId: string; + startDate: Date; + user: UserWithSettings; + withExcludedAccounts?: boolean; + } & AssetProfileIdentifier): Promise { + const marketData: { date: string; value: number }[] = []; + const userCurrency = user.settings.settings.baseCurrency; + const userId = user.id; + + const { chart } = await this.portfolioService.getPerformance({ + dateRange, + filters, + impersonationId, + userId, + withExcludedAccounts + }); + + const [currentSymbolItem, marketDataItems] = await Promise.all([ + this.symbolService.get({ + dataGatheringItem: { + dataSource, + symbol + } + }), + this.marketDataService.marketDataItems({ + orderBy: { + date: 'asc' + }, + where: { + dataSource, + symbol, + date: { + in: chart.map(({ date }) => { + return resetHours(parseDate(date)); + }) + } + } + }) + ]); + + const exchangeRates = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + startDate, + currencies: [currentSymbolItem.currency], + targetCurrency: userCurrency + }); + + const exchangeRateAtStartDate = + exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ + format(startDate, DATE_FORMAT) + ]; + + const marketPriceAtStartDate = marketDataItems?.find(({ date }) => { + return isSameDay(date, startDate); + })?.marketPrice; + + if (!marketPriceAtStartDate) { + Logger.error( + `No historical market data has been found for ${symbol} (${dataSource}) at ${format( + startDate, + DATE_FORMAT + )}`, + 'BenchmarkService' + ); + + return { marketData }; + } + + for (const marketDataItem of marketDataItems) { + const exchangeRate = + exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ + format(marketDataItem.date, DATE_FORMAT) + ]; + + const exchangeRateFactor = + isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) + ? exchangeRate / exchangeRateAtStartDate + : 1; + + marketData.push({ + date: format(marketDataItem.date, DATE_FORMAT), + value: + marketPriceAtStartDate === 0 + ? 0 + : this.benchmarkService.calculateChangeInPercentage( + marketPriceAtStartDate, + marketDataItem.marketPrice * exchangeRateFactor + ) * 100 + }); + } + + const includesEndDate = isSameDay( + parseDate(marketData.at(-1).date), + endDate + ); + + if (currentSymbolItem?.marketPrice && !includesEndDate) { + const exchangeRate = + exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[ + format(endDate, DATE_FORMAT) + ]; + + const exchangeRateFactor = + isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate) + ? exchangeRate / exchangeRateAtStartDate + : 1; + + marketData.push({ + date: format(endDate, DATE_FORMAT), + value: + this.benchmarkService.calculateChangeInPercentage( + marketPriceAtStartDate, + currentSymbolItem.marketPrice * exchangeRateFactor + ) * 100 + }); + } + + return { + marketData + }; + } +} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts new file mode 100644 index 000000000..6df457c64 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-dividends.dto.ts @@ -0,0 +1,15 @@ +import { Granularity } from '@ghostfolio/common/types'; + +import { IsIn, IsISO8601, IsOptional } from 'class-validator'; + +export class GetDividendsDto { + @IsISO8601() + from: string; + + @IsIn(['day', 'month'] as Granularity[]) + @IsOptional() + granularity: Granularity; + + @IsISO8601() + to: string; +} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts new file mode 100644 index 000000000..385c51d52 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-historical.dto.ts @@ -0,0 +1,15 @@ +import { Granularity } from '@ghostfolio/common/types'; + +import { IsIn, IsISO8601, IsOptional } from 'class-validator'; + +export class GetHistoricalDto { + @IsISO8601() + from: string; + + @IsIn(['day', 'month'] as Granularity[]) + @IsOptional() + granularity: Granularity; + + @IsISO8601() + to: string; +} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts new file mode 100644 index 000000000..e83c1be82 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/get-quotes.dto.ts @@ -0,0 +1,10 @@ +import { Transform } from 'class-transformer'; +import { IsString } from 'class-validator'; + +export class GetQuotesDto { + @IsString({ each: true }) + @Transform(({ value }) => + typeof value === 'string' ? value.split(',') : value + ) + symbols: string[]; +} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts new file mode 100644 index 000000000..04165e9a1 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts @@ -0,0 +1,249 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { AssetProfileInvalidError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-invalid.error'; +import { parseDate } from '@ghostfolio/common/helper'; +import { + DataProviderGhostfolioAssetProfileResponse, + DataProviderGhostfolioStatusResponse, + DividendsResponse, + HistoricalResponse, + LookupResponse, + QuotesResponse +} from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Get, + HttpException, + Inject, + Param, + Query, + UseGuards, + Version +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { isISIN } from 'class-validator'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +import { GetDividendsDto } from './get-dividends.dto'; +import { GetHistoricalDto } from './get-historical.dto'; +import { GetQuotesDto } from './get-quotes.dto'; +import { GhostfolioService } from './ghostfolio.service'; + +@Controller('data-providers/ghostfolio') +export class GhostfolioController { + public constructor( + private readonly ghostfolioService: GhostfolioService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get('asset-profile/:symbol') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + public async getAssetProfile( + @Param('symbol') symbol: string + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const assetProfile = await this.ghostfolioService.getAssetProfile({ + symbol + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return assetProfile; + } catch (error) { + if (error instanceof AssetProfileInvalidError) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('dividends/:symbol') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') + public async getDividends( + @Param('symbol') symbol: string, + @Query() query: GetDividendsDto + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const dividends = await this.ghostfolioService.getDividends({ + symbol, + from: parseDate(query.from), + granularity: query.granularity, + to: parseDate(query.to) + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return dividends; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('historical/:symbol') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') + public async getHistorical( + @Param('symbol') symbol: string, + @Query() query: GetHistoricalDto + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const historicalData = await this.ghostfolioService.getHistorical({ + symbol, + from: parseDate(query.from), + granularity: query.granularity, + to: parseDate(query.to) + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return historicalData; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('lookup') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') + public async lookupSymbol( + @Query('includeIndices') includeIndicesParam = 'false', + @Query('query') query = '' + ): Promise { + const includeIndices = includeIndicesParam === 'true'; + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const result = await this.ghostfolioService.lookup({ + includeIndices, + query: isISIN(query.toUpperCase()) + ? query.toUpperCase() + : query.toLowerCase() + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return result; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('quotes') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') + public async getQuotes( + @Query() query: GetQuotesDto + ): Promise { + const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests(); + + if ( + this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS), + StatusCodes.TOO_MANY_REQUESTS + ); + } + + try { + const quotes = await this.ghostfolioService.getQuotes({ + symbols: query.symbols + }); + + await this.ghostfolioService.incrementDailyRequests({ + userId: this.request.user.id + }); + + return quotes; + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + @Get('status') + @HasPermission(permissions.enableDataProviderGhostfolio) + @UseGuards(AuthGuard('api-key'), HasPermissionGuard) + @Version('2') + public async getStatus(): Promise { + return this.ghostfolioService.getStatus({ user: this.request.user }); + } +} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts new file mode 100644 index 000000000..01691bcf4 --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.module.ts @@ -0,0 +1,83 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; +import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; +import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; +import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; +import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; +import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; +import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; +import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; +import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { GhostfolioController } from './ghostfolio.controller'; +import { GhostfolioService } from './ghostfolio.service'; + +@Module({ + controllers: [GhostfolioController], + imports: [ + CryptocurrencyModule, + DataProviderModule, + MarketDataModule, + PrismaModule, + PropertyModule, + RedisCacheModule, + SymbolProfileModule + ], + providers: [ + AlphaVantageService, + CoinGeckoService, + ConfigurationService, + DataProviderService, + EodHistoricalDataService, + FinancialModelingPrepService, + GhostfolioService, + GoogleSheetsService, + ManualService, + RapidApiService, + YahooFinanceService, + YahooFinanceDataEnhancerService, + { + inject: [ + AlphaVantageService, + CoinGeckoService, + EodHistoricalDataService, + FinancialModelingPrepService, + GoogleSheetsService, + ManualService, + RapidApiService, + YahooFinanceService + ], + provide: 'DataProviderInterfaces', + useFactory: ( + alphaVantageService, + coinGeckoService, + eodHistoricalDataService, + financialModelingPrepService, + googleSheetsService, + manualService, + rapidApiService, + yahooFinanceService + ) => [ + alphaVantageService, + coinGeckoService, + eodHistoricalDataService, + financialModelingPrepService, + googleSheetsService, + manualService, + rapidApiService, + yahooFinanceService + ] + } + ] +}) +export class GhostfolioModule {} diff --git a/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts new file mode 100644 index 000000000..d088bf3ac --- /dev/null +++ b/apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts @@ -0,0 +1,375 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { GhostfolioService as GhostfolioDataProviderService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; +import { + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_CURRENCY, + DERIVED_CURRENCIES +} from '@ghostfolio/common/config'; +import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; +import { + DataProviderGhostfolioAssetProfileResponse, + DataProviderHistoricalResponse, + DataProviderInfo, + DividendsResponse, + HistoricalResponse, + LookupItem, + LookupResponse, + QuotesResponse +} from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import { Big } from 'big.js'; + +@Injectable() +export class GhostfolioService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly dataProviderService: DataProviderService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService + ) {} + + public async getAssetProfile({ symbol }: GetAssetProfileParams) { + let result: DataProviderGhostfolioAssetProfileResponse = {}; + + try { + const promises: Promise>[] = []; + + for (const dataProviderService of this.getDataProviderServices()) { + promises.push( + this.dataProviderService + .getAssetProfiles([ + { + symbol, + dataSource: dataProviderService.getName() + } + ]) + .then(async (assetProfiles) => { + const assetProfile = assetProfiles[symbol]; + const dataSourceOrigin = DataSource.GHOSTFOLIO; + + if (assetProfile) { + await this.prismaService.assetProfileResolution.upsert({ + create: { + dataSourceOrigin, + currency: assetProfile.currency, + dataSourceTarget: assetProfile.dataSource, + symbolOrigin: symbol, + symbolTarget: assetProfile.symbol + }, + update: { + requestCount: { + increment: 1 + } + }, + where: { + dataSourceOrigin_symbolOrigin: { + dataSourceOrigin, + symbolOrigin: symbol + } + } + }); + } + + result = { + ...result, + ...assetProfile, + dataSource: dataSourceOrigin + }; + + return assetProfile; + }) + ); + } + + await Promise.all(promises); + + return result; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + + public async getDividends({ + from, + granularity, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetDividendsParams) { + const result: DividendsResponse = { dividends: {} }; + + try { + const promises: Promise<{ + [date: string]: DataProviderHistoricalResponse; + }>[] = []; + + for (const dataProviderService of this.getDataProviderServices()) { + promises.push( + dataProviderService + .getDividends({ + from, + granularity, + requestTimeout, + symbol, + to + }) + .then((dividends) => { + result.dividends = dividends; + + return dividends; + }) + ); + } + + await Promise.all(promises); + + return result; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + + public async getHistorical({ + from, + granularity, + requestTimeout, + to, + symbol + }: GetHistoricalParams) { + const result: HistoricalResponse = { historicalData: {} }; + + try { + const promises: Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }>[] = []; + + for (const dataProviderService of this.getDataProviderServices()) { + promises.push( + dataProviderService + .getHistorical({ + from, + granularity, + requestTimeout, + symbol, + to + }) + .then((historicalData) => { + result.historicalData = historicalData[symbol]; + + return historicalData; + }) + ); + } + + await Promise.all(promises); + + return result; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + + public async getMaxDailyRequests() { + return parseInt( + (await this.propertyService.getByKey( + PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS + )) || '0', + 10 + ); + } + + public async getQuotes({ requestTimeout, symbols }: GetQuotesParams) { + const results: QuotesResponse = { quotes: {} }; + + try { + const promises: Promise[] = []; + + for (const dataProvider of this.getDataProviderServices()) { + const maximumNumberOfSymbolsPerRequest = + dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? + Number.MAX_SAFE_INTEGER; + + for ( + let i = 0; + i < symbols.length; + i += maximumNumberOfSymbolsPerRequest + ) { + const symbolsChunk = symbols.slice( + i, + i + maximumNumberOfSymbolsPerRequest + ); + + const promise = Promise.resolve( + dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) + ); + + promises.push( + promise.then(async (result) => { + for (const [symbol, dataProviderResponse] of Object.entries( + result + )) { + dataProviderResponse.dataSource = 'GHOSTFOLIO'; + + if ( + [ + ...DERIVED_CURRENCIES.map(({ currency }) => { + return `${DEFAULT_CURRENCY}${currency}`; + }), + `${DEFAULT_CURRENCY}USX` + ].includes(symbol) + ) { + continue; + } + + results.quotes[symbol] = dataProviderResponse; + + for (const { + currency, + factor, + rootCurrency + } of DERIVED_CURRENCIES) { + if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { + results.quotes[`${DEFAULT_CURRENCY}${currency}`] = { + ...dataProviderResponse, + currency, + marketPrice: new Big( + result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice + ) + .mul(factor) + .toNumber(), + marketState: 'open' + }; + } + } + } + }) + ); + } + + await Promise.all(promises); + } + + return results; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + + public async getStatus({ user }: { user: UserWithSettings }) { + return { + dailyRequests: user.dataProviderGhostfolioDailyRequests, + dailyRequestsMax: await this.getMaxDailyRequests(), + subscription: user.subscription + }; + } + + public async incrementDailyRequests({ userId }: { userId: string }) { + await this.prismaService.analytics.update({ + data: { + dataProviderGhostfolioDailyRequests: { increment: 1 } + }, + where: { userId } + }); + } + + public async lookup({ + includeIndices = false, + query + }: GetSearchParams): Promise { + const results: LookupResponse = { items: [] }; + + if (!query) { + return results; + } + + try { + let lookupItems: LookupItem[] = []; + const promises: Promise<{ items: LookupItem[] }>[] = []; + + if (query?.length < 2) { + return { items: lookupItems }; + } + + for (const dataProviderService of this.getDataProviderServices()) { + promises.push( + dataProviderService.search({ + includeIndices, + query + }) + ); + } + + const searchResults = await Promise.all(promises); + + for (const { items } of searchResults) { + if (items?.length > 0) { + lookupItems = lookupItems.concat(items); + } + } + + const filteredItems = lookupItems + .filter(({ currency }) => { + // Only allow symbols with supported currency + return currency ? true : false; + }) + .sort(({ name: name1 }, { name: name2 }) => { + return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); + }) + .map((lookupItem) => { + lookupItem.dataProviderInfo = this.getDataProviderInfo(); + lookupItem.dataSource = 'GHOSTFOLIO'; + + return lookupItem; + }); + + results.items = filteredItems; + + return results; + } catch (error) { + Logger.error(error, 'GhostfolioService'); + + throw error; + } + } + + private getDataProviderInfo(): DataProviderInfo { + const ghostfolioDataProviderService = new GhostfolioDataProviderService( + this.configurationService, + this.propertyService + ); + + return { + ...ghostfolioDataProviderService.getDataProviderInfo(), + isPremium: false, + name: 'Ghostfolio Premium' + }; + } + + private getDataProviderServices() { + return this.configurationService + .get('DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER') + .map((dataSource) => { + return this.dataProviderService.getDataProvider(DataSource[dataSource]); + }); + } +} diff --git a/apps/api/src/app/endpoints/market-data/market-data.controller.ts b/apps/api/src/app/endpoints/market-data/market-data.controller.ts new file mode 100644 index 000000000..0dae82d2c --- /dev/null +++ b/apps/api/src/app/endpoints/market-data/market-data.controller.ts @@ -0,0 +1,193 @@ +import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + ghostfolioFearAndGreedIndexDataSourceStocks, + ghostfolioFearAndGreedIndexSymbolCryptocurrencies, + ghostfolioFearAndGreedIndexSymbolStocks +} from '@ghostfolio/common/config'; +import { UpdateBulkMarketDataDto } from '@ghostfolio/common/dtos'; +import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper'; +import { + MarketDataDetailsResponse, + MarketDataOfMarketsResponse +} from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Get, + HttpException, + Inject, + Param, + Post, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource, Prisma } from '@prisma/client'; +import { parseISO } from 'date-fns'; +import { getReasonPhrase, StatusCodes } from 'http-status-codes'; + +@Controller('market-data') +export class MarketDataController { + public constructor( + private readonly adminService: AdminService, + private readonly marketDataService: MarketDataService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly symbolProfileService: SymbolProfileService, + private readonly symbolService: SymbolService + ) {} + + @Get('markets') + @HasPermission(permissions.readMarketDataOfMarkets) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getMarketDataOfMarkets( + @Query('includeHistoricalData') includeHistoricalData = 0 + ): Promise { + const [ + marketDataFearAndGreedIndexCryptocurrencies, + marketDataFearAndGreedIndexStocks + ] = await Promise.all([ + this.symbolService.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies, + symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies + } + }), + this.symbolService.get({ + includeHistoricalData, + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, + symbol: ghostfolioFearAndGreedIndexSymbolStocks + } + }) + ]); + + return { + fearAndGreedIndex: { + CRYPTOCURRENCIES: { + ...marketDataFearAndGreedIndexCryptocurrencies + }, + STOCKS: { + ...marketDataFearAndGreedIndexStocks + } + } + }; + } + + @Get(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getMarketDataBySymbol( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canReadAllAssetProfiles = hasPermission( + this.request.user.permissions, + permissions.readMarketData + ); + + const canReadOwnAssetProfile = + assetProfile?.userId === this.request.user.id && + hasPermission( + this.request.user.permissions, + permissions.readMarketDataOfOwnAssetProfile + ); + + if (!canReadAllAssetProfiles && !canReadOwnAssetProfile) { + throw new HttpException( + assetProfile.userId + ? getReasonPhrase(StatusCodes.NOT_FOUND) + : getReasonPhrase(StatusCodes.FORBIDDEN), + assetProfile.userId ? StatusCodes.NOT_FOUND : StatusCodes.FORBIDDEN + ); + } + + return this.adminService.getMarketDataBySymbol({ dataSource, symbol }); + } + + @Post(':dataSource/:symbol') + @UseGuards(AuthGuard('jwt')) + public async updateMarketData( + @Body() data: UpdateBulkMarketDataDto, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ) { + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const canUpsertAllAssetProfiles = + hasPermission( + this.request.user.permissions, + permissions.createMarketData + ) && + hasPermission( + this.request.user.permissions, + permissions.updateMarketData + ); + + const canUpsertOwnAssetProfile = + assetProfile?.userId === this.request.user.id && + hasPermission( + this.request.user.permissions, + permissions.createMarketDataOfOwnAssetProfile + ) && + hasPermission( + this.request.user.permissions, + permissions.updateMarketDataOfOwnAssetProfile + ); + + if (!canUpsertAllAssetProfiles && !canUpsertOwnAssetProfile) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map( + ({ date, marketPrice }) => ({ + dataSource, + marketPrice, + symbol, + date: parseISO(date), + state: 'CLOSE' + }) + ); + + return this.marketDataService.updateMany({ + data: dataBulkUpdate + }); + } +} diff --git a/apps/api/src/app/endpoints/market-data/market-data.module.ts b/apps/api/src/app/endpoints/market-data/market-data.module.ts new file mode 100644 index 000000000..d5d64673d --- /dev/null +++ b/apps/api/src/app/endpoints/market-data/market-data.module.ts @@ -0,0 +1,23 @@ +import { AdminModule } from '@ghostfolio/api/app/admin/admin.module'; +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { MarketDataModule as MarketDataServiceModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { MarketDataController } from './market-data.controller'; + +@Module({ + controllers: [MarketDataController], + imports: [ + AdminModule, + MarketDataServiceModule, + SymbolModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ] +}) +export class MarketDataModule {} diff --git a/apps/api/src/app/endpoints/platforms/platforms.controller.ts b/apps/api/src/app/endpoints/platforms/platforms.controller.ts new file mode 100644 index 000000000..92ba77297 --- /dev/null +++ b/apps/api/src/app/endpoints/platforms/platforms.controller.ts @@ -0,0 +1,24 @@ +import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { PlatformsResponse } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { Controller, Get, UseGuards } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Controller('platforms') +export class PlatformsController { + public constructor(private readonly platformService: PlatformService) {} + + @Get() + @HasPermission(permissions.readPlatforms) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getPlatforms(): Promise { + const platforms = await this.platformService.getPlatforms({ + orderBy: { name: 'asc' } + }); + + return { platforms }; + } +} diff --git a/apps/api/src/app/endpoints/platforms/platforms.module.ts b/apps/api/src/app/endpoints/platforms/platforms.module.ts new file mode 100644 index 000000000..21d0edf69 --- /dev/null +++ b/apps/api/src/app/endpoints/platforms/platforms.module.ts @@ -0,0 +1,11 @@ +import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; + +import { Module } from '@nestjs/common'; + +import { PlatformsController } from './platforms.controller'; + +@Module({ + controllers: [PlatformsController], + imports: [PlatformModule] +}) +export class PlatformsModule {} diff --git a/apps/api/src/app/endpoints/public/public.controller.ts b/apps/api/src/app/endpoints/public/public.controller.ts new file mode 100644 index 000000000..b4ecd37ba --- /dev/null +++ b/apps/api/src/app/endpoints/public/public.controller.ts @@ -0,0 +1,187 @@ +import { AccessService } from '@ghostfolio/api/app/access/access.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { getSum } from '@ghostfolio/common/helper'; +import { PublicPortfolioResponse } from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Get, + HttpException, + Inject, + Param, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { Type as ActivityType } from '@prisma/client'; +import { Big } from 'big.js'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Controller('public') +export class PublicController { + public constructor( + private readonly accessService: AccessService, + private readonly configurationService: ConfigurationService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly orderService: OrderService, + private readonly portfolioService: PortfolioService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService + ) {} + + @Get(':accessId/portfolio') + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getPublicPortfolio( + @Param('accessId') accessId: string + ): Promise { + const access = await this.accessService.access({ id: accessId }); + + if (!access) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + let hasDetails = true; + + const user = await this.userService.user({ + id: access.userId + }); + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + hasDetails = user.subscription.type === 'Premium'; + } + + const [ + { createdAt, holdings, markets }, + { performance: performance1d }, + { performance: performanceMax }, + { performance: performanceYtd } + ] = await Promise.all([ + this.portfolioService.getDetails({ + impersonationId: access.userId, + userId: user.id, + withMarkets: true + }), + ...['1d', 'max', 'ytd'].map((dateRange) => { + return this.portfolioService.getPerformance({ + dateRange, + impersonationId: undefined, + userId: user.id + }); + }) + ]); + + const { activities } = await this.orderService.getOrders({ + sortColumn: 'date', + sortDirection: 'desc', + take: 10, + types: [ActivityType.BUY, ActivityType.SELL], + userCurrency: user.settings?.settings.baseCurrency ?? DEFAULT_CURRENCY, + userId: user.id, + withExcludedAccountsAndActivities: false + }); + + // Experimental + const latestActivities = this.configurationService.get( + 'ENABLE_FEATURE_SUBSCRIPTION' + ) + ? [] + : activities.map( + ({ + currency, + date, + fee, + quantity, + SymbolProfile, + type, + unitPrice, + value, + valueInBaseCurrency + }) => { + return { + currency, + date, + fee, + quantity, + SymbolProfile, + type, + unitPrice, + value, + valueInBaseCurrency + }; + } + ); + + Object.values(markets ?? {}).forEach((market) => { + delete market.valueInBaseCurrency; + }); + + const publicPortfolioResponse: PublicPortfolioResponse = { + createdAt, + hasDetails, + latestActivities, + markets, + alias: access.alias, + holdings: {}, + performance: { + '1d': { + relativeChange: + performance1d.netPerformancePercentageWithCurrencyEffect + }, + max: { + relativeChange: + performanceMax.netPerformancePercentageWithCurrencyEffect + }, + ytd: { + relativeChange: + performanceYtd.netPerformancePercentageWithCurrencyEffect + } + } + }; + + const totalValue = getSum( + Object.values(holdings).map(({ currency, marketPrice, quantity }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + quantity * marketPrice, + currency, + this.request.user?.settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ) + ); + }) + ).toNumber(); + + for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + publicPortfolioResponse.holdings[symbol] = { + allocationInPercentage: + portfolioPosition.valueInBaseCurrency / totalValue, + assetClass: hasDetails ? portfolioPosition.assetClass : undefined, + countries: hasDetails ? portfolioPosition.countries : [], + currency: hasDetails ? portfolioPosition.currency : undefined, + dataSource: portfolioPosition.dataSource, + dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, + markets: hasDetails ? portfolioPosition.markets : undefined, + name: portfolioPosition.name, + netPerformancePercentWithCurrencyEffect: + portfolioPosition.netPerformancePercentWithCurrencyEffect, + sectors: hasDetails ? portfolioPosition.sectors : [], + symbol: portfolioPosition.symbol, + url: portfolioPosition.url, + valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue + }; + } + + return publicPortfolioResponse; + } +} diff --git a/apps/api/src/app/endpoints/public/public.module.ts b/apps/api/src/app/endpoints/public/public.module.ts new file mode 100644 index 000000000..19e281dde --- /dev/null +++ b/apps/api/src/app/endpoints/public/public.module.ts @@ -0,0 +1,53 @@ +import { AccessModule } from '@ghostfolio/api/app/access/access.module'; +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { PublicController } from './public.controller'; + +@Module({ + controllers: [PublicController], + imports: [ + AccessModule, + BenchmarkModule, + DataProviderModule, + ExchangeRateDataModule, + I18nModule, + ImpersonationModule, + MarketDataModule, + OrderModule, + PortfolioSnapshotQueueModule, + PrismaModule, + RedisCacheModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + UserModule + ], + providers: [ + AccountBalanceService, + AccountService, + CurrentRateService, + PortfolioCalculatorFactory, + PortfolioService, + RulesService + ] +}) +export class PublicModule {} diff --git a/apps/api/src/app/endpoints/sitemap/sitemap.controller.ts b/apps/api/src/app/endpoints/sitemap/sitemap.controller.ts new file mode 100644 index 000000000..b42ae3594 --- /dev/null +++ b/apps/api/src/app/endpoints/sitemap/sitemap.controller.ts @@ -0,0 +1,52 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +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'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { SitemapService } from './sitemap.service'; + +@Controller('sitemap.xml') +export class SitemapController { + public sitemapXml = ''; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly sitemapService: SitemapService + ) { + try { + this.sitemapXml = readFileSync( + join(__dirname, 'assets', 'sitemap.xml'), + 'utf8' + ); + } catch {} + } + + @Get() + @Version(VERSION_NEUTRAL) + public getSitemapXml(@Res() response: Response) { + const currentDate = format(getYesterday(), DATE_FORMAT); + + response.setHeader('content-type', 'application/xml'); + response.send( + interpolate(this.sitemapXml, { + blogPosts: this.sitemapService.getBlogPosts({ currentDate }), + personalFinanceTools: this.configurationService.get( + 'ENABLE_FEATURE_SUBSCRIPTION' + ) + ? this.sitemapService.getPersonalFinanceTools({ currentDate }) + : '', + publicRoutes: this.sitemapService.getPublicRoutes({ + currentDate + }) + }) + ); + } +} diff --git a/apps/api/src/app/endpoints/sitemap/sitemap.module.ts b/apps/api/src/app/endpoints/sitemap/sitemap.module.ts new file mode 100644 index 000000000..73b5d78b0 --- /dev/null +++ b/apps/api/src/app/endpoints/sitemap/sitemap.module.ts @@ -0,0 +1,14 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; + +import { Module } from '@nestjs/common'; + +import { SitemapController } from './sitemap.controller'; +import { SitemapService } from './sitemap.service'; + +@Module({ + controllers: [SitemapController], + imports: [ConfigurationModule, I18nModule], + providers: [SitemapService] +}) +export class SitemapModule {} diff --git a/apps/api/src/app/endpoints/sitemap/sitemap.service.ts b/apps/api/src/app/endpoints/sitemap/sitemap.service.ts new file mode 100644 index 000000000..e7e05330f --- /dev/null +++ b/apps/api/src/app/endpoints/sitemap/sitemap.service.ts @@ -0,0 +1,256 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { SUPPORTED_LANGUAGE_CODES } from '@ghostfolio/common/config'; +import { personalFinanceTools } from '@ghostfolio/common/personal-finance-tools'; +import { PublicRoute } from '@ghostfolio/common/routes/interfaces/public-route.interface'; +import { publicRoutes } from '@ghostfolio/common/routes/routes'; + +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SitemapService { + private static readonly TRANSLATION_TAGGED_MESSAGE_REGEX = + /:.*@@(?[a-zA-Z0-9.]+):(?.+)/; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly i18nService: I18nService + ) {} + + public getBlogPosts({ currentDate }: { currentDate: string }) { + const rootUrl = this.configurationService.get('ROOT_URL'); + + return [ + { + languageCode: 'de', + routerLink: ['2021', '07', 'hallo-ghostfolio'] + }, + { + languageCode: 'en', + routerLink: ['2021', '07', 'hello-ghostfolio'] + }, + { + languageCode: 'en', + routerLink: ['2022', '01', 'ghostfolio-first-months-in-open-source'] + }, + { + languageCode: 'en', + routerLink: ['2022', '07', 'ghostfolio-meets-internet-identity'] + }, + { + languageCode: 'en', + routerLink: ['2022', '07', 'how-do-i-get-my-finances-in-order'] + }, + { + languageCode: 'en', + routerLink: ['2022', '08', '500-stars-on-github'] + }, + { + languageCode: 'en', + routerLink: ['2022', '10', 'hacktoberfest-2022'] + }, + { + languageCode: 'en', + routerLink: ['2022', '11', 'black-friday-2022'] + }, + { + languageCode: 'en', + routerLink: [ + '2022', + '12', + 'the-importance-of-tracking-your-personal-finances' + ] + }, + { + languageCode: 'de', + routerLink: ['2023', '01', 'ghostfolio-auf-sackgeld-vorgestellt'] + }, + { + languageCode: 'en', + routerLink: ['2023', '02', 'ghostfolio-meets-umbrel'] + }, + { + languageCode: 'en', + routerLink: ['2023', '03', 'ghostfolio-reaches-1000-stars-on-github'] + }, + { + languageCode: 'en', + routerLink: [ + '2023', + '05', + 'unlock-your-financial-potential-with-ghostfolio' + ] + }, + { + languageCode: 'en', + routerLink: ['2023', '07', 'exploring-the-path-to-fire'] + }, + { + languageCode: 'en', + routerLink: ['2023', '08', 'ghostfolio-joins-oss-friends'] + }, + { + languageCode: 'en', + routerLink: ['2023', '09', 'ghostfolio-2'] + }, + { + languageCode: 'en', + routerLink: ['2023', '09', 'hacktoberfest-2023'] + }, + { + languageCode: 'en', + routerLink: ['2023', '11', 'black-week-2023'] + }, + { + languageCode: 'en', + routerLink: ['2023', '11', 'hacktoberfest-2023-debriefing'] + }, + { + languageCode: 'en', + routerLink: ['2024', '09', 'hacktoberfest-2024'] + }, + { + languageCode: 'en', + routerLink: ['2024', '11', 'black-weeks-2024'] + }, + { + languageCode: 'en', + routerLink: ['2025', '09', 'hacktoberfest-2025'] + }, + { + languageCode: 'en', + routerLink: ['2025', '11', 'black-weeks-2025'] + } + ] + .map(({ languageCode, routerLink }) => { + return this.createRouteSitemapUrl({ + currentDate, + languageCode, + rootUrl, + route: { + routerLink: [publicRoutes.blog.path, ...routerLink], + path: undefined + } + }); + }) + .join('\n'); + } + + public getPersonalFinanceTools({ currentDate }: { currentDate: string }) { + const rootUrl = this.configurationService.get('ROOT_URL'); + + return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { + const resourcesPath = this.i18nService.getTranslation({ + languageCode, + id: publicRoutes.resources.path.match( + SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX + ).groups.id + }); + + const personalFinanceToolsPath = this.i18nService.getTranslation({ + languageCode, + id: publicRoutes.resources.subRoutes.personalFinanceTools.path.match( + SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX + ).groups.id + }); + + const productPath = this.i18nService.getTranslation({ + languageCode, + id: publicRoutes.resources.subRoutes.personalFinanceTools.subRoutes.product.path.match( + SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX + ).groups.id + }); + + return personalFinanceTools.map(({ alias, key }) => { + const routerLink = [ + resourcesPath, + personalFinanceToolsPath, + `${productPath}-${alias ?? key}` + ]; + + return this.createRouteSitemapUrl({ + currentDate, + languageCode, + rootUrl, + route: { + routerLink, + path: undefined + } + }); + }); + }).join('\n'); + } + + public getPublicRoutes({ currentDate }: { currentDate: string }) { + const rootUrl = this.configurationService.get('ROOT_URL'); + + return SUPPORTED_LANGUAGE_CODES.flatMap((languageCode) => { + const params = { + currentDate, + languageCode, + rootUrl + }; + + return [ + this.createRouteSitemapUrl(params), + ...this.createSitemapUrls(params, publicRoutes) + ]; + }).join('\n'); + } + + private createRouteSitemapUrl({ + currentDate, + languageCode, + rootUrl, + route + }: { + currentDate: string; + languageCode: string; + rootUrl: string; + route?: PublicRoute; + }): string { + const segments = + route?.routerLink.map((link) => { + const match = link.match( + SitemapService.TRANSLATION_TAGGED_MESSAGE_REGEX + ); + + const segment = match + ? (this.i18nService.getTranslation({ + languageCode, + id: match.groups.id + }) ?? match.groups.message) + : link; + + return segment.replace(/^\/+|\/+$/, ''); + }) ?? []; + + const location = [rootUrl, languageCode, ...segments].join('/'); + + return [ + ' ', + ` ${location}`, + ` ${currentDate}T00:00:00+00:00`, + ' ' + ].join('\n'); + } + + private createSitemapUrls( + params: { currentDate: string; languageCode: string; rootUrl: string }, + routes: Record + ): string[] { + return Object.values(routes).flatMap((route) => { + if (route.excludeFromSitemap) { + return []; + } + + const urls = [this.createRouteSitemapUrl({ ...params, route })]; + + if (route.subRoutes) { + urls.push(...this.createSitemapUrls(params, route.subRoutes)); + } + + return urls; + }); + } +} diff --git a/apps/api/src/app/endpoints/tags/tags.controller.ts b/apps/api/src/app/endpoints/tags/tags.controller.ts new file mode 100644 index 000000000..925e1e0ed --- /dev/null +++ b/apps/api/src/app/endpoints/tags/tags.controller.ts @@ -0,0 +1,113 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; +import { CreateTagDto, UpdateTagDto } from '@ghostfolio/common/dtos'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + HttpException, + Inject, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Tag } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Controller('tags') +export class TagsController { + public constructor( + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly tagService: TagService + ) {} + + @Post() + @UseGuards(AuthGuard('jwt')) + public async createTag(@Body() data: CreateTagDto): Promise { + const canCreateOwnTag = hasPermission( + this.request.user.permissions, + permissions.createOwnTag + ); + + const canCreateTag = hasPermission( + this.request.user.permissions, + permissions.createTag + ); + + if (!canCreateOwnTag && !canCreateTag) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + if (canCreateOwnTag && !canCreateTag) { + if (data.userId !== this.request.user.id) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } + + return this.tagService.createTag(data); + } + + @Delete(':id') + @HasPermission(permissions.deleteTag) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteTag(@Param('id') id: string) { + const originalTag = await this.tagService.getTag({ + id + }); + + if (!originalTag) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.deleteTag({ id }); + } + + @Get() + @HasPermission(permissions.readTags) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getTags() { + return this.tagService.getTagsWithActivityCount(); + } + + @HasPermission(permissions.updateTag) + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { + const originalTag = await this.tagService.getTag({ + id + }); + + if (!originalTag) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.tagService.updateTag({ + data: { + ...data + }, + where: { + id + } + }); + } +} diff --git a/apps/api/src/app/endpoints/tags/tags.module.ts b/apps/api/src/app/endpoints/tags/tags.module.ts new file mode 100644 index 000000000..a8a2f1c51 --- /dev/null +++ b/apps/api/src/app/endpoints/tags/tags.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; + +import { Module } from '@nestjs/common'; + +import { TagsController } from './tags.controller'; + +@Module({ + controllers: [TagsController], + imports: [PrismaModule, TagModule] +}) +export class TagsModule {} diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts new file mode 100644 index 000000000..78693239a --- /dev/null +++ b/apps/api/src/app/endpoints/watchlist/watchlist.controller.ts @@ -0,0 +1,100 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { CreateWatchlistItemDto } from '@ghostfolio/common/dtos'; +import { WatchlistResponse } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + Headers, + HttpException, + Inject, + Param, + Post, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { WatchlistService } from './watchlist.service'; + +@Controller('watchlist') +export class WatchlistController { + public constructor( + private readonly impersonationService: ImpersonationService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly watchlistService: WatchlistService + ) {} + + @Post() + @HasPermission(permissions.createWatchlistItem) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { + return this.watchlistService.createWatchlistItem({ + dataSource: data.dataSource, + symbol: data.symbol, + userId: this.request.user.id + }); + } + + @Delete(':dataSource/:symbol') + @HasPermission(permissions.deleteWatchlistItem) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async deleteWatchlistItem( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ) { + const watchlistItems = await this.watchlistService.getWatchlistItems( + this.request.user.id + ); + + const watchlistItem = watchlistItems.find((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }); + + if (!watchlistItem) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return this.watchlistService.deleteWatchlistItem({ + dataSource, + symbol, + userId: this.request.user.id + }); + } + + @Get() + @HasPermission(permissions.readWatchlist) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getWatchlistItems( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + + const watchlist = await this.watchlistService.getWatchlistItems( + impersonationUserId || this.request.user.id + ); + + return { + watchlist + }; + } +} diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.module.ts b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts new file mode 100644 index 000000000..ce9ae12bb --- /dev/null +++ b/apps/api/src/app/endpoints/watchlist/watchlist.module.ts @@ -0,0 +1,31 @@ +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { WatchlistController } from './watchlist.controller'; +import { WatchlistService } from './watchlist.service'; + +@Module({ + controllers: [WatchlistController], + imports: [ + BenchmarkModule, + DataGatheringModule, + DataProviderModule, + ImpersonationModule, + MarketDataModule, + PrismaModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ], + providers: [WatchlistService] +}) +export class WatchlistModule {} diff --git a/apps/api/src/app/endpoints/watchlist/watchlist.service.ts b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts new file mode 100644 index 000000000..666023dbf --- /dev/null +++ b/apps/api/src/app/endpoints/watchlist/watchlist.service.ts @@ -0,0 +1,155 @@ +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { WatchlistResponse } from '@ghostfolio/common/interfaces'; + +import { BadRequestException, Injectable } from '@nestjs/common'; +import { DataSource, Prisma } from '@prisma/client'; + +@Injectable() +export class WatchlistService { + public constructor( + private readonly benchmarkService: BenchmarkService, + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async createWatchlistItem({ + dataSource, + symbol, + userId + }: { + dataSource: DataSource; + symbol: string; + userId: string; + }): Promise { + const symbolProfile = await this.prismaService.symbolProfile.findUnique({ + where: { + dataSource_symbol: { dataSource, symbol } + } + }); + + if (!symbolProfile) { + const assetProfiles = await this.dataProviderService.getAssetProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfiles[symbol]?.currency) { + throw new BadRequestException( + `Asset profile not found for ${symbol} (${dataSource})` + ); + } + + await this.symbolProfileService.add( + assetProfiles[symbol] as Prisma.SymbolProfileCreateInput + ); + } + + await this.dataGatheringService.gatherSymbol({ + dataSource, + symbol + }); + + await this.prismaService.user.update({ + data: { + watchlist: { + connect: { + dataSource_symbol: { dataSource, symbol } + } + } + }, + where: { id: userId } + }); + } + + public async deleteWatchlistItem({ + dataSource, + symbol, + userId + }: { + dataSource: DataSource; + symbol: string; + userId: string; + }) { + await this.prismaService.user.update({ + data: { + watchlist: { + disconnect: { + dataSource_symbol: { dataSource, symbol } + } + } + }, + where: { id: userId } + }); + } + + public async getWatchlistItems( + userId: string + ): Promise { + const user = await this.prismaService.user.findUnique({ + select: { + watchlist: { + select: { dataSource: true, symbol: true } + } + }, + where: { id: userId } + }); + + const [assetProfiles, quotes] = await Promise.all([ + this.symbolProfileService.getSymbolProfiles(user.watchlist), + this.dataProviderService.getQuotes({ + items: user.watchlist.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + }) + ]); + + const watchlist = await Promise.all( + user.watchlist.map(async ({ dataSource, symbol }) => { + const assetProfile = assetProfiles.find((profile) => { + return profile.dataSource === dataSource && profile.symbol === symbol; + }); + + const [allTimeHigh, trends] = await Promise.all([ + this.marketDataService.getMax({ + dataSource, + symbol + }), + this.benchmarkService.getBenchmarkTrends({ dataSource, symbol }) + ]); + + const performancePercent = + this.benchmarkService.calculateChangeInPercentage( + allTimeHigh?.marketPrice, + quotes[symbol]?.marketPrice + ); + + return { + dataSource, + symbol, + marketCondition: + this.benchmarkService.getMarketCondition(performancePercent), + name: assetProfile?.name, + performances: { + allTimeHigh: { + performancePercent, + date: allTimeHigh?.date + } + }, + trend50d: trends.trend50d, + trend200d: trends.trend200d + }; + }) + ); + + return watchlist.sort((a, b) => { + return a.name.localeCompare(b.name); + }); + } +} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.controller.ts b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts new file mode 100644 index 000000000..239b4b27a --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.controller.ts @@ -0,0 +1,45 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces'; + +import { + Controller, + Get, + HttpException, + Param, + UseGuards +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { parseISO } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { ExchangeRateService } from './exchange-rate.service'; + +@Controller('exchange-rate') +export class ExchangeRateController { + public constructor( + private readonly exchangeRateService: ExchangeRateService + ) {} + + @Get(':symbol/:dateString') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getExchangeRate( + @Param('dateString') dateString: string, + @Param('symbol') symbol: string + ): Promise { + const date = parseISO(dateString); + + const exchangeRate = await this.exchangeRateService.getExchangeRate({ + date, + symbol + }); + + if (exchangeRate) { + return { marketPrice: exchangeRate }; + } + + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } +} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.module.ts b/apps/api/src/app/exchange-rate/exchange-rate.module.ts new file mode 100644 index 000000000..e1d9d1891 --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.module.ts @@ -0,0 +1,14 @@ +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; + +import { Module } from '@nestjs/common'; + +import { ExchangeRateController } from './exchange-rate.controller'; +import { ExchangeRateService } from './exchange-rate.service'; + +@Module({ + controllers: [ExchangeRateController], + exports: [ExchangeRateService], + imports: [ExchangeRateDataModule], + providers: [ExchangeRateService] +}) +export class ExchangeRateModule {} diff --git a/apps/api/src/app/exchange-rate/exchange-rate.service.ts b/apps/api/src/app/exchange-rate/exchange-rate.service.ts new file mode 100644 index 000000000..d70fd534c --- /dev/null +++ b/apps/api/src/app/exchange-rate/exchange-rate.service.ts @@ -0,0 +1,27 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; + +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ExchangeRateService { + public constructor( + private readonly exchangeRateDataService: ExchangeRateDataService + ) {} + + public async getExchangeRate({ + date, + symbol + }: { + date: Date; + symbol: string; + }): Promise { + const [currency1, currency2] = symbol.split('-'); + + return this.exchangeRateDataService.toCurrencyAtDate( + 1, + currency1, + currency2, + date + ); + } +} diff --git a/apps/api/src/app/export/export.controller.ts b/apps/api/src/app/export/export.controller.ts new file mode 100644 index 000000000..6fda8f17f --- /dev/null +++ b/apps/api/src/app/export/export.controller.ts @@ -0,0 +1,57 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ExportResponse } from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Get, + Inject, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; + +import { ExportService } from './export.service'; + +@Controller('export') +export class ExportController { + public constructor( + private readonly apiService: ApiService, + private readonly exportService: ExportService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async export( + @Query('accounts') filterByAccounts?: string, + @Query('activityIds') filterByActivityIds?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string + ): Promise { + const activityIds = filterByActivityIds?.split(',') ?? []; + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + return this.exportService.export({ + activityIds, + filters, + userId: this.request.user.id, + userSettings: this.request.user.settings.settings + }); + } +} diff --git a/apps/api/src/app/export/export.module.ts b/apps/api/src/app/export/export.module.ts new file mode 100644 index 000000000..4f40cc417 --- /dev/null +++ b/apps/api/src/app/export/export.module.ts @@ -0,0 +1,25 @@ +import { AccountModule } from '@ghostfolio/api/app/account/account.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; + +import { Module } from '@nestjs/common'; + +import { ExportController } from './export.controller'; +import { ExportService } from './export.service'; + +@Module({ + controllers: [ExportController], + imports: [ + AccountModule, + ApiModule, + MarketDataModule, + OrderModule, + TagModule, + TransformDataSourceInRequestModule + ], + providers: [ExportService] +}) +export class ExportModule {} diff --git a/apps/api/src/app/export/export.service.ts b/apps/api/src/app/export/export.service.ts new file mode 100644 index 000000000..d07b199be --- /dev/null +++ b/apps/api/src/app/export/export.service.ts @@ -0,0 +1,258 @@ +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 { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; +import { + ExportResponse, + Filter, + UserSettings +} from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { Platform, Prisma } from '@prisma/client'; +import { groupBy, uniqBy } from 'lodash'; + +@Injectable() +export class ExportService { + public constructor( + private readonly accountService: AccountService, + private readonly marketDataService: MarketDataService, + private readonly orderService: OrderService, + private readonly tagService: TagService + ) {} + + public async export({ + activityIds, + filters, + userId, + userSettings + }: { + activityIds?: string[]; + filters?: Filter[]; + userId: string; + userSettings: UserSettings; + }): Promise { + const { ACCOUNT: filtersByAccount } = groupBy(filters, ({ type }) => { + return type; + }); + const platformsMap: { [platformId: string]: Platform } = {}; + + let { activities } = await this.orderService.getOrders({ + filters, + userId, + includeDrafts: true, + sortColumn: 'date', + sortDirection: 'asc', + userCurrency: userSettings?.baseCurrency, + withExcludedAccountsAndActivities: true + }); + + if (activityIds?.length > 0) { + activities = activities.filter(({ id }) => { + return activityIds.includes(id); + }); + } + + const where: Prisma.AccountWhereInput = { userId }; + + if (filtersByAccount?.length > 0) { + where.id = { + in: filtersByAccount.map(({ id }) => { + return id; + }) + }; + } + + const accounts = ( + await this.accountService.accounts({ + where, + include: { + balances: true, + platform: true + }, + orderBy: { + name: 'asc' + } + }) + ) + .filter(({ id }) => { + return activityIds?.length > 0 + ? activities.some(({ accountId }) => { + return accountId === id; + }) + : true; + }) + .map( + ({ + balance, + balances, + comment, + currency, + id, + isExcluded, + name, + platform, + platformId + }) => { + if (platformId) { + platformsMap[platformId] = platform; + } + + return { + balance, + balances: balances.map(({ date, value }) => { + return { date: date.toISOString(), value }; + }), + comment, + currency, + id, + isExcluded, + name, + platformId + }; + } + ); + + const customAssetProfiles = uniqBy( + activities + .map(({ SymbolProfile }) => { + return SymbolProfile; + }) + .filter(({ userId: assetProfileUserId }) => { + return assetProfileUserId === userId; + }), + ({ id }) => { + return id; + } + ); + + const marketDataByAssetProfile = Object.fromEntries( + await Promise.all( + customAssetProfiles.map(async ({ dataSource, id, symbol }) => { + const marketData = ( + await this.marketDataService.marketDataItems({ + where: { dataSource, symbol } + }) + ).map(({ date, marketPrice }) => ({ + date: date.toISOString(), + marketPrice + })); + + return [id, marketData] as const; + }) + ) + ); + + const tags = (await this.tagService.getTagsForUser(userId)) + .filter(({ id, isUsed }) => { + return ( + isUsed && + activities.some((activity) => { + return activity.tags.some(({ id: tagId }) => { + return tagId === id; + }); + }) + ); + }) + .map(({ id, name }) => { + return { + id, + name + }; + }); + + return { + meta: { date: new Date().toISOString(), version: environment.version }, + accounts, + assetProfiles: customAssetProfiles.map( + ({ + assetClass, + assetSubClass, + comment, + countries, + currency, + cusip, + dataSource, + figi, + figiComposite, + figiShareClass, + holdings, + id, + isActive, + isin, + name, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + url + }) => { + return { + assetClass, + assetSubClass, + comment, + countries: countries as unknown as Prisma.JsonArray, + currency, + cusip, + dataSource, + figi, + figiComposite, + figiShareClass, + holdings: holdings as unknown as Prisma.JsonArray, + isActive, + isin, + marketData: marketDataByAssetProfile[id], + name, + scraperConfiguration: + scraperConfiguration as unknown as Prisma.JsonArray, + sectors: sectors as unknown as Prisma.JsonArray, + symbol, + symbolMapping, + url + }; + } + ), + platforms: Object.values(platformsMap), + tags, + activities: activities.map( + ({ + accountId, + comment, + currency, + date, + fee, + id, + quantity, + SymbolProfile, + tags: currentTags, + type, + unitPrice + }) => { + return { + accountId, + comment, + fee, + id, + quantity, + type, + unitPrice, + currency: currency ?? SymbolProfile.currency, + dataSource: SymbolProfile.dataSource, + date: date.toISOString(), + symbol: SymbolProfile.symbol, + tags: currentTags.map(({ id: tagId }) => { + return tagId; + }) + }; + } + ), + user: { + settings: { + currency: userSettings?.baseCurrency, + performanceCalculationType: userSettings?.performanceCalculationType + } + } + }; + } +} diff --git a/apps/api/src/app/health/health.controller.ts b/apps/api/src/app/health/health.controller.ts new file mode 100644 index 000000000..5542ae933 --- /dev/null +++ b/apps/api/src/app/health/health.controller.ts @@ -0,0 +1,88 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { + DataEnhancerHealthResponse, + DataProviderHealthResponse +} from '@ghostfolio/common/interfaces'; + +import { + Controller, + Get, + HttpException, + HttpStatus, + Param, + Res, + UseInterceptors +} from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { Response } from 'express'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { HealthService } from './health.service'; + +@Controller('health') +export class HealthController { + public constructor(private readonly healthService: HealthService) {} + + @Get() + public async getHealth(@Res() response: Response) { + const databaseServiceHealthy = await this.healthService.isDatabaseHealthy(); + const redisCacheServiceHealthy = + await this.healthService.isRedisCacheHealthy(); + + if (databaseServiceHealthy && redisCacheServiceHealthy) { + return response + .status(HttpStatus.OK) + .json({ status: getReasonPhrase(StatusCodes.OK) }); + } else { + return response + .status(HttpStatus.SERVICE_UNAVAILABLE) + .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); + } + } + + @Get('data-enhancer/:name') + public async getHealthOfDataEnhancer( + @Param('name') name: string, + @Res() response: Response + ): Promise> { + const hasResponse = + await this.healthService.hasResponseFromDataEnhancer(name); + + if (hasResponse) { + return response.status(HttpStatus.OK).json({ + status: getReasonPhrase(StatusCodes.OK) + }); + } else { + return response + .status(HttpStatus.SERVICE_UNAVAILABLE) + .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); + } + } + + @Get('data-provider/:dataSource') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getHealthOfDataProvider( + @Param('dataSource') dataSource: DataSource, + @Res() response: Response + ): Promise> { + if (!DataSource[dataSource]) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const hasResponse = + await this.healthService.hasResponseFromDataProvider(dataSource); + + if (hasResponse) { + return response + .status(HttpStatus.OK) + .json({ status: getReasonPhrase(StatusCodes.OK) }); + } else { + return response + .status(HttpStatus.SERVICE_UNAVAILABLE) + .json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) }); + } + } +} diff --git a/apps/api/src/app/health/health.module.ts b/apps/api/src/app/health/health.module.ts new file mode 100644 index 000000000..b8c4d5810 --- /dev/null +++ b/apps/api/src/app/health/health.module.ts @@ -0,0 +1,23 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.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 { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +import { HealthController } from './health.controller'; +import { HealthService } from './health.service'; + +@Module({ + controllers: [HealthController], + imports: [ + DataEnhancerModule, + DataProviderModule, + PropertyModule, + RedisCacheModule, + TransformDataSourceInRequestModule + ], + providers: [HealthService] +}) +export class HealthModule {} diff --git a/apps/api/src/app/health/health.service.ts b/apps/api/src/app/health/health.service.ts new file mode 100644 index 000000000..f08f33a1e --- /dev/null +++ b/apps/api/src/app/health/health.service.ts @@ -0,0 +1,46 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +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 { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config'; + +import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; + +@Injectable() +export class HealthService { + public constructor( + private readonly dataEnhancerService: DataEnhancerService, + private readonly dataProviderService: DataProviderService, + private readonly propertyService: PropertyService, + private readonly redisCacheService: RedisCacheService + ) {} + + public async hasResponseFromDataEnhancer(aName: string) { + return this.dataEnhancerService.enhance(aName); + } + + public async hasResponseFromDataProvider(aDataSource: DataSource) { + return this.dataProviderService.checkQuote(aDataSource); + } + + public async isDatabaseHealthy() { + try { + await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + return true; + } catch { + return false; + } + } + + public async isRedisCacheHealthy() { + try { + const isHealthy = await this.redisCacheService.isHealthy(); + + return isHealthy; + } catch { + return false; + } + } +} diff --git a/apps/api/src/app/import/import-data.dto.ts b/apps/api/src/app/import/import-data.dto.ts new file mode 100644 index 000000000..bf45c7cda --- /dev/null +++ b/apps/api/src/app/import/import-data.dto.ts @@ -0,0 +1,34 @@ +import { + CreateAccountWithBalancesDto, + CreateAssetProfileWithMarketDataDto, + CreateOrderDto, + CreateTagDto +} from '@ghostfolio/common/dtos'; + +import { Type } from 'class-transformer'; +import { IsArray, IsOptional, ValidateNested } from 'class-validator'; + +export class ImportDataDto { + @IsArray() + @IsOptional() + @Type(() => CreateAccountWithBalancesDto) + @ValidateNested({ each: true }) + accounts?: CreateAccountWithBalancesDto[]; + + @IsArray() + @Type(() => CreateOrderDto) + @ValidateNested({ each: true }) + activities: CreateOrderDto[]; + + @IsArray() + @IsOptional() + @Type(() => CreateAssetProfileWithMarketDataDto) + @ValidateNested({ each: true }) + assetProfiles?: CreateAssetProfileWithMarketDataDto[]; + + @IsArray() + @IsOptional() + @Type(() => CreateTagDto) + @ValidateNested({ each: true }) + tags?: CreateTagDto[]; +} diff --git a/apps/api/src/app/import/import.controller.ts b/apps/api/src/app/import/import.controller.ts new file mode 100644 index 000000000..81481fd65 --- /dev/null +++ b/apps/api/src/app/import/import.controller.ts @@ -0,0 +1,112 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ImportResponse } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Get, + HttpException, + Inject, + Logger, + Param, + Post, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { ImportDataDto } from './import-data.dto'; +import { ImportService } from './import.service'; + +@Controller('import') +export class ImportController { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly importService: ImportService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @HasPermission(permissions.createOrder) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async import( + @Body() importData: ImportDataDto, + @Query('dryRun') isDryRunParam = 'false' + ): Promise { + const isDryRun = isDryRunParam === 'true'; + + if ( + !hasPermission(this.request.user.permissions, permissions.createAccount) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + let maxActivitiesToImport = this.configurationService.get( + 'MAX_ACTIVITIES_TO_IMPORT' + ); + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Premium' + ) { + maxActivitiesToImport = Number.MAX_SAFE_INTEGER; + } + + try { + const activities = await this.importService.import({ + isDryRun, + maxActivitiesToImport, + accountsWithBalancesDto: importData.accounts ?? [], + activitiesDto: importData.activities, + assetProfilesWithMarketDataDto: importData.assetProfiles ?? [], + tagsDto: importData.tags ?? [], + user: this.request.user + }); + + return { activities }; + } catch (error) { + Logger.error(error, ImportController); + + throw new HttpException( + { + error: getReasonPhrase(StatusCodes.BAD_REQUEST), + message: [error.message] + }, + StatusCodes.BAD_REQUEST + ); + } + } + + @Get('dividends/:dataSource/:symbol') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async gatherDividends( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const activities = await this.importService.getDividends({ + dataSource, + symbol, + userCurrency: this.request.user.settings.settings.baseCurrency, + userId: this.request.user.id + }); + + return { activities }; + } +} diff --git a/apps/api/src/app/import/import.module.ts b/apps/api/src/app/import/import.module.ts new file mode 100644 index 000000000..a4a13f941 --- /dev/null +++ b/apps/api/src/app/import/import.module.ts @@ -0,0 +1,47 @@ +import { AccountModule } from '@ghostfolio/api/app/account/account.module'; +import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; +import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; + +import { Module } from '@nestjs/common'; + +import { ImportController } from './import.controller'; +import { ImportService } from './import.service'; + +@Module({ + controllers: [ImportController], + imports: [ + AccountModule, + ApiModule, + CacheModule, + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + MarketDataModule, + OrderModule, + PlatformModule, + PortfolioModule, + PrismaModule, + RedisCacheModule, + SymbolProfileModule, + TagModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ], + providers: [ImportService] +}) +export class ImportModule {} diff --git a/apps/api/src/app/import/import.service.ts b/apps/api/src/app/import/import.service.ts new file mode 100644 index 000000000..497b8a7e9 --- /dev/null +++ b/apps/api/src/app/import/import.service.ts @@ -0,0 +1,730 @@ +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; +import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; +import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config'; +import { + CreateAssetProfileDto, + CreateAccountDto, + CreateOrderDto +} from '@ghostfolio/common/dtos'; +import { + getAssetProfileIdentifier, + parseDate +} from '@ghostfolio/common/helper'; +import { + Activity, + ActivityError, + AssetProfileIdentifier +} from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import { + AccountWithValue, + OrderWithAccount, + UserWithSettings +} from '@ghostfolio/common/types'; + +import { Injectable } from '@nestjs/common'; +import { DataSource, Prisma } from '@prisma/client'; +import { Big } from 'big.js'; +import { endOfToday, isAfter, isSameSecond, parseISO } from 'date-fns'; +import { omit, uniqBy } from 'lodash'; +import { randomUUID } from 'node:crypto'; + +import { ImportDataDto } from './import-data.dto'; + +@Injectable() +export class ImportService { + public constructor( + private readonly accountService: AccountService, + private readonly apiService: ApiService, + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly marketDataService: MarketDataService, + private readonly orderService: OrderService, + private readonly platformService: PlatformService, + private readonly portfolioService: PortfolioService, + private readonly symbolProfileService: SymbolProfileService, + private readonly tagService: TagService + ) {} + + public async getDividends({ + dataSource, + symbol, + userCurrency, + userId + }: AssetProfileIdentifier & { + userCurrency: string; + userId: string; + }): Promise { + try { + const holding = await this.portfolioService.getHolding({ + dataSource, + symbol, + userId, + impersonationId: undefined + }); + + if (!holding) { + return []; + } + + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByDataSource: dataSource, + filterBySymbol: symbol + }); + + const { dateOfFirstActivity, historicalData } = holding; + + const [{ accounts }, { activities }, [assetProfile], dividends] = + await Promise.all([ + this.portfolioService.getAccountsWithAggregations({ + filters, + userId, + withExcludedAccounts: true + }), + this.orderService.getOrders({ + filters, + userCurrency, + userId, + startDate: parseDate(dateOfFirstActivity) + }), + this.symbolProfileService.getSymbolProfiles([ + { + dataSource, + symbol + } + ]), + await this.dataProviderService.getDividends({ + dataSource, + symbol, + from: parseDate(dateOfFirstActivity), + granularity: 'day', + to: new Date() + }) + ]); + + const account = this.isUniqueAccount(accounts) ? accounts[0] : undefined; + + return await Promise.all( + Object.entries(dividends).map(([dateString, { marketPrice }]) => { + const quantity = + historicalData.find((historicalDataItem) => { + return historicalDataItem.date === dateString; + })?.quantity ?? 0; + + const value = new Big(quantity).mul(marketPrice).toNumber(); + + const date = parseDate(dateString); + const isDuplicate = activities.some((activity) => { + return ( + activity.accountId === account?.id && + activity.SymbolProfile.currency === assetProfile.currency && + activity.SymbolProfile.dataSource === assetProfile.dataSource && + isSameSecond(activity.date, date) && + activity.quantity === quantity && + activity.SymbolProfile.symbol === assetProfile.symbol && + activity.type === 'DIVIDEND' && + activity.unitPrice === marketPrice + ); + }); + + const error: ActivityError = isDuplicate + ? { code: 'IS_DUPLICATE' } + : undefined; + + return { + account, + date, + error, + quantity, + value, + accountId: account?.id, + accountUserId: undefined, + comment: undefined, + currency: undefined, + createdAt: undefined, + fee: 0, + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + id: assetProfile.id, + isDraft: false, + SymbolProfile: assetProfile, + symbolProfileId: assetProfile.id, + type: 'DIVIDEND', + unitPrice: marketPrice, + unitPriceInAssetProfileCurrency: marketPrice, + updatedAt: undefined, + userId: account?.userId, + valueInBaseCurrency: value + }; + }) + ); + } catch { + return []; + } + } + + public async import({ + accountsWithBalancesDto, + activitiesDto, + assetProfilesWithMarketDataDto, + isDryRun = false, + maxActivitiesToImport, + tagsDto, + user + }: { + accountsWithBalancesDto: ImportDataDto['accounts']; + activitiesDto: ImportDataDto['activities']; + assetProfilesWithMarketDataDto: ImportDataDto['assetProfiles']; + isDryRun?: boolean; + maxActivitiesToImport: number; + tagsDto: ImportDataDto['tags']; + user: UserWithSettings; + }): Promise { + const accountIdMapping: { [oldAccountId: string]: string } = {}; + const assetProfileSymbolMapping: { [oldSymbol: string]: string } = {}; + const tagIdMapping: { [oldTagId: string]: string } = {}; + const userCurrency = user.settings.settings.baseCurrency; + + if (!isDryRun && accountsWithBalancesDto?.length) { + const [existingAccounts, existingPlatforms] = await Promise.all([ + this.accountService.accounts({ + where: { + id: { + in: accountsWithBalancesDto.map(({ id }) => { + return id; + }) + } + } + }), + this.platformService.getPlatforms() + ]); + + for (const accountWithBalances of accountsWithBalancesDto) { + // Check if there is any existing account with the same ID + const accountWithSameId = existingAccounts.find((existingAccount) => { + return existingAccount.id === accountWithBalances.id; + }); + + // If there is no account or if the account belongs to a different user then create a new account + if (!accountWithSameId || accountWithSameId.userId !== user.id) { + const account: CreateAccountDto = omit( + accountWithBalances, + 'balances' + ); + + let oldAccountId: string; + const platformId = account.platformId; + + delete account.platformId; + + if (accountWithSameId) { + oldAccountId = account.id; + delete account.id; + } + + let accountObject: Prisma.AccountCreateInput = { + ...account, + balances: { + create: accountWithBalances.balances ?? [] + }, + user: { connect: { id: user.id } } + }; + + if ( + existingPlatforms.some(({ id }) => { + return id === platformId; + }) + ) { + accountObject = { + ...accountObject, + platform: { connect: { id: platformId } } + }; + } + + const newAccount = await this.accountService.createAccount( + accountObject, + user.id + ); + + // Store the new to old account ID mappings for updating activities + if (accountWithSameId && oldAccountId) { + accountIdMapping[oldAccountId] = newAccount.id; + } + } + } + } + + if (!isDryRun && assetProfilesWithMarketDataDto?.length) { + const existingAssetProfiles = + await this.symbolProfileService.getSymbolProfiles( + assetProfilesWithMarketDataDto.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }) + ); + + for (const assetProfileWithMarketData of assetProfilesWithMarketDataDto) { + // Check if there is any existing asset profile + const existingAssetProfile = existingAssetProfiles.find( + ({ dataSource, symbol }) => { + return ( + dataSource === assetProfileWithMarketData.dataSource && + symbol === assetProfileWithMarketData.symbol + ); + } + ); + + // If there is no asset profile or if the asset profile belongs to a different user, then create a new asset profile + if (!existingAssetProfile || existingAssetProfile.userId !== user.id) { + const assetProfile: CreateAssetProfileDto = omit( + assetProfileWithMarketData, + 'marketData' + ); + + // Asset profile belongs to a different user + if (existingAssetProfile) { + const symbol = randomUUID(); + assetProfileSymbolMapping[assetProfile.symbol] = symbol; + assetProfile.symbol = symbol; + } + + // Create a new asset profile + const assetProfileObject: Prisma.SymbolProfileCreateInput = { + ...assetProfile, + user: { connect: { id: user.id } } + }; + + await this.symbolProfileService.add(assetProfileObject); + } + + // Insert or update market data + const marketDataObjects = assetProfileWithMarketData.marketData.map( + (marketData) => { + return { + ...marketData, + dataSource: assetProfileWithMarketData.dataSource, + symbol: assetProfileWithMarketData.symbol + } as Prisma.MarketDataUpdateInput; + } + ); + + await this.marketDataService.updateMany({ data: marketDataObjects }); + } + } + + if (tagsDto?.length) { + const existingTagsOfUser = await this.tagService.getTagsForUser(user.id); + + const canCreateOwnTag = hasPermission( + user.permissions, + permissions.createOwnTag + ); + + for (const tag of tagsDto) { + const existingTagOfUser = existingTagsOfUser.find(({ id }) => { + return id === tag.id; + }); + + if (!existingTagOfUser || existingTagOfUser.userId !== null) { + if (!canCreateOwnTag) { + throw new Error( + `Insufficient permissions to create custom tag ("${tag.name}")` + ); + } + + if (!isDryRun) { + const existingTag = await this.tagService.getTag({ id: tag.id }); + let oldTagId: string; + + if (existingTag) { + oldTagId = tag.id; + delete tag.id; + } + + const tagObject: Prisma.TagCreateInput = { + ...tag, + user: { connect: { id: user.id } } + }; + + const newTag = await this.tagService.createTag(tagObject); + + if (existingTag && oldTagId) { + tagIdMapping[oldTagId] = newTag.id; + } + } + } + } + } + + for (const activity of activitiesDto) { + if (!activity.dataSource) { + if (['FEE', 'INTEREST', 'LIABILITY'].includes(activity.type)) { + activity.dataSource = DataSource.MANUAL; + } else { + activity.dataSource = + this.dataProviderService.getDataSourceForImport(); + } + } + + if (!isDryRun) { + // If a new account is created, then update the accountId in all activities + if (accountIdMapping[activity.accountId]) { + activity.accountId = accountIdMapping[activity.accountId]; + } + + // If a new asset profile is created, then update the symbol in all activities + if (assetProfileSymbolMapping[activity.symbol]) { + activity.symbol = assetProfileSymbolMapping[activity.symbol]; + } + + // If a new tag is created, then update the tag ID in all activities + activity.tags = (activity.tags ?? []).map((tagId) => { + return tagIdMapping[tagId] ?? tagId; + }); + } + } + + const assetProfiles = await this.dataProviderService.validateActivities({ + activitiesDto, + assetProfilesWithMarketDataDto, + maxActivitiesToImport, + user + }); + + const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({ + activitiesDto, + userCurrency, + userId: user.id + }); + + const accounts = (await this.accountService.getAccounts(user.id)).map( + ({ id, name }) => { + return { id, name }; + } + ); + + if (isDryRun) { + accountsWithBalancesDto.forEach(({ id, name }) => { + accounts.push({ id, name }); + }); + } + + const tags = (await this.tagService.getTagsForUser(user.id)).map( + ({ id, name }) => { + return { id, name }; + } + ); + + if (isDryRun) { + tagsDto + .filter(({ id }) => { + return !tags.some(({ id: tagId }) => { + return tagId === id; + }); + }) + .forEach(({ id, name }) => { + tags.push({ id, name }); + }); + } + + const activities: Activity[] = []; + + for (const activity of activitiesExtendedWithErrors) { + const accountId = activity.accountId; + const comment = activity.comment; + const currency = activity.currency; + const date = activity.date; + const error = activity.error; + const fee = activity.fee; + const quantity = activity.quantity; + const SymbolProfile = activity.SymbolProfile; + const tagIds = activity.tagIds ?? []; + const type = activity.type; + const unitPrice = activity.unitPrice; + + const assetProfile = assetProfiles[ + getAssetProfileIdentifier({ + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }) + ] ?? { + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + const { + assetClass, + assetSubClass, + countries, + createdAt, + cusip, + dataSource, + figi, + figiComposite, + figiShareClass, + holdings, + id, + isActive, + isin, + name, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + url, + updatedAt + } = assetProfile; + const validatedAccount = accounts.find(({ id }) => { + return id === accountId; + }); + const validatedTags = tags.filter(({ id: tagId }) => { + return tagIds.some((activityTagId) => { + return activityTagId === tagId; + }); + }); + + let order: + | OrderWithAccount + | (Omit & { + account?: { id: string; name: string }; + tags?: { id: string; name: string }[]; + }); + + if (isDryRun) { + order = { + comment, + currency, + date, + fee, + quantity, + type, + unitPrice, + account: validatedAccount, + accountId: validatedAccount?.id, + accountUserId: undefined, + createdAt: new Date(), + id: randomUUID(), + isDraft: isAfter(date, endOfToday()), + SymbolProfile: { + assetClass, + assetSubClass, + countries, + createdAt, + cusip, + dataSource, + figi, + figiComposite, + figiShareClass, + holdings, + id, + isActive, + isin, + name, + scraperConfiguration, + sectors, + symbol, + symbolMapping, + updatedAt, + url, + comment: assetProfile.comment, + currency: assetProfile.currency, + userId: dataSource === 'MANUAL' ? user.id : undefined + }, + symbolProfileId: undefined, + tags: validatedTags, + updatedAt: new Date(), + userId: user.id + }; + } else { + if (error) { + continue; + } + + order = await this.orderService.createOrder({ + comment, + currency, + date, + fee, + quantity, + type, + unitPrice, + accountId: validatedAccount?.id, + SymbolProfile: { + connectOrCreate: { + create: { + dataSource, + name, + symbol, + currency: assetProfile.currency, + userId: dataSource === 'MANUAL' ? user.id : undefined + }, + where: { + dataSource_symbol: { + dataSource, + symbol + } + } + } + }, + tags: validatedTags.map(({ id }) => { + return { id }; + }), + updateAccountBalance: false, + user: { connect: { id: user.id } }, + userId: user.id + }); + + if (order.SymbolProfile?.symbol) { + // Update symbol that may have been assigned in createOrder() + assetProfile.symbol = order.SymbolProfile.symbol; + } + } + + const value = new Big(quantity).mul(unitPrice).toNumber(); + + const valueInBaseCurrency = this.exchangeRateDataService.toCurrencyAtDate( + value, + currency ?? assetProfile.currency, + userCurrency, + date + ); + + activities.push({ + ...order, + error, + value, + valueInBaseCurrency: await valueInBaseCurrency, + // @ts-ignore + SymbolProfile: assetProfile + }); + } + + 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({ + dataGatheringItems: uniqueActivities.map(({ date, SymbolProfile }) => { + return { + date, + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + + return activities; + } + + private async extendActivitiesWithErrors({ + activitiesDto, + userCurrency, + userId + }: { + activitiesDto: Partial[]; + userCurrency: string; + userId: string; + }): Promise[]> { + const { activities: existingActivities } = + await this.orderService.getOrders({ + userCurrency, + userId, + includeDrafts: true, + withExcludedAccountsAndActivities: true + }); + + return activitiesDto.map( + ({ + accountId, + comment, + currency, + dataSource, + date: dateString, + fee, + quantity, + symbol, + tags, + type, + unitPrice + }) => { + const date = parseISO(dateString); + const isDuplicate = existingActivities.some((activity) => { + return ( + activity.accountId === accountId && + activity.comment === comment && + (activity.currency === currency || + activity.SymbolProfile.currency === currency) && + activity.SymbolProfile.dataSource === dataSource && + isSameSecond(activity.date, date) && + activity.fee === fee && + activity.quantity === quantity && + activity.SymbolProfile.symbol === symbol && + activity.type === type && + activity.unitPrice === unitPrice + ); + }); + + const error: ActivityError = isDuplicate + ? { code: 'IS_DUPLICATE' } + : undefined; + + return { + accountId, + comment, + currency, + date, + error, + fee, + quantity, + type, + unitPrice, + SymbolProfile: { + dataSource, + symbol, + activitiesCount: undefined, + assetClass: undefined, + assetSubClass: undefined, + countries: undefined, + createdAt: undefined, + currency: undefined, + holdings: undefined, + id: undefined, + isActive: true, + sectors: undefined, + updatedAt: undefined + }, + tagIds: tags + }; + } + ); + } + + private isUniqueAccount(accounts: AccountWithValue[]) { + const uniqueAccountIds = new Set(); + + for (const { id } of accounts) { + uniqueAccountIds.add(id); + } + + return uniqueAccountIds.size === 1; + } +} diff --git a/apps/api/src/app/info/info.controller.ts b/apps/api/src/app/info/info.controller.ts new file mode 100644 index 000000000..7011713dd --- /dev/null +++ b/apps/api/src/app/info/info.controller.ts @@ -0,0 +1,17 @@ +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { InfoResponse } from '@ghostfolio/common/interfaces'; + +import { Controller, Get, UseInterceptors } from '@nestjs/common'; + +import { InfoService } from './info.service'; + +@Controller('info') +export class InfoController { + public constructor(private readonly infoService: InfoService) {} + + @Get() + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getInfo(): Promise { + return this.infoService.get(); + } +} diff --git a/apps/api/src/app/info/info.module.ts b/apps/api/src/app/info/info.module.ts new file mode 100644 index 000000000..9ded44600 --- /dev/null +++ b/apps/api/src/app/info/info.module.ts @@ -0,0 +1,42 @@ +import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; + +import { InfoController } from './info.controller'; +import { InfoService } from './info.service'; + +@Module({ + controllers: [InfoController], + imports: [ + BenchmarkModule, + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + JwtModule.register({ + secret: process.env.JWT_SECRET_KEY, + signOptions: { expiresIn: '30 days' } + }), + PlatformModule, + PropertyModule, + RedisCacheModule, + SubscriptionModule, + SymbolProfileModule, + TransformDataSourceInResponseModule, + UserModule + ], + providers: [InfoService] +}) +export class InfoModule {} diff --git a/apps/api/src/app/info/info.service.ts b/apps/api/src/app/info/info.service.ts new file mode 100644 index 000000000..9b4a4d597 --- /dev/null +++ b/apps/api/src/app/info/info.service.ts @@ -0,0 +1,331 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_CURRENCY, + HEADER_KEY_TOKEN, + PROPERTY_BETTER_UPTIME_MONITOR_ID, + PROPERTY_COUNTRIES_OF_SUBSCRIBERS, + PROPERTY_DEMO_USER_ID, + PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_SLACK_COMMUNITY_USERS, + ghostfolioFearAndGreedIndexDataSourceStocks +} from '@ghostfolio/common/config'; +import { + DATE_FORMAT, + encodeDataSource, + extractNumberFromString +} from '@ghostfolio/common/helper'; +import { InfoItem, Statistics } from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { Injectable, Logger } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import * as cheerio from 'cheerio'; +import { format, subDays } from 'date-fns'; + +@Injectable() +export class InfoService { + private static CACHE_KEY_STATISTICS = 'STATISTICS'; + + public constructor( + private readonly benchmarkService: BenchmarkService, + private readonly configurationService: ConfigurationService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly jwtService: JwtService, + private readonly propertyService: PropertyService, + private readonly redisCacheService: RedisCacheService, + private readonly subscriptionService: SubscriptionService, + private readonly userService: UserService + ) {} + + public async get(): Promise { + const info: Partial = {}; + let isReadOnlyMode: boolean; + + const globalPermissions: string[] = []; + + if (this.configurationService.get('ENABLE_FEATURE_AUTH_GOOGLE')) { + globalPermissions.push(permissions.enableAuthGoogle); + } + + if (this.configurationService.get('ENABLE_FEATURE_AUTH_OIDC')) { + globalPermissions.push(permissions.enableAuthOidc); + } + + if (this.configurationService.get('ENABLE_FEATURE_AUTH_TOKEN')) { + globalPermissions.push(permissions.enableAuthToken); + } + + if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + info.fearAndGreedDataSource = encodeDataSource( + ghostfolioFearAndGreedIndexDataSourceStocks + ); + } else { + info.fearAndGreedDataSource = + ghostfolioFearAndGreedIndexDataSourceStocks; + } + + globalPermissions.push(permissions.enableFearAndGreedIndex); + } + + if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { + isReadOnlyMode = await this.propertyService.getByKey( + PROPERTY_IS_READ_ONLY_MODE + ); + } + + if (this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { + globalPermissions.push(permissions.enableStatistics); + } + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + globalPermissions.push(permissions.enableSubscription); + + info.countriesOfSubscribers = + (await this.propertyService.getByKey( + PROPERTY_COUNTRIES_OF_SUBSCRIBERS + )) ?? []; + } + + if (this.configurationService.get('ENABLE_FEATURE_SYSTEM_MESSAGE')) { + globalPermissions.push(permissions.enableSystemMessage); + } + + const [ + benchmarks, + demoAuthToken, + isUserSignupEnabled, + statistics, + subscriptionOffer + ] = await Promise.all([ + this.benchmarkService.getBenchmarkAssetProfiles(), + this.getDemoAuthToken(), + this.propertyService.isUserSignupEnabled(), + this.getStatistics(), + this.subscriptionService.getSubscriptionOffer({ key: 'default' }) + ]); + + if (isUserSignupEnabled) { + globalPermissions.push(permissions.createUserAccount); + } + + return { + ...info, + benchmarks, + demoAuthToken, + globalPermissions, + isReadOnlyMode, + statistics, + subscriptionOffer, + baseCurrency: DEFAULT_CURRENCY, + currencies: this.exchangeRateDataService.getCurrencies() + }; + } + + private async countActiveUsers(aDays: number) { + return this.userService.count({ + where: { + AND: [ + { + NOT: { + analytics: null + } + }, + { + analytics: { + lastRequestAt: { + gt: subDays(new Date(), aDays) + } + } + } + ] + } + }); + } + + private async countDockerHubPulls(): Promise { + try { + const { pull_count } = (await fetch( + 'https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio', + { + headers: { 'User-Agent': 'request' }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ).then((res) => res.json())) as { pull_count: number }; + + return pull_count; + } catch (error) { + Logger.error(error, 'InfoService - DockerHub'); + + return undefined; + } + } + + private async countGitHubContributors(): Promise { + try { + const body = await fetch('https://github.com/ghostfolio/ghostfolio', { + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }).then((res) => res.text()); + + const $ = cheerio.load(body); + + return extractNumberFromString({ + value: $( + 'a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter' + ).text() + }); + } catch (error) { + Logger.error(error, 'InfoService - GitHub'); + + return undefined; + } + } + + private async countGitHubStargazers(): Promise { + try { + const { stargazers_count } = (await fetch( + 'https://api.github.com/repos/ghostfolio/ghostfolio', + { + headers: { 'User-Agent': 'request' }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ).then((res) => res.json())) as { stargazers_count: number }; + + return stargazers_count; + } catch (error) { + Logger.error(error, 'InfoService - GitHub'); + + return undefined; + } + } + + private async countNewUsers(aDays: number) { + return this.userService.count({ + where: { + AND: [ + { + NOT: { + analytics: null + } + }, + { + createdAt: { + gt: subDays(new Date(), aDays) + } + } + ] + } + }); + } + + private async countSlackCommunityUsers() { + return await this.propertyService.getByKey( + PROPERTY_SLACK_COMMUNITY_USERS + ); + } + + private async getDemoAuthToken() { + const demoUserId = await this.propertyService.getByKey( + PROPERTY_DEMO_USER_ID + ); + + if (demoUserId) { + return this.jwtService.sign({ + id: demoUserId + }); + } + + return undefined; + } + + private async getStatistics() { + if (!this.configurationService.get('ENABLE_FEATURE_STATISTICS')) { + return undefined; + } + + let statistics: Statistics; + + try { + statistics = JSON.parse( + await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS) + ); + + if (statistics) { + return statistics; + } + } catch {} + + const activeUsers1d = await this.countActiveUsers(1); + const activeUsers30d = await this.countActiveUsers(30); + const newUsers30d = await this.countNewUsers(30); + + const dockerHubPulls = await this.countDockerHubPulls(); + const gitHubContributors = await this.countGitHubContributors(); + const gitHubStargazers = await this.countGitHubStargazers(); + const slackCommunityUsers = await this.countSlackCommunityUsers(); + const uptime = await this.getUptime(); + + statistics = { + activeUsers1d, + activeUsers30d, + dockerHubPulls, + gitHubContributors, + gitHubStargazers, + newUsers30d, + slackCommunityUsers, + uptime + }; + + await this.redisCacheService.set( + InfoService.CACHE_KEY_STATISTICS, + JSON.stringify(statistics) + ); + + return statistics; + } + + private async getUptime(): Promise { + { + try { + const monitorId = await this.propertyService.getByKey( + PROPERTY_BETTER_UPTIME_MONITOR_ID + ); + + const { data } = await fetch( + `https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format( + subDays(new Date(), 90), + DATE_FORMAT + )}&to${format(new Date(), DATE_FORMAT)}`, + { + headers: { + [HEADER_KEY_TOKEN]: `Bearer ${this.configurationService.get( + 'API_KEY_BETTER_UPTIME' + )}` + }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ).then((res) => res.json()); + + return data.attributes.availability / 100; + } catch (error) { + Logger.error(error, 'InfoService - Better Stack'); + + return undefined; + } + } + } +} diff --git a/apps/api/src/app/logo/logo.controller.ts b/apps/api/src/app/logo/logo.controller.ts new file mode 100644 index 000000000..fdbe430c9 --- /dev/null +++ b/apps/api/src/app/logo/logo.controller.ts @@ -0,0 +1,56 @@ +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; + +import { + Controller, + Get, + HttpStatus, + Param, + Query, + Res, + UseInterceptors +} from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { Response } from 'express'; + +import { LogoService } from './logo.service'; + +@Controller('logo') +export class LogoController { + public constructor(private readonly logoService: LogoService) {} + + @Get(':dataSource/:symbol') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getLogoByDataSourceAndSymbol( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string, + @Res() response: Response + ) { + try { + const { buffer, type } = + await this.logoService.getLogoByDataSourceAndSymbol({ + dataSource, + symbol + }); + + response.contentType(type); + response.send(buffer); + } catch { + response.status(HttpStatus.NOT_FOUND).send(); + } + } + + @Get() + public async getLogoByUrl( + @Query('url') url: string, + @Res() response: Response + ) { + try { + const { buffer, type } = await this.logoService.getLogoByUrl(url); + + response.contentType(type); + response.send(buffer); + } catch { + response.status(HttpStatus.NOT_FOUND).send(); + } + } +} diff --git a/apps/api/src/app/logo/logo.module.ts b/apps/api/src/app/logo/logo.module.ts new file mode 100644 index 000000000..1f59df1c8 --- /dev/null +++ b/apps/api/src/app/logo/logo.module.ts @@ -0,0 +1,19 @@ +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { LogoController } from './logo.controller'; +import { LogoService } from './logo.service'; + +@Module({ + controllers: [LogoController], + imports: [ + ConfigurationModule, + SymbolProfileModule, + TransformDataSourceInRequestModule + ], + providers: [LogoService] +}) +export class LogoModule {} diff --git a/apps/api/src/app/logo/logo.service.ts b/apps/api/src/app/logo/logo.service.ts new file mode 100644 index 000000000..ba1acdd29 --- /dev/null +++ b/apps/api/src/app/logo/logo.service.ts @@ -0,0 +1,63 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +import { HttpException, Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class LogoService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async getLogoByDataSourceAndSymbol({ + dataSource, + symbol + }: AssetProfileIdentifier) { + if (!DataSource[dataSource]) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const [assetProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + if (!assetProfile?.url) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return this.getBuffer(assetProfile.url); + } + + public getLogoByUrl(aUrl: string) { + return this.getBuffer(aUrl); + } + + private async getBuffer(aUrl: string) { + const blob = await fetch( + `https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`, + { + headers: { 'User-Agent': 'request' }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ).then((res) => res.blob()); + + return { + buffer: await blob.arrayBuffer().then((arrayBuffer) => { + return Buffer.from(arrayBuffer); + }), + type: blob.type + }; + } +} diff --git a/apps/api/src/app/order/order.controller.ts b/apps/api/src/app/order/order.controller.ts new file mode 100644 index 000000000..c7021809e --- /dev/null +++ b/apps/api/src/app/order/order.controller.ts @@ -0,0 +1,337 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; +import { CreateOrderDto, UpdateOrderDto } from '@ghostfolio/common/dtos'; +import { + ActivitiesResponse, + ActivityResponse +} from '@ghostfolio/common/interfaces'; +import { permissions } from '@ghostfolio/common/permissions'; +import type { DateRange, RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + Headers, + HttpException, + Inject, + Param, + Post, + Put, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Order as OrderModel, Prisma } from '@prisma/client'; +import { parseISO } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { OrderService } from './order.service'; + +@Controller('order') +export class OrderController { + public constructor( + private readonly apiService: ApiService, + private readonly dataProviderService: DataProviderService, + private readonly dataGatheringService: DataGatheringService, + private readonly impersonationService: ImpersonationService, + private readonly orderService: OrderService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Delete() + @HasPermission(permissions.deleteOrder) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async deleteOrders( + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + return this.orderService.deleteOrders({ + filters, + userId: this.request.user.id + }); + } + + @Delete(':id') + @HasPermission(permissions.deleteOrder) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteOrder(@Param('id') id: string): Promise { + const order = await this.orderService.order({ + id, + userId: this.request.user.id + }); + + if (!order) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.orderService.deleteOrder({ + id + }); + } + + @Get() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getAllOrders( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('range') dateRange?: DateRange, + @Query('skip') skip?: number, + @Query('sortColumn') sortColumn?: string, + @Query('sortDirection') sortDirection?: Prisma.SortOrder, + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string, + @Query('take') take?: number + ): Promise { + let endDate: Date; + let startDate: Date; + + if (dateRange) { + ({ endDate, startDate } = getIntervalFromDateRange(dateRange)); + } + + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + const userCurrency = this.request.user.settings.settings.baseCurrency; + + const { activities, count } = await this.orderService.getOrders({ + endDate, + filters, + sortColumn, + sortDirection, + startDate, + userCurrency, + includeDrafts: true, + skip: isNaN(skip) ? undefined : skip, + take: isNaN(take) ? undefined : take, + userId: impersonationUserId || this.request.user.id, + withExcludedAccountsAndActivities: true + }); + + return { activities, count }; + } + + @Get(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getOrderById( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('id') id: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + const userCurrency = this.request.user.settings.settings.baseCurrency; + + const { activities } = await this.orderService.getOrders({ + userCurrency, + includeDrafts: true, + userId: impersonationUserId || this.request.user.id, + withExcludedAccountsAndActivities: true + }); + + const activity = activities.find((activity) => { + return activity.id === id; + }); + + if (!activity) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return activity; + } + + @HasPermission(permissions.createOrder) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async createOrder(@Body() data: CreateOrderDto): Promise { + try { + await this.dataProviderService.validateActivities({ + activitiesDto: [ + { + currency: data.currency, + dataSource: data.dataSource, + symbol: data.symbol, + type: data.type + } + ], + maxActivitiesToImport: 1, + user: this.request.user + }); + } catch (error) { + throw new HttpException( + { + error: getReasonPhrase(StatusCodes.BAD_REQUEST), + message: [error.message] + }, + StatusCodes.BAD_REQUEST + ); + } + + const currency = data.currency; + const customCurrency = data.customCurrency; + const dataSource = data.dataSource; + + if (customCurrency) { + data.currency = customCurrency; + + delete data.customCurrency; + } + + delete data.dataSource; + + const order = await this.orderService.createOrder({ + ...data, + date: parseISO(data.date), + SymbolProfile: { + connectOrCreate: { + create: { + currency, + dataSource, + symbol: data.symbol + }, + where: { + dataSource_symbol: { + dataSource, + symbol: data.symbol + } + } + } + }, + tags: data.tags?.map((id) => { + return { id }; + }), + user: { connect: { id: this.request.user.id } }, + userId: this.request.user.id + }); + + if (dataSource && !order.isDraft) { + // Gather symbol data in the background, if data source is set + // (not MANUAL) and not draft + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: [ + { + dataSource, + date: order.date, + symbol: data.symbol + } + ], + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + + return order; + } + + @HasPermission(permissions.updateOrder) + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) { + const originalOrder = await this.orderService.order({ + id + }); + + if (!originalOrder || originalOrder.userId !== this.request.user.id) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const date = parseISO(data.date); + + const accountId = data.accountId; + const customCurrency = data.customCurrency; + const dataSource = data.dataSource; + + delete data.accountId; + + if (customCurrency) { + data.currency = customCurrency; + + delete data.customCurrency; + } + + delete data.dataSource; + + return this.orderService.updateOrder({ + data: { + ...data, + date, + account: { + connect: { + id_userId: { id: accountId, userId: this.request.user.id } + } + }, + SymbolProfile: { + connect: { + dataSource_symbol: { + dataSource, + symbol: data.symbol + } + }, + update: { + assetClass: data.assetClass, + assetSubClass: data.assetSubClass, + name: data.symbol + } + }, + tags: data.tags?.map((id) => { + return { id }; + }), + user: { connect: { id: this.request.user.id } } + }, + where: { + id + } + }); + } +} diff --git a/apps/api/src/app/order/order.module.ts b/apps/api/src/app/order/order.module.ts new file mode 100644 index 000000000..9bc837aa6 --- /dev/null +++ b/apps/api/src/app/order/order.module.ts @@ -0,0 +1,40 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CacheModule } from '@ghostfolio/api/app/cache/cache.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { OrderController } from './order.controller'; +import { OrderService } from './order.service'; + +@Module({ + controllers: [OrderController], + exports: [OrderService], + imports: [ + ApiModule, + CacheModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + ImpersonationModule, + PrismaModule, + RedactValuesInResponseModule, + RedisCacheModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ], + providers: [AccountBalanceService, AccountService, OrderService] +}) +export class OrderModule {} diff --git a/apps/api/src/app/order/order.service.ts b/apps/api/src/app/order/order.service.ts new file mode 100644 index 000000000..9a4f1e46b --- /dev/null +++ b/apps/api/src/app/order/order.service.ts @@ -0,0 +1,925 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { AssetProfileChangedEvent } from '@ghostfolio/api/events/asset-profile-changed.event'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, + ghostfolioPrefix, + TAG_ID_EXCLUDE_FROM_ANALYSIS +} from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { + ActivitiesResponse, + Activity, + AssetProfileIdentifier, + EnhancedSymbolProfile, + Filter +} from '@ghostfolio/common/interfaces'; +import { OrderWithAccount } from '@ghostfolio/common/types'; + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + AssetClass, + AssetSubClass, + DataSource, + Order, + Prisma, + Tag, + Type as ActivityType +} from '@prisma/client'; +import { Big } from 'big.js'; +import { isUUID } from 'class-validator'; +import { endOfToday, isAfter } from 'date-fns'; +import { groupBy, uniqBy } from 'lodash'; +import { randomUUID } from 'node:crypto'; + +@Injectable() +export class OrderService { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly accountService: AccountService, + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly eventEmitter: EventEmitter2, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async assignTags({ + dataSource, + symbol, + tags, + userId + }: { tags: Tag[]; userId: string } & AssetProfileIdentifier) { + const orders = await this.prismaService.order.findMany({ + where: { + userId, + SymbolProfile: { + dataSource, + symbol + } + } + }); + + await Promise.all( + orders.map(({ id }) => + this.prismaService.order.update({ + data: { + tags: { + // The set operation replaces all existing connections with the provided ones + set: tags.map((tag) => { + return { id: tag.id }; + }) + } + }, + where: { id } + }) + ) + ); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + } + + public async createOrder( + data: Prisma.OrderCreateInput & { + accountId?: string; + assetClass?: AssetClass; + assetSubClass?: AssetSubClass; + currency?: string; + symbol?: string; + tags?: { id: string }[]; + updateAccountBalance?: boolean; + userId: string; + } + ): Promise { + let account: Prisma.AccountCreateNestedOneWithoutActivitiesInput; + + if (data.accountId) { + account = { + connect: { + id_userId: { + userId: data.userId, + id: data.accountId + } + } + }; + } + + const accountId = data.accountId; + const tags = data.tags ?? []; + const updateAccountBalance = data.updateAccountBalance ?? false; + const userId = data.userId; + + if ( + ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || + (data.SymbolProfile.connectOrCreate.create.dataSource === 'MANUAL' && + data.type === 'BUY') + ) { + const assetClass = data.assetClass; + const assetSubClass = data.assetSubClass; + const dataSource: DataSource = 'MANUAL'; + + let name = data.SymbolProfile.connectOrCreate.create.name; + let symbol: string; + + if ( + data.SymbolProfile.connectOrCreate.create.symbol.startsWith( + `${ghostfolioPrefix}_` + ) || + isUUID(data.SymbolProfile.connectOrCreate.create.symbol) + ) { + // Connect custom asset profile (clone) + symbol = data.SymbolProfile.connectOrCreate.create.symbol; + } else { + // Create custom asset profile + name = name ?? data.SymbolProfile.connectOrCreate.create.symbol; + symbol = randomUUID(); + } + + data.SymbolProfile.connectOrCreate.create.assetClass = assetClass; + data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass; + data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; + data.SymbolProfile.connectOrCreate.create.name = name; + data.SymbolProfile.connectOrCreate.create.symbol = symbol; + data.SymbolProfile.connectOrCreate.create.userId = userId; + data.SymbolProfile.connectOrCreate.where.dataSource_symbol = { + dataSource, + symbol + }; + } + + if (data.SymbolProfile.connectOrCreate.create.dataSource !== 'MANUAL') { + this.dataGatheringService.addJobToQueue({ + data: { + dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, + symbol: data.SymbolProfile.connectOrCreate.create.symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, + jobId: getAssetProfileIdentifier({ + dataSource: data.SymbolProfile.connectOrCreate.create.dataSource, + symbol: data.SymbolProfile.connectOrCreate.create.symbol + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + } + }); + } + + delete data.accountId; + delete data.assetClass; + delete data.assetSubClass; + + if (!data.comment) { + delete data.comment; + } + + delete data.symbol; + delete data.tags; + delete data.updateAccountBalance; + delete data.userId; + + const orderData: Prisma.OrderCreateInput = data; + + const isDraft = ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) + ? false + : isAfter(data.date as Date, endOfToday()); + + const order = await this.prismaService.order.create({ + data: { + ...orderData, + account, + isDraft, + tags: { + connect: tags + } + }, + include: { SymbolProfile: true } + }); + + if (updateAccountBalance === true) { + let amount = new Big(data.unitPrice) + .mul(data.quantity) + .plus(data.fee) + .toNumber(); + + if (['BUY', 'FEE'].includes(data.type)) { + amount = new Big(amount).mul(-1).toNumber(); + } + + await this.accountService.updateAccountBalance({ + accountId, + amount, + userId, + currency: data.SymbolProfile.connectOrCreate.create.currency, + date: data.date as Date + }); + } + + this.eventEmitter.emit( + AssetProfileChangedEvent.getName(), + new AssetProfileChangedEvent({ + currency: order.SymbolProfile.currency, + dataSource: order.SymbolProfile.dataSource, + symbol: order.SymbolProfile.symbol + }) + ); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + + return order; + } + + public async deleteOrder( + where: Prisma.OrderWhereUniqueInput + ): Promise { + const order = await this.prismaService.order.delete({ + where + }); + + const [symbolProfile] = + await this.symbolProfileService.getSymbolProfilesByIds([ + order.symbolProfileId + ]); + + if (symbolProfile.activitiesCount === 0) { + await this.symbolProfileService.deleteById(order.symbolProfileId); + } + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + + return order; + } + + public async deleteOrders({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }): Promise { + const { activities } = await this.getOrders({ + filters, + userId, + includeDrafts: true, + userCurrency: undefined, + withExcludedAccountsAndActivities: true + }); + + const { count } = await this.prismaService.order.deleteMany({ + where: { + id: { + in: activities.map(({ id }) => { + return id; + }) + } + } + }); + + const symbolProfiles = + await this.symbolProfileService.getSymbolProfilesByIds( + activities.map(({ symbolProfileId }) => { + return symbolProfileId; + }) + ); + + for (const { activitiesCount, id } of symbolProfiles) { + if (activitiesCount === 0) { + await this.symbolProfileService.deleteById(id); + } + } + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ userId }) + ); + + return count; + } + + /** + * Generates synthetic orders for cash holdings based on account balance history. + * Treat currencies as assets with a fixed unit price of 1.0 (in their own currency) to allow + * performance tracking based on exchange rate fluctuations. + * + * @param cashDetails - The cash balance details. + * @param filters - Optional filters to apply. + * @param userCurrency - The base currency of the user. + * @param userId - The ID of the user. + * @returns A response containing the list of synthetic cash activities. + */ + public async getCashOrders({ + cashDetails, + filters = [], + userCurrency, + userId + }: { + cashDetails: CashDetails; + filters?: Filter[]; + userCurrency: string; + userId: string; + }): Promise { + const filtersByAssetClass = filters.filter(({ type }) => { + return type === 'ASSET_CLASS'; + }); + + if ( + filtersByAssetClass.length > 0 && + !filtersByAssetClass.find(({ id }) => { + return id === AssetClass.LIQUIDITY; + }) + ) { + // If asset class filters are present and none of them is liquidity, return an empty response + return { + activities: [], + count: 0 + }; + } + + const activities: Activity[] = []; + + for (const account of cashDetails.accounts) { + const { balances } = await this.accountBalanceService.getAccountBalances({ + userCurrency, + userId, + filters: [{ id: account.id, type: 'ACCOUNT' }] + }); + + let currentBalance = 0; + let currentBalanceInBaseCurrency = 0; + + for (const balanceItem of balances) { + const syntheticActivityTemplate: Activity = { + userId, + accountId: account.id, + accountUserId: account.userId, + comment: account.name, + createdAt: new Date(balanceItem.date), + currency: account.currency, + date: new Date(balanceItem.date), + fee: 0, + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + id: balanceItem.id, + isDraft: false, + quantity: 1, + SymbolProfile: { + activitiesCount: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + createdAt: new Date(balanceItem.date), + currency: account.currency, + dataSource: + this.dataProviderService.getDataSourceForExchangeRates(), + holdings: [], + id: account.currency, + isActive: true, + name: account.currency, + sectors: [], + symbol: account.currency, + updatedAt: new Date(balanceItem.date) + }, + symbolProfileId: account.currency, + type: ActivityType.BUY, + unitPrice: 1, + unitPriceInAssetProfileCurrency: 1, + updatedAt: new Date(balanceItem.date), + valueInBaseCurrency: 0, + value: 0 + }; + + if (currentBalance < balanceItem.value) { + // BUY + activities.push({ + ...syntheticActivityTemplate, + quantity: balanceItem.value - currentBalance, + type: ActivityType.BUY, + value: balanceItem.value - currentBalance, + valueInBaseCurrency: + balanceItem.valueInBaseCurrency - currentBalanceInBaseCurrency + }); + } else if (currentBalance > balanceItem.value) { + // SELL + activities.push({ + ...syntheticActivityTemplate, + quantity: currentBalance - balanceItem.value, + type: ActivityType.SELL, + value: currentBalance - balanceItem.value, + valueInBaseCurrency: + currentBalanceInBaseCurrency - balanceItem.valueInBaseCurrency + }); + } + + currentBalance = balanceItem.value; + currentBalanceInBaseCurrency = balanceItem.valueInBaseCurrency; + } + } + + return { + activities, + count: activities.length + }; + } + + public async getLatestOrder({ dataSource, symbol }: AssetProfileIdentifier) { + return this.prismaService.order.findFirst({ + orderBy: { + date: 'desc' + }, + where: { + SymbolProfile: { dataSource, symbol } + } + }); + } + + public async getOrders({ + endDate, + filters, + includeDrafts = false, + skip, + sortColumn, + sortDirection = 'asc', + startDate, + take = Number.MAX_SAFE_INTEGER, + types, + userCurrency, + userId, + withExcludedAccountsAndActivities = false + }: { + endDate?: Date; + filters?: Filter[]; + includeDrafts?: boolean; + skip?: number; + sortColumn?: string; + sortDirection?: Prisma.SortOrder; + startDate?: Date; + take?: number; + types?: ActivityType[]; + userCurrency: string; + userId: string; + withExcludedAccountsAndActivities?: boolean; + }): Promise { + let orderBy: Prisma.Enumerable = [ + { date: 'asc' } + ]; + + const where: Prisma.OrderWhereInput = { userId }; + + if (endDate || startDate) { + where.AND = []; + + if (endDate) { + where.AND.push({ date: { lte: endDate } }); + } + + if (startDate) { + where.AND.push({ date: { gt: startDate } }); + } + } + + const { + ACCOUNT: filtersByAccount, + ASSET_CLASS: filtersByAssetClass, + TAG: filtersByTag + } = groupBy(filters, ({ type }) => { + return type; + }); + + const filterByDataSource = filters?.find(({ type }) => { + return type === 'DATA_SOURCE'; + })?.id; + + const filterBySymbol = filters?.find(({ type }) => { + return type === 'SYMBOL'; + })?.id; + + const searchQuery = filters?.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + + if (filtersByAccount?.length > 0) { + where.accountId = { + in: filtersByAccount.map(({ id }) => { + return id; + }) + }; + } + + if (includeDrafts === false) { + where.isDraft = false; + } + + if (filtersByAssetClass?.length > 0) { + where.SymbolProfile = { + OR: [ + { + AND: [ + { + OR: filtersByAssetClass.map(({ id }) => { + return { assetClass: AssetClass[id] }; + }) + }, + { + OR: [ + { SymbolProfileOverrides: { is: null } }, + { SymbolProfileOverrides: { assetClass: null } } + ] + } + ] + }, + { + SymbolProfileOverrides: { + OR: filtersByAssetClass.map(({ id }) => { + return { assetClass: AssetClass[id] }; + }) + } + } + ] + }; + } + + if (filterByDataSource && filterBySymbol) { + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + AND: [ + { dataSource: filterByDataSource as DataSource }, + { symbol: filterBySymbol } + ] + } + ] + }; + } else { + where.SymbolProfile = { + AND: [ + { dataSource: filterByDataSource as DataSource }, + { symbol: filterBySymbol } + ] + }; + } + } + + if (searchQuery) { + const searchQueryWhereInput: Prisma.SymbolProfileWhereInput[] = [ + { id: { mode: 'insensitive', startsWith: searchQuery } }, + { isin: { mode: 'insensitive', startsWith: searchQuery } }, + { name: { mode: 'insensitive', startsWith: searchQuery } }, + { symbol: { mode: 'insensitive', startsWith: searchQuery } } + ]; + + if (where.SymbolProfile) { + where.SymbolProfile = { + AND: [ + where.SymbolProfile, + { + OR: searchQueryWhereInput + } + ] + }; + } else { + where.SymbolProfile = { + OR: searchQueryWhereInput + }; + } + } + + if (filtersByTag?.length > 0) { + where.tags = { + some: { + OR: filtersByTag.map(({ id }) => { + return { id }; + }) + } + }; + } + + if (sortColumn) { + orderBy = [{ [sortColumn]: sortDirection }]; + } + + if (types) { + where.type = { in: types }; + } + + if (withExcludedAccountsAndActivities === false) { + where.OR = [ + { account: null }, + { account: { NOT: { isExcluded: true } } } + ]; + + where.tags = { + ...where.tags, + none: { + id: TAG_ID_EXCLUDE_FROM_ANALYSIS + } + }; + } + + const [orders, count] = await Promise.all([ + this.orders({ + skip, + take, + where, + include: { + account: { + include: { + platform: true + } + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + SymbolProfile: true, + tags: true + }, + orderBy: [...orderBy, { id: sortDirection }] + }), + this.prismaService.order.count({ where }) + ]); + + const assetProfileIdentifiers = uniqBy( + orders.map(({ SymbolProfile }) => { + return { + dataSource: SymbolProfile.dataSource, + symbol: SymbolProfile.symbol + }; + }), + ({ dataSource, symbol }) => { + return getAssetProfileIdentifier({ + dataSource, + symbol + }); + } + ); + + const assetProfiles = await this.symbolProfileService.getSymbolProfiles( + assetProfileIdentifiers + ); + + const activities = await Promise.all( + orders.map(async (order) => { + const assetProfile = assetProfiles.find(({ dataSource, symbol }) => { + return ( + dataSource === order.SymbolProfile.dataSource && + symbol === order.SymbolProfile.symbol + ); + }); + + const value = new Big(order.quantity).mul(order.unitPrice).toNumber(); + + const [ + feeInAssetProfileCurrency, + feeInBaseCurrency, + unitPriceInAssetProfileCurrency, + valueInBaseCurrency + ] = await Promise.all([ + this.exchangeRateDataService.toCurrencyAtDate( + order.fee, + order.currency ?? order.SymbolProfile.currency, + order.SymbolProfile.currency, + order.date + ), + this.exchangeRateDataService.toCurrencyAtDate( + order.fee, + order.currency ?? order.SymbolProfile.currency, + userCurrency, + order.date + ), + this.exchangeRateDataService.toCurrencyAtDate( + order.unitPrice, + order.currency ?? order.SymbolProfile.currency, + order.SymbolProfile.currency, + order.date + ), + this.exchangeRateDataService.toCurrencyAtDate( + value, + order.currency ?? order.SymbolProfile.currency, + userCurrency, + order.date + ) + ]); + + return { + ...order, + feeInAssetProfileCurrency, + feeInBaseCurrency, + unitPriceInAssetProfileCurrency, + value, + valueInBaseCurrency, + SymbolProfile: assetProfile + }; + }) + ); + + return { activities, count }; + } + + /** + * Retrieves all orders required for the portfolio calculator, including both standard asset orders + * and optional synthetic orders representing cash activities. + */ + @LogPerformance + public async getOrdersForPortfolioCalculator({ + filters, + userCurrency, + userId, + withCash = false + }: { + /** Optional filters to apply to the orders. */ + filters?: Filter[]; + /** The base currency of the user. */ + userCurrency: string; + /** The ID of the user. */ + userId: string; + /** Whether to include cash activities in the result. */ + withCash?: boolean; + }) { + const orders = await this.getOrders({ + filters, + userCurrency, + userId, + withExcludedAccountsAndActivities: false // TODO + }); + + if (withCash) { + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); + + const cashOrders = await this.getCashOrders({ + cashDetails, + filters, + userCurrency, + userId + }); + + orders.activities.push(...cashOrders.activities); + orders.count += cashOrders.count; + } + + return orders; + } + + public async getStatisticsByCurrency( + currency: EnhancedSymbolProfile['currency'] + ): Promise<{ + activitiesCount: EnhancedSymbolProfile['activitiesCount']; + dateOfFirstActivity: EnhancedSymbolProfile['dateOfFirstActivity']; + }> { + const { _count, _min } = await this.prismaService.order.aggregate({ + _count: true, + _min: { + date: true + }, + where: { SymbolProfile: { currency } } + }); + + return { + activitiesCount: _count as number, + dateOfFirstActivity: _min.date + }; + } + + public async order( + orderWhereUniqueInput: Prisma.OrderWhereUniqueInput + ): Promise { + return this.prismaService.order.findUnique({ + where: orderWhereUniqueInput + }); + } + + public async updateOrder({ + data, + where + }: { + data: Prisma.OrderUpdateInput & { + assetClass?: AssetClass; + assetSubClass?: AssetSubClass; + currency?: string; + symbol?: string; + tags?: { id: string }[]; + type?: ActivityType; + }; + where: Prisma.OrderWhereUniqueInput; + }): Promise { + if (!data.comment) { + data.comment = null; + } + + const tags = data.tags ?? []; + + let isDraft = false; + + if ( + ['FEE', 'INTEREST', 'LIABILITY'].includes(data.type) || + (data.SymbolProfile.connect.dataSource_symbol.dataSource === 'MANUAL' && + data.type === 'BUY') + ) { + if (data.account?.connect?.id_userId?.id === null) { + data.account = { disconnect: true }; + } + + delete data.SymbolProfile.connect; + delete data.SymbolProfile.update.name; + } else { + delete data.SymbolProfile.update; + + isDraft = isAfter(data.date as Date, endOfToday()); + + if (!isDraft) { + // Gather symbol data of order in the background, if not draft + this.dataGatheringService.gatherSymbols({ + dataGatheringItems: [ + { + dataSource: + data.SymbolProfile.connect.dataSource_symbol.dataSource, + date: data.date as Date, + symbol: data.SymbolProfile.connect.dataSource_symbol.symbol + } + ], + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + } + + delete data.assetClass; + delete data.assetSubClass; + delete data.symbol; + delete data.tags; + + // Remove existing tags + await this.prismaService.order.update({ + where, + data: { tags: { set: [] } } + }); + + const order = await this.prismaService.order.update({ + where, + data: { + ...data, + isDraft, + tags: { + connect: tags + } + } + }); + + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId: order.userId + }) + ); + + return order; + } + + private async orders(params: { + include?: Prisma.OrderInclude; + skip?: number; + take?: number; + cursor?: Prisma.OrderWhereUniqueInput; + where?: Prisma.OrderWhereInput; + orderBy?: Prisma.Enumerable; + }): Promise { + const { include, skip, take, cursor, where, orderBy } = params; + + return this.prismaService.order.findMany({ + cursor, + include, + orderBy, + skip, + take, + where + }); + } +} diff --git a/apps/api/src/app/platform/platform.controller.ts b/apps/api/src/app/platform/platform.controller.ts new file mode 100644 index 000000000..ebf03e3a9 --- /dev/null +++ b/apps/api/src/app/platform/platform.controller.ts @@ -0,0 +1,88 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { CreatePlatformDto, UpdatePlatformDto } from '@ghostfolio/common/dtos'; +import { permissions } from '@ghostfolio/common/permissions'; + +import { + Body, + Controller, + Delete, + Get, + HttpException, + Param, + Post, + Put, + UseGuards +} from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { Platform } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { PlatformService } from './platform.service'; + +@Controller('platform') +export class PlatformController { + public constructor(private readonly platformService: PlatformService) {} + + @Get() + @HasPermission(permissions.readPlatformsWithAccountCount) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getPlatforms() { + return this.platformService.getPlatformsWithAccountCount(); + } + + @HasPermission(permissions.createPlatform) + @Post() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async createPlatform( + @Body() data: CreatePlatformDto + ): Promise { + return this.platformService.createPlatform(data); + } + + @HasPermission(permissions.updatePlatform) + @Put(':id') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updatePlatform( + @Param('id') id: string, + @Body() data: UpdatePlatformDto + ) { + const originalPlatform = await this.platformService.getPlatform({ + id + }); + + if (!originalPlatform) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.platformService.updatePlatform({ + data: { + ...data + }, + where: { + id + } + }); + } + + @Delete(':id') + @HasPermission(permissions.deletePlatform) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deletePlatform(@Param('id') id: string) { + const originalPlatform = await this.platformService.getPlatform({ + id + }); + + if (!originalPlatform) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.platformService.deletePlatform({ id }); + } +} diff --git a/apps/api/src/app/platform/platform.module.ts b/apps/api/src/app/platform/platform.module.ts new file mode 100644 index 000000000..42ae9d193 --- /dev/null +++ b/apps/api/src/app/platform/platform.module.ts @@ -0,0 +1,14 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { PlatformController } from './platform.controller'; +import { PlatformService } from './platform.service'; + +@Module({ + controllers: [PlatformController], + exports: [PlatformService], + imports: [PrismaModule], + providers: [PlatformService] +}) +export class PlatformModule {} diff --git a/apps/api/src/app/platform/platform.service.ts b/apps/api/src/app/platform/platform.service.ts new file mode 100644 index 000000000..200b4de00 --- /dev/null +++ b/apps/api/src/app/platform/platform.service.ts @@ -0,0 +1,84 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Injectable } from '@nestjs/common'; +import { Platform, Prisma } from '@prisma/client'; + +@Injectable() +export class PlatformService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createPlatform(data: Prisma.PlatformCreateInput) { + return this.prismaService.platform.create({ + data + }); + } + + public async deletePlatform( + where: Prisma.PlatformWhereUniqueInput + ): Promise { + return this.prismaService.platform.delete({ where }); + } + + public async getPlatform( + platformWhereUniqueInput: Prisma.PlatformWhereUniqueInput + ): Promise { + return this.prismaService.platform.findUnique({ + where: platformWhereUniqueInput + }); + } + + public async getPlatforms({ + cursor, + orderBy, + skip, + take, + where + }: { + cursor?: Prisma.PlatformWhereUniqueInput; + orderBy?: Prisma.PlatformOrderByWithRelationInput; + skip?: number; + take?: number; + where?: Prisma.PlatformWhereInput; + } = {}) { + return this.prismaService.platform.findMany({ + cursor, + orderBy, + skip, + take, + where + }); + } + + public async getPlatformsWithAccountCount() { + const platformsWithAccountCount = + await this.prismaService.platform.findMany({ + include: { + _count: { + select: { accounts: true } + } + } + }); + + return platformsWithAccountCount.map(({ _count, id, name, url }) => { + return { + id, + name, + url, + accountCount: _count.accounts + }; + }); + } + + public async updatePlatform({ + data, + where + }: { + data: Prisma.PlatformUpdateInput; + where: Prisma.PlatformWhereUniqueInput; + }): Promise { + return this.prismaService.platform.update({ + data, + where + }); + } +} diff --git a/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts new file mode 100644 index 000000000..1460892fa --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts @@ -0,0 +1,29 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +export class MwrPortfolioCalculator extends PortfolioCalculator { + protected calculateOverallPerformance(): PortfolioSnapshot { + throw new Error('Method not implemented.'); + } + + protected getPerformanceCalculationType() { + return PerformanceCalculationType.MWR; + } + + protected getSymbolMetrics({}: { + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & AssetProfileIdentifier): SymbolMetrics { + throw new Error('Method not implemented.'); + } +} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts new file mode 100644 index 000000000..f4c99916f --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator-test-utils.ts @@ -0,0 +1,44 @@ +import { ExportResponse } from '@ghostfolio/common/interfaces'; + +import { readFileSync } from 'node:fs'; + +export const activityDummyData = { + accountId: undefined, + accountUserId: undefined, + comment: undefined, + createdAt: new Date(), + currency: undefined, + fee: undefined, + feeInAssetProfileCurrency: undefined, + feeInBaseCurrency: undefined, + id: undefined, + isDraft: false, + symbolProfileId: undefined, + unitPrice: undefined, + unitPriceInAssetProfileCurrency: undefined, + updatedAt: new Date(), + userId: undefined, + value: undefined, + valueInBaseCurrency: undefined +}; + +export const symbolProfileDummyData = { + activitiesCount: undefined, + assetClass: undefined, + assetSubClass: undefined, + countries: [], + createdAt: undefined, + holdings: [], + id: undefined, + isActive: true, + sectors: [], + updatedAt: undefined +}; + +export const userDummyData = { + id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' +}; + +export function loadExportFile(filePath: string): ExportResponse { + return JSON.parse(readFileSync(filePath, 'utf8')); +} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts new file mode 100644 index 000000000..7b5ab1a0d --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts @@ -0,0 +1,107 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { + Activity, + Filter, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Injectable } from '@nestjs/common'; + +import { MwrPortfolioCalculator } from './mwr/portfolio-calculator'; +import { PortfolioCalculator } from './portfolio-calculator'; +import { RoaiPortfolioCalculator } from './roai/portfolio-calculator'; +import { RoiPortfolioCalculator } from './roi/portfolio-calculator'; +import { TwrPortfolioCalculator } from './twr/portfolio-calculator'; + +@Injectable() +export class PortfolioCalculatorFactory { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly currentRateService: CurrentRateService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly portfolioSnapshotService: PortfolioSnapshotService, + private readonly redisCacheService: RedisCacheService + ) {} + + public createCalculator({ + accountBalanceItems = [], + activities, + calculationType, + currency, + filters = [], + userId + }: { + accountBalanceItems?: HistoricalDataItem[]; + activities: Activity[]; + calculationType: PerformanceCalculationType; + currency: string; + filters?: Filter[]; + userId: string; + }): PortfolioCalculator { + switch (calculationType) { + case PerformanceCalculationType.MWR: + return new MwrPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + filters, + userId, + configurationService: this.configurationService, + currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService + }); + + case PerformanceCalculationType.ROAI: + return new RoaiPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + filters, + userId, + configurationService: this.configurationService, + currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService + }); + + case PerformanceCalculationType.ROI: + return new RoiPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + filters, + userId, + configurationService: this.configurationService, + currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService + }); + + case PerformanceCalculationType.TWR: + return new TwrPortfolioCalculator({ + accountBalanceItems, + activities, + currency, + filters, + userId, + configurationService: this.configurationService, + currentRateService: this.currentRateService, + exchangeRateDataService: this.exchangeRateDataService, + portfolioSnapshotService: this.portfolioSnapshotService, + redisCacheService: this.redisCacheService + }); + + default: + throw new Error('Invalid calculation type'); + } + } +} diff --git a/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts new file mode 100644 index 000000000..553cb8c90 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/portfolio-calculator.ts @@ -0,0 +1,1173 @@ +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface'; +import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + INVESTMENT_ACTIVITY_TYPES, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH, + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW +} from '@ghostfolio/common/config'; +import { + DATE_FORMAT, + getSum, + parseDate, + resetHours +} from '@ghostfolio/common/helper'; +import { + Activity, + AssetProfileIdentifier, + DataProviderInfo, + Filter, + HistoricalDataItem, + InvestmentItem, + ResponseError, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { GroupBy } from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Logger } from '@nestjs/common'; +import { AssetSubClass } from '@prisma/client'; +import { Big } from 'big.js'; +import { plainToClass } from 'class-transformer'; +import { + differenceInDays, + eachDayOfInterval, + eachYearOfInterval, + endOfDay, + endOfYear, + format, + isAfter, + isBefore, + isWithinInterval, + min, + startOfDay, + startOfYear, + subDays +} from 'date-fns'; +import { isNumber, sortBy, sum, uniqBy } from 'lodash'; + +export abstract class PortfolioCalculator { + protected static readonly ENABLE_LOGGING = false; + + protected accountBalanceItems: HistoricalDataItem[]; + protected activities: PortfolioOrder[]; + + private configurationService: ConfigurationService; + private currency: string; + private currentRateService: CurrentRateService; + private dataProviderInfos: DataProviderInfo[]; + private endDate: Date; + private exchangeRateDataService: ExchangeRateDataService; + private filters: Filter[]; + private portfolioSnapshotService: PortfolioSnapshotService; + private redisCacheService: RedisCacheService; + private snapshot: PortfolioSnapshot; + private snapshotPromise: Promise; + private startDate: Date; + private transactionPoints: TransactionPoint[]; + private userId: string; + + public constructor({ + accountBalanceItems, + activities, + configurationService, + currency, + currentRateService, + exchangeRateDataService, + filters, + portfolioSnapshotService, + redisCacheService, + userId + }: { + accountBalanceItems: HistoricalDataItem[]; + activities: Activity[]; + configurationService: ConfigurationService; + currency: string; + currentRateService: CurrentRateService; + exchangeRateDataService: ExchangeRateDataService; + filters: Filter[]; + portfolioSnapshotService: PortfolioSnapshotService; + redisCacheService: RedisCacheService; + userId: string; + }) { + this.accountBalanceItems = accountBalanceItems; + this.configurationService = configurationService; + this.currency = currency; + this.currentRateService = currentRateService; + this.exchangeRateDataService = exchangeRateDataService; + this.filters = filters; + + let dateOfFirstActivity = new Date(); + + if (this.accountBalanceItems[0]) { + dateOfFirstActivity = parseDate(this.accountBalanceItems[0].date); + } + + this.activities = activities + .map( + ({ + date, + feeInAssetProfileCurrency, + feeInBaseCurrency, + quantity, + SymbolProfile, + tags = [], + type, + unitPriceInAssetProfileCurrency + }) => { + if (isBefore(date, dateOfFirstActivity)) { + dateOfFirstActivity = date; + } + + if (isAfter(date, new Date())) { + // Adapt date to today if activity is in future (e.g. liability) + // to include it in the interval + date = endOfDay(new Date()); + } + + return { + SymbolProfile, + tags, + type, + date: format(date, DATE_FORMAT), + fee: new Big(feeInAssetProfileCurrency), + feeInBaseCurrency: new Big(feeInBaseCurrency), + quantity: new Big(quantity), + unitPrice: new Big(unitPriceInAssetProfileCurrency) + }; + } + ) + .sort((a, b) => { + return a.date?.localeCompare(b.date); + }); + + this.portfolioSnapshotService = portfolioSnapshotService; + this.redisCacheService = redisCacheService; + this.userId = userId; + + const { endDate, startDate } = getIntervalFromDateRange( + 'max', + subDays(dateOfFirstActivity, 1) + ); + + this.endDate = endOfDay(endDate); + this.startDate = startOfDay(startDate); + + this.computeTransactionPoints(); + + this.snapshotPromise = this.initialize(); + } + + protected abstract calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot; + + @LogPerformance + public async computeSnapshot(): Promise { + const lastTransactionPoint = this.transactionPoints.at(-1); + + const transactionPoints = this.transactionPoints?.filter(({ date }) => { + return isBefore(parseDate(date), this.endDate); + }); + + if (!transactionPoints.length) { + return { + activitiesCount: 0, + createdAt: new Date(), + currentValueInBaseCurrency: new Big(0), + errors: [], + hasErrors: false, + historicalData: [], + positions: [], + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }; + } + + const currencies: { [symbol: string]: string } = {}; + const dataGatheringItems: DataGatheringItem[] = []; + let firstIndex = transactionPoints.length; + let firstTransactionPoint: TransactionPoint = null; + let totalInterestWithCurrencyEffect = new Big(0); + let totalLiabilitiesWithCurrencyEffect = new Big(0); + + for (const { + assetSubClass, + currency, + dataSource, + symbol + } of transactionPoints[firstIndex - 1].items) { + // Gather data for all assets except CASH + if (assetSubClass !== 'CASH') { + dataGatheringItems.push({ + dataSource, + symbol + }); + } + + currencies[symbol] = currency; + } + + for (let i = 0; i < transactionPoints.length; i++) { + if ( + !isBefore(parseDate(transactionPoints[i].date), this.startDate) && + firstTransactionPoint === null + ) { + firstTransactionPoint = transactionPoints[i]; + firstIndex = i; + } + } + + const exchangeRatesByCurrency = + await this.exchangeRateDataService.getExchangeRatesByCurrency({ + currencies: Array.from(new Set(Object.values(currencies))), + endDate: this.endDate, + startDate: this.startDate, + targetCurrency: this.currency + }); + + const { + dataProviderInfos, + errors: currentRateErrors, + values: marketSymbols + } = await this.currentRateService.getValues({ + dataGatheringItems, + dateQuery: { + gte: this.startDate, + lt: this.endDate + } + }); + + this.dataProviderInfos = dataProviderInfos; + + const marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + } = {}; + + for (const marketSymbol of marketSymbols) { + const date = format(marketSymbol.date, DATE_FORMAT); + + if (!marketSymbolMap[date]) { + marketSymbolMap[date] = {}; + } + + if (marketSymbol.marketPrice) { + marketSymbolMap[date][marketSymbol.symbol] = new Big( + marketSymbol.marketPrice + ); + } + } + + const endDateString = format(this.endDate, DATE_FORMAT); + + const daysInMarket = differenceInDays(this.endDate, this.startDate); + + const chartDateMap = this.getChartDateMap({ + endDate: this.endDate, + startDate: this.startDate, + step: Math.round( + daysInMarket / + Math.min( + daysInMarket, + this.configurationService.get('MAX_CHART_ITEMS') + ) + ) + }); + + for (const accountBalanceItem of this.accountBalanceItems) { + chartDateMap[accountBalanceItem.date] = true; + } + + const chartDates = sortBy(Object.keys(chartDateMap), (chartDate) => { + return chartDate; + }); + + if (firstIndex > 0) { + firstIndex--; + } + + const errors: ResponseError['errors'] = []; + let hasAnySymbolMetricsErrors = false; + + const positions: (TimelinePosition & { + includeInHoldings: boolean; + })[] = []; + + const accumulatedValuesByDate: { + [date: string]: { + investmentValueWithCurrencyEffect: Big; + totalAccountBalanceWithCurrencyEffect: Big; + totalCurrentValue: Big; + totalCurrentValueWithCurrencyEffect: Big; + totalInvestmentValue: Big; + totalInvestmentValueWithCurrencyEffect: Big; + totalNetPerformanceValue: Big; + totalNetPerformanceValueWithCurrencyEffect: Big; + totalTimeWeightedInvestmentValue: Big; + totalTimeWeightedInvestmentValueWithCurrencyEffect: Big; + }; + } = {}; + + const valuesBySymbol: { + [symbol: string]: { + currentValues: { [date: string]: Big }; + currentValuesWithCurrencyEffect: { [date: string]: Big }; + investmentValuesAccumulated: { [date: string]: Big }; + investmentValuesAccumulatedWithCurrencyEffect: { [date: string]: Big }; + investmentValuesWithCurrencyEffect: { [date: string]: Big }; + netPerformanceValues: { [date: string]: Big }; + netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; + timeWeightedInvestmentValues: { [date: string]: Big }; + timeWeightedInvestmentValuesWithCurrencyEffect: { [date: string]: Big }; + }; + } = {}; + + for (const item of lastTransactionPoint.items) { + const marketPriceInBaseCurrency = ( + marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice + ).mul( + exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ + endDateString + ] ?? 1 + ); + + const { + currentValues, + currentValuesWithCurrencyEffect, + grossPerformance, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + hasErrors, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceWithCurrencyEffectMap, + timeWeightedInvestment, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + timeWeightedInvestmentWithCurrencyEffect, + totalDividend, + totalDividendInBaseCurrency, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilitiesInBaseCurrency + } = this.getSymbolMetrics({ + chartDateMap, + marketSymbolMap, + dataSource: item.dataSource, + end: this.endDate, + exchangeRates: + exchangeRatesByCurrency[`${item.currency}${this.currency}`], + start: this.startDate, + symbol: item.symbol + }); + + hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; + + const includeInTotalAssetValue = + item.assetSubClass !== AssetSubClass.CASH; + + if (includeInTotalAssetValue) { + valuesBySymbol[item.symbol] = { + currentValues, + currentValuesWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect + }; + } + + positions.push({ + includeInTotalAssetValue, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect, + activitiesCount: item.activitiesCount, + averagePrice: item.averagePrice, + currency: item.currency, + dataSource: item.dataSource, + dateOfFirstActivity: item.dateOfFirstActivity, + dividend: totalDividend, + dividendInBaseCurrency: totalDividendInBaseCurrency, + fee: item.fee, + feeInBaseCurrency: item.feeInBaseCurrency, + grossPerformance: !hasErrors ? (grossPerformance ?? null) : null, + grossPerformancePercentage: !hasErrors + ? (grossPerformancePercentage ?? null) + : null, + grossPerformancePercentageWithCurrencyEffect: !hasErrors + ? (grossPerformancePercentageWithCurrencyEffect ?? null) + : null, + grossPerformanceWithCurrencyEffect: !hasErrors + ? (grossPerformanceWithCurrencyEffect ?? null) + : null, + includeInHoldings: item.includeInHoldings, + investment: totalInvestment, + investmentWithCurrencyEffect: totalInvestmentWithCurrencyEffect, + marketPrice: + marketSymbolMap[endDateString]?.[item.symbol]?.toNumber() ?? 1, + marketPriceInBaseCurrency: marketPriceInBaseCurrency?.toNumber() ?? 1, + netPerformance: !hasErrors ? (netPerformance ?? null) : null, + netPerformancePercentage: !hasErrors + ? (netPerformancePercentage ?? null) + : null, + netPerformancePercentageWithCurrencyEffectMap: !hasErrors + ? (netPerformancePercentageWithCurrencyEffectMap ?? null) + : null, + netPerformanceWithCurrencyEffectMap: !hasErrors + ? (netPerformanceWithCurrencyEffectMap ?? null) + : null, + quantity: item.quantity, + symbol: item.symbol, + tags: item.tags, + valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul( + item.quantity + ) + }); + + totalInterestWithCurrencyEffect = totalInterestWithCurrencyEffect.plus( + totalInterestInBaseCurrency + ); + + totalLiabilitiesWithCurrencyEffect = + totalLiabilitiesWithCurrencyEffect.plus(totalLiabilitiesInBaseCurrency); + + if ( + (hasErrors || + currentRateErrors.find(({ dataSource, symbol }) => { + return dataSource === item.dataSource && symbol === item.symbol; + })) && + item.investment.gt(0) && + item.skipErrors === false + ) { + errors.push({ dataSource: item.dataSource, symbol: item.symbol }); + } + } + + const accountBalanceItemsMap = this.accountBalanceItems.reduce( + (map, { date, value }) => { + map[date] = new Big(value); + + return map; + }, + {} as { [date: string]: Big } + ); + + const accountBalanceMap: { [date: string]: Big } = {}; + + let lastKnownBalance = new Big(0); + + for (const dateString of chartDates) { + if (accountBalanceItemsMap[dateString] !== undefined) { + // If there's an exact balance for this date, update lastKnownBalance + lastKnownBalance = accountBalanceItemsMap[dateString]; + } + + // Add the most recent balance to the accountBalanceMap + accountBalanceMap[dateString] = lastKnownBalance; + + for (const symbol of Object.keys(valuesBySymbol)) { + const symbolValues = valuesBySymbol[symbol]; + + const currentValue = + symbolValues.currentValues?.[dateString] ?? new Big(0); + + const currentValueWithCurrencyEffect = + symbolValues.currentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const investmentValueAccumulated = + symbolValues.investmentValuesAccumulated?.[dateString] ?? new Big(0); + + const investmentValueAccumulatedWithCurrencyEffect = + symbolValues.investmentValuesAccumulatedWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + + const investmentValueWithCurrencyEffect = + symbolValues.investmentValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const netPerformanceValue = + symbolValues.netPerformanceValues?.[dateString] ?? new Big(0); + + const netPerformanceValueWithCurrencyEffect = + symbolValues.netPerformanceValuesWithCurrencyEffect?.[dateString] ?? + new Big(0); + + const timeWeightedInvestmentValue = + symbolValues.timeWeightedInvestmentValues?.[dateString] ?? new Big(0); + + const timeWeightedInvestmentValueWithCurrencyEffect = + symbolValues.timeWeightedInvestmentValuesWithCurrencyEffect?.[ + dateString + ] ?? new Big(0); + + accumulatedValuesByDate[dateString] = { + investmentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.investmentValueWithCurrencyEffect ?? new Big(0) + ).add(investmentValueWithCurrencyEffect), + totalAccountBalanceWithCurrencyEffect: accountBalanceMap[dateString], + totalCurrentValue: ( + accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0) + ).add(currentValue), + totalCurrentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalCurrentValueWithCurrencyEffect ?? new Big(0) + ).add(currentValueWithCurrencyEffect), + totalInvestmentValue: ( + accumulatedValuesByDate[dateString]?.totalInvestmentValue ?? + new Big(0) + ).add(investmentValueAccumulated), + totalInvestmentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalInvestmentValueWithCurrencyEffect ?? new Big(0) + ).add(investmentValueAccumulatedWithCurrencyEffect), + totalNetPerformanceValue: ( + accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ?? + new Big(0) + ).add(netPerformanceValue), + totalNetPerformanceValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0) + ).add(netPerformanceValueWithCurrencyEffect), + totalTimeWeightedInvestmentValue: ( + accumulatedValuesByDate[dateString] + ?.totalTimeWeightedInvestmentValue ?? new Big(0) + ).add(timeWeightedInvestmentValue), + totalTimeWeightedInvestmentValueWithCurrencyEffect: ( + accumulatedValuesByDate[dateString] + ?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0) + ).add(timeWeightedInvestmentValueWithCurrencyEffect) + }; + } + } + + const historicalData: HistoricalDataItem[] = Object.entries( + accumulatedValuesByDate + ).map(([date, values]) => { + const { + investmentValueWithCurrencyEffect, + totalAccountBalanceWithCurrencyEffect, + totalCurrentValue, + totalCurrentValueWithCurrencyEffect, + totalInvestmentValue, + totalInvestmentValueWithCurrencyEffect, + totalNetPerformanceValue, + totalNetPerformanceValueWithCurrencyEffect, + totalTimeWeightedInvestmentValue, + totalTimeWeightedInvestmentValueWithCurrencyEffect + } = values; + + const netPerformanceInPercentage = totalTimeWeightedInvestmentValue.eq(0) + ? 0 + : totalNetPerformanceValue + .div(totalTimeWeightedInvestmentValue) + .toNumber(); + + const netPerformanceInPercentageWithCurrencyEffect = + totalTimeWeightedInvestmentValueWithCurrencyEffect.eq(0) + ? 0 + : totalNetPerformanceValueWithCurrencyEffect + .div(totalTimeWeightedInvestmentValueWithCurrencyEffect) + .toNumber(); + + return { + date, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + investmentValueWithCurrencyEffect: + investmentValueWithCurrencyEffect.toNumber(), + netPerformance: totalNetPerformanceValue.toNumber(), + netPerformanceWithCurrencyEffect: + totalNetPerformanceValueWithCurrencyEffect.toNumber(), + netWorth: totalCurrentValueWithCurrencyEffect + .plus(totalAccountBalanceWithCurrencyEffect) + .toNumber(), + totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(), + totalInvestment: totalInvestmentValue.toNumber(), + totalInvestmentValueWithCurrencyEffect: + totalInvestmentValueWithCurrencyEffect.toNumber(), + value: totalCurrentValue.toNumber(), + valueWithCurrencyEffect: totalCurrentValueWithCurrencyEffect.toNumber() + }; + }); + + const overall = this.calculateOverallPerformance(positions); + + const positionsIncludedInHoldings = positions + .filter(({ includeInHoldings }) => { + return includeInHoldings; + }) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + .map(({ includeInHoldings, ...rest }) => { + return rest; + }); + + return { + ...overall, + errors, + historicalData, + totalInterestWithCurrencyEffect, + totalLiabilitiesWithCurrencyEffect, + hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors, + positions: positionsIncludedInHoldings + }; + } + + protected abstract getPerformanceCalculationType(): PerformanceCalculationType; + + public getDataProviderInfos() { + return this.dataProviderInfos; + } + + public async getDividendInBaseCurrency() { + await this.snapshotPromise; + + return getSum( + this.snapshot.positions.map(({ dividendInBaseCurrency }) => { + return dividendInBaseCurrency; + }) + ); + } + + public async getFeesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalFeesWithCurrencyEffect; + } + + public async getInterestInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalInterestWithCurrencyEffect; + } + + public getInvestments(): { date: string; investment: Big }[] { + if (this.transactionPoints.length === 0) { + return []; + } + + return this.transactionPoints.map((transactionPoint) => { + return { + date: transactionPoint.date, + investment: transactionPoint.items.reduce( + (investment, transactionPointSymbol) => + investment.plus(transactionPointSymbol.investment), + new Big(0) + ) + }; + }); + } + + public getInvestmentsByGroup({ + data, + groupBy + }: { + data: HistoricalDataItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + const groupedData: { [dateGroup: string]: Big } = {}; + + for (const { date, investmentValueWithCurrencyEffect } of data) { + const dateGroup = + groupBy === 'month' ? date.substring(0, 7) : date.substring(0, 4); + groupedData[dateGroup] = (groupedData[dateGroup] ?? new Big(0)).plus( + investmentValueWithCurrencyEffect + ); + } + + return Object.keys(groupedData).map((dateGroup) => ({ + date: groupBy === 'month' ? `${dateGroup}-01` : `${dateGroup}-01-01`, + investment: groupedData[dateGroup].toNumber() + })); + } + + public async getLiabilitiesInBaseCurrency() { + await this.snapshotPromise; + + return this.snapshot.totalLiabilitiesWithCurrencyEffect; + } + + public async getPerformance({ end, start }) { + await this.snapshotPromise; + + const { historicalData } = this.snapshot; + + const chart: HistoricalDataItem[] = []; + + let netPerformanceAtStartDate: number; + let netPerformanceWithCurrencyEffectAtStartDate: number; + const totalInvestmentValuesWithCurrencyEffect: number[] = []; + + for (const historicalDataItem of historicalData) { + const date = resetHours(parseDate(historicalDataItem.date)); + + if (!isBefore(date, start) && !isAfter(date, end)) { + if (!isNumber(netPerformanceAtStartDate)) { + netPerformanceAtStartDate = historicalDataItem.netPerformance; + + netPerformanceWithCurrencyEffectAtStartDate = + historicalDataItem.netPerformanceWithCurrencyEffect; + } + + const netPerformanceSinceStartDate = + historicalDataItem.netPerformance - netPerformanceAtStartDate; + + const netPerformanceWithCurrencyEffectSinceStartDate = + historicalDataItem.netPerformanceWithCurrencyEffect - + netPerformanceWithCurrencyEffectAtStartDate; + + if (historicalDataItem.totalInvestmentValueWithCurrencyEffect > 0) { + totalInvestmentValuesWithCurrencyEffect.push( + historicalDataItem.totalInvestmentValueWithCurrencyEffect + ); + } + + const timeWeightedInvestmentValue = + totalInvestmentValuesWithCurrencyEffect.length > 0 + ? sum(totalInvestmentValuesWithCurrencyEffect) / + totalInvestmentValuesWithCurrencyEffect.length + : 0; + + chart.push({ + ...historicalDataItem, + netPerformance: + historicalDataItem.netPerformance - netPerformanceAtStartDate, + netPerformanceWithCurrencyEffect: + netPerformanceWithCurrencyEffectSinceStartDate, + netPerformanceInPercentage: + timeWeightedInvestmentValue === 0 + ? 0 + : netPerformanceSinceStartDate / timeWeightedInvestmentValue, + netPerformanceInPercentageWithCurrencyEffect: + timeWeightedInvestmentValue === 0 + ? 0 + : netPerformanceWithCurrencyEffectSinceStartDate / + timeWeightedInvestmentValue + // TODO: Add net worth + // netWorth: totalCurrentValueWithCurrencyEffect + // .plus(totalAccountBalanceWithCurrencyEffect) + // .toNumber() + // netWorth: 0 + }); + } + } + + return { chart }; + } + + public async getSnapshot() { + await this.snapshotPromise; + + return this.snapshot; + } + + public getStartDate() { + let firstAccountBalanceDate: Date; + let firstActivityDate: Date; + + try { + const firstAccountBalanceDateString = this.accountBalanceItems[0]?.date; + firstAccountBalanceDate = firstAccountBalanceDateString + ? parseDate(firstAccountBalanceDateString) + : new Date(); + } catch (error) { + firstAccountBalanceDate = new Date(); + } + + try { + const firstActivityDateString = this.transactionPoints[0].date; + firstActivityDate = firstActivityDateString + ? parseDate(firstActivityDateString) + : new Date(); + } catch (error) { + firstActivityDate = new Date(); + } + + return min([firstAccountBalanceDate, firstActivityDate]); + } + + protected abstract getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap: { [date: string]: boolean }; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + } & AssetProfileIdentifier): SymbolMetrics; + + public getTransactionPoints() { + return this.transactionPoints; + } + + private getChartDateMap({ + endDate, + startDate, + step + }: { + endDate: Date; + startDate: Date; + step: number; + }): { [date: string]: true } { + // Create a map of all relevant chart dates: + // 1. Add transaction point dates + const chartDateMap = this.transactionPoints.reduce((result, { date }) => { + result[date] = true; + return result; + }, {}); + + // 2. Add dates between transactions respecting the specified step size + for (const date of eachDayOfInterval( + { end: endDate, start: startDate }, + { step } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + if (step > 1) { + // Reduce the step size of last 90 days + for (const date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 90) }, + { step: 3 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + + // Reduce the step size of last 30 days + for (const date of eachDayOfInterval( + { end: endDate, start: subDays(endDate, 30) }, + { step: 1 } + )) { + chartDateMap[format(date, DATE_FORMAT)] = true; + } + } + + // Make sure the end date is present + chartDateMap[format(endDate, DATE_FORMAT)] = true; + + // Make sure some key dates are present + for (const dateRange of ['1d', '1y', '5y', 'max', 'mtd', 'wtd', 'ytd']) { + const { endDate: dateRangeEnd, startDate: dateRangeStart } = + getIntervalFromDateRange(dateRange); + + if ( + !isBefore(dateRangeStart, startDate) && + !isAfter(dateRangeStart, endDate) + ) { + chartDateMap[format(dateRangeStart, DATE_FORMAT)] = true; + } + + if ( + !isBefore(dateRangeEnd, startDate) && + !isAfter(dateRangeEnd, endDate) + ) { + chartDateMap[format(dateRangeEnd, DATE_FORMAT)] = true; + } + } + + // Make sure the first and last date of each calendar year is present + const interval = { start: startDate, end: endDate }; + + for (const date of eachYearOfInterval(interval)) { + const yearStart = startOfYear(date); + const yearEnd = endOfYear(date); + + if (isWithinInterval(yearStart, interval)) { + // Add start of year (YYYY-01-01) + chartDateMap[format(yearStart, DATE_FORMAT)] = true; + } + + if (isWithinInterval(yearEnd, interval)) { + // Add end of year (YYYY-12-31) + chartDateMap[format(yearEnd, DATE_FORMAT)] = true; + } + } + + return chartDateMap; + } + + @LogPerformance + private computeTransactionPoints() { + this.transactionPoints = []; + const symbols: { [symbol: string]: TransactionPointSymbol } = {}; + + let lastDate: string = null; + let lastTransactionPoint: TransactionPoint = null; + + for (const { + date, + fee, + feeInBaseCurrency, + quantity, + SymbolProfile, + tags, + type, + unitPrice + } of this.activities) { + let currentTransactionPointItem: TransactionPointSymbol; + + const assetSubClass = SymbolProfile.assetSubClass; + const currency = SymbolProfile.currency; + const dataSource = SymbolProfile.dataSource; + const factor = getFactor(type); + const skipErrors = !!SymbolProfile.userId; // Skip errors for custom asset profiles + const symbol = SymbolProfile.symbol; + + const oldAccumulatedSymbol = symbols[symbol]; + + if (oldAccumulatedSymbol) { + let investment = oldAccumulatedSymbol.investment; + + let newQuantity = quantity + .mul(factor) + .plus(oldAccumulatedSymbol.quantity); + + if (type === 'BUY') { + if (oldAccumulatedSymbol.investment.gte(0)) { + investment = oldAccumulatedSymbol.investment.plus( + quantity.mul(unitPrice) + ); + } else { + investment = oldAccumulatedSymbol.investment.plus( + quantity.mul(oldAccumulatedSymbol.averagePrice) + ); + } + } else if (type === 'SELL') { + if (oldAccumulatedSymbol.investment.gt(0)) { + investment = oldAccumulatedSymbol.investment.minus( + quantity.mul(oldAccumulatedSymbol.averagePrice) + ); + } else { + investment = oldAccumulatedSymbol.investment.minus( + quantity.mul(unitPrice) + ); + } + } + + if (newQuantity.abs().lt(Number.EPSILON)) { + // Reset to zero if quantity is (almost) zero to avoid rounding issues + investment = new Big(0); + newQuantity = new Big(0); + } + + currentTransactionPointItem = { + assetSubClass, + currency, + dataSource, + investment, + skipErrors, + symbol, + activitiesCount: oldAccumulatedSymbol.activitiesCount + 1, + averagePrice: newQuantity.eq(0) + ? new Big(0) + : investment.div(newQuantity).abs(), + dateOfFirstActivity: oldAccumulatedSymbol.dateOfFirstActivity, + dividend: new Big(0), + fee: oldAccumulatedSymbol.fee.plus(fee), + feeInBaseCurrency: + oldAccumulatedSymbol.feeInBaseCurrency.plus(feeInBaseCurrency), + includeInHoldings: oldAccumulatedSymbol.includeInHoldings, + quantity: newQuantity, + tags: oldAccumulatedSymbol.tags.concat(tags) + }; + } else { + currentTransactionPointItem = { + assetSubClass, + currency, + dataSource, + fee, + feeInBaseCurrency, + skipErrors, + symbol, + tags, + activitiesCount: 1, + averagePrice: unitPrice, + dateOfFirstActivity: date, + dividend: new Big(0), + includeInHoldings: INVESTMENT_ACTIVITY_TYPES.includes(type), + investment: unitPrice.mul(quantity).mul(factor), + quantity: quantity.mul(factor) + }; + } + + currentTransactionPointItem.tags = uniqBy( + currentTransactionPointItem.tags, + 'id' + ); + + symbols[SymbolProfile.symbol] = currentTransactionPointItem; + + const items = lastTransactionPoint?.items ?? []; + + const newItems = items.filter(({ symbol }) => { + return symbol !== SymbolProfile.symbol; + }); + + newItems.push(currentTransactionPointItem); + + newItems.sort((a, b) => { + return a.symbol?.localeCompare(b.symbol); + }); + + let fees = new Big(0); + + if (type === 'FEE') { + fees = fee; + } + + let interest = new Big(0); + + if (type === 'INTEREST') { + interest = quantity.mul(unitPrice); + } + + let liabilities = new Big(0); + + if (type === 'LIABILITY') { + liabilities = quantity.mul(unitPrice); + } + + if (lastDate !== date || lastTransactionPoint === null) { + lastTransactionPoint = { + date, + fees, + interest, + liabilities, + items: newItems + }; + + this.transactionPoints.push(lastTransactionPoint); + } else { + lastTransactionPoint.fees = lastTransactionPoint.fees.plus(fees); + lastTransactionPoint.interest = + lastTransactionPoint.interest.plus(interest); + lastTransactionPoint.items = newItems; + lastTransactionPoint.liabilities = + lastTransactionPoint.liabilities.plus(liabilities); + } + + lastDate = date; + } + } + + @LogPerformance + private async initialize() { + const startTimeTotal = performance.now(); + + let cachedPortfolioSnapshot: PortfolioSnapshot; + let isCachedPortfolioSnapshotExpired = false; + const jobId = this.userId; + + try { + const cachedPortfolioSnapshotValue = await this.redisCacheService.get( + this.redisCacheService.getPortfolioSnapshotKey({ + filters: this.filters, + userId: this.userId + }) + ); + + const { expiration, portfolioSnapshot }: PortfolioSnapshotValue = + JSON.parse(cachedPortfolioSnapshotValue); + + cachedPortfolioSnapshot = plainToClass( + PortfolioSnapshot, + portfolioSnapshot + ); + + if (isAfter(new Date(), new Date(expiration))) { + isCachedPortfolioSnapshotExpired = true; + } + } catch {} + + if (cachedPortfolioSnapshot) { + this.snapshot = cachedPortfolioSnapshot; + + Logger.debug( + `Fetched portfolio snapshot from cache in ${( + (performance.now() - startTimeTotal) / + 1000 + ).toFixed(3)} seconds`, + 'PortfolioCalculator' + ); + + if (isCachedPortfolioSnapshotExpired) { + // Compute in the background + this.portfolioSnapshotService.addJobToQueue({ + data: { + calculationType: this.getPerformanceCalculationType(), + filters: this.filters, + userCurrency: this.currency, + userId: this.userId + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId, + priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW + } + }); + } + } else { + // Wait for computation + await this.portfolioSnapshotService.addJobToQueue({ + data: { + calculationType: this.getPerformanceCalculationType(), + filters: this.filters, + userCurrency: this.currency, + userId: this.userId + }, + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + opts: { + ...PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS, + jobId, + priority: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH + } + }); + + const job = await this.portfolioSnapshotService.getJob(jobId); + + if (job) { + await job.finished(); + } + + await this.initialize(); + } + } +} diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts new file mode 100644 index 000000000..9a93d0419 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-buy.spec.ts @@ -0,0 +1,217 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('595.6'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('139.75'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + grossPerformance: new Big('36.6'), + grossPerformancePercentage: new Big('0.07706261539956593567'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.07706261539956593567' + ), + grossPerformanceWithCurrencyEffect: new Big('36.6'), + investment: new Big('559'), + investmentWithCurrencyEffect: new Big('559'), + netPerformance: new Big('33.4'), + netPerformancePercentage: new Big('0.07032490039195361342'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.06986689805847808234') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('33.4') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('4'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('474.93846153846153846154'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '474.93846153846153846154' + ), + valueInBaseCurrency: new Big('595.6') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('559'), + totalInvestmentWithCurrencyEffect: new Big('559'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 33.4, + netPerformanceInPercentage: 0.07032490039195362, + netPerformanceInPercentageWithCurrencyEffect: 0.07032490039195362, + netPerformanceWithCurrencyEffect: 33.4, + totalInvestment: 559, + totalInvestmentValueWithCurrencyEffect: 559 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('559') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 559 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 559 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts new file mode 100644 index 000000000..c876d0db1 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts @@ -0,0 +1,231 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell in two activities', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 3, + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.04408677396780965649'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.04408677396780965649' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.0552834149755073478') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-15.8') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('285.80000000000000396627'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '285.80000000000000396627' + ), + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550734703, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 0 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts new file mode 100644 index 000000000..ae921d6d9 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts @@ -0,0 +1,215 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-22'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 142.9 + }, + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.65, + feeInBaseCurrency: 1.65, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'SELL', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-22', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('3.2'), + feeInBaseCurrency: new Big('3.2'), + grossPerformance: new Big('-12.6'), + grossPerformancePercentage: new Big('-0.0440867739678096571'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.0440867739678096571' + ), + grossPerformanceWithCurrencyEffect: new Big('-12.6'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('-15.8'), + netPerformancePercentage: new Big('-0.0552834149755073478'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.0552834149755073478') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-15.8') + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('0'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('285.8'), + timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('3.2'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: -15.8, + netPerformanceInPercentage: -0.05528341497550734703, + netPerformanceInPercentageWithCurrencyEffect: -0.05528341497550734703, + netPerformanceWithCurrencyEffect: -15.8, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-22', investment: new Big('285.8') }, + { date: '2021-11-30', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 0 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts new file mode 100644 index 000000000..6207f1417 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts @@ -0,0 +1,294 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BALN.SW buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 136.6 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataDates = portfolioSnapshot.historicalData.map( + ({ date }) => { + return date; + } + ); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('297.8'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 1, + averagePrice: new Big('136.6'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-11-30', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('1.55'), + feeInBaseCurrency: new Big('1.55'), + grossPerformance: new Big('24.6'), + grossPerformancePercentage: new Big('0.09004392386530014641'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.09004392386530014641' + ), + grossPerformanceWithCurrencyEffect: new Big('24.6'), + investment: new Big('273.2'), + investmentWithCurrencyEffect: new Big('273.2'), + netPerformance: new Big('23.05'), + netPerformancePercentage: new Big('0.08437042459736456808'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.08437042459736456808') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('10.00'), // 2 * (148.9 - 143.9) -> no fees in this time period + '1y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + '5y': new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + max: new Big('23.05'), // 2 * (148.9 - 136.6) - 1.55 + mtd: new Big('24.60'), // 2 * (148.9 - 136.6) -> no fees in this time period + wtd: new Big('13.80'), // 2 * (148.9 - 142.0) -> no fees in this time period + ytd: new Big('23.05') // 2 * (148.9 - 136.6) - 1.55 + }, + marketPrice: 148.9, + marketPriceInBaseCurrency: 148.9, + quantity: new Big('2'), + symbol: 'BALN.SW', + tags: [], + timeWeightedInvestment: new Big('273.2'), + timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), + valueInBaseCurrency: new Big('297.8') + } + ], + totalFeesWithCurrencyEffect: new Big('1.55'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('273.2'), + totalInvestmentWithCurrencyEffect: new Big('273.2'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(historicalDataDates).not.toContain('2021-01-01'); + expect(historicalDataDates).not.toContain('2021-12-31'); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + date: '2021-12-18', + netPerformance: 23.05, + netPerformanceInPercentage: 0.08437042459736457, + netPerformanceInPercentageWithCurrencyEffect: 0.08437042459736457, + netPerformanceWithCurrencyEffect: 23.05, + totalInvestment: 273.2, + totalInvestmentValueWithCurrencyEffect: 273.2 + }) + ); + + expect(investments).toEqual([ + { date: '2021-11-30', investment: new Big('273.2') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-11-01', investment: 273.2 }, + { date: '2021-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 273.2 } + ]); + }); + + it.only('with BALN.SW buy (with unit price lower than closing price)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 135.0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const snapshotOnBuyDate = portfolioSnapshot.historicalData.find( + ({ date }) => { + return date === '2021-11-30'; + } + ); + + // Closing price on 2021-11-30: 136.6 + expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65 + }); + + it.only('with BALN.SW buy (with unit price lower than closing price), calculated on buy date', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-11-30').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-11-30'), + feeInAssetProfileCurrency: 1.55, + feeInBaseCurrency: 1.55, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'CHF', + dataSource: 'YAHOO', + name: 'Bâloise Holding AG', + symbol: 'BALN.SW' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 135.0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + const snapshotOnBuyDate = portfolioSnapshot.historicalData.find( + ({ date }) => { + return date === '2021-11-30'; + } + ); + + // Closing price on 2021-11-30: 136.6 + expect(snapshotOnBuyDate?.netPerformanceWithCurrencyEffect).toEqual(1.65); // 2 * (136.6 - 135.0) - 1.55 = 1.65 + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts new file mode 100644 index 000000000..774c1d2f6 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur-in-base-currency-eur.spec.ts @@ -0,0 +1,151 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join(__dirname, '../../../../../../../test/import/ok/btceur.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD buy (in EUR)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 3.94, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'EUR', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataDates = portfolioSnapshot.historicalData.map( + ({ date }) => { + return date; + } + ); + + expect(historicalDataDates).not.toContain('2021-01-01'); + expect(historicalDataDates).toContain('2021-12-31'); + expect(historicalDataDates).toContain('2022-01-01'); + expect(historicalDataDates).not.toContain('2022-12-31'); + + expect(portfolioSnapshot.positions[0].fee).toEqual(new Big(4.46)); + expect( + portfolioSnapshot.positions[0].feeInBaseCurrency.toNumber() + ).toBeCloseTo(3.94, 1); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts new file mode 100644 index 000000000..055356325 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts @@ -0,0 +1,260 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join(__dirname, '../../../../../../../test/import/ok/btceur.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD buy (in EUR)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataDates = portfolioSnapshot.historicalData.map( + ({ date }) => { + return date; + } + ); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2021-12-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2021-12-12: 50098.3 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2021-12-12', + investmentValueWithCurrencyEffect: 44558.42, + netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 + netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceWithCurrencyEffect: 5535.42, + netWorth: 50098.3, // 1 * 50098.3 = 50098.3 + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 50098.3, // 1 * 50098.3 = 50098.3 + valueWithCurrencyEffect: 50098.3 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-01-14', + investmentValueWithCurrencyEffect: 0, + netPerformance: -1463.18, + netPerformanceInPercentage: -0.032837340282712, + netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, + netPerformanceWithCurrencyEffect: -1463.18, + netWorth: 43099.7, + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 43099.7, + valueWithCurrencyEffect: 43099.7 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('43099.7'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 1, + averagePrice: new Big('44558.42'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-12-12', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.46'), + feeInBaseCurrency: new Big('4.46'), + grossPerformance: new Big('-1458.72'), + grossPerformancePercentage: new Big('-0.03273724696701543726'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.03273724696701543726' + ), + grossPerformanceWithCurrencyEffect: new Big('-1458.72'), + investment: new Big('44558.42'), + investmentWithCurrencyEffect: new Big('44558.42'), + netPerformance: new Big('-1463.18'), + netPerformancePercentage: new Big('-0.03283734028271199921'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.03283734028271199921') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-1463.18') + }, + marketPrice: 43099.7, + marketPriceInBaseCurrency: 43099.7, + quantity: new Big('1'), + symbol: 'BTCUSD', + tags: [], + timeWeightedInvestment: new Big('44558.42'), + timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), + valueInBaseCurrency: new Big('43099.7') + } + ], + totalFeesWithCurrencyEffect: new Big('4.46'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('44558.42'), + totalInvestmentWithCurrencyEffect: new Big('44558.42'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(historicalDataDates).not.toContain('2021-01-01'); + expect(historicalDataDates).toContain('2021-12-31'); + expect(historicalDataDates).toContain('2022-01-01'); + expect(historicalDataDates).not.toContain('2022-12-31'); + + expect(investments).toEqual([ + { date: '2021-12-12', investment: new Big('44558.42') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-12-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts new file mode 100644 index 000000000..11765fc49 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts @@ -0,0 +1,271 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + // TODO + describe.skip('get current positions', () => { + it.only('with BTCUSD buy and sell partially', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2018-01-01').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2015-01-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 2, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Bitcoin USD', + symbol: 'BTCUSD' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 320.43 + }, + { + ...activityDummyData, + date: new Date('2017-12-31'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Bitcoin USD', + symbol: 'BTCUSD' + }, + type: 'SELL', + unitPriceInAssetProfileCurrency: 14156.4 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('13298.425356'), + errors: [], + grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'), + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('320.43'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2015-01-01', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + grossPerformance: new Big('27172.74').mul(0.97373), + grossPerformancePercentage: new Big('0.4241983590271396608571'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.4164017412624815597008' + ), + grossPerformanceWithCurrencyEffect: new Big( + '26516.208701400000064086' + ), + investment: new Big('320.43').mul(0.97373), + investmentWithCurrencyEffect: new Big('318.542667299999967957'), + marketPrice: 13657.2, + marketPriceInBaseCurrency: 13298.425356, + netPerformance: new Big('27172.74').mul(0.97373), + netPerformancePercentage: new Big('0.4241983590271396608571'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.417188277288666871633') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('26516.208701400000064086') + }, + quantity: new Big('1'), + symbol: 'BTCUSD', + tags: [], + timeWeightedInvestment: new Big('623.73914366102470265325'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '636.79389574611155533947' + ), + valueInBaseCurrency: new Big('13298.425356') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('320.43').mul(0.97373), + totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: new Big('27172.74').mul(0.97373).toNumber(), + netPerformanceInPercentage: 42.41983590271396609433, + netPerformanceInPercentageWithCurrencyEffect: 41.64017412624815597854, + netPerformanceWithCurrencyEffect: 26516.208701400000064086, + totalInvestment: 318.542667299999967957, + totalInvestmentValueWithCurrencyEffect: 318.542667299999967957 + }) + ); + + expect(investments).toEqual([ + { date: '2015-01-01', investment: new Big('640.86') }, + { date: '2017-12-31', investment: new Big('320.43') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2015-01-01', investment: 637.0853345999999 }, + { date: '2015-02-01', investment: 0 }, + { date: '2015-03-01', investment: 0 }, + { date: '2015-04-01', investment: 0 }, + { date: '2015-05-01', investment: 0 }, + { date: '2015-06-01', investment: 0 }, + { date: '2015-07-01', investment: 0 }, + { date: '2015-08-01', investment: 0 }, + { date: '2015-09-01', investment: 0 }, + { date: '2015-10-01', investment: 0 }, + { date: '2015-11-01', investment: 0 }, + { date: '2015-12-01', investment: 0 }, + { date: '2016-01-01', investment: 0 }, + { date: '2016-02-01', investment: 0 }, + { date: '2016-03-01', investment: 0 }, + { date: '2016-04-01', investment: 0 }, + { date: '2016-05-01', investment: 0 }, + { date: '2016-06-01', investment: 0 }, + { date: '2016-07-01', investment: 0 }, + { date: '2016-08-01', investment: 0 }, + { date: '2016-09-01', investment: 0 }, + { date: '2016-10-01', investment: 0 }, + { date: '2016-11-01', investment: 0 }, + { date: '2016-12-01', investment: 0 }, + { date: '2017-01-01', investment: 0 }, + { date: '2017-02-01', investment: 0 }, + { date: '2017-03-01', investment: 0 }, + { date: '2017-04-01', investment: 0 }, + { date: '2017-05-01', investment: 0 }, + { date: '2017-06-01', investment: 0 }, + { date: '2017-07-01', investment: 0 }, + { date: '2017-08-01', investment: 0 }, + { date: '2017-09-01', investment: 0 }, + { date: '2017-10-01', investment: 0 }, + { date: '2017-11-01', investment: 0 }, + { date: '2017-12-01', investment: -318.54266729999995 }, + { date: '2018-01-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2015-01-01', investment: 637.0853345999999 }, + { date: '2016-01-01', investment: 0 }, + { date: '2017-01-01', investment: -318.54266729999995 }, + { date: '2018-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts new file mode 100644 index 000000000..6a45f79c6 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-short.spec.ts @@ -0,0 +1,127 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join(__dirname, '../../../../../../../test/import/ok/btcusd-short.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD short sell (in USD)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot.positions[0].averagePrice).toEqual( + Big(45647.95) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts new file mode 100644 index 000000000..64882061f --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts @@ -0,0 +1,260 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join(__dirname, '../../../../../../../test/import/ok/btcusd.json') + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with BTCUSD buy (in USD)', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: 4.46, + feeInBaseCurrency: 4.46, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: activity.dataSource, + name: 'Bitcoin', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: 44558.42 + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const historicalDataDates = portfolioSnapshot.historicalData.map( + ({ date }) => { + return date; + } + ); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2021-12-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2021-12-12: 50098.3 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2021-12-12', + investmentValueWithCurrencyEffect: 44558.42, + netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 + netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412 + netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42 + netWorth: 50098.3, // 1 * 50098.3 = 50098.3 + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 50098.3, // 1 * 50098.3 = 50098.3 + valueWithCurrencyEffect: 50098.3 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-01-14', + investmentValueWithCurrencyEffect: 0, + netPerformance: -1463.18, + netPerformanceInPercentage: -0.032837340282712, + netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, + netPerformanceWithCurrencyEffect: -1463.18, + netWorth: 43099.7, + totalAccountBalance: 0, + totalInvestment: 44558.42, + totalInvestmentValueWithCurrencyEffect: 44558.42, + value: 43099.7, + valueWithCurrencyEffect: 43099.7 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('43099.7'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 1, + averagePrice: new Big('44558.42'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-12-12', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.46'), + feeInBaseCurrency: new Big('4.46'), + grossPerformance: new Big('-1458.72'), + grossPerformancePercentage: new Big('-0.03273724696701543726'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '-0.03273724696701543726' + ), + grossPerformanceWithCurrencyEffect: new Big('-1458.72'), + investment: new Big('44558.42'), + investmentWithCurrencyEffect: new Big('44558.42'), + netPerformance: new Big('-1463.18'), + netPerformancePercentage: new Big('-0.03283734028271199921'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('-0.03283734028271199921') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('-1463.18') + }, + marketPrice: 43099.7, + marketPriceInBaseCurrency: 43099.7, + quantity: new Big('1'), + symbol: 'BTCUSD', + tags: [], + timeWeightedInvestment: new Big('44558.42'), + timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), + valueInBaseCurrency: new Big('43099.7') + } + ], + totalFeesWithCurrencyEffect: new Big('4.46'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('44558.42'), + totalInvestmentWithCurrencyEffect: new Big('44558.42'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(historicalDataDates).not.toContain('2021-01-01'); + expect(historicalDataDates).toContain('2021-12-31'); + expect(historicalDataDates).toContain('2022-01-01'); + expect(historicalDataDates).not.toContain('2022-12-31'); + + expect(investments).toEqual([ + { date: '2021-12-12', investment: new Big('44558.42') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2021-12-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2021-01-01', investment: 44558.42 }, + { date: '2022-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts new file mode 100644 index 000000000..a53ebcf05 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-cash.spec.ts @@ -0,0 +1,290 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { TimelinePosition } from '@ghostfolio/common/models'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { randomUUID } from 'node:crypto'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let accountBalanceService: AccountBalanceService; + let accountService: AccountService; + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let dataProviderService: DataProviderService; + let exchangeRateDataService: ExchangeRateDataService; + let orderService: OrderService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + accountBalanceService = new AccountBalanceService( + null, + exchangeRateDataService, + null + ); + + accountService = new AccountService( + accountBalanceService, + null, + exchangeRateDataService, + null + ); + + redisCacheService = new RedisCacheService(null, configurationService); + + dataProviderService = new DataProviderService( + configurationService, + null, + null, + null, + null, + redisCacheService + ); + + currentRateService = new CurrentRateService( + dataProviderService, + null, + null, + null + ); + + orderService = new OrderService( + accountBalanceService, + accountService, + null, + dataProviderService, + null, + exchangeRateDataService, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('Cash Performance', () => { + it('should calculate performance for cash assets in CHF default currency', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-01-01').getTime()); + + const accountId = randomUUID(); + + jest + .spyOn(accountBalanceService, 'getAccountBalances') + .mockResolvedValue({ + balances: [ + { + accountId, + id: randomUUID(), + date: parseDate('2023-12-31'), + value: 1000, + valueInBaseCurrency: 850 + }, + { + accountId, + id: randomUUID(), + date: parseDate('2024-12-31'), + value: 2000, + valueInBaseCurrency: 1800 + } + ] + }); + + jest.spyOn(accountService, 'getCashDetails').mockResolvedValue({ + accounts: [ + { + balance: 2000, + comment: null, + createdAt: parseDate('2023-12-31'), + currency: 'USD', + id: accountId, + isExcluded: false, + name: 'USD', + platformId: null, + updatedAt: parseDate('2023-12-31'), + userId: userDummyData.id + } + ], + balanceInBaseCurrency: 1820 + }); + + jest + .spyOn(dataProviderService, 'getDataSourceForExchangeRates') + .mockReturnValue(DataSource.YAHOO); + + jest.spyOn(orderService, 'getOrders').mockResolvedValue({ + activities: [], + count: 0 + }); + + const { activities } = await orderService.getOrdersForPortfolioCalculator( + { + userCurrency: 'CHF', + userId: userDummyData.id, + withCash: true + } + ); + + jest.spyOn(currentRateService, 'getValues').mockResolvedValue({ + dataProviderInfos: [], + errors: [], + values: [] + }); + + const accountBalanceItems = + await accountBalanceService.getAccountBalanceItems({ + userCurrency: 'CHF', + userId: userDummyData.id + }); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const position = portfolioSnapshot.positions.find(({ symbol }) => { + return symbol === 'USD'; + }); + + /** + * Investment: 2000 USD * 0.91 = 1820 CHF + * Investment value with currency effect: (1000 USD * 0.85) + (1000 USD * 0.90) = 1750 CHF + * Net performance: (1000 USD * 1.0) - (1000 USD * 1.0) = 0 CHF + * Total account balance: 2000 USD * 0.85 = 1700 CHF (using the exchange rate on 2024-12-31) + * Value in base currency: 2000 USD * 0.91 = 1820 CHF + */ + expect(position).toMatchObject({ + activitiesCount: 2, + averagePrice: new Big(1), + currency: 'USD', + dataSource: DataSource.YAHOO, + dateOfFirstActivity: '2023-12-31', + dividend: new Big(0), + dividendInBaseCurrency: new Big(0), + fee: new Big(0), + feeInBaseCurrency: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.08211603004634809014' + ), + grossPerformanceWithCurrencyEffect: new Big(70), + includeInTotalAssetValue: false, + investment: new Big(1820), + investmentWithCurrencyEffect: new Big(1750), + marketPrice: 1, + marketPriceInBaseCurrency: 0.91, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: { + '1d': new Big('0.01111111111111111111'), + '1y': new Big('0.06937181021989792704'), + '5y': new Big('0.0818817546090273363'), + max: new Big('0.0818817546090273363'), + mtd: new Big('0.01111111111111111111'), + wtd: new Big('-0.05517241379310344828'), + ytd: new Big('0.01111111111111111111') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big(20), + '1y': new Big(60), + '5y': new Big(70), + max: new Big(70), + mtd: new Big(20), + wtd: new Big(-80), + ytd: new Big(20) + }, + quantity: new Big(2000), + symbol: 'USD', + timeWeightedInvestment: new Big('912.47956403269754768392'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '852.45231607629427792916' + ), + valueInBaseCurrency: new Big(1820) + }); + + expect(portfolioSnapshot).toMatchObject({ + hasErrors: false, + totalFeesWithCurrencyEffect: new Big(0), + totalInterestWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big(0) + }); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts new file mode 100644 index 000000000..a3fbc0758 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts @@ -0,0 +1,137 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with fee activity', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-01'), + feeInAssetProfileCurrency: 49, + feeInBaseCurrency: 49, + quantity: 0, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Account Opening Fee', + symbol: '2c463fb3-af07-486e-adb0-8301b3d72141' + }, + type: 'FEE', + unitPriceInAssetProfileCurrency: 0 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: true, + positions: [], + totalFeesWithCurrencyEffect: new Big('49'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts new file mode 100644 index 000000000..122a9aaed --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts @@ -0,0 +1,233 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return ExchangeRateDataServiceMock; + }) + }; + } +); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with GOOGL buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-03'), + feeInAssetProfileCurrency: 1, + feeInBaseCurrency: 0.9238, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Alphabet Inc.', + symbol: 'GOOGL' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 89.12 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('103.10483'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 1, + averagePrice: new Big('89.12'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2023-01-03', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('1'), + feeInBaseCurrency: new Big('0.9238'), + grossPerformance: new Big('27.33').mul(0.8854), + grossPerformancePercentage: new Big('0.3066651705565529623'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.25235044599563974109' + ), + grossPerformanceWithCurrencyEffect: new Big('20.775774'), + investment: new Big('89.12').mul(0.8854), + investmentWithCurrencyEffect: new Big('82.329056'), + netPerformance: new Big('26.33').mul(0.8854), + netPerformancePercentage: new Big('0.29544434470377019749'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.24112962014285697628') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.851974') + }, + marketPrice: 116.45, + marketPriceInBaseCurrency: 103.10483, + quantity: new Big('1'), + symbol: 'GOOGL', + tags: [], + timeWeightedInvestment: new Big('89.12').mul(0.8854), + timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), + valueInBaseCurrency: new Big('103.10483') + } + ], + totalFeesWithCurrencyEffect: new Big('0.9238'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('89.12').mul(0.8854), + totalInvestmentWithCurrencyEffect: new Big('82.329056'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: new Big('26.33').mul(0.8854).toNumber(), + netPerformanceInPercentage: 0.29544434470377019749, + netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628, + netPerformanceWithCurrencyEffect: 19.851974, + totalInvestment: new Big('89.12').mul(0.8854).toNumber(), + totalInvestmentValueWithCurrencyEffect: 82.329056 + }) + ); + + expect(investments).toEqual([ + { date: '2023-01-03', investment: new Big('89.12') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2023-01-01', investment: 82.329056 }, + { + date: '2023-02-01', + investment: 0 + }, + { + date: '2023-03-01', + investment: 0 + }, + { + date: '2023-04-01', + investment: 0 + }, + { + date: '2023-05-01', + investment: 0 + }, + { + date: '2023-06-01', + investment: 0 + }, + { + date: '2023-07-01', + investment: 0 + } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2023-01-01', investment: 82.329056 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts new file mode 100644 index 000000000..d5b22e864 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-jnug-buy-and-sell-and-buy-and-sell.spec.ts @@ -0,0 +1,190 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/jnug-buy-and-sell-and-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with JNUG buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2025-12-28').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Direxion Daily Junior Gold Miners Index Bull 2X Shares', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 4, + averagePrice: new Big('0'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2025-12-11', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4'), + feeInBaseCurrency: new Big('4'), + grossPerformance: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + grossPerformanceWithCurrencyEffect: new Big('43.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('39.95'), // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + netPerformanceWithCurrencyEffectMap: { + max: new Big('39.95') // (1890.00 - 1885.05) + (2080.10 - 2041.10) - 4 + }, + marketPrice: 237.8000030517578, + marketPriceInBaseCurrency: 237.8000030517578, + quantity: new Big('0'), + symbol: 'JNUG', + tags: [], + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('4'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([ + { date: '2025-12-11', investment: new Big('1885.05') }, + { date: '2025-12-18', investment: new Big('2041.1') }, + { date: '2025-12-28', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2025-12-01', investment: 0 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2025-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts new file mode 100644 index 000000000..acbf6a66b --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts @@ -0,0 +1,118 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with liability activity', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2023-01-01'), // Date in future + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Loan', + symbol: '55196015-1365-4560-aa60-8751ae6d18f8' + }, + type: 'LIABILITY', + unitPriceInAssetProfileCurrency: 3000 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot.totalLiabilitiesWithCurrencyEffect).toEqual( + new Big(3000) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts new file mode 100644 index 000000000..baa6ae1ed --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-and-sell.spec.ts @@ -0,0 +1,147 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + currentRateService = new CurrentRateService(null, null, null, null); + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + portfolioSnapshotService = new PortfolioSnapshotService(null); + redisCacheService = new RedisCacheService(null, null); + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get transaction point', () => { + it('with MSFT buy and sell with fractional quantities (multiples of 1/3)', () => { + jest.useFakeTimers().setSystemTime(parseDate('2024-04-01').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2024-03-08'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 0.3333333333333333, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 408 + }, + { + ...activityDummyData, + date: new Date('2024-03-13'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 0.6666666666666666, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 400 + }, + { + ...activityDummyData, + date: new Date('2024-03-14'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'SELL', + unitPriceInAssetProfileCurrency: 411 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const transactionPoints = portfolioCalculator.getTransactionPoints(); + const lastTransactionPoint = + transactionPoints[transactionPoints.length - 1]; + const position = lastTransactionPoint.items.find( + (item) => item.symbol === 'MSFT' + ); + + expect(position.investment.toNumber()).toBe(0); + expect(position.quantity.toNumber()).toBe(0); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts new file mode 100644 index 000000000..e7eff6682 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts @@ -0,0 +1,183 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with MSFT buy', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2021-09-16'), + feeInAssetProfileCurrency: 19, + feeInBaseCurrency: 19, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 298.58 + }, + { + ...activityDummyData, + date: new Date('2021-11-16'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Inc.', + symbol: 'MSFT' + }, + type: 'DIVIDEND', + unitPriceInAssetProfileCurrency: 0.62 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('298.58'), + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-09-16', + dividend: new Big('0.62'), + dividendInBaseCurrency: new Big('0.62'), + fee: new Big('19'), + grossPerformance: new Big('33.25'), + grossPerformancePercentage: new Big('0.11136043941322258691'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.11136043941322258691' + ), + grossPerformanceWithCurrencyEffect: new Big('33.25'), + investment: new Big('298.58'), + investmentWithCurrencyEffect: new Big('298.58'), + marketPrice: 331.83, + marketPriceInBaseCurrency: 331.83, + netPerformance: new Big('14.25'), + netPerformancePercentage: new Big('0.04772590260566682296'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.04772590260566682296') + }, + netPerformanceWithCurrencyEffectMap: { + '1d': new Big('-5.39'), + '5y': new Big('14.25'), + max: new Big('14.25'), + wtd: new Big('-5.39') + }, + quantity: new Big('1'), + symbol: 'MSFT', + tags: [] + } + ], + totalFeesWithCurrencyEffect: new Big('19'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('298.58'), + totalInvestmentWithCurrencyEffect: new Big('298.58'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + totalInvestment: 298.58, + totalInvestmentValueWithCurrencyEffect: 298.58 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts new file mode 100644 index 000000000..6c47af7ca --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts @@ -0,0 +1,120 @@ +import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it('with no orders', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2021-12-18').getTime()); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities: [], + calculationType: PerformanceCalculationType.ROAI, + currency: 'CHF', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big(0), + hasErrors: false, + historicalData: [], + positions: [], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(investments).toEqual([]); + + expect(investmentsByMonth).toEqual([]); + + expect(investmentsByYear).toEqual([]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts new file mode 100644 index 000000000..3034e3a1f --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts @@ -0,0 +1,213 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/novn-buy-and-sell-partially.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell partially', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('87.8'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('75.80'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2022-03-07', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('4.25'), + feeInBaseCurrency: new Big('4.25'), + grossPerformance: new Big('21.93'), + grossPerformancePercentage: new Big('0.15113417083448194384'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.15113417083448194384' + ), + grossPerformanceWithCurrencyEffect: new Big('21.93'), + investment: new Big('75.80'), + investmentWithCurrencyEffect: new Big('75.80'), + netPerformance: new Big('17.68'), + netPerformancePercentage: new Big('0.12184460284330327256'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.12348284960422163588') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('17.68') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('1'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('145.10285714285714285714'), + timeWeightedInvestmentWithCurrencyEffect: new Big( + '145.10285714285714285714' + ), + valueInBaseCurrency: new Big('87.8') + } + ], + totalFeesWithCurrencyEffect: new Big('4.25'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('75.80'), + totalInvestmentWithCurrencyEffect: new Big('75.80'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 17.68, + netPerformanceInPercentage: 0.12184460284330327256, + netPerformanceInPercentageWithCurrencyEffect: 0.12184460284330327256, + netPerformanceWithCurrencyEffect: 17.68, + totalInvestment: 75.8, + totalInvestmentValueWithCurrencyEffect: 75.8 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('75.8') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -75.8 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2022-01-01', investment: 75.8 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts new file mode 100644 index 000000000..c79fdef58 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts @@ -0,0 +1,264 @@ +import { + activityDummyData, + loadExportFile, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity, ExportResponse } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; +import { join } from 'node:path'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let exportResponse: ExportResponse; + + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeAll(() => { + exportResponse = loadExportFile( + join( + __dirname, + '../../../../../../../test/import/ok/novn-buy-and-sell.json' + ) + ); + }); + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('get current positions', () => { + it.only('with NOVN.SW buy and sell', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime()); + + const activities: Activity[] = exportResponse.activities.map( + (activity) => ({ + ...activityDummyData, + ...activity, + date: parseDate(activity.date), + feeInAssetProfileCurrency: activity.fee, + feeInBaseCurrency: activity.fee, + SymbolProfile: { + ...symbolProfileDummyData, + currency: activity.currency, + dataSource: activity.dataSource, + name: 'Novartis AG', + symbol: activity.symbol + }, + unitPriceInAssetProfileCurrency: activity.unitPrice + }) + ); + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: exportResponse.user.settings.currency, + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + const investments = portfolioCalculator.getInvestments(); + + const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'month' + }); + + const investmentsByYear = portfolioCalculator.getInvestmentsByGroup({ + data: portfolioSnapshot.historicalData, + groupBy: 'year' + }); + + expect(portfolioSnapshot.historicalData[0]).toEqual({ + date: '2022-03-06', + investmentValueWithCurrencyEffect: 0, + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + /** + * Closing price on 2022-03-07 is unknown, + * hence it uses the last unit price (2022-04-11): 87.8 + */ + expect(portfolioSnapshot.historicalData[1]).toEqual({ + date: '2022-03-07', + investmentValueWithCurrencyEffect: 151.6, + netPerformance: 24, // 2 * (87.8 - 75.8) = 24 + netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 + netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438 + netPerformanceWithCurrencyEffect: 24, + netWorth: 175.6, // 2 * 87.8 = 175.6 + totalAccountBalance: 0, + totalInvestment: 151.6, + totalInvestmentValueWithCurrencyEffect: 151.6, + value: 175.6, // 2 * 87.8 = 175.6 + valueWithCurrencyEffect: 175.6 + }); + + expect( + portfolioSnapshot.historicalData[ + portfolioSnapshot.historicalData.length - 1 + ] + ).toEqual({ + date: '2022-04-11', + investmentValueWithCurrencyEffect: 0, + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744, + netPerformanceWithCurrencyEffect: 19.86, + netWorth: 0, + totalAccountBalance: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0, + value: 0, + valueWithCurrencyEffect: 0 + }); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('0'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 2, + averagePrice: new Big('0'), + currency: 'CHF', + dataSource: 'YAHOO', + dateOfFirstActivity: '2022-03-07', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + grossPerformance: new Big('19.86'), + grossPerformancePercentage: new Big('0.13100263852242744063'), + grossPerformancePercentageWithCurrencyEffect: new Big( + '0.13100263852242744063' + ), + grossPerformanceWithCurrencyEffect: new Big('19.86'), + investment: new Big('0'), + investmentWithCurrencyEffect: new Big('0'), + netPerformance: new Big('19.86'), + netPerformancePercentage: new Big('0.13100263852242744063'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0.13100263852242744063') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('19.86') + }, + marketPrice: 87.8, + marketPriceInBaseCurrency: 87.8, + quantity: new Big('0'), + symbol: 'NOVN.SW', + tags: [], + timeWeightedInvestment: new Big('151.6'), + timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), + valueInBaseCurrency: new Big('0') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('0'), + totalInvestmentWithCurrencyEffect: new Big('0'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 19.86, + netPerformanceInPercentage: 0.13100263852242744063, + netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063, + netPerformanceWithCurrencyEffect: 19.86, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 + }) + ); + + expect(investments).toEqual([ + { date: '2022-03-07', investment: new Big('151.6') }, + { date: '2022-04-08', investment: new Big('0') } + ]); + + expect(investmentsByMonth).toEqual([ + { date: '2022-03-01', investment: 151.6 }, + { date: '2022-04-01', investment: -151.6 } + ]); + + expect(investmentsByYear).toEqual([ + { date: '2022-01-01', investment: 0 } + ]); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts new file mode 100644 index 000000000..e518a5994 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-valuable.spec.ts @@ -0,0 +1,171 @@ +import { + activityDummyData, + symbolProfileDummyData, + userDummyData +} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; +import { parseDate } from '@ghostfolio/common/helper'; +import { Activity } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Big } from 'big.js'; + +jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { + return { + CurrentRateService: jest.fn().mockImplementation(() => { + return CurrentRateServiceMock; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', + () => { + return { + PortfolioSnapshotService: jest.fn().mockImplementation(() => { + return PortfolioSnapshotServiceMock; + }) + }; + } +); + +jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { + return { + RedisCacheService: jest.fn().mockImplementation(() => { + return RedisCacheServiceMock; + }) + }; +}); + +describe('PortfolioCalculator', () => { + let configurationService: ConfigurationService; + let currentRateService: CurrentRateService; + let exchangeRateDataService: ExchangeRateDataService; + let portfolioCalculatorFactory: PortfolioCalculatorFactory; + let portfolioSnapshotService: PortfolioSnapshotService; + let redisCacheService: RedisCacheService; + + beforeEach(() => { + configurationService = new ConfigurationService(); + + currentRateService = new CurrentRateService(null, null, null, null); + + exchangeRateDataService = new ExchangeRateDataService( + null, + null, + null, + null + ); + + portfolioSnapshotService = new PortfolioSnapshotService(null); + + redisCacheService = new RedisCacheService(null, null); + + portfolioCalculatorFactory = new PortfolioCalculatorFactory( + configurationService, + currentRateService, + exchangeRateDataService, + portfolioSnapshotService, + redisCacheService + ); + }); + + describe('compute portfolio snapshot', () => { + it.only('with valuable activity', async () => { + jest.useFakeTimers().setSystemTime(parseDate('2022-01-31').getTime()); + + const activities: Activity[] = [ + { + ...activityDummyData, + date: new Date('2022-01-01'), + feeInAssetProfileCurrency: 0, + feeInBaseCurrency: 0, + quantity: 1, + SymbolProfile: { + ...symbolProfileDummyData, + currency: 'USD', + dataSource: 'MANUAL', + name: 'Penthouse Apartment', + symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde' + }, + type: 'BUY', + unitPriceInAssetProfileCurrency: 500000 + } + ]; + + const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ + activities, + calculationType: PerformanceCalculationType.ROAI, + currency: 'USD', + userId: userDummyData.id + }); + + const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); + + expect(portfolioSnapshot).toMatchObject({ + currentValueInBaseCurrency: new Big('500000'), + errors: [], + hasErrors: false, + positions: [ + { + activitiesCount: 1, + averagePrice: new Big('500000'), + currency: 'USD', + dataSource: 'MANUAL', + dateOfFirstActivity: '2022-01-01', + dividend: new Big('0'), + dividendInBaseCurrency: new Big('0'), + fee: new Big('0'), + feeInBaseCurrency: new Big('0'), + grossPerformance: new Big('0'), + grossPerformancePercentage: new Big('0'), + grossPerformancePercentageWithCurrencyEffect: new Big('0'), + grossPerformanceWithCurrencyEffect: new Big('0'), + investment: new Big('500000'), + investmentWithCurrencyEffect: new Big('500000'), + marketPrice: 1, + marketPriceInBaseCurrency: 500000, + netPerformance: new Big('0'), + netPerformancePercentage: new Big('0'), + netPerformancePercentageWithCurrencyEffectMap: { + max: new Big('0') + }, + netPerformanceWithCurrencyEffectMap: { + max: new Big('0') + }, + quantity: new Big('1'), + symbol: 'dac95060-d4f2-4653-a253-2c45e6fb5cde', + tags: [], + timeWeightedInvestment: new Big('500000'), + timeWeightedInvestmentWithCurrencyEffect: new Big('500000'), + valueInBaseCurrency: new Big('500000') + } + ], + totalFeesWithCurrencyEffect: new Big('0'), + totalInterestWithCurrencyEffect: new Big('0'), + totalInvestment: new Big('500000'), + totalInvestmentWithCurrencyEffect: new Big('500000'), + totalLiabilitiesWithCurrencyEffect: new Big('0') + }); + + expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject( + expect.objectContaining({ + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + totalInvestment: 500000, + totalInvestmentValueWithCurrencyEffect: 500000 + }) + ); + }); + }); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts new file mode 100644 index 000000000..99bee2c21 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts @@ -0,0 +1,3 @@ +describe('PortfolioCalculator', () => { + test.skip('Skip empty test', () => 1); +}); diff --git a/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts new file mode 100644 index 000000000..be69048df --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts @@ -0,0 +1,1009 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; +import { DateRange } from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Logger } from '@nestjs/common'; +import { Big } from 'big.js'; +import { + addMilliseconds, + differenceInDays, + eachYearOfInterval, + format, + isBefore, + isThisYear +} from 'date-fns'; +import { cloneDeep, sortBy } from 'lodash'; + +export class RoaiPortfolioCalculator extends PortfolioCalculator { + private chartDates: string[]; + + protected calculateOverallPerformance( + positions: TimelinePosition[] + ): PortfolioSnapshot { + let currentValueInBaseCurrency = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let hasErrors = false; + let netPerformance = new Big(0); + let totalFeesWithCurrencyEffect = new Big(0); + const totalInterestWithCurrencyEffect = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalTimeWeightedInvestment = new Big(0); + let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); + + for (const currentPosition of positions.filter( + ({ includeInTotalAssetValue }) => { + return includeInTotalAssetValue; + } + )) { + if (currentPosition.feeInBaseCurrency) { + totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus( + currentPosition.feeInBaseCurrency + ); + } + + if (currentPosition.valueInBaseCurrency) { + currentValueInBaseCurrency = currentValueInBaseCurrency.plus( + currentPosition.valueInBaseCurrency + ); + } else { + hasErrors = true; + } + + if (currentPosition.investment) { + totalInvestment = totalInvestment.plus(currentPosition.investment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + currentPosition.investmentWithCurrencyEffect + ); + } else { + hasErrors = true; + } + + if (currentPosition.grossPerformance) { + grossPerformance = grossPerformance.plus( + currentPosition.grossPerformance + ); + + grossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.plus( + currentPosition.grossPerformanceWithCurrencyEffect + ); + + netPerformance = netPerformance.plus(currentPosition.netPerformance); + } else if (!currentPosition.quantity.eq(0)) { + hasErrors = true; + } + + if (currentPosition.timeWeightedInvestment) { + totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus( + currentPosition.timeWeightedInvestment + ); + + totalTimeWeightedInvestmentWithCurrencyEffect = + totalTimeWeightedInvestmentWithCurrencyEffect.plus( + currentPosition.timeWeightedInvestmentWithCurrencyEffect + ); + } else if (!currentPosition.quantity.eq(0)) { + Logger.warn( + `Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`, + 'PortfolioCalculator' + ); + + hasErrors = true; + } + } + + return { + currentValueInBaseCurrency, + hasErrors, + positions, + totalFeesWithCurrencyEffect, + totalInterestWithCurrencyEffect, + totalInvestment, + totalInvestmentWithCurrencyEffect, + activitiesCount: this.activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, + createdAt: new Date(), + errors: [], + historicalData: [], + totalLiabilitiesWithCurrencyEffect: new Big(0) + }; + } + + protected getPerformanceCalculationType() { + return PerformanceCalculationType.ROAI; + } + + protected getSymbolMetrics({ + chartDateMap, + dataSource, + end, + exchangeRates, + marketSymbolMap, + start, + symbol + }: { + chartDateMap?: { [date: string]: boolean }; + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + } & AssetProfileIdentifier): SymbolMetrics { + const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; + const currentValues: { [date: string]: Big } = {}; + const currentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let fees = new Big(0); + let feesAtStartDate = new Big(0); + let feesAtStartDateWithCurrencyEffect = new Big(0); + let feesWithCurrencyEffect = new Big(0); + let grossPerformance = new Big(0); + let grossPerformanceWithCurrencyEffect = new Big(0); + let grossPerformanceAtStartDate = new Big(0); + let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0); + let grossPerformanceFromSells = new Big(0); + let grossPerformanceFromSellsWithCurrencyEffect = new Big(0); + let initialValue: Big; + let initialValueWithCurrencyEffect: Big; + let investmentAtStartDate: Big; + let investmentAtStartDateWithCurrencyEffect: Big; + const investmentValuesAccumulated: { [date: string]: Big } = {}; + const investmentValuesAccumulatedWithCurrencyEffect: { + [date: string]: Big; + } = {}; + const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {}; + let lastAveragePrice = new Big(0); + let lastAveragePriceWithCurrencyEffect = new Big(0); + const netPerformanceValues: { [date: string]: Big } = {}; + const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {}; + const timeWeightedInvestmentValues: { [date: string]: Big } = {}; + + const timeWeightedInvestmentValuesWithCurrencyEffect: { + [date: string]: Big; + } = {}; + + const totalAccountBalanceInBaseCurrency = new Big(0); + let totalDividend = new Big(0); + let totalDividendInBaseCurrency = new Big(0); + let totalInterest = new Big(0); + let totalInterestInBaseCurrency = new Big(0); + let totalInvestment = new Big(0); + let totalInvestmentFromBuyTransactions = new Big(0); + let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); + let totalInvestmentWithCurrencyEffect = new Big(0); + let totalLiabilities = new Big(0); + let totalLiabilitiesInBaseCurrency = new Big(0); + let totalQuantityFromBuyTransactions = new Big(0); + let totalUnits = new Big(0); + let valueAtStartDate: Big; + let valueAtStartDateWithCurrencyEffect: Big; + + // Clone orders to keep the original values in this.orders + let orders: PortfolioOrderItem[] = cloneDeep( + this.activities.filter(({ SymbolProfile }) => { + return SymbolProfile.symbol === symbol; + }) + ); + + const isCash = orders[0]?.SymbolProfile?.assetSubClass === 'CASH'; + + if (orders.length <= 0) { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: false, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + netPerformanceWithCurrencyEffectMap: {}, + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0) + }; + } + + const dateOfFirstTransaction = new Date(orders[0].date); + + const endDateString = format(end, DATE_FORMAT); + const startDateString = format(start, DATE_FORMAT); + + const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol]; + let unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol]; + + let latestActivity = orders.at(-1); + + if ( + dataSource === 'MANUAL' && + ['BUY', 'SELL'].includes(latestActivity?.type) && + latestActivity?.unitPrice && + !unitPriceAtEndDate + ) { + // For BUY / SELL activities with a MANUAL data source where no historical market price is available, + // the calculation should fall back to using the activity’s unit price. + unitPriceAtEndDate = latestActivity.unitPrice; + } else if (isCash) { + unitPriceAtEndDate = new Big(1); + } + + if ( + !unitPriceAtEndDate || + (!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start)) + ) { + return { + currentValues: {}, + currentValuesWithCurrencyEffect: {}, + feesWithCurrencyEffect: new Big(0), + grossPerformance: new Big(0), + grossPerformancePercentage: new Big(0), + grossPerformancePercentageWithCurrencyEffect: new Big(0), + grossPerformanceWithCurrencyEffect: new Big(0), + hasErrors: true, + initialValue: new Big(0), + initialValueWithCurrencyEffect: new Big(0), + investmentValuesAccumulated: {}, + investmentValuesAccumulatedWithCurrencyEffect: {}, + investmentValuesWithCurrencyEffect: {}, + netPerformance: new Big(0), + netPerformancePercentage: new Big(0), + netPerformancePercentageWithCurrencyEffectMap: {}, + netPerformanceWithCurrencyEffectMap: {}, + netPerformanceValues: {}, + netPerformanceValuesWithCurrencyEffect: {}, + timeWeightedInvestment: new Big(0), + timeWeightedInvestmentValues: {}, + timeWeightedInvestmentValuesWithCurrencyEffect: {}, + timeWeightedInvestmentWithCurrencyEffect: new Big(0), + totalAccountBalanceInBaseCurrency: new Big(0), + totalDividend: new Big(0), + totalDividendInBaseCurrency: new Big(0), + totalInterest: new Big(0), + totalInterestInBaseCurrency: new Big(0), + totalInvestment: new Big(0), + totalInvestmentWithCurrencyEffect: new Big(0), + totalLiabilities: new Big(0), + totalLiabilitiesInBaseCurrency: new Big(0) + }; + } + + // Add a synthetic order at the start and the end date + orders.push({ + date: startDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'start', + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined + }, + type: 'BUY', + unitPrice: unitPriceAtStartDate + }); + + orders.push({ + date: endDateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + itemType: 'end', + SymbolProfile: { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined + }, + quantity: new Big(0), + type: 'BUY', + unitPrice: unitPriceAtEndDate + }); + + let lastUnitPrice: Big; + + const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {}; + + for (const order of orders) { + ordersByDate[order.date] = ordersByDate[order.date] ?? []; + ordersByDate[order.date].push(order); + } + + if (!this.chartDates) { + this.chartDates = Object.keys(chartDateMap).sort(); + } + + for (const dateString of this.chartDates) { + if (dateString < startDateString) { + continue; + } else if (dateString > endDateString) { + break; + } + + if (ordersByDate[dateString]?.length > 0) { + for (const order of ordersByDate[dateString]) { + order.unitPriceFromMarketData = + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice; + } + } else { + orders.push({ + date: dateString, + fee: new Big(0), + feeInBaseCurrency: new Big(0), + quantity: new Big(0), + SymbolProfile: { + dataSource, + symbol, + assetSubClass: isCash ? 'CASH' : undefined + }, + type: 'BUY', + unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice, + unitPriceFromMarketData: + marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice + }); + } + + latestActivity = orders.at(-1); + + lastUnitPrice = + latestActivity.unitPriceFromMarketData ?? latestActivity.unitPrice; + } + + // Sort orders so that the start and end placeholder order are at the correct + // position + orders = sortBy(orders, ({ date, itemType }) => { + let sortIndex = new Date(date); + + if (itemType === 'end') { + sortIndex = addMilliseconds(sortIndex, 1); + } else if (itemType === 'start') { + sortIndex = addMilliseconds(sortIndex, -1); + } + + return sortIndex.getTime(); + }); + + const indexOfStartOrder = orders.findIndex(({ itemType }) => { + return itemType === 'start'; + }); + + const indexOfEndOrder = orders.findIndex(({ itemType }) => { + return itemType === 'end'; + }); + + let totalInvestmentDays = 0; + let sumOfTimeWeightedInvestments = new Big(0); + let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0); + + for (let i = 0; i < orders.length; i += 1) { + const order = orders[i]; + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log(); + console.log(); + console.log( + i + 1, + order.date, + order.type, + order.itemType ? `(${order.itemType})` : '' + ); + } + + const exchangeRateAtOrderDate = exchangeRates[order.date]; + + if (order.type === 'DIVIDEND') { + const dividend = order.quantity.mul(order.unitPrice); + + totalDividend = totalDividend.plus(dividend); + totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( + dividend.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'INTEREST') { + const interest = order.quantity.mul(order.unitPrice); + + totalInterest = totalInterest.plus(interest); + totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( + interest.mul(exchangeRateAtOrderDate ?? 1) + ); + } else if (order.type === 'LIABILITY') { + const liabilities = order.quantity.mul(order.unitPrice); + + totalLiabilities = totalLiabilities.plus(liabilities); + totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus( + liabilities.mul(exchangeRateAtOrderDate ?? 1) + ); + } + + if (order.itemType === 'start') { + // Take the unit price of the order as the market price if there are no + // orders of this symbol before the start date + order.unitPrice = + indexOfStartOrder === 0 + ? orders[i + 1]?.unitPrice + : unitPriceAtStartDate; + } + + if (order.fee) { + order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1); + order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul( + exchangeRateAtOrderDate ?? 1 + ); + } + + const unitPrice = ['BUY', 'SELL'].includes(order.type) + ? order.unitPrice + : order.unitPriceFromMarketData; + + if (unitPrice) { + order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1); + + order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul( + exchangeRateAtOrderDate ?? 1 + ); + } + + const marketPriceInBaseCurrency = + order.unitPriceFromMarketData?.mul(currentExchangeRate ?? 1) ?? + new Big(0); + const marketPriceInBaseCurrencyWithCurrencyEffect = + order.unitPriceFromMarketData?.mul(exchangeRateAtOrderDate ?? 1) ?? + new Big(0); + + const valueOfInvestmentBeforeTransaction = totalUnits.mul( + marketPriceInBaseCurrency + ); + + const valueOfInvestmentBeforeTransactionWithCurrencyEffect = + totalUnits.mul(marketPriceInBaseCurrencyWithCurrencyEffect); + + if (!investmentAtStartDate && i >= indexOfStartOrder) { + investmentAtStartDate = totalInvestment ?? new Big(0); + + investmentAtStartDateWithCurrencyEffect = + totalInvestmentWithCurrencyEffect ?? new Big(0); + + valueAtStartDate = valueOfInvestmentBeforeTransaction; + + valueAtStartDateWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; + } + + let transactionInvestment = new Big(0); + let transactionInvestmentWithCurrencyEffect = new Big(0); + + if (order.type === 'BUY') { + transactionInvestment = order.quantity + .mul(order.unitPriceInBaseCurrency) + .mul(getFactor(order.type)); + + transactionInvestmentWithCurrencyEffect = order.quantity + .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) + .mul(getFactor(order.type)); + + totalQuantityFromBuyTransactions = + totalQuantityFromBuyTransactions.plus(order.quantity); + + totalInvestmentFromBuyTransactions = + totalInvestmentFromBuyTransactions.plus(transactionInvestment); + + totalInvestmentFromBuyTransactionsWithCurrencyEffect = + totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + } else if (order.type === 'SELL') { + if (totalUnits.gt(0)) { + transactionInvestment = totalInvestment + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + transactionInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect + .div(totalUnits) + .mul(order.quantity) + .mul(getFactor(order.type)); + } + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log('order.quantity', order.quantity.toNumber()); + console.log('transactionInvestment', transactionInvestment.toNumber()); + + console.log( + 'transactionInvestmentWithCurrencyEffect', + transactionInvestmentWithCurrencyEffect.toNumber() + ); + } + + const totalInvestmentBeforeTransaction = totalInvestment; + + const totalInvestmentBeforeTransactionWithCurrencyEffect = + totalInvestmentWithCurrencyEffect; + + totalInvestment = totalInvestment.plus(transactionInvestment); + + totalInvestmentWithCurrencyEffect = + totalInvestmentWithCurrencyEffect.plus( + transactionInvestmentWithCurrencyEffect + ); + + if (i >= indexOfStartOrder && !initialValue) { + if ( + i === indexOfStartOrder && + !valueOfInvestmentBeforeTransaction.eq(0) + ) { + initialValue = valueOfInvestmentBeforeTransaction; + + initialValueWithCurrencyEffect = + valueOfInvestmentBeforeTransactionWithCurrencyEffect; + } else if (transactionInvestment.gt(0)) { + initialValue = transactionInvestment; + + initialValueWithCurrencyEffect = + transactionInvestmentWithCurrencyEffect; + } + } + + fees = fees.plus(order.feeInBaseCurrency ?? 0); + + feesWithCurrencyEffect = feesWithCurrencyEffect.plus( + order.feeInBaseCurrencyWithCurrencyEffect ?? 0 + ); + + totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type))); + + const valueOfInvestment = totalUnits.mul(marketPriceInBaseCurrency); + + const valueOfInvestmentWithCurrencyEffect = totalUnits.mul( + marketPriceInBaseCurrencyWithCurrencyEffect + ); + + const grossPerformanceFromSell = + order.type === 'SELL' + ? order.unitPriceInBaseCurrency + .minus(lastAveragePrice) + .mul(order.quantity) + : new Big(0); + + const grossPerformanceFromSellWithCurrencyEffect = + order.type === 'SELL' + ? order.unitPriceInBaseCurrencyWithCurrencyEffect + .minus(lastAveragePriceWithCurrencyEffect) + .mul(order.quantity) + : new Big(0); + + grossPerformanceFromSells = grossPerformanceFromSells.plus( + grossPerformanceFromSell + ); + + grossPerformanceFromSellsWithCurrencyEffect = + grossPerformanceFromSellsWithCurrencyEffect.plus( + grossPerformanceFromSellWithCurrencyEffect + ); + + lastAveragePrice = totalQuantityFromBuyTransactions.eq(0) + ? new Big(0) + : totalInvestmentFromBuyTransactions.div( + totalQuantityFromBuyTransactions + ); + + lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq( + 0 + ) + ? new Big(0) + : totalInvestmentFromBuyTransactionsWithCurrencyEffect.div( + totalQuantityFromBuyTransactions + ); + + if (totalUnits.eq(0)) { + // Reset tracking variables when position is fully closed + totalInvestmentFromBuyTransactions = new Big(0); + totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0); + totalQuantityFromBuyTransactions = new Big(0); + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log( + 'grossPerformanceFromSells', + grossPerformanceFromSells.toNumber() + ); + console.log( + 'grossPerformanceFromSellWithCurrencyEffect', + grossPerformanceFromSellWithCurrencyEffect.toNumber() + ); + } + + const newGrossPerformance = valueOfInvestment + .minus(totalInvestment) + .plus(grossPerformanceFromSells); + + const newGrossPerformanceWithCurrencyEffect = + valueOfInvestmentWithCurrencyEffect + .minus(totalInvestmentWithCurrencyEffect) + .plus(grossPerformanceFromSellsWithCurrencyEffect); + + grossPerformance = newGrossPerformance; + + grossPerformanceWithCurrencyEffect = + newGrossPerformanceWithCurrencyEffect; + + if (order.itemType === 'start') { + feesAtStartDate = fees; + feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect; + grossPerformanceAtStartDate = grossPerformance; + + grossPerformanceAtStartDateWithCurrencyEffect = + grossPerformanceWithCurrencyEffect; + } + + if (i > indexOfStartOrder) { + // Only consider periods with an investment for the calculation of + // the time weighted investment + if ( + valueOfInvestmentBeforeTransaction.gt(0) && + ['BUY', 'SELL'].includes(order.type) + ) { + // Calculate the number of days since the previous order + const orderDate = new Date(order.date); + const previousOrderDate = new Date(orders[i - 1].date); + + let daysSinceLastOrder = differenceInDays( + orderDate, + previousOrderDate + ); + if (daysSinceLastOrder <= 0) { + // The time between two activities on the same day is unknown + // -> Set it to the smallest floating point number greater than 0 + daysSinceLastOrder = Number.EPSILON; + } + + // Sum up the total investment days since the start date to calculate + // the time weighted investment + totalInvestmentDays += daysSinceLastOrder; + + sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add( + valueAtStartDate + .minus(investmentAtStartDate) + .plus(totalInvestmentBeforeTransaction) + .mul(daysSinceLastOrder) + ); + + sumOfTimeWeightedInvestmentsWithCurrencyEffect = + sumOfTimeWeightedInvestmentsWithCurrencyEffect.add( + valueAtStartDateWithCurrencyEffect + .minus(investmentAtStartDateWithCurrencyEffect) + .plus(totalInvestmentBeforeTransactionWithCurrencyEffect) + .mul(daysSinceLastOrder) + ); + } + + currentValues[order.date] = valueOfInvestment; + + currentValuesWithCurrencyEffect[order.date] = + valueOfInvestmentWithCurrencyEffect; + + netPerformanceValues[order.date] = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + netPerformanceValuesWithCurrencyEffect[order.date] = + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .minus( + feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect) + ); + + investmentValuesAccumulated[order.date] = totalInvestment; + + investmentValuesAccumulatedWithCurrencyEffect[order.date] = + totalInvestmentWithCurrencyEffect; + + investmentValuesWithCurrencyEffect[order.date] = ( + investmentValuesWithCurrencyEffect[order.date] ?? new Big(0) + ).add(transactionInvestmentWithCurrencyEffect); + + // If duration is effectively zero (first day), use the actual investment as the base. + // Otherwise, use the calculated time-weighted average. + timeWeightedInvestmentValues[order.date] = + totalInvestmentDays > Number.EPSILON + ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) + : totalInvestment.gt(0) + ? totalInvestment + : new Big(0); + + timeWeightedInvestmentValuesWithCurrencyEffect[order.date] = + totalInvestmentDays > Number.EPSILON + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : totalInvestmentWithCurrencyEffect.gt(0) + ? totalInvestmentWithCurrencyEffect + : new Big(0); + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log('totalInvestment', totalInvestment.toNumber()); + + console.log( + 'totalInvestmentWithCurrencyEffect', + totalInvestmentWithCurrencyEffect.toNumber() + ); + + console.log( + 'totalGrossPerformance', + grossPerformance.minus(grossPerformanceAtStartDate).toNumber() + ); + + console.log( + 'totalGrossPerformanceWithCurrencyEffect', + grossPerformanceWithCurrencyEffect + .minus(grossPerformanceAtStartDateWithCurrencyEffect) + .toNumber() + ); + } + + if (i === indexOfEndOrder) { + break; + } + } + + const totalGrossPerformance = grossPerformance.minus( + grossPerformanceAtStartDate + ); + + const totalGrossPerformanceWithCurrencyEffect = + grossPerformanceWithCurrencyEffect.minus( + grossPerformanceAtStartDateWithCurrencyEffect + ); + + const totalNetPerformance = grossPerformance + .minus(grossPerformanceAtStartDate) + .minus(fees.minus(feesAtStartDate)); + + const timeWeightedAverageInvestmentBetweenStartAndEndDate = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestments.div(totalInvestmentDays) + : new Big(0); + + const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect = + totalInvestmentDays > 0 + ? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div( + totalInvestmentDays + ) + : new Big(0); + + const grossPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalGrossPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); + + const grossPerformancePercentageWithCurrencyEffect = + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt( + 0 + ) + ? totalGrossPerformanceWithCurrencyEffect.div( + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + ) + : new Big(0); + + const feesPerUnit = totalUnits.gt(0) + ? fees.minus(feesAtStartDate).div(totalUnits) + : new Big(0); + + const feesPerUnitWithCurrencyEffect = totalUnits.gt(0) + ? feesWithCurrencyEffect + .minus(feesAtStartDateWithCurrencyEffect) + .div(totalUnits) + : new Big(0); + + const netPerformancePercentage = + timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0) + ? totalNetPerformance.div( + timeWeightedAverageInvestmentBetweenStartAndEndDate + ) + : new Big(0); + + const netPerformancePercentageWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + const netPerformanceWithCurrencyEffectMap: { + [key: DateRange]: Big; + } = {}; + + for (const dateRange of [ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd', + ...eachYearOfInterval({ end, start }) + .filter((date) => { + return !isThisYear(date); + }) + .map((date) => { + return format(date, 'yyyy'); + }) + ] as DateRange[]) { + const dateInterval = getIntervalFromDateRange(dateRange); + const endDate = dateInterval.endDate; + let startDate = dateInterval.startDate; + + if (isBefore(startDate, start)) { + startDate = start; + } + + const rangeEndDateString = format(endDate, DATE_FORMAT); + const rangeStartDateString = format(startDate, DATE_FORMAT); + + const currentValuesAtDateRangeStartWithCurrencyEffect = + currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0); + + const investmentValuesAccumulatedAtStartDateWithCurrencyEffect = + investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ?? + new Big(0); + + const grossPerformanceAtDateRangeStartWithCurrencyEffect = + currentValuesAtDateRangeStartWithCurrencyEffect.minus( + investmentValuesAccumulatedAtStartDateWithCurrencyEffect + ); + + let average = new Big(0); + let dayCount = 0; + + for (let i = this.chartDates.length - 1; i >= 0; i -= 1) { + const date = this.chartDates[i]; + + if (date > rangeEndDateString) { + continue; + } else if (date < rangeStartDateString) { + break; + } + + if ( + investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big && + investmentValuesAccumulatedWithCurrencyEffect[date].gt(0) + ) { + average = average.add( + investmentValuesAccumulatedWithCurrencyEffect[date].add( + grossPerformanceAtDateRangeStartWithCurrencyEffect + ) + ); + + dayCount++; + } + } + + if (dayCount > 0) { + average = average.div(dayCount); + } + + netPerformanceWithCurrencyEffectMap[dateRange] = + netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus( + // If the date range is 'max', take 0 as a start value. Otherwise, + // the value of the end of the day of the start date is taken which + // differs from the buying price. + dateRange === 'max' + ? new Big(0) + : (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ?? + new Big(0)) + ) ?? new Big(0); + + netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0) + ? netPerformanceWithCurrencyEffectMap[dateRange].div(average) + : new Big(0); + } + + if (PortfolioCalculator.ENABLE_LOGGING) { + console.log( + ` + ${symbol} + Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed( + 2 + )} -> ${unitPriceAtEndDate.toFixed(2)} + Total investment: ${totalInvestment.toFixed(2)} + Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed( + 2 + )} + Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed( + 2 + )} + Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( + 2 + )} + Total dividend: ${totalDividend.toFixed(2)} + Gross performance: ${totalGrossPerformance.toFixed( + 2 + )} / ${grossPerformancePercentage.mul(100).toFixed(2)}% + Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed( + 2 + )} / ${grossPerformancePercentageWithCurrencyEffect + .mul(100) + .toFixed(2)}% + Fees per unit: ${feesPerUnit.toFixed(2)} + Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed( + 2 + )} + Net performance: ${totalNetPerformance.toFixed( + 2 + )} / ${netPerformancePercentage.mul(100).toFixed(2)}% + Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[ + 'max' + ].toFixed(2)}%` + ); + } + + return { + currentValues, + currentValuesWithCurrencyEffect, + feesWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + initialValue, + initialValueWithCurrencyEffect, + investmentValuesAccumulated, + investmentValuesAccumulatedWithCurrencyEffect, + investmentValuesWithCurrencyEffect, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceValues, + netPerformanceValuesWithCurrencyEffect, + netPerformanceWithCurrencyEffectMap, + timeWeightedInvestmentValues, + timeWeightedInvestmentValuesWithCurrencyEffect, + totalAccountBalanceInBaseCurrency, + totalDividend, + totalDividendInBaseCurrency, + totalInterest, + totalInterestInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect, + totalLiabilities, + totalLiabilitiesInBaseCurrency, + grossPerformance: totalGrossPerformance, + grossPerformanceWithCurrencyEffect: + totalGrossPerformanceWithCurrencyEffect, + hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate), + netPerformance: totalNetPerformance, + timeWeightedInvestment: + timeWeightedAverageInvestmentBetweenStartAndEndDate, + timeWeightedInvestmentWithCurrencyEffect: + timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect + }; + } +} diff --git a/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts new file mode 100644 index 000000000..b4929c570 --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts @@ -0,0 +1,29 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +export class RoiPortfolioCalculator extends PortfolioCalculator { + protected calculateOverallPerformance(): PortfolioSnapshot { + throw new Error('Method not implemented.'); + } + + protected getPerformanceCalculationType() { + return PerformanceCalculationType.ROI; + } + + protected getSymbolMetrics({}: { + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & AssetProfileIdentifier): SymbolMetrics { + throw new Error('Method not implemented.'); + } +} diff --git a/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts new file mode 100644 index 000000000..8a58f816a --- /dev/null +++ b/apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts @@ -0,0 +1,29 @@ +import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; +import { + AssetProfileIdentifier, + SymbolMetrics +} from '@ghostfolio/common/interfaces'; +import { PortfolioSnapshot } from '@ghostfolio/common/models'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +export class TwrPortfolioCalculator extends PortfolioCalculator { + protected calculateOverallPerformance(): PortfolioSnapshot { + throw new Error('Method not implemented.'); + } + + protected getPerformanceCalculationType() { + return PerformanceCalculationType.TWR; + } + + protected getSymbolMetrics({}: { + end: Date; + exchangeRates: { [dateString: string]: number }; + marketSymbolMap: { + [date: string]: { [symbol: string]: Big }; + }; + start: Date; + step?: number; + } & AssetProfileIdentifier): SymbolMetrics { + throw new Error('Method not implemented.'); + } +} diff --git a/apps/api/src/app/portfolio/current-rate.service.mock.ts b/apps/api/src/app/portfolio/current-rate.service.mock.ts new file mode 100644 index 000000000..8e027f971 --- /dev/null +++ b/apps/api/src/app/portfolio/current-rate.service.mock.ts @@ -0,0 +1,145 @@ +import { parseDate, resetHours } from '@ghostfolio/common/helper'; + +import { + addDays, + eachDayOfInterval, + endOfDay, + isBefore, + isSameDay +} from 'date-fns'; + +import { GetValueObject } from './interfaces/get-value-object.interface'; +import { GetValuesObject } from './interfaces/get-values-object.interface'; +import { GetValuesParams } from './interfaces/get-values-params.interface'; + +function mockGetValue(symbol: string, date: Date) { + switch (symbol) { + case '55196015-1365-4560-aa60-8751ae6d18f8': + if (isSameDay(parseDate('2022-01-31'), date)) { + return { marketPrice: 3000 }; + } + + return { marketPrice: 0 }; + + case 'BALN.SW': + if (isSameDay(parseDate('2021-11-12'), date)) { + return { marketPrice: 146 }; + } else if (isSameDay(parseDate('2021-11-22'), date)) { + return { marketPrice: 142.9 }; + } else if (isSameDay(parseDate('2021-11-26'), date)) { + return { marketPrice: 139.9 }; + } else if (isSameDay(parseDate('2021-11-30'), date)) { + return { marketPrice: 136.6 }; + } else if (isSameDay(parseDate('2021-12-12'), date)) { + return { marketPrice: 142.0 }; + } else if (isSameDay(parseDate('2021-12-17'), date)) { + return { marketPrice: 143.9 }; + } else if (isSameDay(parseDate('2021-12-18'), date)) { + return { marketPrice: 148.9 }; + } + + return { marketPrice: 0 }; + + case 'BTCUSD': + if (isSameDay(parseDate('2015-01-01'), date)) { + return { marketPrice: 314.25 }; + } else if (isSameDay(parseDate('2017-12-31'), date)) { + return { marketPrice: 14156.4 }; + } else if (isSameDay(parseDate('2018-01-01'), date)) { + return { marketPrice: 13657.2 }; + } else if (isSameDay(parseDate('2021-12-12'), date)) { + return { marketPrice: 50098.3 }; + } else if (isSameDay(parseDate('2022-01-14'), date)) { + return { marketPrice: 43099.7 }; + } + + return { marketPrice: 0 }; + + case 'GOOGL': + if (isSameDay(parseDate('2023-01-03'), date)) { + return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2023-07-10'), date)) { + return { marketPrice: 116.45 }; + } + + return { marketPrice: 0 }; + + case 'JNUG': + if (isSameDay(parseDate('2025-12-10'), date)) { + return { marketPrice: 204.5599975585938 }; + } else if (isSameDay(parseDate('2025-12-17'), date)) { + return { marketPrice: 203.9700012207031 }; + } else if (isSameDay(parseDate('2025-12-28'), date)) { + return { marketPrice: 237.8000030517578 }; + } + + return { marketPrice: 0 }; + + case 'MSFT': + if (isSameDay(parseDate('2021-09-16'), date)) { + return { marketPrice: 89.12 }; + } else if (isSameDay(parseDate('2021-11-16'), date)) { + return { marketPrice: 339.51 }; + } else if (isSameDay(parseDate('2023-07-09'), date)) { + return { marketPrice: 337.22 }; + } else if (isSameDay(parseDate('2023-07-10'), date)) { + return { marketPrice: 331.83 }; + } + + return { marketPrice: 0 }; + + case 'NOVN.SW': + if (isSameDay(parseDate('2022-04-11'), date)) { + return { marketPrice: 87.8 }; + } + + return { marketPrice: 0 }; + + default: + return { marketPrice: 0 }; + } +} + +export const CurrentRateServiceMock = { + getValues: ({ + dataGatheringItems, + dateQuery + }: GetValuesParams): Promise => { + const values: GetValueObject[] = []; + + if (dateQuery.lt) { + for ( + let date = resetHours(dateQuery.gte); + isBefore(date, endOfDay(dateQuery.lt)); + date = addDays(date, 1) + ) { + for (const dataGatheringItem of dataGatheringItems) { + values.push({ + date, + dataSource: dataGatheringItem.dataSource, + marketPrice: mockGetValue(dataGatheringItem.symbol, date) + .marketPrice, + symbol: dataGatheringItem.symbol + }); + } + } + } else { + for (const date of eachDayOfInterval({ + end: dateQuery.lt, + start: dateQuery.gte + })) { + for (const dataGatheringItem of dataGatheringItems) { + values.push({ + date, + dataSource: dataGatheringItem.dataSource, + marketPrice: mockGetValue(dataGatheringItem.symbol, date) + .marketPrice, + symbol: dataGatheringItem.symbol + }); + } + } + } + + return Promise.resolve({ values, dataProviderInfos: [], errors: [] }); + } +}; diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts new file mode 100644 index 000000000..d8b7482e7 --- /dev/null +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -0,0 +1,152 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +import { DataSource, MarketData } from '@prisma/client'; + +import { CurrentRateService } from './current-rate.service'; +import { DateQuery } from './interfaces/date-query.interface'; +import { GetValuesObject } from './interfaces/get-values-object.interface'; + +jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => { + return { + MarketDataService: jest.fn().mockImplementation(() => { + return { + get: (date: Date, symbol: string) => { + return Promise.resolve({ + date, + symbol, + createdAt: date, + dataSource: DataSource.YAHOO, + id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584', + marketPrice: 1847.839966, + state: 'CLOSE' + }); + }, + getRange: ({ + assetProfileIdentifiers, + dateQuery + }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; + dateQuery: DateQuery; + skip?: number; + take?: number; + }) => { + return Promise.resolve([ + { + createdAt: dateQuery.gte, + dataSource: assetProfileIdentifiers[0].dataSource, + date: dateQuery.gte, + id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', + marketPrice: 1841.823902, + state: 'CLOSE', + symbol: assetProfileIdentifiers[0].symbol + }, + { + createdAt: dateQuery.lt, + dataSource: assetProfileIdentifiers[0].dataSource, + date: dateQuery.lt, + id: '082d6893-df27-4c91-8a5d-092e84315b56', + marketPrice: 1847.839966, + state: 'CLOSE', + symbol: assetProfileIdentifiers[0].symbol + } + ]); + }, + getRangeCount: ({}: { + assetProfileIdentifiers: AssetProfileIdentifier[]; + dateRangeEnd: Date; + dateRangeStart: Date; + }) => { + return Promise.resolve(2); + } + }; + }) + }; +}); + +jest.mock( + '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service', + () => { + return { + ExchangeRateDataService: jest.fn().mockImplementation(() => { + return { + initialize: () => Promise.resolve(), + toCurrency: (value: number) => { + return 1 * value; + }, + getExchangeRates: () => Promise.resolve() + }; + }) + }; + } +); + +jest.mock('@ghostfolio/api/services/property/property.service', () => { + return { + PropertyService: jest.fn().mockImplementation(() => { + return { + getByKey: () => Promise.resolve({}) + }; + }) + }; +}); + +describe('CurrentRateService', () => { + let currentRateService: CurrentRateService; + let dataProviderService: DataProviderService; + let marketDataService: MarketDataService; + let propertyService: PropertyService; + + beforeAll(async () => { + propertyService = new PropertyService(null); + + dataProviderService = new DataProviderService( + null, + [], + null, + null, + propertyService, + null + ); + + marketDataService = new MarketDataService(null); + + currentRateService = new CurrentRateService( + dataProviderService, + marketDataService, + null, + null + ); + }); + + it('getValues', async () => { + expect( + await currentRateService.getValues({ + dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }], + dateQuery: { + lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), + gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) + } + }) + ).toMatchObject({ + dataProviderInfos: [], + errors: [], + values: [ + { + dataSource: 'YAHOO', + date: new Date('2020-01-01T00:00:00.000Z'), + marketPrice: 1841.823902, + symbol: 'AMZN' + }, + { + dataSource: 'YAHOO', + date: new Date('2020-01-02T00:00:00.000Z'), + marketPrice: 1847.839966, + symbol: 'AMZN' + } + ] + }); + }); +}); diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts new file mode 100644 index 000000000..5d39a54bb --- /dev/null +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -0,0 +1,179 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { resetHours } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + DataProviderInfo, + ResponseError +} from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { Inject, Injectable } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { isBefore, isToday } from 'date-fns'; +import { isEmpty, uniqBy } from 'lodash'; + +import { GetValueObject } from './interfaces/get-value-object.interface'; +import { GetValuesObject } from './interfaces/get-values-object.interface'; +import { GetValuesParams } from './interfaces/get-values-params.interface'; + +@Injectable() +export class CurrentRateService { + private static readonly MARKET_DATA_PAGE_SIZE = 50000; + + public constructor( + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly orderService: OrderService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @LogPerformance + // TODO: Pass user instead of using this.request.user + public async getValues({ + dataGatheringItems, + dateQuery + }: GetValuesParams): Promise { + const dataProviderInfos: DataProviderInfo[] = []; + + const includesToday = + (!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) && + (!dateQuery.gte || isBefore(dateQuery.gte, new Date())) && + (!dateQuery.in || this.containsToday(dateQuery.in)); + + const quoteErrors: ResponseError['errors'] = []; + const today = resetHours(new Date()); + const values: GetValueObject[] = []; + + if (includesToday) { + const quotesBySymbol = await this.dataProviderService.getQuotes({ + items: dataGatheringItems, + user: this.request?.user + }); + + for (const { dataSource, symbol } of dataGatheringItems) { + const quote = quotesBySymbol[symbol]; + + if (quote?.dataProviderInfo) { + dataProviderInfos.push(quote.dataProviderInfo); + } + + if (quote?.marketPrice) { + values.push({ + dataSource, + symbol, + date: today, + marketPrice: quote.marketPrice + }); + } else { + quoteErrors.push({ + dataSource, + symbol + }); + } + } + } + + const assetProfileIdentifiers: AssetProfileIdentifier[] = + dataGatheringItems.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }); + + const marketDataCount = await this.marketDataService.getRangeCount({ + assetProfileIdentifiers, + dateQuery + }); + + for ( + let i = 0; + i < marketDataCount; + i += CurrentRateService.MARKET_DATA_PAGE_SIZE + ) { + // Use page size to limit the number of records fetched at once + const data = await this.marketDataService.getRange({ + assetProfileIdentifiers, + dateQuery, + skip: i, + take: CurrentRateService.MARKET_DATA_PAGE_SIZE + }); + + values.push( + ...data.map(({ dataSource, date, marketPrice, symbol }) => ({ + dataSource, + date, + marketPrice, + symbol + })) + ); + } + + const response: GetValuesObject = { + dataProviderInfos, + errors: quoteErrors.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + values: uniqBy(values, ({ date, symbol }) => { + return `${date}-${symbol}`; + }) + }; + + if (!isEmpty(quoteErrors)) { + for (const { dataSource, symbol } of quoteErrors) { + try { + // If missing quote, fallback to the latest available historical market price + let value: GetValueObject = response.values.find((currentValue) => { + return currentValue.symbol === symbol && isToday(currentValue.date); + }); + + if (!value) { + // Fallback to unit price of latest activity + const latestActivity = await this.orderService.getLatestOrder({ + dataSource, + symbol + }); + + value = { + dataSource, + symbol, + date: today, + marketPrice: latestActivity?.unitPrice ?? 0 + }; + + response.values.push(value); + } + + const [latestValue] = response.values + .filter((currentValue) => { + return currentValue.symbol === symbol && currentValue.marketPrice; + }) + .sort((a, b) => { + if (a.date < b.date) { + return 1; + } + + if (a.date > b.date) { + return -1; + } + + return 0; + }); + + value.marketPrice = latestValue.marketPrice; + } catch {} + } + } + + return response; + } + + private containsToday(dates: Date[]): boolean { + for (const date of dates) { + if (isToday(date)) { + return true; + } + } + return false; + } +} diff --git a/apps/api/src/app/portfolio/interfaces/date-query.interface.ts b/apps/api/src/app/portfolio/interfaces/date-query.interface.ts new file mode 100644 index 000000000..7145d4111 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/date-query.interface.ts @@ -0,0 +1,5 @@ +export interface DateQuery { + gte?: Date; + in?: Date[]; + lt?: Date; +} diff --git a/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts new file mode 100644 index 000000000..34b426693 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/get-value-object.interface.ts @@ -0,0 +1,6 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +export interface GetValueObject extends AssetProfileIdentifier { + date: Date; + marketPrice: number; +} diff --git a/apps/api/src/app/portfolio/interfaces/get-values-object.interface.ts b/apps/api/src/app/portfolio/interfaces/get-values-object.interface.ts new file mode 100644 index 000000000..ef6cb8f96 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/get-values-object.interface.ts @@ -0,0 +1,9 @@ +import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces'; + +import { GetValueObject } from './get-value-object.interface'; + +export interface GetValuesObject { + dataProviderInfos: DataProviderInfo[]; + errors: ResponseError['errors']; + values: GetValueObject[]; +} diff --git a/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts b/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts new file mode 100644 index 000000000..ffb74ee9b --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/get-values-params.interface.ts @@ -0,0 +1,8 @@ +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; + +import { DateQuery } from './date-query.interface'; + +export interface GetValuesParams { + dataGatheringItems: DataGatheringItem[]; + dateQuery: DateQuery; +} diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts new file mode 100644 index 000000000..42759b521 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts @@ -0,0 +1,11 @@ +import { Big } from 'big.js'; + +import { PortfolioOrder } from './portfolio-order.interface'; + +export interface PortfolioOrderItem extends PortfolioOrder { + feeInBaseCurrencyWithCurrencyEffect?: Big; + itemType?: 'end' | 'start'; + unitPriceFromMarketData?: Big; + unitPriceInBaseCurrency?: Big; + unitPriceInBaseCurrencyWithCurrencyEffect?: Big; +} diff --git a/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts new file mode 100644 index 000000000..2dbd68f12 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/portfolio-order.interface.ts @@ -0,0 +1,13 @@ +import { Activity } from '@ghostfolio/common/interfaces'; + +export interface PortfolioOrder extends Pick { + date: string; + fee: Big; + feeInBaseCurrency: Big; + quantity: Big; + SymbolProfile: Pick< + Activity['SymbolProfile'], + 'assetSubClass' | 'currency' | 'dataSource' | 'name' | 'symbol' | 'userId' + >; + unitPrice: Big; +} diff --git a/apps/api/src/app/portfolio/interfaces/snapshot-value.interface.ts b/apps/api/src/app/portfolio/interfaces/snapshot-value.interface.ts new file mode 100644 index 000000000..3d205416c --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/snapshot-value.interface.ts @@ -0,0 +1,4 @@ +export interface PortfolioSnapshotValue { + expiration: number; + portfolioSnapshot: string; +} diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts new file mode 100644 index 000000000..7f3f54ff5 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/transaction-point-symbol.interface.ts @@ -0,0 +1,20 @@ +import { AssetSubClass, DataSource, Tag } from '@prisma/client'; +import { Big } from 'big.js'; + +export interface TransactionPointSymbol { + activitiesCount: number; + assetSubClass: AssetSubClass; + averagePrice: Big; + currency: string; + dataSource: DataSource; + dateOfFirstActivity: string; + dividend: Big; + fee: Big; + feeInBaseCurrency: Big; + includeInHoldings: boolean; + investment: Big; + quantity: Big; + skipErrors: boolean; + symbol: string; + tags?: Tag[]; +} diff --git a/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts new file mode 100644 index 000000000..698b202e4 --- /dev/null +++ b/apps/api/src/app/portfolio/interfaces/transaction-point.interface.ts @@ -0,0 +1,11 @@ +import { Big } from 'big.js'; + +import { TransactionPointSymbol } from './transaction-point-symbol.interface'; + +export interface TransactionPoint { + date: string; + fees: Big; + interest: Big; + items: TransactionPointSymbol[]; + liabilities: Big; +} diff --git a/apps/api/src/app/portfolio/portfolio.controller.ts b/apps/api/src/app/portfolio/portfolio.controller.ts new file mode 100644 index 000000000..5ed3c0009 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.controller.ts @@ -0,0 +1,675 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { + hasNotDefinedValuesInObject, + nullifyValuesInObject +} from '@ghostfolio/api/helper/object.helper'; +import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { ApiService } from '@ghostfolio/api/services/api/api.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; +import { + HEADER_KEY_IMPERSONATION, + UNKNOWN_KEY +} from '@ghostfolio/common/config'; +import { + PortfolioDetails, + PortfolioDividendsResponse, + PortfolioHoldingResponse, + PortfolioHoldingsResponse, + PortfolioInvestmentsResponse, + PortfolioPerformanceResponse, + PortfolioReportResponse +} from '@ghostfolio/common/interfaces'; +import { + hasReadRestrictedAccessPermission, + isRestrictedView, + permissions +} from '@ghostfolio/common/permissions'; +import type { + DateRange, + GroupBy, + RequestWithUser +} from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Get, + Headers, + HttpException, + Inject, + Param, + Put, + Query, + UseGuards, + UseInterceptors, + Version +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { PortfolioService } from './portfolio.service'; +import { UpdateHoldingTagsDto } from './update-holding-tags.dto'; + +@Controller('portfolio') +export class PortfolioController { + public constructor( + private readonly apiService: ApiService, + private readonly configurationService: ConfigurationService, + private readonly impersonationService: ImpersonationService, + private readonly orderService: OrderService, + private readonly portfolioService: PortfolioService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + @Get('details') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getDetails( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string, + @Query('withMarkets') withMarketsParam = 'false' + ): Promise { + const withMarkets = withMarketsParam === 'true'; + + let hasDetails = true; + let hasError = false; + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + hasDetails = this.request.user.subscription.type === 'Premium'; + } + + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + const { + accounts, + createdAt, + hasErrors, + holdings, + markets, + marketsAdvanced, + platforms, + summary + } = await this.portfolioService.getDetails({ + dateRange, + filters, + impersonationId, + withMarkets, + userId: this.request.user.id, + withSummary: true + }); + + if (hasErrors || hasNotDefinedValuesInObject(holdings)) { + hasError = true; + } + + let portfolioSummary = summary; + + if ( + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) + ) { + const totalInvestment = Object.values(holdings) + .map(({ investment }) => { + return investment; + }) + .reduce((a, b) => a + b, 0); + + const totalValue = Object.values(holdings) + .filter(({ assetClass, assetSubClass }) => { + return ( + assetClass !== AssetClass.LIQUIDITY && + assetSubClass !== AssetSubClass.CASH + ); + }) + .map(({ valueInBaseCurrency }) => { + return valueInBaseCurrency; + }) + .reduce((a, b) => { + return a + b; + }, 0); + + for (const [, portfolioPosition] of Object.entries(holdings)) { + portfolioPosition.investment = + portfolioPosition.investment / totalInvestment; + portfolioPosition.valueInPercentage = + portfolioPosition.valueInBaseCurrency / totalValue; + } + + for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) { + accounts[name].valueInPercentage = valueInBaseCurrency / totalValue; + } + + for (const [name, { valueInBaseCurrency }] of Object.entries(platforms)) { + platforms[name].valueInPercentage = valueInBaseCurrency / totalValue; + } + } + + if ( + hasDetails === false || + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) + ) { + Object.values(markets ?? {}).forEach((market) => { + delete market.valueInBaseCurrency; + }); + Object.values(marketsAdvanced ?? {}).forEach((market) => { + delete market.valueInBaseCurrency; + }); + + portfolioSummary = nullifyValuesInObject(summary, [ + 'cash', + 'committedFunds', + 'currentNetWorth', + 'currentValueInBaseCurrency', + 'dividendInBaseCurrency', + 'emergencyFund', + 'excludedAccountsAndActivities', + 'fees', + 'filteredValueInBaseCurrency', + 'fireWealth', + 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', + 'interestInBaseCurrency', + 'items', + 'liabilities', + 'liabilitiesInBaseCurrency', + 'netPerformance', + 'netPerformanceWithCurrencyEffect', + 'totalBuy', + 'totalInvestment', + 'totalInvestmentValueWithCurrencyEffect', + 'totalSell', + 'totalValueInBaseCurrency' + ]); + } + + for (const [symbol, portfolioPosition] of Object.entries(holdings)) { + holdings[symbol] = { + ...portfolioPosition, + assetClass: + hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY + ? portfolioPosition.assetClass + : undefined, + assetSubClass: + hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH + ? portfolioPosition.assetSubClass + : undefined, + countries: hasDetails ? portfolioPosition.countries : [], + currency: hasDetails ? portfolioPosition.currency : undefined, + holdings: hasDetails ? portfolioPosition.holdings : [], + markets: hasDetails ? portfolioPosition.markets : undefined, + marketsAdvanced: hasDetails + ? portfolioPosition.marketsAdvanced + : undefined, + sectors: hasDetails ? portfolioPosition.sectors : [] + }; + } + + return { + accounts, + createdAt, + hasError, + holdings, + platforms, + markets: hasDetails + ? markets + : { + [UNKNOWN_KEY]: { + id: UNKNOWN_KEY, + valueInPercentage: 1 + }, + developedMarkets: { + id: 'developedMarkets', + valueInPercentage: 0 + }, + emergingMarkets: { + id: 'emergingMarkets', + valueInPercentage: 0 + }, + otherMarkets: { + id: 'otherMarkets', + valueInPercentage: 0 + } + }, + marketsAdvanced: hasDetails + ? marketsAdvanced + : { + [UNKNOWN_KEY]: { + id: UNKNOWN_KEY, + valueInPercentage: 0 + }, + asiaPacific: { + id: 'asiaPacific', + valueInPercentage: 0 + }, + emergingMarkets: { + id: 'emergingMarkets', + valueInPercentage: 0 + }, + europe: { + id: 'europe', + valueInPercentage: 0 + }, + japan: { + id: 'japan', + valueInPercentage: 0 + }, + northAmerica: { + id: 'northAmerica', + valueInPercentage: 0 + }, + otherMarkets: { + id: 'otherMarkets', + valueInPercentage: 0 + } + }, + summary: portfolioSummary + }; + } + + @Get('dividends') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getDividends( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('groupBy') groupBy?: GroupBy, + @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + const userCurrency = this.request.user.settings.settings.baseCurrency; + + const { endDate, startDate } = getIntervalFromDateRange(dateRange); + + const { activities } = await this.orderService.getOrders({ + endDate, + filters, + startDate, + userCurrency, + userId: impersonationUserId || this.request.user.id, + types: ['DIVIDEND'] + }); + + let dividends = this.portfolioService.getDividends({ + activities, + groupBy + }); + + if ( + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) + ) { + const maxDividend = dividends.reduce( + (investment, item) => Math.max(investment, item.investment), + 1 + ); + + dividends = dividends.map((item) => ({ + date: item.date, + investment: item.investment / maxDividend + })); + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + dividends = dividends.map((item) => { + return nullifyValuesInObject(item, ['investment']); + }); + } + + return { dividends }; + } + + @Get('holding/:dataSource/:symbol') + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getHolding( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getHolding({ + dataSource, + impersonationId, + symbol, + userId: this.request.user.id + }); + + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return holding; + } + + @Get('holdings') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getHoldings( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('holdingType') filterByHoldingType?: string, + @Query('query') filterBySearchQuery?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterByHoldingType, + filterBySearchQuery, + filterBySymbol, + filterByTags + }); + + const holdings = await this.portfolioService.getHoldings({ + dateRange, + filters, + impersonationId, + userId: this.request.user.id + }); + + return { holdings }; + } + + @Get('investments') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + public async getInvestments( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('groupBy') groupBy?: GroupBy, + @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string + ): Promise { + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + let { investments, streaks } = await this.portfolioService.getInvestments({ + dateRange, + filters, + groupBy, + impersonationId, + savingsRate: this.request.user?.settings?.settings.savingsRate, + userId: this.request.user.id + }); + + if ( + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) + ) { + const maxInvestment = investments.reduce( + (investment, item) => Math.max(investment, item.investment), + 1 + ); + + investments = investments.map((item) => ({ + date: item.date, + investment: item.investment / maxInvestment + })); + + streaks = nullifyValuesInObject(streaks, [ + 'currentStreak', + 'longestStreak' + ]); + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + investments = investments.map((item) => { + return nullifyValuesInObject(item, ['investment']); + }); + + streaks = nullifyValuesInObject(streaks, [ + 'currentStreak', + 'longestStreak' + ]); + } + + return { investments, streaks }; + } + + @Get('performance') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(PerformanceLoggingInterceptor) + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + @Version('2') + public async getPerformanceV2( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Query('accounts') filterByAccounts?: string, + @Query('assetClasses') filterByAssetClasses?: string, + @Query('dataSource') filterByDataSource?: string, + @Query('range') dateRange: DateRange = 'max', + @Query('symbol') filterBySymbol?: string, + @Query('tags') filterByTags?: string, + @Query('withExcludedAccounts') withExcludedAccountsParam = 'false' + ): Promise { + const withExcludedAccounts = withExcludedAccountsParam === 'true'; + + const filters = this.apiService.buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByDataSource, + filterBySymbol, + filterByTags + }); + + const performanceInformation = await this.portfolioService.getPerformance({ + dateRange, + filters, + impersonationId, + withExcludedAccounts, + userId: this.request.user.id + }); + + if ( + hasReadRestrictedAccessPermission({ + impersonationId, + user: this.request.user + }) || + isRestrictedView(this.request.user) || + this.request.user.settings.settings.viewMode === 'ZEN' + ) { + performanceInformation.chart = performanceInformation.chart.map( + ({ + date, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + netWorth, + totalInvestment, + value + }) => { + return { + date, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + netWorthInPercentage: + performanceInformation.performance.currentNetWorth === 0 + ? 0 + : new Big(netWorth) + .div(performanceInformation.performance.currentNetWorth) + .toNumber(), + totalInvestment: + performanceInformation.performance.totalInvestment === 0 + ? 0 + : new Big(totalInvestment) + .div(performanceInformation.performance.totalInvestment) + .toNumber(), + valueInPercentage: + performanceInformation.performance.currentValueInBaseCurrency === + 0 + ? 0 + : new Big(value) + .div( + performanceInformation.performance + .currentValueInBaseCurrency + ) + .toNumber() + }; + } + ); + + performanceInformation.performance = nullifyValuesInObject( + performanceInformation.performance, + [ + 'currentNetWorth', + 'currentValueInBaseCurrency', + 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', + 'netPerformance', + 'netPerformanceWithCurrencyEffect', + 'totalInvestment' + ] + ); + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + performanceInformation.chart = performanceInformation.chart.map( + (item) => { + return nullifyValuesInObject(item, ['totalInvestment', 'value']); + } + ); + performanceInformation.performance = nullifyValuesInObject( + performanceInformation.performance, + ['netPerformance'] + ); + } + + return performanceInformation; + } + + @Get('report') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async getReport( + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + ): Promise { + const report = await this.portfolioService.getReport({ + impersonationId, + userId: this.request.user.id + }); + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + this.request.user.subscription.type === 'Basic' + ) { + for (const category of report.xRay.categories) { + category.rules = null; + } + + report.xRay.statistics = { + rulesActiveCount: 0, + rulesFulfilledCount: 0 + }; + } + + return report; + } + + @HasPermission(permissions.updateOrder) + @Put('holding/:dataSource/:symbol/tags') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateHoldingTags( + @Body() data: UpdateHoldingTagsDto, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string, + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string + ): Promise { + const holding = await this.portfolioService.getHolding({ + dataSource, + impersonationId, + symbol, + userId: this.request.user.id + }); + + if (!holding) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + await this.portfolioService.updateTags({ + dataSource, + impersonationId, + symbol, + tags: data.tags, + userId: this.request.user.id + }); + } +} diff --git a/apps/api/src/app/portfolio/portfolio.module.ts b/apps/api/src/app/portfolio/portfolio.module.ts new file mode 100644 index 000000000..6dd5811a3 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.module.ts @@ -0,0 +1,66 @@ +import { AccessModule } from '@ghostfolio/api/app/access/access.module'; +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { PerformanceLoggingModule } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.module'; +import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { ApiModule } from '@ghostfolio/api/services/api/api.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; +import { CurrentRateService } from './current-rate.service'; +import { PortfolioController } from './portfolio.controller'; +import { PortfolioService } from './portfolio.service'; +import { RulesService } from './rules.service'; + +@Module({ + controllers: [PortfolioController], + exports: [PortfolioService], + imports: [ + AccessModule, + ApiModule, + BenchmarkModule, + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + I18nModule, + ImpersonationModule, + MarketDataModule, + OrderModule, + PerformanceLoggingModule, + PortfolioSnapshotQueueModule, + PrismaModule, + RedactValuesInResponseModule, + RedisCacheModule, + SymbolProfileModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule, + UserModule + ], + providers: [ + AccountBalanceService, + AccountService, + CurrentRateService, + PortfolioCalculatorFactory, + PortfolioService, + RulesService + ] +}) +export class PortfolioModule {} diff --git a/apps/api/src/app/portfolio/portfolio.service.ts b/apps/api/src/app/portfolio/portfolio.service.ts new file mode 100644 index 000000000..b96f5ef70 --- /dev/null +++ b/apps/api/src/app/portfolio/portfolio.service.ts @@ -0,0 +1,2220 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { AccountService } from '@ghostfolio/api/app/account/account.service'; +import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; +import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; +import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; +import { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity'; +import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income'; +import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; +import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; +import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; +import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; +import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; +import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; +import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; +import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; +import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; +import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; +import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; +import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan'; +import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; +import { + DEFAULT_CURRENCY, + TAG_ID_EMERGENCY_FUND, + TAG_ID_EXCLUDE_FROM_ANALYSIS, + UNKNOWN_KEY +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; +import { + AccountsResponse, + Activity, + EnhancedSymbolProfile, + Filter, + HistoricalDataItem, + InvestmentItem, + PortfolioDetails, + PortfolioHoldingResponse, + PortfolioInvestmentsResponse, + PortfolioPerformanceResponse, + PortfolioPosition, + PortfolioReportResponse, + PortfolioReportRule, + PortfolioSummary, + UserSettings +} from '@ghostfolio/common/interfaces'; +import { TimelinePosition } from '@ghostfolio/common/models'; +import { + AccountWithValue, + DateRange, + GroupBy, + RequestWithUser, + UserWithSettings +} from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Inject, Injectable } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { + Account, + Type as ActivityType, + AssetClass, + AssetSubClass, + DataSource, + Order, + Platform, + Prisma, + Tag +} from '@prisma/client'; +import { Big } from 'big.js'; +import { + differenceInDays, + format, + isAfter, + isBefore, + isSameMonth, + isSameYear, + parseISO, + set +} from 'date-fns'; + +import { PortfolioCalculator } from './calculator/portfolio-calculator'; +import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory'; +import { RulesService } from './rules.service'; + +const Fuse = require('fuse.js'); + +const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json'); +const developedMarkets = require('../../assets/countries/developed-markets.json'); +const emergingMarkets = require('../../assets/countries/emerging-markets.json'); +const europeMarkets = require('../../assets/countries/europe-markets.json'); + +@Injectable() +export class PortfolioService { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly accountService: AccountService, + private readonly benchmarkService: BenchmarkService, + private readonly calculatorFactory: PortfolioCalculatorFactory, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly i18nService: I18nService, + private readonly impersonationService: ImpersonationService, + private readonly orderService: OrderService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly rulesService: RulesService, + private readonly symbolProfileService: SymbolProfileService, + private readonly userService: UserService + ) {} + + public async getAccounts({ + filters, + userId, + withExcludedAccounts = false + }: { + filters?: Filter[]; + userId: string; + withExcludedAccounts?: boolean; + }): Promise { + const where: Prisma.AccountWhereInput = { userId }; + + const filterByAccount = filters?.find(({ type }) => { + return type === 'ACCOUNT'; + })?.id; + + const filterByDataSource = filters?.find(({ type }) => { + return type === 'DATA_SOURCE'; + })?.id; + + const filterBySymbol = filters?.find(({ type }) => { + return type === 'SYMBOL'; + })?.id; + + if (filterByAccount) { + where.id = filterByAccount; + } + + if (filterByDataSource && filterBySymbol) { + where.activities = { + some: { + SymbolProfile: { + AND: [ + { dataSource: filterByDataSource as DataSource }, + { symbol: filterBySymbol } + ] + } + } + }; + } + + const [accounts, details] = await Promise.all([ + this.accountService.accounts({ + where, + include: { + activities: { include: { SymbolProfile: true } }, + platform: true + }, + orderBy: { name: 'asc' } + }), + this.getDetails({ + filters, + withExcludedAccounts, + impersonationId: userId, + userId: this.request.user.id + }) + ]); + + const userCurrency = this.request.user.settings.settings.baseCurrency; + + return Promise.all( + accounts.map(async (account) => { + let activitiesCount = 0; + let dividendInBaseCurrency = 0; + let interestInBaseCurrency = 0; + + for (const { + currency, + date, + isDraft, + quantity, + SymbolProfile, + type, + unitPrice + } of account.activities) { + switch (type) { + case ActivityType.DIVIDEND: + dividendInBaseCurrency += + await this.exchangeRateDataService.toCurrencyAtDate( + new Big(quantity).mul(unitPrice).toNumber(), + currency ?? SymbolProfile.currency, + userCurrency, + date + ); + break; + case ActivityType.INTEREST: + interestInBaseCurrency += + await this.exchangeRateDataService.toCurrencyAtDate( + unitPrice, + currency ?? SymbolProfile.currency, + userCurrency, + date + ); + break; + } + + if (!isDraft) { + activitiesCount += 1; + } + } + + const valueInBaseCurrency = + details.accounts[account.id]?.valueInBaseCurrency ?? 0; + + const result = { + ...account, + activitiesCount, + dividendInBaseCurrency, + interestInBaseCurrency, + valueInBaseCurrency, + allocationInPercentage: 0, + balanceInBaseCurrency: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ), + value: this.exchangeRateDataService.toCurrency( + valueInBaseCurrency, + userCurrency, + account.currency + ) + }; + + delete result.activities; + + return result; + }) + ); + } + + public async getAccountsWithAggregations({ + filters, + userId, + withExcludedAccounts = false + }: { + filters?: Filter[]; + userId: string; + withExcludedAccounts?: boolean; + }): Promise { + let accounts = await this.getAccounts({ + filters, + userId, + withExcludedAccounts + }); + + let activitiesCount = 0; + + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + + if (searchQuery) { + const fuse = new Fuse(accounts, { + keys: ['name', 'platform.name'], + threshold: 0.3 + }); + + accounts = fuse.search(searchQuery).map(({ item }) => { + return item; + }); + } + + let totalBalanceInBaseCurrency = new Big(0); + let totalDividendInBaseCurrency = new Big(0); + let totalInterestInBaseCurrency = new Big(0); + let totalValueInBaseCurrency = new Big(0); + + for (const account of accounts) { + activitiesCount += account.activitiesCount; + + totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus( + account.balanceInBaseCurrency + ); + totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus( + account.dividendInBaseCurrency + ); + totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus( + account.interestInBaseCurrency + ); + totalValueInBaseCurrency = totalValueInBaseCurrency.plus( + account.valueInBaseCurrency + ); + } + + for (const account of accounts) { + account.allocationInPercentage = + totalValueInBaseCurrency.toNumber() > Number.EPSILON + ? Big(account.valueInBaseCurrency) + .div(totalValueInBaseCurrency) + .toNumber() + : 0; + } + + return { + accounts, + activitiesCount, + totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(), + totalDividendInBaseCurrency: totalDividendInBaseCurrency.toNumber(), + totalInterestInBaseCurrency: totalInterestInBaseCurrency.toNumber(), + totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber() + }; + } + + public getDividends({ + activities, + groupBy + }: { + activities: Activity[]; + groupBy?: GroupBy; + }): InvestmentItem[] { + let dividends = activities.map(({ currency, date, value }) => { + return { + date: format(date, DATE_FORMAT), + investment: this.exchangeRateDataService.toCurrency( + value, + currency, + this.getUserCurrency() + ) + }; + }); + + if (groupBy) { + dividends = this.getDividendsByGroup({ dividends, groupBy }); + } + + return dividends; + } + + public async getHoldings({ + dateRange, + filters, + impersonationId, + userId + }: { + dateRange: DateRange; + filters?: Filter[]; + impersonationId: string; + userId: string; + }) { + userId = await this.getUserId(impersonationId, userId); + const { holdings: holdingsMap } = await this.getDetails({ + dateRange, + filters, + impersonationId, + userId + }); + + let holdings = Object.values(holdingsMap); + + const searchQuery = filters.find(({ type }) => { + return type === 'SEARCH_QUERY'; + })?.id; + + if (searchQuery) { + const fuse = new Fuse(holdings, { + keys: ['isin', 'name', 'symbol'], + threshold: 0.3 + }); + + holdings = fuse.search(searchQuery).map(({ item }) => { + return item; + }); + } + + return holdings; + } + + public async getInvestments({ + dateRange, + filters, + groupBy, + impersonationId, + savingsRate, + userId + }: { + dateRange: DateRange; + filters?: Filter[]; + groupBy?: GroupBy; + impersonationId: string; + savingsRate: number; + userId: string; + }): Promise { + userId = await this.getUserId(impersonationId, userId); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); + + const { endDate, startDate } = getIntervalFromDateRange(dateRange); + + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ + filters, + userCurrency, + userId + }); + + if (activities.length === 0) { + return { + investments: [], + streaks: { currentStreak: 0, longestStreak: 0 } + }; + } + + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + filters, + userId, + calculationType: this.getUserPerformanceCalculationType(user), + currency: userCurrency + }); + + const { historicalData } = await portfolioCalculator.getSnapshot(); + + const items = historicalData.filter(({ date }) => { + return !isBefore(date, startDate) && !isAfter(date, endDate); + }); + + let investments: InvestmentItem[]; + + if (groupBy) { + investments = portfolioCalculator.getInvestmentsByGroup({ + groupBy, + data: items + }); + } else { + investments = items.map(({ date, investmentValueWithCurrencyEffect }) => { + return { + date, + investment: investmentValueWithCurrencyEffect + }; + }); + } + + let streaks: PortfolioInvestmentsResponse['streaks']; + + if (savingsRate) { + streaks = this.getStreaks({ + investments, + savingsRate: groupBy === 'year' ? 12 * savingsRate : savingsRate + }); + } + + return { + investments, + streaks + }; + } + + public async getDetails({ + dateRange = 'max', + filters, + impersonationId, + userId, + withExcludedAccounts = false, + withMarkets = false, + withSummary = false + }: { + dateRange?: DateRange; + filters?: Filter[]; + impersonationId: string; + userId: string; + withExcludedAccounts?: boolean; + withMarkets?: boolean; + withSummary?: boolean; + }): Promise { + userId = await this.getUserId(impersonationId, userId); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); + + const emergencyFund = new Big( + (user.settings?.settings as UserSettings)?.emergencyFund ?? 0 + ); + + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ + filters, + userCurrency, + userId + }); + + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + filters, + userId, + calculationType: this.getUserPerformanceCalculationType(user), + currency: userCurrency + }); + + const { createdAt, currentValueInBaseCurrency, hasErrors, positions } = + await portfolioCalculator.getSnapshot(); + + const cashDetails = await this.accountService.getCashDetails({ + filters, + userId, + currency: userCurrency + }); + + const holdings: PortfolioDetails['holdings'] = {}; + + const totalValueInBaseCurrency = currentValueInBaseCurrency.plus( + cashDetails.balanceInBaseCurrency + ); + + const isFilteredByAccount = + filters?.some(({ type }) => { + return type === 'ACCOUNT'; + }) ?? false; + + const isFilteredByClosedHoldings = + filters?.some(({ id, type }) => { + return id === 'CLOSED' && type === 'HOLDING_TYPE'; + }) ?? false; + + let filteredValueInBaseCurrency = isFilteredByAccount + ? totalValueInBaseCurrency + : currentValueInBaseCurrency; + + if ( + filters?.length === 0 || + (filters?.length === 1 && + filters[0].id === AssetClass.LIQUIDITY && + filters[0].type === 'ASSET_CLASS') + ) { + filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus( + cashDetails.balanceInBaseCurrency + ); + } + + const assetProfileIdentifiers = positions.map(({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + }); + + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + assetProfileIdentifiers + ); + + const cashSymbolProfiles = this.getCashSymbolProfiles(cashDetails); + symbolProfiles.push(...cashSymbolProfiles); + + const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {}; + for (const symbolProfile of symbolProfiles) { + symbolProfileMap[symbolProfile.symbol] = symbolProfile; + } + + const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {}; + for (const position of positions) { + portfolioItemsNow[position.symbol] = position; + } + + for (const { + activitiesCount, + currency, + dateOfFirstActivity, + dividend, + grossPerformance, + grossPerformanceWithCurrencyEffect, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + investment, + marketPrice, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceWithCurrencyEffectMap, + quantity, + symbol, + tags, + valueInBaseCurrency + } of positions) { + if (isFilteredByClosedHoldings === true) { + if (!quantity.eq(0)) { + // Ignore positions with a quantity + continue; + } + } else { + if (quantity.eq(0)) { + // Ignore positions without any quantity + continue; + } + } + + const assetProfile = symbolProfileMap[symbol]; + + let markets: PortfolioPosition['markets']; + let marketsAdvanced: PortfolioPosition['marketsAdvanced']; + + if (withMarkets) { + ({ markets, marketsAdvanced } = this.getMarkets({ + assetProfile + })); + } + + holdings[symbol] = { + activitiesCount, + currency, + markets, + marketsAdvanced, + marketPrice, + symbol, + tags, + allocationInPercentage: filteredValueInBaseCurrency.eq(0) + ? 0 + : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(), + assetClass: assetProfile.assetClass, + assetSubClass: assetProfile.assetSubClass, + countries: assetProfile.countries, + dataSource: assetProfile.dataSource, + dateOfFirstActivity: parseDate(dateOfFirstActivity), + dividend: dividend?.toNumber() ?? 0, + grossPerformance: grossPerformance?.toNumber() ?? 0, + grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0, + grossPerformancePercentWithCurrencyEffect: + grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0, + grossPerformanceWithCurrencyEffect: + grossPerformanceWithCurrencyEffect?.toNumber() ?? 0, + holdings: assetProfile.holdings.map( + ({ allocationInPercentage, name }) => { + return { + allocationInPercentage, + name, + valueInBaseCurrency: valueInBaseCurrency + .mul(allocationInPercentage) + .toNumber() + }; + } + ), + investment: investment.toNumber(), + name: assetProfile.name, + netPerformance: netPerformance?.toNumber() ?? 0, + netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0, + netPerformancePercentWithCurrencyEffect: + netPerformancePercentageWithCurrencyEffectMap?.[ + dateRange + ]?.toNumber() ?? 0, + netPerformanceWithCurrencyEffect: + netPerformanceWithCurrencyEffectMap?.[dateRange]?.toNumber() ?? 0, + quantity: quantity.toNumber(), + sectors: assetProfile.sectors, + url: assetProfile.url, + valueInBaseCurrency: valueInBaseCurrency.toNumber() + }; + } + + const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ + activities, + filters, + portfolioItemsNow, + userCurrency, + userId, + withExcludedAccounts + }); + + if ( + filters?.length === 1 && + filters[0].id === TAG_ID_EMERGENCY_FUND && + filters[0].type === 'TAG' + ) { + const emergencyFundCashPositions = this.getCashPositions({ + cashDetails, + userCurrency, + value: filteredValueInBaseCurrency + }); + + const emergencyFundInCash = emergencyFund + .minus( + this.getEmergencyFundHoldingsValueInBaseCurrency({ + holdings + }) + ) + .toNumber(); + + filteredValueInBaseCurrency = emergencyFund; + + accounts[UNKNOWN_KEY] = { + balance: 0, + currency: userCurrency, + name: UNKNOWN_KEY, + valueInBaseCurrency: emergencyFundInCash + }; + + holdings[userCurrency] = { + ...emergencyFundCashPositions[userCurrency], + investment: emergencyFundInCash, + valueInBaseCurrency: emergencyFundInCash + }; + } + + let markets: PortfolioDetails['markets']; + let marketsAdvanced: PortfolioDetails['marketsAdvanced']; + + if (withMarkets) { + ({ markets, marketsAdvanced } = this.getAggregatedMarkets(holdings)); + } + + let summary: PortfolioSummary; + + if (withSummary) { + summary = await this.getSummary({ + filteredValueInBaseCurrency, + impersonationId, + portfolioCalculator, + userCurrency, + userId, + balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, + emergencyFundHoldingsValueInBaseCurrency: + this.getEmergencyFundHoldingsValueInBaseCurrency({ + holdings + }) + }); + } + + return { + accounts, + createdAt, + hasErrors, + holdings, + markets, + marketsAdvanced, + platforms, + summary + }; + } + + public async getHolding({ + dataSource, + impersonationId, + symbol, + userId + }: { + dataSource: DataSource; + impersonationId: string; + symbol: string; + userId: string; + }): Promise { + userId = await this.getUserId(impersonationId, userId); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); + + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ + userCurrency, + userId + }); + + if (activities.length === 0) { + return undefined; + } + + const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ + { dataSource, symbol } + ]); + + const portfolioCalculator = this.calculatorFactory.createCalculator({ + activities, + userId, + calculationType: this.getUserPerformanceCalculationType(user), + currency: userCurrency + }); + + const transactionPoints = portfolioCalculator.getTransactionPoints(); + + const { positions } = await portfolioCalculator.getSnapshot(); + + const holding = positions.find((position) => { + return position.dataSource === dataSource && position.symbol === symbol; + }); + + if (!holding) { + return undefined; + } + + const { + activitiesCount, + averagePrice, + currency, + dateOfFirstActivity, + dividendInBaseCurrency, + feeInBaseCurrency, + grossPerformance, + grossPerformancePercentage, + grossPerformancePercentageWithCurrencyEffect, + grossPerformanceWithCurrencyEffect, + investmentWithCurrencyEffect, + marketPrice, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffectMap, + netPerformanceWithCurrencyEffectMap, + quantity, + tags, + timeWeightedInvestment, + timeWeightedInvestmentWithCurrencyEffect + } = holding; + + const activitiesOfHolding = activities.filter(({ SymbolProfile }) => { + return ( + SymbolProfile.dataSource === dataSource && + SymbolProfile.symbol === symbol + ); + }); + + const dividendYieldPercent = getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays( + new Date(), + parseDate(dateOfFirstActivity) + ), + netPerformancePercentage: timeWeightedInvestment.eq(0) + ? new Big(0) + : dividendInBaseCurrency.div(timeWeightedInvestment) + }); + + const dividendYieldPercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays( + new Date(), + parseDate(dateOfFirstActivity) + ), + netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(0) + ? new Big(0) + : dividendInBaseCurrency.div(timeWeightedInvestmentWithCurrencyEffect) + }); + + const historicalData = await this.dataProviderService.getHistorical( + [{ dataSource, symbol }], + 'day', + parseISO(dateOfFirstActivity), + new Date() + ); + + const historicalDataArray: HistoricalDataItem[] = []; + let marketPriceMax = Math.max( + activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + marketPrice + ); + let marketPriceMaxDate = + marketPrice > activitiesOfHolding[0].unitPriceInAssetProfileCurrency + ? new Date() + : activitiesOfHolding[0].date; + let marketPriceMin = Math.min( + activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + marketPrice + ); + + if (historicalData[symbol]) { + let j = -1; + for (const [date, { marketPrice }] of Object.entries( + historicalData[symbol] + )) { + while ( + j + 1 < transactionPoints.length && + !isAfter(parseDate(transactionPoints[j + 1].date), parseDate(date)) + ) { + j++; + } + + let currentAveragePrice = 0; + let currentQuantity = 0; + + const currentSymbol = transactionPoints[j]?.items.find( + (transactionPointSymbol) => { + return transactionPointSymbol.symbol === symbol; + } + ); + + if (currentSymbol) { + currentAveragePrice = currentSymbol.averagePrice.toNumber(); + currentQuantity = currentSymbol.quantity.toNumber(); + } + + historicalDataArray.push({ + date, + averagePrice: currentAveragePrice, + marketPrice: + historicalDataArray.length > 0 ? marketPrice : currentAveragePrice, + quantity: currentQuantity + }); + + if (marketPrice > marketPriceMax) { + marketPriceMax = marketPrice; + marketPriceMaxDate = parseISO(date); + } + marketPriceMin = Math.min( + marketPrice ?? Number.MAX_SAFE_INTEGER, + marketPriceMin + ); + } + } else { + // Add historical entry for buy date, if no historical data available + historicalDataArray.push({ + averagePrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + date: dateOfFirstActivity, + marketPrice: activitiesOfHolding[0].unitPriceInAssetProfileCurrency, + quantity: activitiesOfHolding[0].quantity + }); + } + + const performancePercent = + this.benchmarkService.calculateChangeInPercentage( + marketPriceMax, + marketPrice + ); + + return { + activitiesCount, + dateOfFirstActivity, + marketPrice, + marketPriceMax, + marketPriceMin, + SymbolProfile, + tags, + averagePrice: averagePrice.toNumber(), + dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0], + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), + dividendYieldPercent: dividendYieldPercent.toNumber(), + dividendYieldPercentWithCurrencyEffect: + dividendYieldPercentWithCurrencyEffect.toNumber(), + feeInBaseCurrency: feeInBaseCurrency.toNumber(), + grossPerformance: grossPerformance?.toNumber(), + grossPerformancePercent: grossPerformancePercentage?.toNumber(), + grossPerformancePercentWithCurrencyEffect: + grossPerformancePercentageWithCurrencyEffect?.toNumber(), + grossPerformanceWithCurrencyEffect: + grossPerformanceWithCurrencyEffect?.toNumber(), + historicalData: historicalDataArray, + investmentInBaseCurrencyWithCurrencyEffect: + investmentWithCurrencyEffect?.toNumber(), + netPerformance: netPerformance?.toNumber(), + netPerformancePercent: netPerformancePercentage?.toNumber(), + netPerformancePercentWithCurrencyEffect: + netPerformancePercentageWithCurrencyEffectMap?.['max']?.toNumber(), + netPerformanceWithCurrencyEffect: + netPerformanceWithCurrencyEffectMap?.['max']?.toNumber(), + performances: { + allTimeHigh: { + performancePercent, + date: marketPriceMaxDate + } + }, + quantity: quantity.toNumber(), + value: this.exchangeRateDataService.toCurrency( + quantity.mul(marketPrice ?? 0).toNumber(), + currency, + userCurrency + ) + }; + } + + public async getPerformance({ + dateRange = 'max', + filters, + impersonationId, + userId + }: { + dateRange?: DateRange; + filters?: Filter[]; + impersonationId: string; + userId: string; + withExcludedAccounts?: boolean; + }): Promise { + userId = await this.getUserId(impersonationId, userId); + const user = await this.userService.user({ id: userId }); + const userCurrency = this.getUserCurrency(user); + + const [accountBalanceItems, { activities }] = await Promise.all([ + this.accountBalanceService.getAccountBalanceItems({ + filters, + userId, + userCurrency + }), + this.orderService.getOrdersForPortfolioCalculator({ + filters, + userCurrency, + userId + }) + ]); + + if (accountBalanceItems.length === 0 && activities.length === 0) { + return { + chart: [], + firstOrderDate: undefined, + hasErrors: false, + performance: { + currentNetWorth: 0, + currentValueInBaseCurrency: 0, + netPerformance: 0, + netPerformancePercentage: 0, + netPerformancePercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + totalInvestment: 0, + totalInvestmentValueWithCurrencyEffect: 0 + } + }; + } + + const portfolioCalculator = this.calculatorFactory.createCalculator({ + accountBalanceItems, + activities, + filters, + userId, + calculationType: this.getUserPerformanceCalculationType(user), + currency: userCurrency + }); + + const { errors, hasErrors, historicalData } = + await portfolioCalculator.getSnapshot(); + + const { endDate, startDate } = getIntervalFromDateRange(dateRange); + + const { chart } = await portfolioCalculator.getPerformance({ + end: endDate, + start: startDate + }); + + const { + netPerformance, + netPerformanceInPercentage, + netPerformanceInPercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + netWorth, + totalInvestment, + totalInvestmentValueWithCurrencyEffect, + valueWithCurrencyEffect + } = chart?.at(-1) ?? { + netPerformance: 0, + netPerformanceInPercentage: 0, + netPerformanceInPercentageWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + netWorth: 0, + totalInvestment: 0, + valueWithCurrencyEffect: 0 + }; + + return { + chart, + errors, + hasErrors, + firstOrderDate: parseDate(historicalData[0]?.date), + performance: { + netPerformance, + netPerformanceWithCurrencyEffect, + totalInvestment, + totalInvestmentValueWithCurrencyEffect, + currentNetWorth: netWorth, + currentValueInBaseCurrency: valueWithCurrencyEffect, + netPerformancePercentage: netPerformanceInPercentage, + netPerformancePercentageWithCurrencyEffect: + netPerformanceInPercentageWithCurrencyEffect + } + }; + } + + public async getReport({ + impersonationId, + userId + }: { + impersonationId: string; + userId: string; + }): Promise { + userId = await this.getUserId(impersonationId, userId); + const userSettings = this.request.user.settings.settings as UserSettings; + + const { accounts, holdings, markets, marketsAdvanced, summary } = + await this.getDetails({ + impersonationId, + userId, + withMarkets: true, + withSummary: true + }); + + const marketsAdvancedTotalInBaseCurrency = getSum( + Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => { + return new Big(valueInBaseCurrency); + }) + ).toNumber(); + + const marketsTotalInBaseCurrency = getSum( + Object.values(markets).map(({ valueInBaseCurrency }) => { + return new Big(valueInBaseCurrency); + }) + ).toNumber(); + + const categories: PortfolioReportResponse['xRay']['categories'] = [ + { + key: 'liquidity', + name: this.i18nService.getTranslation({ + id: 'rule.liquidity.category', + languageCode: userSettings.language + }), + rules: await this.rulesService.evaluate( + [ + new BuyingPower( + this.exchangeRateDataService, + this.i18nService, + summary.cash, + userSettings.language + ) + ], + userSettings + ) + }, + { + key: 'emergencyFund', + name: this.i18nService.getTranslation({ + id: 'rule.emergencyFund.category', + languageCode: userSettings.language + }), + rules: await this.rulesService.evaluate( + [ + new EmergencyFundSetup( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + this.getTotalEmergencyFund({ + userSettings, + emergencyFundHoldingsValueInBaseCurrency: + this.getEmergencyFundHoldingsValueInBaseCurrency({ holdings }) + }).toNumber() + ) + ], + userSettings + ) + }, + { + key: 'currencyClusterRisk', + name: this.i18nService.getTranslation({ + id: 'rule.currencyClusterRisk.category', + languageCode: userSettings.language + }), + rules: + summary.activityCount > 0 + ? await this.rulesService.evaluate( + [ + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ), + new CurrencyClusterRiskCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + Object.values(holdings), + userSettings.language + ) + ], + userSettings + ) + : undefined + }, + { + key: 'assetClassClusterRisk', + name: this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRisk.category', + languageCode: userSettings.language + }), + rules: + summary.activityCount > 0 + ? await this.rulesService.evaluate( + [ + new AssetClassClusterRiskEquity( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ), + new AssetClassClusterRiskFixedIncome( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + Object.values(holdings) + ) + ], + userSettings + ) + : undefined + }, + { + key: 'accountClusterRisk', + name: this.i18nService.getTranslation({ + id: 'rule.accountClusterRisk.category', + languageCode: userSettings.language + }), + rules: + summary.activityCount > 0 + ? await this.rulesService.evaluate( + [ + new AccountClusterRiskCurrentInvestment( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + accounts + ), + new AccountClusterRiskSingleAccount( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + accounts + ) + ], + userSettings + ) + : undefined + }, + { + key: 'economicMarketClusterRisk', + name: this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRisk.category', + languageCode: userSettings.language + }), + rules: + summary.activityCount > 0 + ? await this.rulesService.evaluate( + [ + new EconomicMarketClusterRiskDevelopedMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.developedMarkets.valueInBaseCurrency, + userSettings.language + ), + new EconomicMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + marketsTotalInBaseCurrency, + markets.emergingMarkets.valueInBaseCurrency, + userSettings.language + ) + ], + userSettings + ) + : undefined + }, + { + key: 'regionalMarketClusterRisk', + name: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRisk.category', + languageCode: userSettings.language + }), + rules: + summary.activityCount > 0 + ? await this.rulesService.evaluate( + [ + new RegionalMarketClusterRiskAsiaPacific( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.asiaPacific.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEmergingMarkets( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.emergingMarkets.valueInBaseCurrency + ), + new RegionalMarketClusterRiskEurope( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.europe.valueInBaseCurrency + ), + new RegionalMarketClusterRiskJapan( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.japan.valueInBaseCurrency + ), + new RegionalMarketClusterRiskNorthAmerica( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + marketsAdvancedTotalInBaseCurrency, + marketsAdvanced.northAmerica.valueInBaseCurrency + ) + ], + userSettings + ) + : undefined + }, + { + key: 'fees', + name: this.i18nService.getTranslation({ + id: 'rule.fees.category', + languageCode: userSettings.language + }), + rules: await this.rulesService.evaluate( + [ + new FeeRatioInitialInvestment( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + summary.committedFunds, + summary.fees + ), + new FeeRatioTotalInvestmentVolume( + this.exchangeRateDataService, + this.i18nService, + userSettings.language, + summary.totalBuy + summary.totalSell, + summary.fees + ) + ], + userSettings + ) + } + ]; + + return { + xRay: { + categories, + statistics: this.getReportStatistics( + categories.flatMap(({ rules }) => { + return rules ?? []; + }) + ) + } + }; + } + + public async updateTags({ + dataSource, + impersonationId, + symbol, + tags, + userId + }: { + dataSource: DataSource; + impersonationId: string; + symbol: string; + tags: Tag[]; + userId: string; + }) { + userId = await this.getUserId(impersonationId, userId); + + await this.orderService.assignTags({ dataSource, symbol, tags, userId }); + } + + private getAggregatedMarkets(holdings: Record): { + markets: PortfolioDetails['markets']; + marketsAdvanced: PortfolioDetails['marketsAdvanced']; + } { + const markets: PortfolioDetails['markets'] = { + [UNKNOWN_KEY]: { + id: UNKNOWN_KEY, + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + developedMarkets: { + id: 'developedMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + emergingMarkets: { + id: 'emergingMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + otherMarkets: { + id: 'otherMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + } + }; + + const marketsAdvanced: PortfolioDetails['marketsAdvanced'] = { + [UNKNOWN_KEY]: { + id: UNKNOWN_KEY, + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + asiaPacific: { + id: 'asiaPacific', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + emergingMarkets: { + id: 'emergingMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + europe: { + id: 'europe', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + japan: { + id: 'japan', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + northAmerica: { + id: 'northAmerica', + valueInBaseCurrency: 0, + valueInPercentage: 0 + }, + otherMarkets: { + id: 'otherMarkets', + valueInBaseCurrency: 0, + valueInPercentage: 0 + } + }; + + for (const [, position] of Object.entries(holdings)) { + const value = position.valueInBaseCurrency; + + if (position.assetClass !== AssetClass.LIQUIDITY) { + if (position.countries.length > 0) { + markets.developedMarkets.valueInBaseCurrency += + position.markets.developedMarkets * value; + markets.emergingMarkets.valueInBaseCurrency += + position.markets.emergingMarkets * value; + markets.otherMarkets.valueInBaseCurrency += + position.markets.otherMarkets * value; + + marketsAdvanced.asiaPacific.valueInBaseCurrency += + position.marketsAdvanced.asiaPacific * value; + marketsAdvanced.emergingMarkets.valueInBaseCurrency += + position.marketsAdvanced.emergingMarkets * value; + marketsAdvanced.europe.valueInBaseCurrency += + position.marketsAdvanced.europe * value; + marketsAdvanced.japan.valueInBaseCurrency += + position.marketsAdvanced.japan * value; + marketsAdvanced.northAmerica.valueInBaseCurrency += + position.marketsAdvanced.northAmerica * value; + marketsAdvanced.otherMarkets.valueInBaseCurrency += + position.marketsAdvanced.otherMarkets * value; + } else { + markets[UNKNOWN_KEY].valueInBaseCurrency += value; + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency += value; + } + } + } + + const marketsTotalInBaseCurrency = getSum( + Object.values(markets).map(({ valueInBaseCurrency }) => { + return new Big(valueInBaseCurrency); + }) + ).toNumber(); + + markets.developedMarkets.valueInPercentage = + markets.developedMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; + markets.emergingMarkets.valueInPercentage = + markets.emergingMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; + markets.otherMarkets.valueInPercentage = + markets.otherMarkets.valueInBaseCurrency / marketsTotalInBaseCurrency; + markets[UNKNOWN_KEY].valueInPercentage = + markets[UNKNOWN_KEY].valueInBaseCurrency / marketsTotalInBaseCurrency; + + const marketsAdvancedTotal = + marketsAdvanced.asiaPacific.valueInBaseCurrency + + marketsAdvanced.emergingMarkets.valueInBaseCurrency + + marketsAdvanced.europe.valueInBaseCurrency + + marketsAdvanced.japan.valueInBaseCurrency + + marketsAdvanced.northAmerica.valueInBaseCurrency + + marketsAdvanced.otherMarkets.valueInBaseCurrency + + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency; + + marketsAdvanced.asiaPacific.valueInPercentage = + marketsAdvanced.asiaPacific.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.emergingMarkets.valueInPercentage = + marketsAdvanced.emergingMarkets.valueInBaseCurrency / + marketsAdvancedTotal; + marketsAdvanced.europe.valueInPercentage = + marketsAdvanced.europe.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.japan.valueInPercentage = + marketsAdvanced.japan.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.northAmerica.valueInPercentage = + marketsAdvanced.northAmerica.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced.otherMarkets.valueInPercentage = + marketsAdvanced.otherMarkets.valueInBaseCurrency / marketsAdvancedTotal; + marketsAdvanced[UNKNOWN_KEY].valueInPercentage = + marketsAdvanced[UNKNOWN_KEY].valueInBaseCurrency / marketsAdvancedTotal; + + return { markets, marketsAdvanced }; + } + + private getCashPositions({ + cashDetails, + userCurrency, + value + }: { + cashDetails: CashDetails; + userCurrency: string; + value: Big; + }) { + const cashPositions: PortfolioDetails['holdings'] = { + [userCurrency]: this.getInitialCashPosition({ + balance: 0, + currency: userCurrency + }) + }; + + for (const account of cashDetails.accounts) { + const convertedBalance = this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + + if (convertedBalance === 0) { + continue; + } + + if (cashPositions[account.currency]) { + cashPositions[account.currency].investment += convertedBalance; + cashPositions[account.currency].valueInBaseCurrency += convertedBalance; + } else { + cashPositions[account.currency] = this.getInitialCashPosition({ + balance: convertedBalance, + currency: account.currency + }); + } + } + + for (const symbol of Object.keys(cashPositions)) { + // Calculate allocations for each currency + cashPositions[symbol].allocationInPercentage = value.gt(0) + ? new Big(cashPositions[symbol].valueInBaseCurrency) + .div(value) + .toNumber() + : 0; + } + + return cashPositions; + } + + private getCashSymbolProfiles(cashDetails: CashDetails) { + const cashSymbols = [ + ...new Set(cashDetails.accounts.map(({ currency }) => currency)) + ]; + + return cashSymbols.map((currency) => { + const account = cashDetails.accounts.find( + ({ currency: accountCurrency }) => { + return accountCurrency === currency; + } + ); + + return { + currency, + activitiesCount: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + createdAt: account.createdAt, + dataSource: DataSource.MANUAL, + holdings: [], + id: currency, + isActive: true, + name: currency, + sectors: [], + symbol: currency, + updatedAt: account.updatedAt + }; + }); + } + + private getDividendsByGroup({ + dividends, + groupBy + }: { + dividends: InvestmentItem[]; + groupBy: GroupBy; + }): InvestmentItem[] { + if (dividends.length === 0) { + return []; + } + + const dividendsByGroup: InvestmentItem[] = []; + let currentDate: Date; + let investmentByGroup = new Big(0); + + for (const [index, dividend] of dividends.entries()) { + if ( + isSameYear(parseDate(dividend.date), currentDate) && + (groupBy === 'year' || + isSameMonth(parseDate(dividend.date), currentDate)) + ) { + // Same group: Add up dividends + + investmentByGroup = investmentByGroup.plus(dividend.investment); + } else { + // New group: Store previous group and reset + + if (currentDate) { + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + + currentDate = parseDate(dividend.date); + investmentByGroup = new Big(dividend.investment); + } + + if (index === dividends.length - 1) { + // Store current month (latest order) + dividendsByGroup.push({ + date: format( + set(currentDate, { + date: 1, + month: groupBy === 'year' ? 0 : currentDate.getMonth() + }), + DATE_FORMAT + ), + investment: investmentByGroup.toNumber() + }); + } + } + + return dividendsByGroup; + } + + private getEmergencyFundHoldingsValueInBaseCurrency({ + holdings + }: { + holdings: PortfolioDetails['holdings']; + }) { + // TODO: Use current value of activities instead of holdings + // tagged with EMERGENCY_FUND_TAG_ID + const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => { + return ( + tags?.some(({ id }) => { + return id === TAG_ID_EMERGENCY_FUND; + }) ?? false + ); + }); + + let valueInBaseCurrencyOfEmergencyFundHoldings = new Big(0); + + for (const { valueInBaseCurrency } of emergencyFundHoldings) { + valueInBaseCurrencyOfEmergencyFundHoldings = + valueInBaseCurrencyOfEmergencyFundHoldings.plus(valueInBaseCurrency); + } + + return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber(); + } + + private getInitialCashPosition({ + balance, + currency + }: { + balance: number; + currency: string; + }): PortfolioPosition { + return { + currency, + activitiesCount: 0, + allocationInPercentage: 0, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CASH, + countries: [], + dataSource: undefined, + dateOfFirstActivity: undefined, + dividend: 0, + grossPerformance: 0, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: 0, + holdings: [], + investment: balance, + marketPrice: 0, + name: currency, + netPerformance: 0, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + quantity: 0, + sectors: [], + symbol: currency, + tags: [], + valueInBaseCurrency: balance + }; + } + + private getMarkets({ + assetProfile + }: { + assetProfile: EnhancedSymbolProfile; + }) { + const markets = { + [UNKNOWN_KEY]: 0, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }; + const marketsAdvanced = { + [UNKNOWN_KEY]: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }; + + if (assetProfile.countries.length > 0) { + for (const country of assetProfile.countries) { + if (developedMarkets.includes(country.code)) { + markets.developedMarkets = new Big(markets.developedMarkets) + .plus(country.weight) + .toNumber(); + } else if (emergingMarkets.includes(country.code)) { + markets.emergingMarkets = new Big(markets.emergingMarkets) + .plus(country.weight) + .toNumber(); + } else { + markets.otherMarkets = new Big(markets.otherMarkets) + .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(); + } + } + } + + markets[UNKNOWN_KEY] = new Big(1) + .minus(markets.developedMarkets) + .minus(markets.emergingMarkets) + .minus(markets.otherMarkets) + .toNumber(); + + marketsAdvanced[UNKNOWN_KEY] = new Big(1) + .minus(marketsAdvanced.asiaPacific) + .minus(marketsAdvanced.emergingMarkets) + .minus(marketsAdvanced.europe) + .minus(marketsAdvanced.japan) + .minus(marketsAdvanced.northAmerica) + .minus(marketsAdvanced.otherMarkets) + .toNumber(); + + return { markets, marketsAdvanced }; + } + + private getReportStatistics( + evaluatedRules: PortfolioReportRule[] + ): PortfolioReportResponse['xRay']['statistics'] { + const rulesActiveCount = Object.values(evaluatedRules) + .flat() + .filter((rule) => { + return rule?.isActive === true; + }).length; + + const rulesFulfilledCount = Object.values(evaluatedRules) + .flat() + .filter((rule) => { + return rule?.value === true; + }).length; + + return { rulesActiveCount, rulesFulfilledCount }; + } + + private getStreaks({ + investments, + savingsRate + }: { + investments: InvestmentItem[]; + savingsRate: number; + }) { + let currentStreak = 0; + let longestStreak = 0; + + for (const { investment } of investments) { + if (investment >= savingsRate) { + currentStreak++; + longestStreak = Math.max(longestStreak, currentStreak); + } else { + currentStreak = 0; + } + } + + return { currentStreak, longestStreak }; + } + + private async getSummary({ + balanceInBaseCurrency, + emergencyFundHoldingsValueInBaseCurrency, + filteredValueInBaseCurrency, + impersonationId, + portfolioCalculator, + userCurrency, + userId + }: { + balanceInBaseCurrency: number; + emergencyFundHoldingsValueInBaseCurrency: number; + filteredValueInBaseCurrency: Big; + impersonationId: string; + portfolioCalculator: PortfolioCalculator; + userCurrency: string; + userId: string; + }): Promise { + userId = await this.getUserId(impersonationId, userId); + const user = await this.userService.user({ id: userId }); + + const { activities } = await this.orderService.getOrders({ + userCurrency, + userId, + withExcludedAccountsAndActivities: true + }); + + const excludedActivities: Activity[] = []; + const nonExcludedActivities: Activity[] = []; + + for (const activity of activities) { + if ( + activity.account?.isExcluded || + activity.tags?.some(({ id }) => { + return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; + }) + ) { + excludedActivities.push(activity); + } else { + nonExcludedActivities.push(activity); + } + } + + const { + currentValueInBaseCurrency, + totalInvestment, + totalInvestmentWithCurrencyEffect + } = await portfolioCalculator.getSnapshot(); + + const { performance } = await this.getPerformance({ + impersonationId, + userId + }); + + const { + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect + } = performance; + + const totalEmergencyFund = this.getTotalEmergencyFund({ + emergencyFundHoldingsValueInBaseCurrency, + userSettings: user.settings?.settings as UserSettings + }); + + const dateOfFirstActivity = portfolioCalculator.getStartDate(); + + const dividendInBaseCurrency = + await portfolioCalculator.getDividendInBaseCurrency(); + + const fees = await portfolioCalculator.getFeesInBaseCurrency(); + const interest = await portfolioCalculator.getInterestInBaseCurrency(); + + const liabilities = + await portfolioCalculator.getLiabilitiesInBaseCurrency(); + + const totalBuy = this.getSumOfActivityType({ + userCurrency, + activities: nonExcludedActivities, + activityType: 'BUY' + }).toNumber(); + + const totalSell = this.getSumOfActivityType({ + userCurrency, + activities: nonExcludedActivities, + activityType: 'SELL' + }).toNumber(); + + const cash = new Big(balanceInBaseCurrency) + .minus(totalEmergencyFund) + .plus(emergencyFundHoldingsValueInBaseCurrency) + .toNumber(); + + const committedFunds = new Big(totalBuy).minus(totalSell); + + const totalOfExcludedActivities = this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'BUY' + }).minus( + this.getSumOfActivityType({ + userCurrency, + activities: excludedActivities, + activityType: 'SELL' + }) + ); + + const cashDetailsWithExcludedAccounts = + await this.accountService.getCashDetails({ + userId, + currency: userCurrency, + withExcludedAccounts: true + }); + + const excludedBalanceInBaseCurrency = new Big( + cashDetailsWithExcludedAccounts.balanceInBaseCurrency + ).minus(balanceInBaseCurrency); + + const excludedAccountsAndActivities = excludedBalanceInBaseCurrency + .plus(totalOfExcludedActivities) + .toNumber(); + + const netWorth = new Big(balanceInBaseCurrency) + .plus(currentValueInBaseCurrency) + .plus(excludedAccountsAndActivities) + .minus(liabilities) + .toNumber(); + + const daysInMarket = differenceInDays(new Date(), dateOfFirstActivity); + + const annualizedPerformancePercent = getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: new Big(netPerformancePercentage) + })?.toNumber(); + + const annualizedPerformancePercentWithCurrencyEffect = + getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage: new Big( + netPerformancePercentageWithCurrencyEffect + ) + })?.toNumber(); + + return { + annualizedPerformancePercent, + annualizedPerformancePercentWithCurrencyEffect, + cash, + dateOfFirstActivity, + excludedAccountsAndActivities, + netPerformance, + netPerformancePercentage, + netPerformancePercentageWithCurrencyEffect, + netPerformanceWithCurrencyEffect, + totalBuy, + totalSell, + activityCount: activities.filter(({ type }) => { + return ['BUY', 'SELL'].includes(type); + }).length, + committedFunds: committedFunds.toNumber(), + currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(), + dividendInBaseCurrency: dividendInBaseCurrency.toNumber(), + emergencyFund: { + assets: emergencyFundHoldingsValueInBaseCurrency, + cash: totalEmergencyFund + .minus(emergencyFundHoldingsValueInBaseCurrency) + .toNumber(), + total: totalEmergencyFund.toNumber() + }, + fees: fees.toNumber(), + filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), + filteredValueInPercentage: netWorth + ? filteredValueInBaseCurrency.div(netWorth).toNumber() + : undefined, + fireWealth: { + today: { + valueInBaseCurrency: new Big(currentValueInBaseCurrency) + .minus(emergencyFundHoldingsValueInBaseCurrency) + .toNumber() + } + }, + grossPerformance: new Big(netPerformance).plus(fees).toNumber(), + grossPerformanceWithCurrencyEffect: new Big( + netPerformanceWithCurrencyEffect + ) + .plus(fees) + .toNumber(), + interestInBaseCurrency: interest.toNumber(), + liabilitiesInBaseCurrency: liabilities.toNumber(), + totalInvestment: totalInvestment.toNumber(), + totalInvestmentValueWithCurrencyEffect: + totalInvestmentWithCurrencyEffect.toNumber(), + totalValueInBaseCurrency: netWorth + }; + } + + private getSumOfActivityType({ + activities, + activityType, + userCurrency + }: { + activities: Activity[]; + activityType: ActivityType; + userCurrency: string; + }) { + return getSum( + activities + .filter(({ isDraft, type }) => { + return isDraft === false && type === activityType; + }) + .map(({ currency, quantity, SymbolProfile, unitPrice }) => { + return new Big( + this.exchangeRateDataService.toCurrency( + new Big(quantity).mul(unitPrice).toNumber(), + currency ?? SymbolProfile.currency, + userCurrency + ) + ); + }) + ); + } + + private getTotalEmergencyFund({ + emergencyFundHoldingsValueInBaseCurrency, + userSettings + }: { + emergencyFundHoldingsValueInBaseCurrency: number; + userSettings: UserSettings; + }) { + return new Big( + Math.max( + emergencyFundHoldingsValueInBaseCurrency, + userSettings?.emergencyFund ?? 0 + ) + ); + } + + private getUserCurrency(aUser?: UserWithSettings) { + return ( + aUser?.settings?.settings.baseCurrency ?? + this.request.user?.settings?.settings.baseCurrency ?? + DEFAULT_CURRENCY + ); + } + + private async getUserId(aImpersonationId: string, aUserId: string) { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(aImpersonationId); + + return impersonationUserId || aUserId; + } + + private getUserPerformanceCalculationType( + aUser: UserWithSettings + ): PerformanceCalculationType { + return aUser?.settings?.settings.performanceCalculationType; + } + + private async getValueOfAccountsAndPlatforms({ + activities, + filters = [], + portfolioItemsNow, + userCurrency, + userId, + withExcludedAccounts = false + }: { + activities: Activity[]; + filters?: Filter[]; + portfolioItemsNow: Record; + userCurrency: string; + userId: string; + withExcludedAccounts?: boolean; + }) { + const accounts: PortfolioDetails['accounts'] = {}; + const platforms: PortfolioDetails['platforms'] = {}; + + let currentAccounts: (Account & { + Order?: Order[]; + platform?: Platform; + })[] = []; + + if (filters.length === 0) { + currentAccounts = await this.accountService.getAccounts(userId); + } else if (filters.length === 1 && filters[0].type === 'ACCOUNT') { + currentAccounts = await this.accountService.accounts({ + include: { platform: true }, + where: { id: filters[0].id } + }); + } else { + const accountIds = Array.from( + new Set( + activities + .filter(({ accountId }) => { + return accountId; + }) + .map(({ accountId }) => { + return accountId; + }) + ) + ); + + currentAccounts = await this.accountService.accounts({ + include: { platform: true }, + where: { id: { in: accountIds } } + }); + } + + currentAccounts = currentAccounts.filter((account) => { + return withExcludedAccounts || account.isExcluded === false; + }); + + for (const account of currentAccounts) { + const ordersByAccount = activities.filter(({ accountId }) => { + return accountId === account.id; + }); + + accounts[account.id] = { + balance: account.balance, + currency: account.currency, + name: account.name, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ) + }; + + if (platforms[account.platformId || UNKNOWN_KEY]?.valueInBaseCurrency) { + platforms[account.platformId || UNKNOWN_KEY].valueInBaseCurrency += + this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ); + } else { + platforms[account.platformId || UNKNOWN_KEY] = { + balance: account.balance, + currency: account.currency, + name: account.platform?.name, + valueInBaseCurrency: this.exchangeRateDataService.toCurrency( + account.balance, + account.currency, + userCurrency + ) + }; + } + + for (const { + account, + quantity, + SymbolProfile, + type + } of ordersByAccount) { + const currentValueOfSymbolInBaseCurrency = + getFactor(type) * + quantity * + (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ?? + 0); + + if (accounts[account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) { + accounts[account?.id || UNKNOWN_KEY].valueInBaseCurrency += + currentValueOfSymbolInBaseCurrency; + } else { + accounts[account?.id || UNKNOWN_KEY] = { + balance: 0, + currency: account?.currency, + name: account?.name, + valueInBaseCurrency: currentValueOfSymbolInBaseCurrency + }; + } + + if ( + platforms[account?.platformId || UNKNOWN_KEY]?.valueInBaseCurrency + ) { + platforms[account?.platformId || UNKNOWN_KEY].valueInBaseCurrency += + currentValueOfSymbolInBaseCurrency; + } else { + platforms[account?.platformId || UNKNOWN_KEY] = { + balance: 0, + currency: account?.currency, + name: account?.platform?.name, + valueInBaseCurrency: currentValueOfSymbolInBaseCurrency + }; + } + } + } + + return { accounts, platforms }; + } +} diff --git a/apps/api/src/app/portfolio/rules.service.ts b/apps/api/src/app/portfolio/rules.service.ts new file mode 100644 index 000000000..5bfb116e0 --- /dev/null +++ b/apps/api/src/app/portfolio/rules.service.ts @@ -0,0 +1,39 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { + PortfolioReportRule, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class RulesService { + public async evaluate( + aRules: Rule[], + aUserSettings: UserSettings + ): Promise { + return aRules.map((rule) => { + const settings = rule.getSettings(aUserSettings); + + if (settings?.isActive) { + const { evaluation, value } = rule.evaluate(settings); + + return { + evaluation, + value, + configuration: rule.getConfiguration(), + isActive: true, + key: rule.getKey(), + name: rule.getName() + }; + } else { + return { + isActive: false, + key: rule.getKey(), + name: rule.getName() + }; + } + }); + } +} diff --git a/apps/api/src/app/portfolio/update-holding-tags.dto.ts b/apps/api/src/app/portfolio/update-holding-tags.dto.ts new file mode 100644 index 000000000..11efe189d --- /dev/null +++ b/apps/api/src/app/portfolio/update-holding-tags.dto.ts @@ -0,0 +1,7 @@ +import { Tag } from '@prisma/client'; +import { IsArray } from 'class-validator'; + +export class UpdateHoldingTagsDto { + @IsArray() + tags: Tag[]; +} diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts new file mode 100644 index 000000000..d0e3228b7 --- /dev/null +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -0,0 +1,35 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { createKeyv } from '@keyv/redis'; +import { CacheModule } from '@nestjs/cache-manager'; +import { Module } from '@nestjs/common'; + +import { RedisCacheService } from './redis-cache.service'; + +@Module({ + exports: [RedisCacheService], + imports: [ + CacheModule.registerAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: async (configurationService: ConfigurationService) => { + const redisPassword = encodeURIComponent( + configurationService.get('REDIS_PASSWORD') + ); + + return { + stores: [ + createKeyv( + `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` + ) + ], + ttl: configurationService.get('CACHE_TTL') + }; + } + }), + ConfigurationModule + ], + providers: [RedisCacheService] +}) +export class RedisCacheModule {} diff --git a/apps/api/src/app/redis-cache/redis-cache.service.mock.ts b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts new file mode 100644 index 000000000..feb669ab0 --- /dev/null +++ b/apps/api/src/app/redis-cache/redis-cache.service.mock.ts @@ -0,0 +1,26 @@ +import { Filter } from '@ghostfolio/common/interfaces'; + +export const RedisCacheServiceMock = { + cache: new Map(), + get: (key: string): Promise => { + const value = RedisCacheServiceMock.cache.get(key) || null; + + return Promise.resolve(value); + }, + getPortfolioSnapshotKey: ({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }): string => { + const filtersHash = filters?.length; + + return `portfolio-snapshot-${userId}${filtersHash > 0 ? `-${filtersHash}` : ''}`; + }, + set: (key: string, value: string): Promise => { + RedisCacheServiceMock.cache.set(key, value); + + return Promise.resolve(value); + } +}; diff --git a/apps/api/src/app/redis-cache/redis-cache.service.ts b/apps/api/src/app/redis-cache/redis-cache.service.ts new file mode 100644 index 000000000..1ea0a6137 --- /dev/null +++ b/apps/api/src/app/redis-cache/redis-cache.service.ts @@ -0,0 +1,138 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { AssetProfileIdentifier, Filter } from '@ghostfolio/common/interfaces'; + +import { CACHE_MANAGER, Cache } from '@nestjs/cache-manager'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import Keyv from 'keyv'; +import ms from 'ms'; +import { createHash } from 'node:crypto'; + +@Injectable() +export class RedisCacheService { + private client: Keyv; + + public constructor( + @Inject(CACHE_MANAGER) private readonly cache: Cache, + private readonly configurationService: ConfigurationService + ) { + this.client = cache.stores[0]; + + this.client.deserialize = (value) => { + try { + return JSON.parse(value); + } catch {} + + return value; + }; + + this.client.on('error', (error) => { + Logger.error(error, 'RedisCacheService'); + }); + } + + public async get(key: string): Promise { + return this.cache.get(key); + } + + public async getKeys(aPrefix?: string): Promise { + const keys: string[] = []; + const prefix = aPrefix; + + try { + for await (const [key] of this.client.iterator({})) { + if ((prefix && key.startsWith(prefix)) || !prefix) { + keys.push(key); + } + } + } catch {} + + return keys; + } + + public getPortfolioSnapshotKey({ + filters, + userId + }: { + filters?: Filter[]; + userId: string; + }) { + let portfolioSnapshotKey = `portfolio-snapshot-${userId}`; + + if (filters?.length > 0) { + const filtersHash = createHash('sha256') + .update(JSON.stringify(filters)) + .digest('hex'); + + portfolioSnapshotKey = `${portfolioSnapshotKey}-${filtersHash}`; + } + + return portfolioSnapshotKey; + } + + public getQuoteKey({ dataSource, symbol }: AssetProfileIdentifier) { + return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; + } + + public async isHealthy() { + const testKey = '__health_check__'; + const testValue = Date.now().toString(); + + try { + await Promise.race([ + (async () => { + await this.set(testKey, testValue, ms('1 second')); + const result = await this.get(testKey); + + if (result !== testValue) { + throw new Error('Redis health check failed: value mismatch'); + } + })(), + new Promise((_, reject) => + setTimeout( + () => reject(new Error('Redis health check failed: timeout')), + ms('2 seconds') + ) + ) + ]); + + return true; + } catch (error) { + Logger.error(error?.message, 'RedisCacheService'); + + return false; + } finally { + try { + await this.remove(testKey); + } catch {} + } + } + + public async remove(key: string) { + return this.cache.del(key); + } + + public async removePortfolioSnapshotsByUserId({ + userId + }: { + userId: string; + }) { + const keys = await this.getKeys( + `${this.getPortfolioSnapshotKey({ userId })}` + ); + + return this.cache.mdel(keys); + } + + public async reset() { + return this.cache.clear(); + } + + public async set(key: string, value: string, ttl?: number) { + return this.cache.set( + key, + value, + ttl ?? this.configurationService.get('CACHE_TTL') + ); + } +} diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts new file mode 100644 index 000000000..e1c705fdd --- /dev/null +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -0,0 +1,135 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_LANGUAGE_CODE, + PROPERTY_COUPONS +} from '@ghostfolio/common/config'; +import { + Coupon, + CreateStripeCheckoutSessionResponse +} from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Get, + HttpCode, + HttpException, + Inject, + Logger, + Post, + Req, + Res, + UseGuards +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { Request, Response } from 'express'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { SubscriptionService } from './subscription.service'; + +@Controller('subscription') +export class SubscriptionController { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly propertyService: PropertyService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly subscriptionService: SubscriptionService + ) {} + + @Post('redeem-coupon') + @HttpCode(StatusCodes.OK) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) { + if (!this.request.user) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + let coupons = + (await this.propertyService.getByKey(PROPERTY_COUPONS)) ?? []; + + const coupon = coupons.find((currentCoupon) => { + return currentCoupon.code === couponCode; + }); + + if (coupon === undefined) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + await this.subscriptionService.createSubscription({ + duration: coupon.duration, + price: 0, + userId: this.request.user.id + }); + + // Destroy coupon + coupons = coupons.filter((currentCoupon) => { + return currentCoupon.code !== couponCode; + }); + await this.propertyService.put({ + key: PROPERTY_COUPONS, + value: JSON.stringify(coupons) + }); + + Logger.log( + `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`, + 'SubscriptionController' + ); + + return { + message: getReasonPhrase(StatusCodes.OK), + statusCode: StatusCodes.OK + }; + } + + @Get('stripe/callback') + public async stripeCallback( + @Req() request: Request, + @Res() response: Response + ) { + const userId = await this.subscriptionService.createSubscriptionViaStripe( + request.query.checkoutSessionId as string + ); + + Logger.log( + `Subscription for user '${userId}' has been created via Stripe`, + 'SubscriptionController' + ); + + response.redirect( + `${this.configurationService.get( + 'ROOT_URL' + )}/${DEFAULT_LANGUAGE_CODE}/account/membership` + ); + } + + @Post('stripe/checkout-session') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public createStripeCheckoutSession( + @Body() { couponId, priceId }: { couponId?: string; priceId: string } + ): Promise { + try { + return this.subscriptionService.createStripeCheckoutSession({ + couponId, + priceId, + user: this.request.user + }); + } catch (error) { + Logger.error(error, 'SubscriptionController'); + + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } +} diff --git a/apps/api/src/app/subscription/subscription.module.ts b/apps/api/src/app/subscription/subscription.module.ts new file mode 100644 index 000000000..bf4bba7b7 --- /dev/null +++ b/apps/api/src/app/subscription/subscription.module.ts @@ -0,0 +1,16 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +import { SubscriptionController } from './subscription.controller'; +import { SubscriptionService } from './subscription.service'; + +@Module({ + controllers: [SubscriptionController], + exports: [SubscriptionService], + imports: [ConfigurationModule, PrismaModule, PropertyModule], + providers: [SubscriptionService] +}) +export class SubscriptionModule {} diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts new file mode 100644 index 000000000..689ee3e6a --- /dev/null +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -0,0 +1,228 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_LANGUAGE_CODE, + PROPERTY_STRIPE_CONFIG +} from '@ghostfolio/common/config'; +import { SubscriptionType } from '@ghostfolio/common/enums'; +import { parseDate } from '@ghostfolio/common/helper'; +import { + CreateStripeCheckoutSessionResponse, + SubscriptionOffer +} from '@ghostfolio/common/interfaces'; +import { + SubscriptionOfferKey, + UserWithSettings +} from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; +import { Subscription } from '@prisma/client'; +import { addMilliseconds, isBefore } from 'date-fns'; +import ms, { StringValue } from 'ms'; +import Stripe from 'stripe'; + +@Injectable() +export class SubscriptionService { + private stripe: Stripe; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService + ) { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + this.stripe = new Stripe( + this.configurationService.get('STRIPE_SECRET_KEY'), + { + apiVersion: '2026-01-28.clover' + } + ); + } + } + + public async createStripeCheckoutSession({ + couponId, + priceId, + user + }: { + couponId?: string; + priceId: string; + user: UserWithSettings; + }): Promise { + const subscriptionOffers: { + [offer in SubscriptionOfferKey]: SubscriptionOffer; + } = + (await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) ?? {}; + + const subscriptionOffer = Object.values(subscriptionOffers).find( + (subscriptionOffer) => { + return subscriptionOffer.priceId === priceId; + } + ); + + const stripeCheckoutSessionCreateParams: Stripe.Checkout.SessionCreateParams = + { + cancel_url: `${this.configurationService.get('ROOT_URL')}/${ + user.settings.settings.language + }/account`, + client_reference_id: user.id, + line_items: [ + { + price: priceId, + quantity: 1 + } + ], + locale: + (user.settings?.settings + ?.language as Stripe.Checkout.SessionCreateParams.Locale) ?? + DEFAULT_LANGUAGE_CODE, + metadata: subscriptionOffer + ? { subscriptionOffer: JSON.stringify(subscriptionOffer) } + : {}, + mode: 'payment', + payment_method_types: ['card'], + success_url: `${this.configurationService.get( + 'ROOT_URL' + )}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}` + }; + + if (couponId) { + stripeCheckoutSessionCreateParams.discounts = [ + { + coupon: couponId + } + ]; + } + + const session = await this.stripe.checkout.sessions.create( + stripeCheckoutSessionCreateParams + ); + + return { + sessionUrl: session.url + }; + } + + public async createSubscription({ + duration = '1 year', + durationExtension, + price, + userId + }: { + duration?: StringValue; + durationExtension?: StringValue; + price: number; + userId: string; + }) { + let expiresAt = addMilliseconds(new Date(), ms(duration)); + + if (durationExtension) { + expiresAt = addMilliseconds(expiresAt, ms(durationExtension)); + } + + await this.prismaService.subscription.create({ + data: { + expiresAt, + price, + user: { + connect: { + id: userId + } + } + } + }); + } + + public async createSubscriptionViaStripe(aCheckoutSessionId: string) { + try { + let durationExtension: StringValue; + + const session = + await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); + + const subscriptionOffer: SubscriptionOffer = JSON.parse( + session.metadata.subscriptionOffer ?? '{}' + ); + + if (subscriptionOffer) { + durationExtension = subscriptionOffer.durationExtension; + } + + await this.createSubscription({ + durationExtension, + price: session.amount_total / 100, + userId: session.client_reference_id + }); + + return session.client_reference_id; + } catch (error) { + Logger.error(error, 'SubscriptionService'); + } + } + + public async getSubscription({ + createdAt, + subscriptions + }: { + createdAt: UserWithSettings['createdAt']; + subscriptions: Subscription[]; + }): Promise { + if (subscriptions.length > 0) { + const { expiresAt, price } = subscriptions.reduce((a, b) => { + return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b; + }); + + let offerKey: SubscriptionOfferKey = price ? 'renewal' : 'default'; + + if (isBefore(createdAt, parseDate('2023-01-01'))) { + offerKey = 'renewal-early-bird-2023'; + } else if (isBefore(createdAt, parseDate('2024-01-01'))) { + offerKey = 'renewal-early-bird-2024'; + } else if (isBefore(createdAt, parseDate('2025-12-01'))) { + offerKey = 'renewal-early-bird-2025'; + } + + const offer = await this.getSubscriptionOffer({ + key: offerKey + }); + + return { + offer, + expiresAt: isBefore(new Date(), expiresAt) ? expiresAt : undefined, + type: isBefore(new Date(), expiresAt) + ? SubscriptionType.Premium + : SubscriptionType.Basic + }; + } else { + const offer = await this.getSubscriptionOffer({ + key: 'default' + }); + + return { + offer, + type: SubscriptionType.Basic + }; + } + } + + public async getSubscriptionOffer({ + key + }: { + key: SubscriptionOfferKey; + }): Promise { + if (!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + return undefined; + } + + const offers: { + [offer in SubscriptionOfferKey]: SubscriptionOffer; + } = + (await this.propertyService.getByKey(PROPERTY_STRIPE_CONFIG)) ?? {}; + + return { + ...offers[key], + isRenewal: key.startsWith('renewal') + }; + } +} diff --git a/apps/api/src/app/symbol/symbol.controller.ts b/apps/api/src/app/symbol/symbol.controller.ts new file mode 100644 index 000000000..501692ae5 --- /dev/null +++ b/apps/api/src/app/symbol/symbol.controller.ts @@ -0,0 +1,127 @@ +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; +import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; +import { + DataProviderHistoricalResponse, + LookupResponse, + SymbolItem +} from '@ghostfolio/common/interfaces'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Controller, + Get, + HttpException, + Inject, + Param, + Query, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { AuthGuard } from '@nestjs/passport'; +import { DataSource } from '@prisma/client'; +import { parseISO } from 'date-fns'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { isDate, isEmpty } from 'lodash'; + +import { SymbolService } from './symbol.service'; + +@Controller('symbol') +export class SymbolController { + public constructor( + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly symbolService: SymbolService + ) {} + + /** + * Must be before /:symbol + */ + @Get('lookup') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async lookupSymbol( + @Query('includeIndices') includeIndicesParam = 'false', + @Query('query') query = '' + ): Promise { + const includeIndices = includeIndicesParam === 'true'; + + try { + return this.symbolService.lookup({ + includeIndices, + query, + user: this.request.user + }); + } catch { + throw new HttpException( + getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), + StatusCodes.INTERNAL_SERVER_ERROR + ); + } + } + + /** + * Must be after /lookup + */ + @Get(':dataSource/:symbol') + @UseInterceptors(TransformDataSourceInRequestInterceptor) + @UseInterceptors(TransformDataSourceInResponseInterceptor) + public async getSymbolData( + @Param('dataSource') dataSource: DataSource, + @Param('symbol') symbol: string, + @Query('includeHistoricalData') includeHistoricalData = 0 + ): Promise { + if (!DataSource[dataSource]) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + const result = await this.symbolService.get({ + includeHistoricalData, + dataGatheringItem: { dataSource, symbol } + }); + + if (!result || isEmpty(result)) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return result; + } + + @Get(':dataSource/:symbol/:dateString') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async gatherSymbolForDate( + @Param('dataSource') dataSource: DataSource, + @Param('dateString') dateString: string, + @Param('symbol') symbol: string + ): Promise { + const date = parseISO(dateString); + + if (!isDate(date)) { + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + + const result = await this.symbolService.getForDate({ + dataSource, + date, + symbol + }); + + if (!result || isEmpty(result)) { + throw new HttpException( + getReasonPhrase(StatusCodes.NOT_FOUND), + StatusCodes.NOT_FOUND + ); + } + + return result; + } +} diff --git a/apps/api/src/app/symbol/symbol.module.ts b/apps/api/src/app/symbol/symbol.module.ts new file mode 100644 index 000000000..223a0a832 --- /dev/null +++ b/apps/api/src/app/symbol/symbol.module.ts @@ -0,0 +1,24 @@ +import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; +import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { SymbolController } from './symbol.controller'; +import { SymbolService } from './symbol.service'; + +@Module({ + controllers: [SymbolController], + exports: [SymbolService], + imports: [ + DataProviderModule, + MarketDataModule, + PrismaModule, + TransformDataSourceInRequestModule, + TransformDataSourceInResponseModule + ], + providers: [SymbolService] +}) +export class SymbolModule {} diff --git a/apps/api/src/app/symbol/symbol.service.ts b/apps/api/src/app/symbol/symbol.service.ts new file mode 100644 index 000000000..15498e80d --- /dev/null +++ b/apps/api/src/app/symbol/symbol.service.ts @@ -0,0 +1,127 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + HistoricalDataItem, + LookupResponse, + SymbolItem +} from '@ghostfolio/common/interfaces'; +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; +import { format, subDays } from 'date-fns'; + +@Injectable() +export class SymbolService { + public constructor( + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService + ) {} + + public async get({ + dataGatheringItem, + includeHistoricalData + }: { + dataGatheringItem: DataGatheringItem; + includeHistoricalData?: number; + }): Promise { + const quotes = await this.dataProviderService.getQuotes({ + items: [dataGatheringItem] + }); + const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {}; + + if (dataGatheringItem.dataSource && marketPrice >= 0) { + let historicalData: HistoricalDataItem[] = []; + + if (includeHistoricalData > 0) { + const days = includeHistoricalData; + + const marketData = await this.marketDataService.getRange({ + assetProfileIdentifiers: [ + { + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol + } + ], + dateQuery: { gte: subDays(new Date(), days) } + }); + + historicalData = marketData.map(({ date, marketPrice: value }) => { + return { + value, + date: date.toISOString() + }; + }); + } + + return { + currency, + historicalData, + marketPrice, + dataSource: dataGatheringItem.dataSource, + symbol: dataGatheringItem.symbol + }; + } + + return undefined; + } + + public async getForDate({ + dataSource, + date = new Date(), + symbol + }: DataGatheringItem): Promise { + let historicalData: { + [symbol: string]: { + [date: string]: DataProviderHistoricalResponse; + }; + } = { + [symbol]: {} + }; + + try { + historicalData = await this.dataProviderService.getHistoricalRaw({ + assetProfileIdentifiers: [{ dataSource, symbol }], + from: date, + to: date + }); + } catch {} + + return { + marketPrice: + historicalData?.[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice + }; + } + + public async lookup({ + includeIndices = false, + query, + user + }: { + includeIndices?: boolean; + query: string; + user: UserWithSettings; + }): Promise { + const results: LookupResponse = { items: [] }; + + if (!query) { + return results; + } + + try { + const { items } = await this.dataProviderService.search({ + includeIndices, + query, + user + }); + results.items = items; + return results; + } catch (error) { + Logger.error(error, 'SymbolService'); + + throw error; + } + } +} diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts new file mode 100644 index 000000000..6346ce43a --- /dev/null +++ b/apps/api/src/app/user/user.controller.ts @@ -0,0 +1,233 @@ +import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; +import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config'; +import { + DeleteOwnUserDto, + UpdateOwnAccessTokenDto, + UpdateUserSettingDto +} from '@ghostfolio/common/dtos'; +import { + AccessTokenResponse, + User, + UserItem, + UserSettings +} from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { + Body, + Controller, + Delete, + Get, + Headers, + HttpException, + Inject, + Param, + Post, + Put, + UseGuards, + UseInterceptors +} from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { AuthGuard } from '@nestjs/passport'; +import { User as UserModel } from '@prisma/client'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; +import { merge, size } from 'lodash'; + +import { UserService } from './user.service'; + +@Controller('user') +export class UserController { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly impersonationService: ImpersonationService, + private readonly jwtService: JwtService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, + @Inject(REQUEST) private readonly request: RequestWithUser, + private readonly userService: UserService + ) {} + + @Delete() + @HasPermission(permissions.deleteOwnUser) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteOwnUser( + @Body() data: DeleteOwnUserDto + ): Promise { + const user = await this.validateAccessToken( + data.accessToken, + this.request.user.id + ); + + return this.userService.deleteUser({ + id: user.id + }); + } + + @Delete(':id') + @HasPermission(permissions.deleteUser) + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async deleteUser(@Param('id') id: string): Promise { + if (id === this.request.user.id) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return this.userService.deleteUser({ + id + }); + } + + @HasPermission(permissions.accessAdminControl) + @Post(':id/access-token') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateUserAccessToken( + @Param('id') id: string + ): Promise { + return this.rotateUserAccessToken(id); + } + + @HasPermission(permissions.updateOwnAccessToken) + @Post('access-token') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateOwnAccessToken( + @Body() data: UpdateOwnAccessTokenDto + ): Promise { + const user = await this.validateAccessToken( + data.accessToken, + this.request.user.id + ); + + return this.rotateUserAccessToken(user.id); + } + + @Get() + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + @UseInterceptors(RedactValuesInResponseInterceptor) + public async getUser( + @Headers('accept-language') acceptLanguage: string, + @Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string + ): Promise { + const impersonationUserId = + await this.impersonationService.validateImpersonationId(impersonationId); + + return this.userService.getUser({ + impersonationUserId, + locale: acceptLanguage?.split(',')?.[0], + user: this.request.user + }); + } + + @Post() + public async signupUser(): Promise { + const isUserSignupEnabled = + await this.propertyService.isUserSignupEnabled(); + + if (!isUserSignupEnabled) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const { accessToken, id, role } = await this.userService.createUser(); + + return { + accessToken, + role, + authToken: this.jwtService.sign({ + id + }) + }; + } + + @Put('setting') + @UseGuards(AuthGuard('jwt'), HasPermissionGuard) + public async updateUserSetting(@Body() data: UpdateUserSettingDto) { + if ( + size(data) === 1 && + (data.benchmark || data.dateRange) && + this.request.user.role === 'DEMO' + ) { + // Allow benchmark or date range change for demo user + } else if ( + !hasPermission( + this.request.user.permissions, + permissions.updateUserSettings + ) + ) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + const emitPortfolioChangedEvent = 'baseCurrency' in data; + + const userSettings: UserSettings = merge( + {}, + this.request.user.settings.settings as UserSettings, + data + ); + + for (const key in userSettings) { + if (userSettings[key] === false || userSettings[key] === null) { + delete userSettings[key]; + } + } + + return this.userService.updateUserSetting({ + emitPortfolioChangedEvent, + userSettings, + userId: this.request.user.id + }); + } + + private async rotateUserAccessToken( + userId: string + ): Promise { + const { accessToken, hashedAccessToken } = + this.userService.generateAccessToken({ + userId + }); + + await this.prismaService.user.update({ + data: { accessToken: hashedAccessToken }, + where: { id: userId } + }); + + return { accessToken }; + } + + private async validateAccessToken( + accessToken: string, + userId: string + ): Promise { + const hashedAccessToken = this.userService.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); + + const [user] = await this.userService.users({ + where: { accessToken: hashedAccessToken, id: userId } + }); + + if (!user) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return user; + } +} diff --git a/apps/api/src/app/user/user.module.ts b/apps/api/src/app/user/user.module.ts new file mode 100644 index 000000000..7ca68d275 --- /dev/null +++ b/apps/api/src/app/user/user.module.ts @@ -0,0 +1,37 @@ +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; +import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { I18nModule } from '@ghostfolio/api/services/i18n/i18n.module'; +import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { TagModule } from '@ghostfolio/api/services/tag/tag.module'; + +import { Module } from '@nestjs/common'; +import { JwtModule } from '@nestjs/jwt'; + +import { UserController } from './user.controller'; +import { UserService } from './user.service'; + +@Module({ + controllers: [UserController], + exports: [UserService], + imports: [ + ConfigurationModule, + I18nModule, + ImpersonationModule, + JwtModule.register({ + secret: process.env.JWT_SECRET_KEY, + signOptions: { expiresIn: '30 days' } + }), + OrderModule, + PrismaModule, + PropertyModule, + RedactValuesInResponseModule, + SubscriptionModule, + TagModule + ], + providers: [UserService] +}) +export class UserModule {} diff --git a/apps/api/src/app/user/user.service.ts b/apps/api/src/app/user/user.service.ts new file mode 100644 index 000000000..08328851d --- /dev/null +++ b/apps/api/src/app/user/user.service.ts @@ -0,0 +1,733 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; +import { environment } from '@ghostfolio/api/environments/environment'; +import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event'; +import { getRandomString } from '@ghostfolio/api/helper/string.helper'; +import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; +import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; +import { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity'; +import { AssetClassClusterRiskFixedIncome } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/fixed-income'; +import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; +import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; +import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/developed-markets'; +import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; +import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; +import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; +import { FeeRatioTotalInvestmentVolume } from '@ghostfolio/api/models/rules/fees/fee-ratio-total-investment-volume'; +import { BuyingPower } from '@ghostfolio/api/models/rules/liquidity/buying-power'; +import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific'; +import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets'; +import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe'; +import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan'; +import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { TagService } from '@ghostfolio/api/services/tag/tag.service'; +import { + DEFAULT_CURRENCY, + DEFAULT_LANGUAGE_CODE, + PROPERTY_IS_READ_ONLY_MODE, + PROPERTY_SYSTEM_MESSAGE, + TAG_ID_EXCLUDE_FROM_ANALYSIS, + locale as defaultLocale +} from '@ghostfolio/common/config'; +import { + User as IUser, + SystemMessage, + UserSettings +} from '@ghostfolio/common/interfaces'; +import { + getPermissions, + hasRole, + permissions +} from '@ghostfolio/common/permissions'; +import { UserWithSettings } from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Prisma, Role, User } from '@prisma/client'; +import { differenceInDays, subDays } from 'date-fns'; +import { without } from 'lodash'; +import { createHmac } from 'node:crypto'; + +@Injectable() +export class UserService { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly eventEmitter: EventEmitter2, + private readonly i18nService: I18nService, + private readonly orderService: OrderService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, + private readonly subscriptionService: SubscriptionService, + private readonly tagService: TagService + ) {} + + public async count(args?: Prisma.UserCountArgs) { + return this.prismaService.user.count(args); + } + + public createAccessToken({ + password, + salt + }: { + password: string; + salt: string; + }): string { + const hash = createHmac('sha512', salt); + hash.update(password); + + return hash.digest('hex'); + } + + public generateAccessToken({ userId }: { userId: string }) { + const accessToken = this.createAccessToken({ + password: userId, + salt: getRandomString(10) + }); + + const hashedAccessToken = this.createAccessToken({ + password: accessToken, + salt: this.configurationService.get('ACCESS_TOKEN_SALT') + }); + + return { accessToken, hashedAccessToken }; + } + + public async getUser({ + impersonationUserId, + locale = defaultLocale, + user + }: { + impersonationUserId: string; + locale?: string; + user: UserWithSettings; + }): Promise { + const { id, permissions, settings, subscription } = user; + + const userData = await Promise.all([ + this.prismaService.access.findMany({ + include: { + user: true + }, + orderBy: { alias: 'asc' }, + where: { granteeUserId: id } + }), + this.prismaService.account.findMany({ + orderBy: { + name: 'asc' + }, + where: { + userId: impersonationUserId || user.id + } + }), + this.prismaService.order.count({ + where: { userId: impersonationUserId || user.id } + }), + this.prismaService.order.findFirst({ + orderBy: { + date: 'asc' + }, + where: { userId: impersonationUserId || user.id } + }), + this.tagService.getTagsForUser(impersonationUserId || user.id) + ]); + + const access = userData[0]; + const accounts = userData[1]; + const activitiesCount = userData[2]; + const firstActivity = userData[3]; + let tags = userData[4].filter((tag) => { + return tag.id !== TAG_ID_EXCLUDE_FROM_ANALYSIS; + }); + + let systemMessage: SystemMessage; + + const systemMessageProperty = + await this.propertyService.getByKey( + PROPERTY_SYSTEM_MESSAGE + ); + + if (systemMessageProperty?.targetGroups?.includes(subscription?.type)) { + systemMessage = systemMessageProperty; + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + subscription.type === 'Basic' + ) { + tags = []; + } + + return { + activitiesCount, + id, + permissions, + subscription, + systemMessage, + tags, + access: access.map((accessItem) => { + return { + alias: accessItem.alias, + id: accessItem.id, + permissions: accessItem.permissions + }; + }), + accounts: accounts.sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }), + dateOfFirstActivity: firstActivity?.date ?? new Date(), + settings: { + ...(settings.settings as UserSettings), + locale: (settings.settings as UserSettings)?.locale ?? locale + } + }; + } + + public async hasAdmin() { + const usersWithAdminRole = await this.users({ + where: { + role: { + equals: 'ADMIN' + } + } + }); + + return usersWithAdminRole.length > 0; + } + + public async user( + userWhereUniqueInput: Prisma.UserWhereUniqueInput + ): Promise { + const { + _count, + accessesGet, + accessToken, + accounts, + analytics, + authChallenge, + createdAt, + id, + provider, + role, + settings, + subscriptions, + thirdPartyId, + updatedAt + } = await this.prismaService.user.findUnique({ + include: { + _count: { + select: { + activities: true + } + }, + accessesGet: true, + accounts: { + include: { platform: true } + }, + analytics: true, + settings: true, + subscriptions: true + }, + where: userWhereUniqueInput + }); + + const activitiesCount = _count?.activities ?? 0; + + const user: UserWithSettings = { + accessesGet, + accessToken, + accounts, + authChallenge, + createdAt, + id, + provider, + role, + settings: settings as UserWithSettings['settings'], + thirdPartyId, + updatedAt, + activityCount: analytics?.activityCount, + dataProviderGhostfolioDailyRequests: + analytics?.dataProviderGhostfolioDailyRequests + }; + + if (user?.settings) { + if (!user.settings.settings) { + user.settings.settings = {}; + } + } else if (user) { + // Set default settings if needed + user.settings = { + settings: {}, + updatedAt: new Date(), + userId: user?.id + }; + } + + // Set default value for annual interest rate + if (!(user.settings.settings as UserSettings)?.annualInterestRate) { + (user.settings.settings as UserSettings).annualInterestRate = 5; + } + + // Set default value for base currency + if (!(user.settings.settings as UserSettings)?.baseCurrency) { + (user.settings.settings as UserSettings).baseCurrency = DEFAULT_CURRENCY; + } + + // Set default value for date range + (user.settings.settings as UserSettings).dateRange = + (user.settings.settings as UserSettings).viewMode === 'ZEN' + ? 'max' + : ((user.settings.settings as UserSettings)?.dateRange ?? 'max'); + + // Set default value for performance calculation type + if (!(user.settings.settings as UserSettings)?.performanceCalculationType) { + (user.settings.settings as UserSettings).performanceCalculationType = + PerformanceCalculationType.ROAI; + } + + // Set default value for projected total amount + if (!(user.settings.settings as UserSettings)?.projectedTotalAmount) { + (user.settings.settings as UserSettings).projectedTotalAmount = 0; + } + + // Set default value for safe withdrawal rate + if (!(user.settings.settings as UserSettings)?.safeWithdrawalRate) { + (user.settings.settings as UserSettings).safeWithdrawalRate = 0.04; + } + + // Set default value for savings rate + if (!(user.settings.settings as UserSettings)?.savingsRate) { + (user.settings.settings as UserSettings).savingsRate = 0; + } + + // Set default value for view mode + if (!(user.settings.settings as UserSettings).viewMode) { + (user.settings.settings as UserSettings).viewMode = 'DEFAULT'; + } + + (user.settings.settings as UserSettings).xRayRules = { + AccountClusterRiskCurrentInvestment: + new AccountClusterRiskCurrentInvestment( + undefined, + undefined, + undefined, + {} + ).getSettings(user.settings.settings), + AccountClusterRiskSingleAccount: new AccountClusterRiskSingleAccount( + undefined, + undefined, + undefined, + {} + ).getSettings(user.settings.settings), + AssetClassClusterRiskEquity: new AssetClassClusterRiskEquity( + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + AssetClassClusterRiskFixedIncome: new AssetClassClusterRiskFixedIncome( + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + BuyingPower: new BuyingPower( + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + CurrencyClusterRiskBaseCurrencyCurrentInvestment: + new CurrencyClusterRiskBaseCurrencyCurrentInvestment( + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + CurrencyClusterRiskCurrentInvestment: + new CurrencyClusterRiskCurrentInvestment( + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + EconomicMarketClusterRiskDevelopedMarkets: + new EconomicMarketClusterRiskDevelopedMarkets( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + EconomicMarketClusterRiskEmergingMarkets: + new EconomicMarketClusterRiskEmergingMarkets( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + EmergencyFundSetup: new EmergencyFundSetup( + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + FeeRatioInitialInvestment: new FeeRatioInitialInvestment( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + FeeRatioTotalInvestmentVolume: new FeeRatioTotalInvestmentVolume( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + RegionalMarketClusterRiskAsiaPacific: + new RegionalMarketClusterRiskAsiaPacific( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + RegionalMarketClusterRiskEmergingMarkets: + new RegionalMarketClusterRiskEmergingMarkets( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + RegionalMarketClusterRiskEurope: new RegionalMarketClusterRiskEurope( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + RegionalMarketClusterRiskJapan: new RegionalMarketClusterRiskJapan( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings), + RegionalMarketClusterRiskNorthAmerica: + new RegionalMarketClusterRiskNorthAmerica( + undefined, + undefined, + undefined, + undefined, + undefined + ).getSettings(user.settings.settings) + }; + + let currentPermissions = getPermissions(user.role); + + if (user.provider === 'ANONYMOUS') { + currentPermissions.push(permissions.deleteOwnUser); + currentPermissions.push(permissions.updateOwnAccessToken); + } + + if (!(user.settings.settings as UserSettings).isExperimentalFeatures) { + // currentPermissions = without( + // currentPermissions, + // permissions.xyz + // ); + } + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + user.subscription = await this.subscriptionService.getSubscription({ + subscriptions, + createdAt: user.createdAt + }); + + if (user.subscription?.type === 'Basic') { + const daysSinceRegistration = differenceInDays( + new Date(), + user.createdAt + ); + let frequency = 7; + + if (activitiesCount > 1000 || daysSinceRegistration > 720) { + frequency = 1; + } else if (activitiesCount > 750 || daysSinceRegistration > 360) { + frequency = 2; + } else if (activitiesCount > 500 || daysSinceRegistration > 180) { + frequency = 3; + } else if (activitiesCount > 250 || daysSinceRegistration > 60) { + frequency = 4; + } else if (daysSinceRegistration > 30) { + frequency = 5; + } else if (daysSinceRegistration > 15) { + frequency = 6; + } + + if (analytics?.activityCount % frequency === 1) { + currentPermissions.push(permissions.enableSubscriptionInterstitial); + } + + currentPermissions = without( + currentPermissions, + permissions.accessHoldingsChart, + permissions.createAccess, + permissions.createMarketDataOfOwnAssetProfile, + permissions.createOwnTag, + permissions.createWatchlistItem, + permissions.readAiPrompt, + permissions.readMarketDataOfOwnAssetProfile, + permissions.updateMarketDataOfOwnAssetProfile + ); + + // Reset benchmark + user.settings.settings.benchmark = undefined; + + // Reset holdings view mode + user.settings.settings.holdingsViewMode = undefined; + } else if (user.subscription?.type === 'Premium') { + if (!hasRole(user, Role.DEMO)) { + currentPermissions.push(permissions.createApiKey); + currentPermissions.push(permissions.enableDataProviderGhostfolio); + currentPermissions.push(permissions.readMarketDataOfMarkets); + currentPermissions.push(permissions.reportDataGlitch); + } + + currentPermissions = without( + currentPermissions, + permissions.deleteOwnUser + ); + + // Reset offer + user.subscription.offer.coupon = undefined; + user.subscription.offer.couponId = undefined; + user.subscription.offer.durationExtension = undefined; + user.subscription.offer.label = undefined; + } + + if (hasRole(user, Role.ADMIN)) { + currentPermissions.push(permissions.syncDemoUserAccount); + } + } + + if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) { + if (hasRole(user, Role.ADMIN)) { + currentPermissions.push(permissions.toggleReadOnlyMode); + } + + const isReadOnlyMode = await this.propertyService.getByKey( + PROPERTY_IS_READ_ONLY_MODE + ); + + if (isReadOnlyMode) { + currentPermissions = currentPermissions.filter((permission) => { + return !( + permission.startsWith('create') || + permission.startsWith('delete') || + permission.startsWith('update') + ); + }); + } + } + + if (!environment.production && hasRole(user, Role.ADMIN)) { + currentPermissions.push(permissions.impersonateAllUsers); + } + + user.accounts = user.accounts.sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + + user.permissions = currentPermissions.sort(); + + return user; + } + + public async users(params: { + skip?: number; + take?: number; + cursor?: Prisma.UserWhereUniqueInput; + where?: Prisma.UserWhereInput; + orderBy?: Prisma.UserOrderByWithRelationInput; + }): Promise { + const { skip, take, cursor, where, orderBy } = params; + return this.prismaService.user.findMany({ + skip, + take, + cursor, + where, + orderBy + }); + } + + public async createUser( + { + data + }: { + data: Prisma.UserCreateInput; + } = { data: {} } + ): Promise { + if (!data.provider) { + data.provider = 'ANONYMOUS'; + } + + if (!data.role) { + const hasAdmin = await this.hasAdmin(); + + data.role = hasAdmin ? 'USER' : 'ADMIN'; + } + + const user = await this.prismaService.user.create({ + data: { + ...data, + accounts: { + create: { + currency: DEFAULT_CURRENCY, + name: this.i18nService.getTranslation({ + id: 'myAccount', + languageCode: DEFAULT_LANGUAGE_CODE // TODO + }) + } + }, + settings: { + create: { + settings: { + currency: DEFAULT_CURRENCY + } + } + } + } + }); + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + await this.prismaService.analytics.create({ + data: { + user: { connect: { id: user.id } } + } + }); + } + + if (data.provider === 'ANONYMOUS') { + const { accessToken, hashedAccessToken } = this.generateAccessToken({ + userId: user.id + }); + + await this.prismaService.user.update({ + data: { accessToken: hashedAccessToken }, + where: { id: user.id } + }); + + return { ...user, accessToken }; + } + + return user; + } + + public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise { + try { + await this.prismaService.access.deleteMany({ + where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] } + }); + } catch {} + + try { + await this.prismaService.account.deleteMany({ + where: { userId: where.id } + }); + } catch {} + + try { + await this.prismaService.analytics.delete({ + where: { userId: where.id } + }); + } catch {} + + try { + await this.orderService.deleteOrders({ + userId: where.id + }); + } catch {} + + try { + await this.prismaService.settings.delete({ + where: { userId: where.id } + }); + } catch {} + + return this.prismaService.user.delete({ + where + }); + } + + public async resetAnalytics() { + return this.prismaService.analytics.updateMany({ + data: { + dataProviderGhostfolioDailyRequests: 0 + }, + where: { + updatedAt: { + gte: subDays(new Date(), 1) + } + } + }); + } + + public async updateUser({ + data, + where + }: { + data: Prisma.UserUpdateInput; + where: Prisma.UserWhereUniqueInput; + }): Promise { + return this.prismaService.user.update({ + data, + where + }); + } + + public async updateUserSetting({ + emitPortfolioChangedEvent, + userId, + userSettings + }: { + emitPortfolioChangedEvent: boolean; + userId: string; + userSettings: UserSettings; + }) { + const { settings } = await this.prismaService.settings.upsert({ + create: { + settings: userSettings as unknown as Prisma.JsonObject, + user: { + connect: { + id: userId + } + } + }, + update: { + settings: userSettings as unknown as Prisma.JsonObject + }, + where: { + userId + } + }); + + if (emitPortfolioChangedEvent) { + this.eventEmitter.emit( + PortfolioChangedEvent.getName(), + new PortfolioChangedEvent({ + userId + }) + ); + } + + return settings; + } +} diff --git a/apps/api/src/assets/countries/asia-pacific-markets.json b/apps/api/src/assets/countries/asia-pacific-markets.json new file mode 100644 index 000000000..adbb0750e --- /dev/null +++ b/apps/api/src/assets/countries/asia-pacific-markets.json @@ -0,0 +1 @@ +["AU", "HK", "NZ", "SG"] diff --git a/apps/api/src/assets/countries/developed-markets.json b/apps/api/src/assets/countries/developed-markets.json new file mode 100644 index 000000000..5e281d475 --- /dev/null +++ b/apps/api/src/assets/countries/developed-markets.json @@ -0,0 +1,26 @@ +[ + "AT", + "AU", + "BE", + "CA", + "CH", + "DE", + "DK", + "ES", + "FI", + "FR", + "GB", + "HK", + "IE", + "IL", + "IT", + "JP", + "LU", + "NL", + "NO", + "NZ", + "PT", + "SE", + "SG", + "US" +] diff --git a/apps/api/src/assets/countries/emerging-markets.json b/apps/api/src/assets/countries/emerging-markets.json new file mode 100644 index 000000000..328187964 --- /dev/null +++ b/apps/api/src/assets/countries/emerging-markets.json @@ -0,0 +1,28 @@ +[ + "AE", + "BR", + "CL", + "CN", + "CO", + "CY", + "CZ", + "EG", + "GR", + "HK", + "HU", + "ID", + "IN", + "KR", + "KW", + "MX", + "MY", + "PE", + "PH", + "PL", + "QA", + "SA", + "TH", + "TR", + "TW", + "ZA" +] diff --git a/apps/api/src/assets/countries/europe-markets.json b/apps/api/src/assets/countries/europe-markets.json new file mode 100644 index 000000000..26eb2176c --- /dev/null +++ b/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" +] diff --git a/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json new file mode 100644 index 000000000..d00ded6ef --- /dev/null +++ b/apps/api/src/assets/cryptocurrencies/cryptocurrencies.json @@ -0,0 +1,19139 @@ +{ + "1": "just buy $1 worth of this coin", + "3": "The Three Musketeers", + "4": "4", + "7": "Lucky7", + "8": "8", + "21": "2131KOBUSHIDE", + "32": "Project 32", + "42": "Semantic Layer", + "47": "President Trump", + "67": "The Official 67 Coin", + "300": "300 token", + "365": "365Coin", + "369": "Nikola Tesla Token", + "404": "404Coin", + "433": "433 Token", + "611": "SixEleven", + "777": "Jackpot", + "808": "808", + "888": "888", + "1337": "EliteCoin", + "1717": "1717 Masonic Commemorative Token", + "2015": "2015 coin", + "2016": "2016 coin", + "2024": "2024", + "2025": "2025 TOKEN", + "2026": "2026", + "2049": "TOKEN 2049", + "2192": "LERNITAS", + "4444": "4444 Meme", + "50501": "50501movement", + "$MAID": "MaidCoin", + "$TREAM": "World Stream Finance", + "00": "ZER0ZER0", + "007": "007 coin", + "0DOG": "Bitcoin Dogs", + "0G": "0G", + "0KN": "0 Knowledge Network", + "0LNETWORK": "0L Network", + "0NE": "Stone", + "0X0": "0x0.ai", + "0X1": "0x1.tools: AI Multi-tool Plaform", + "0XBTC": "0xBitcoin", + "0XCOCO": "0xCoco", + "0XDEV": "DEVAI", + "0XG": "0xGpu.ai", + "0XGAS": "0xGasless", + "0XL": "0x Leverage", + "0XOS": "0xOS AI", + "0XSEARCH": "Search", + "0XVOX": "HashVox AI", + "0xDIARY": "The 0xDiary Token", + "0xVPN": "0xVPN.org", + "1-UP": "1-UP", + "1000SATS": "SATS", + "1000X": "1000x by Virtuals", + "101M": "101M", + "10SET": "Tenset", + "1ART": "ArtWallet", + "1CAT": "Bitcoin Cats", + "1COIN": "1 coin can change your life", + "1CR": "1Credit", + "1EARTH": "EarthFund", + "1ECO": "1eco", + "1EX": "1ex Trading Board", + "1FLR": "Flare Token", + "1GOLD": "1irstGold", + "1GUY": "1GUY", + "1HUB": "1HubAI", + "1INCH": "1inch", + "1IQ": "People with 1 IQ", + "1IRST": "1irstcoin", + "1MCT": "MicroCreditToken", + "1MDC": "1MDC", + "1MIL": "1MillionNFTs", + "1MT": "1Move", + "1NFT": "1NFT", + "1ON8": "Little Dragon", + "1OZT": "Tala", + "1PECO": "1peco", + "1PIECE": "OnePiece", + "1R0R": "R0AR TOKEN", + "1SG": "1SG", + "1SOL": "1Sol", + "1ST": "FirstBlood", + "1TRC": "1TRONIC", + "1UP": "Uptrennd", + "1WO": "1World", + "2022M": "2022MOON", + "2026MEMECLUB": "2026", + "20EX": "20ex", + "21BTC": "21.co Wrapped BTC", + "21X": "21X", + "2BACCO": "2BACCO Coin", + "2BASED": "2Based Finance", + "2CRZ": "2crazyNFT", + "2DAI": "2DAI.io", + "2GCC": "2G Carbon Coin", + "2GIVE": "2GiveCoin", + "2GT": "2GETHER", + "2KEY": "2key.network", + "2LC": "2local", + "2MOON": "The Moon Metaverse", + "2OMB": "2omb Finance", + "2SHARES": "2SHARE", + "2TF": "2TF", + "2Z": "DoubleZero", + "300F": "300FIT", + "314DAO": "Tonken 314 DAO", + "32BIT": "32Bitcoin", + "360NS": "360 NOSCOPE INSTASWAP WALLBANG", + "37C": "37Protocol", + "3AC": "THREE ARROWZ CAPITEL", + "3AIR": "3air", + "3CEO": "FLOKI SHIBA PEPE CEO", + "3CRV": "LP 3pool Curve", + "3D3D": "3d3d", + "3DES": "3DES", + "3DVANCE": "3D Vance", + "3FT": "ThreeFold Token", + "3KDS": "3KDS", + "3KM": "3 Kingdoms Multiverse", + "3P": "Web3Camp", + "3RDEYE": "3rd Eye", + "3ULL": "3ULL Coin", + "3ULLV1": "Playa3ull Games v1", + "3XD": "3DChain", + "401JK": "401jk", + "404A": "404Aliens", + "404BLOCKS": "404Blocks", + "420CHAN": "420chan", + "42COIN": "42 Coin", + "4ART": "4ART Coin", + "4CHAN": "4Chan", + "4CZ": "FourCZ", + "4DOGE": "4DOGE", + "4EVER": "4EVERLAND", + "4JNET": "4JNET", + "4MW": "For Meta World", + "4RZ": "4REALZA COIN", + "4THPILLAR": "4th Pillar Four Token", + "4TOKEN": "Ignore Fud", + "4WIN": "4TRUMP", + "4WMM": "4-Way Mirror Money", + "50C": "50Cent", + "50TRUMP": "50TRUMP", + "50X": "50x.com", + "5IRE": "5ire", + "5PT": "Five Pillars Token", + "69MINUTES": "69 Minutes", + "77G": "GraphenTech", + "7E": "7ELEVEN", + "88MPH": "88mph", + "8BIT": "8BIT Coin", + "8BITCOIN": "8-Bit COIN", + "8BT": "8 Circuit Studios", + "8LNDS": "8Lends", + "8PAY": "8Pay", + "8X8": "8X8 Protocol", + "99BTC": "99 Bitcoins", + "9BIT": "The9bit", + "9DOGS": "NINE DOGS", + "9GAG": "9GAG", + "9MM": "Shigure UI", + "A": "Vaulta", + "A1INCH": "1inch (Arbitrum Bridge)", + "A2A": "A2A", + "A2I": "Arcana AI", + "A2Z": "Arena-Z", + "A4": "A4 Finance", + "A47": "AGENDA 47", + "A4M": "AlienForm", + "A51": "A51 Finance", + "A5T": "Alpha5", + "A7A5": "A7A5", + "A8": "Ancient8", + "AA": "ARAI Token", + "AAA": "Moon Rabbit", + "AAAHHM": "Plankton in Pain", + "AAAI": "AAAI_agent by Virtuals", + "AAB": "AAX Token", + "AABL": "Abble", + "AAC": "Double-A Chain", + "AAG": "AAG Ventures", + "AAI": "AutoAir AI", + "AALON": "American Airlines Group (Ondo Tokenized)", + "AAPLON": "Apple (Ondo Tokenized)", + "AAPLX": "Apple xStock", + "AAPX": "AMPnet", + "AARBWBTC": "Aave Arbitrum WBTC", + "AARDY": "Baby Aardvark", + "AARK": "Aark", + "AART": "ALL.ART", + "AAST": "AASToken", + "AAT": "Agricultural Trade Chain", + "AAVAWBTC": "Aave aWBTC", + "AAVE": "Aave", + "AAVEE": "AAVE.e (Avalanche Bride)", + "AAVEGOTCHIFOMO": "Aavegotchi FOMO", + "AAX": "Academic Labs", + "AAZ": "ATLAZ", + "AB": "Newton", + "AB1INCH": "1inch (Avalanche Bride)", + "ABA": "EcoBall", + "ABBC": "ABBC Coin", + "ABBVX": "AbbVie xStock", + "ABC": "ABC Chain", + "ABCC": "ABCC Token", + "ABCD": "Crypto Inu", + "ABCM": "ABCMETA", + "ABCRV": "Curve DAO Token (Arbitrum Bridge)", + "ABD": "AB DEFI", + "ABDS": "ABDS Token", + "ABE": "ABE", + "ABEL": "Abelian", + "ABET": "Altbet", + "ABEY": "Abey", + "ABIC": "Arabic", + "ABJ": "Abjcoin", + "ABL": "Airbloc", + "ABLE": "Able Finance", + "ABLINK": "Chainlink (Arbitrum Bridge)", + "ABN": "Antofy", + "ABO": "Albino", + "ABOND": "ApeBond", + "ABONDV1": "ApeSwap", + "ABR": "Allbridge", + "ABSIMPSON": "abstract simpson", + "ABSTER": "Abster", + "ABT": "ArcBlock", + "ABTC": "aBTC", + "ABTX": "Abbott xStock", + "ABUL": "Abulaba", + "ABUNI": "Uniswap Protocol Token (Arbitrum Bridge)", + "ABUSDC": "USD Coin (Arbitrum Bridge)", + "ABX": "Arbidex", + "ABY": "ArtByte", + "ABYS": "Trinity Of The Fabled", + "ABYSS": "Abyss Finance", + "AC": "Asia Coin", + "AC3": "AC3", + "ACA": "Acala", + "ACALAUSD": "Acala Dollar (Acala)", + "ACAT": "Alphacat", + "ACATO": "ACA Token", + "ACCEL": "Accel Defi", + "ACCES": "Metacces", + "ACCN": "Accelerator Network", + "ACD": "Alliance Cargo Direct", + "ACDC": "Volt", + "ACE": "Fusionist", + "ACEENTERTAIN": "ACE Entertainment Token", + "ACEN": "Acent", + "ACEO": "Ace of Pentacles", + "ACES": "AcesCoin", + "ACET": "Acet", + "ACETH": "Acether", + "ACH": "Alchemy Pay", + "ACHAIN": "Achain", + "ACHC": "AchieveCoin", + "ACHI": "achi", + "ACID": "AcidCoin", + "ACK": "Arcade Kingdoms", + "ACL": "Auction Light", + "ACM": "AC Milan Fan Token", + "ACN": "AvonCoin", + "ACNX": "Accenture xStock", + "ACOIN": "ACoin", + "ACOLYT": "Acolyte by Virtuals", + "ACP": "Arena Of Faith", + "ACPT": "Crypto Accept", + "ACQ": "Acquire.Fi", + "ACRE": "Arable Protocol", + "ACRED": "Apollo Diversified Credit Securitize Fund", + "ACRIA": "Acria.AI", + "ACS": "Access Protocol", + "ACSI": "ACryptoSI", + "ACT": "Act I The AI Prophecy", + "ACTA": "Acta Finance", + "ACTIN": "Actinium", + "ACTN": "Action Coin", + "ACU": "ACU Platform", + "ACX": "Across Protocol", + "ACXT": "ACDX Exchange Token", + "ACYC": "All Coins Yield Capital", + "AD": "ADreward", + "ADA": "Cardano", + "ADAB": "Adab Solutions", + "ADACASH": "ADACash", + "ADAI": "Aave Interest bearing DAI", + "ADAIV1": "Aave DAI", + "ADAM": "Adam Back", + "ADANA": "Adanaspor Fan Token", + "ADAO": "ADADao", + "ADAPAD": "ADAPad", + "ADASOL": "ADA", + "ADASTRA": "Ad Astra", + "ADAT": "Adadex Tools", + "ADAX": "ADAX", + "ADB": "Adbank", + "ADC": "AudioCoin", + "ADCO": "Advertise Coin", + "ADD": "ADD.xyz", + "ADDAMS": "ADDAMS AI", + "ADDY": "Adamant", + "ADE": "AADex Finance", + "ADEL": "Akropolis Delphi", + "ADF": "Art de Finance", + "ADH": "Adhive", + "ADI": "ADI", + "ADITUS": "Aditus", + "ADIX": "Adix Token", + "ADK": "Aidos Kuneen", + "ADL": "Adel", + "ADM": "ADAMANT Messenger", + "ADN": "Aladdin", + "ADNT": "Aiden", + "ADO": "ADO Protocol", + "ADOG": "America Dog", + "ADOGE": "Arbidoge", + "ADON": "Adonis", + "ADP": "Adappter Token", + "ADR": "Adroverse", + "ADRI": "AdRise", + "ADRX": "Adrenaline Chain", + "ADS": "Adshares", + "ADT": "AdToken", + "ADUX": "Adult X Token", + "ADVT": "Advantis", + "ADX": "Ambire AdEx", + "ADXX": "AnonyDoxx", + "ADZ": "Adzcoin", + "AE": "Aeternity", + "AEC": "AcesCoin", + "AEG": "Aether Games", + "AEGGS": "aEGGS", + "AEGIS": "Aegis", + "AEGS": "Aegisum", + "AELIN": "Aelin", + "AEN": "Aenco", + "AENS": "AEN Smart", + "AENT": "AEN", + "AEON": "AEON", + "AER": "Aeryus", + "AERGO": "AERGO", + "AERGOV1": "Aergo v1", + "AERM": "Aerium", + "AERO": "Aerodrome Finance", + "AEROBUD": "Aerobud", + "AEROCOIN": "Aero Coin", + "AEROME": "AeroMe", + "AEROT": "AEROTYME", + "AES": "Artis Aes Evolution", + "AET": "AfterEther", + "AETH": "Aave ETH", + "AETHC": "Ankr Reward-Bearing Staked ETH", + "AETHERV2": "AetherV2", + "AETHRA": "Aethra AI", + "AETHUSDT": "Aave Ethereum USDT", + "AETHWETH": "Aave Ethereum WETH", + "AEUR": "Anchored Coins AEUR", + "AEVO": "Aevo", + "AEVUM": "Aevum", + "AFB": "A Fund Baby", + "AFC": "Arsenal Fan Token", + "AFCT": "Allforcrypto", + "AFEN": "AFEN Blockchain", + "AFFC": "Affil Coin", + "AFG": "Army of Fortune Gem", + "AFIN": "Asian Fintech", + "AFIRE": "AI Fire", + "AFIT": "Actifit", + "AFK": "AFKDAO", + "AFNTY": "Affinity", + "AFO": "AllForOneBusiness", + "AFP": "Animal Farm Pigs", + "AFR": "Afreum", + "AFRO": "Afrostar", + "AFROX": "AfroDex", + "AFSUI": "Aftermath Staked SUI", + "AFT": "AIFlow Token", + "AFTT": "Africa Trading Chain", + "AFX": "Afrix", + "AFYON": "Afyonspor Fan Token", + "AG": "AGAME", + "AG8": "ATROMG8", + "AGA": "Agora DEX Token", + "AGATA": "Agatech", + "AGATOKEN": "AGA Token", + "AGB": "Apes Go Bananas", + "AGC": "Argocoin", + "AGEN": "Agent Krasnov", + "AGENT": "AgentLayer", + "AGENTFUN": "AgentFun.AI", + "AGET": "Agetron", + "AGETH": "Kelp Gain", + "AGF": "Augmented Finance", + "AGG": "AGG", + "AGI": "Delysium", + "AGIALPHA": "AGI ALPHA AGENT", + "AGIFTTOKEN": "aGifttoken", + "AGII": "AGII", + "AGIL": "Agility LSD", + "AGIV1": "SingularityNET v1", + "AGIX": "SingularityNET", + "AGIXBT": "AGIXBT by Virtuals", + "AGIXT": "AGiXT", + "AGLA": "Angola", + "AGLD": "Adventure Gold", + "AGM": "Argoneum", + "AGN": "Agnus Ai", + "AGNT": "iAgent Protocol", + "AGO": "AgoDefi", + "AGON": "AGON Agent", + "AGORK": "@gork", + "AGOV": "Answer Governance", + "AGPC": "AGPC", + "AGRI": "AgriDex Token", + "AGRO": "Bit Agro", + "AGRS": "Agoras Token", + "AGS": "Aegis", + "AGT": "Alaya Governance Token", + "AGURI": "Aguri-Chan", + "AGUSTO": "Agusto", + "AGV": "Astra Guild Ventures", + "AGVC": "AgaveCoin", + "AGVE": "Agave", + "AGX": "Agricoin", + "AHARWBTC": "Aave Harmony WBTC", + "AHOO": "Ahoolee", + "AHT": "AhaToken", + "AI": "Sleepless", + "AI16Z": "ElizaOS", + "AI21X": "ai21x", + "AI23T": "23 Turtles", + "AI3": "Autonomys Network", + "AI4": "AI⁴", + "AI69SAKURA": "Sakura", + "AIA": "DeAgentAI", + "AIACHAIN": "AIA Chain", + "AIAF": "AI Agent Factory", + "AIAGENT": "AI Agents", + "AIAGENTAPP": "Aiagent.app", + "AIAI": "All In AI", + "AIAKITA": "AiAkita", + "AIAT": "AI Analysis Token", + "AIAV": "AI AVatar", + "AIB": "AdvancedInternetBlock", + "AIBABYDOGE": "AIBabyDoge", + "AIBB": "AiBB", + "AIBCOIN": "AIBLOCK", + "AIBK": "AIB Utility Token", + "AIBOT": "CherryAI", + "AIBU": "AIBUZZ TOKEN", + "AIC": "AI Companions", + "AICE": "Aicean", + "AICELL": "AICell", + "AICH": "AIChain", + "AICO": "AICON", + "AICODE": "AI CODE", + "AICORE": "AICORE", + "AICRYPTO": "AI Crypto", + "AICRYPTOKEN": "AI Crypto Token", + "AID": "AidCoin", + "AIDA": "Ai-Da robot", + "AIDI": "Aidi Inu", + "AIDOC": "AI Doctor", + "AIDOG": "AiDoge", + "AIDOGE": "ArbDoge AI", + "AIDOGEX": "AI DogeX", + "AIDOGEXLM": "AIDOGE Stellar", + "AIDT": "AIDUS TOKEN", + "AIDUS": "AIDUS Token", + "AIE": "A.I.Earn", + "AIEN": "AIENGLISH", + "AIEPK": "EpiK Protocol", + "AIF": "AI FREEDOM TOKEN", + "AIFI": "AIFinance Token", + "AIFLOKI": "AI Floki", + "AIFUN": "AI Agent Layer", + "AIG": "A.I Genesis", + "AIGA": "Aigang", + "AIGPU": "AIGPU Token", + "AIH": "AIHub", + "AII": "Artificial Idiot", + "AIINU": "AI INU", + "AIKEK": "AlphaKEK.AI", + "AILAYER": "AILayer", + "AILINK": "AiLink Token", + "AIM": "ModiHost", + "AIMAGA": "Presidentexe", + "AIMARKET": "Acria.AI AIMARKET", + "AIMBOT": "AimBot AI", + "AIMEE": "AIMEE", + "AIMET": "AI Metaverse", + "AIMONICA": "Aimonica Brands", + "AIMR": "MeromAI", + "AIMS": "HighCastle Token", + "AIMX": "MindMatrix", + "AIMXV1": "Aimedis v1", + "AIMXV2": "Aimedis", + "AIN": "Infinity Ground", + "AINA": "Ainastasia", + "AINET": "AI Network", + "AINFT": "EthernaFi", + "AINN": "AINN", + "AINTI": "AIntivirus", + "AINU": "Ainu Token", + "AIO": "AIO", + "AION": "Aion", + "AIONE": "AiONE", + "AIONIX": "Aionix, the Hub of AI", + "AIOS": "INT OS", + "AIOSHI": "AiOShi Apple Companion", + "AIOT": "OKZOO", + "AIOZ": "AIOZ Network", + "AIP": "AI Protocol", + "AIPAD": "AIPAD", + "AIPE": "AI Prediction Ecosystem", + "AIPEPE": "AI PEPE KING", + "AIPF": "AI Powered Finance", + "AIPG": "AI Power Grid", + "AIPIN": "AI PIN", + "AIPO": "Aipocalypto", + "AIPUMP": "aiPump", + "AIR": "Altair", + "AIRB": "BillionAir", + "AIRBTC": "AIRBTC", + "AIRDROP": "AIRDROP2049", + "AIRE": "Tokenaire", + "AIRENA": "AI AGENT ARENA", + "AIRENE": "AIRENE by Virtuals", + "AIREVOLUTION": "AI Revolution Coin", + "AIRFRY": "airfryer coin", + "AIRI": "aiRight", + "AIRIAN": "AIRian", + "AIRT": "Aircraft", + "AIRTNT": "Tenti", + "AIRTOKEN": "AirToken", + "AIRX": "Aircoins", + "AIS": "AISociety", + "AISCII": "AISCII", + "AISHIB": "ARBSHIB", + "AIST": "Artificial intelligence staking token", + "AISW": "AISwap", + "AIT": "AIT Protocol", + "AITECH": "Artificial Intelligence Utility Token", + "AITEK": "AI Technology", + "AITHEON": "Aitheon", + "AITHER": "Aither Protocol", + "AITIGER": "BNB Tiger AI", + "AITK": "AITK", + "AITN": "Artificial Intelligence Technology Network", + "AITRA": "Aitra", + "AITRUMP": "AITRUMP", + "AITT": "AITrading", + "AITV": "AITV", + "AIUS": "Arbius", + "AIV": "AIVille Governance Token", + "AIVA": "AI Voice Agents", + "AIVERONICA": "AIVeronica", + "AIVIA": "AI Virtual Agents", + "AIVV1": "AIVille Governance Token", + "AIWALLET": "AiWallet Token", + "AIWS": "AIWS", + "AIX": "Ai Xovia", + "AIX9": "AthenaX9", + "AIXBT": "aixbt by Virtuals", + "AIXCB": "aixCB by Virtuals", + "AIXERC": "AI-X", + "AIXT": "AIXTerminal", + "AJC": "AI Judge Companion", + "AJNA": "Ajna Protocol", + "AJUN": "Ajuna Network", + "AK12": "AK12", + "AKA": "Akroma", + "AKAL": "AKA Liberty", + "AKASHA": "Akasha by Bloomverse", + "AKE": "AKE", + "AKI": "Aki Network", + "AKIO": "Akio", + "AKIT": "Akita Inu", + "AKITA": "Akita Inu", + "AKITAI": "AKITA INU", + "AKITAX": "Akitavax", + "AKN": "Akoin", + "AKNC": "Aave KNC v1", + "AKOBI": "AKOBI", + "AKREP": "Antalyaspor Token", + "AKRO": "Akropolis", + "AKT": "Akash Network", + "AKTIO": "AKTIO Coin", + "AKUMA": "Akuma Inu", + "AKV": "Akiverse Governance", + "AL": "ArchLoot", + "ALA": "Alanyaspor Fan Token", + "ALAN": "Alan the Alien", + "ALASKA": "Alaska", + "ALATOKEN": "ALA", + "ALB": "Alien Base", + "ALBART": "Albärt", + "ALBE": "ALBETROS", + "ALBEDO": "ALBEDO", + "ALBT": "AllianceBlock", + "ALC": "Arab League Coin", + "ALCAZAR": "Alcazar", + "ALCE": "Alcedo", + "ALCH": "Alchemist AI", + "ALCHE": "Alchemist", + "ALCHEMYV1": "Alchemy v1", + "ALCHEMYV2": "Alchemy", + "ALCX": "Alchemix", + "ALD": "AladdinDAO", + "ALDIN": "Alaaddin.ai", + "ALE": "Ailey", + "ALEO": "ALEO", + "ALEPH": "Aleph.im", + "ALEX": "ALEX Lab", + "ALEXANDRITE": "Alexandrite", + "ALEXIUS": "Alexius Maximus", + "ALF": "AlphaCoin", + "ALG": "Algory", + "ALGB": "Algebra", + "ALGERIA": "Algeria", + "ALGO": "Algorand", + "ALGOBLK": "AlgoBlocks", + "ALGOW": "Algowave", + "ALH": "AlloHash", + "ALI": "Alethea Artificial Liquid Intelligence Token", + "ALIAS": "Alias", + "ALIBABAAI": "Alibaba AI Agent", + "ALIC": "AliCoin", + "ALICE": "My Neighbor Alice", + "ALICEA": "Alice AI", + "ALICEW": "Alice Weidel", + "ALIEN": "AlienCoin", + "ALIENPEP": "Alien Pepe", + "ALIENS": "Aliens", + "ALIENX": "ALIENX", + "ALIF": " ALIF COIN", + "ALINK": "Aave LINK v1", + "ALIS": "ALISmedia", + "ALIT": "Alitas", + "ALITA": "Alita Network", + "ALITATOKEN": "Alita Token", + "ALIX": "AlinX", + "ALK": "Alkemi Network DAO Token", + "ALKI": "Alkimi", + "ALKIMI": "ALKIMI", + "ALLBI": "ALL BEST ICO", + "ALLC": "All Crypto Mechanics", + "ALLEY": "NFT Alley", + "ALLIN": "All in", + "ALLMEE": "All.me", + "ALLO": "Allora", + "ALM": "Alium Finance", + "ALMAN": "Alman", + "ALMANAK": "Almanak", + "ALMC": "Awkward Look Monkey Club", + "ALME": "Alita", + "ALMEELA": "Almeela", + "ALMOND": "Almond", + "ALN": "Aluna", + "ALNV1": "Aluna v1", + "ALOHA": "Aloha", + "ALOKA": "ALOKA", + "ALON": "Alon", + "ALOR": "The Algorix", + "ALOT": "Dexalot", + "ALP": "Alphacon", + "ALPA": "Alpaca", + "ALPACA": "Alpaca Finance", + "ALPACAS": "Bitcoin Mascot", + "ALPH": "Alephium", + "ALPHA": "Alpha Finance Lab", + "ALPHAAI": "Alpha AI", + "ALPHABET": "Alphabet", + "ALPHAC": "Alpha Coin", + "ALPHAF": "Alpha Fi", + "ALPHAG": "Alpha Gardeners", + "ALPHAPETTO": "Alpha Petto Shells", + "ALPHAPLATFORM": "Alpha Token", + "ALPHAS": "Alpha Shards", + "ALPHR": "Alphr", + "ALPINE": "Alpine F1 Team Fan Token", + "ALPRO": "Assets Alphabet", + "ALPS": "Alpenschillling", + "ALT": "Altlayer", + "ALTA": "Alta Finance", + "ALTB": "Altbase", + "ALTCOIN": "ALTcoin", + "ALTCOM": "AltCommunity Coin", + "ALTD": "Altitude", + "ALTMAN": "SAM", + "ALTOCAR": "AltoCar", + "ALTR": "Altranium", + "ALTT": "Altcoinist", + "ALU": "Altura", + "ALUSD": "Alchemix USD", + "ALUX": "Alux Bank", + "ALV": "Allive", + "ALV1": "ArchLoot v1", + "ALVA": "Alvara Protocol", + "ALVACOIN": "Alva", + "ALWAYS": "Always Evolving", + "ALX": "ALAX", + "ALY": "Ally", + "AM": "Aston Martin Cognizant", + "AMA": "MrWeb", + "AMADEUS": "AMADEUS", + "AMAL": "AMAL", + "AMAPT": "Amnis Finance", + "AMARA": "AMARA", + "AMATEN": "Amaten", + "AMATO": "AMATO", + "AMAZINGTEAM": "AmazingTeamDAO", + "AMB": "AirDAO", + "AMBER": "AmberCoin", + "AMBO": "Sheertopia", + "AMBR": "Ambra", + "AMBRX": "Amber xStock", + "AMBT": "AMBT Token", + "AMC": "AI Meta Coin", + "AMCON": "AMC Entertainment (Ondo Tokenized)", + "AMDC": "Allmedi Coin", + "AMDG": "AMDG", + "AMDX": "AMD xStock", + "AME": "Amepay", + "AMEP": "America Party", + "AMER": "America", + "AMERI": "AMERICAN EAGLE", + "AMERIC": "American True Hero", + "AMERICA": "America", + "AMERICAI": "AMERICA AI Agent", + "AMERICANCOIN": "AmericanCoin", + "AMETA": "Alpha City", + "AMF": "AddMeFast", + "AMG": "DeHeroGame Amazing Token", + "AMI": "AMMYI Coin", + "AMIO": "Amino Network", + "AMIS": "AMIS", + "AMKT": "Alongside Crypto Market Index", + "AMLT": "AMLT", + "AMM": "MicroMoney", + "AMMO": "Ammo Rewards", + "AMN": "Amon", + "AMO": "AMO Coin", + "AMOGUS": "Sussy Baka Impostor", + "AMON": "AmonD", + "AMORE": "Amocucinare", + "AMOS": "Amos", + "AMP": "Amp", + "AMPL": "Ampleforth", + "AMPLIFI": "AmpliFi", + "AMR": "Amero", + "AMS": "Amsterdam Coin", + "AMT": "Acumen", + "AMU": "Amulet", + "AMV": "Avatar Musk Verse", + "AMX": "Amero", + "AMY": "Amygws", + "AMZE": "The Amaze World", + "AMZNX": "Amazon xStocks", + "ANA": "Nirvana ANA", + "ANAL": "AnalCoin", + "ANALOS": "analoS", + "ANALY": "Analysoor", + "ANARCHISTS": "Anarchists Prime", + "ANAT": "Anatolia Token", + "ANB": "Angryb", + "ANC": "Anchor Protocol", + "ANCHOR": "AnchorSwap", + "ANCIENTKING": "Ancient Kingdom", + "ANCP": "Anacrypt", + "ANCT": "Anchor", + "AND": "AndromedaCoin", + "ANDC": "Android chain", + "ANDR": "Andromeda", + "ANDROTTWEILER": "Androttweiler Token", + "ANDWU": "Chinese Andy", + "ANDX": "Arrano", + "ANDY": "ANDY", + "ANDYB": "AndyBlast", + "ANDYBNB": "Andy", + "ANDYBSC": "Andy BSC", + "ANDYBSCVIP": "ANDY", + "ANDYMAN": "ANDYMAN", + "ANDYSOL": "Andy on SOL", + "ANEX": "AstroNexus", + "ANGEL": "Crypto Angel", + "ANGL": "Angel Token", + "ANGLE": "ANGLE", + "ANGO": "Aureus Nummus Gold", + "ANGRYSLERF": "ANGRYSLERF", + "ANI": "Anime Token", + "ANIM": "Animalia", + "ANIMA": "Realm Anima", + "ANIME": "Animecoin", + "ANIMECOIN": "Animecoin", + "ANIMEONBASE": "Anime", + "ANITA": "Anita AI", + "ANJ": "Aragon Court", + "ANJI": "Anji", + "ANK": "AlphaLink", + "ANKA": "Ankaragücü Fan Token", + "ANKORUS": "Ankorus Token", + "ANKR": "Ankr Network", + "ANKRBNB": "Ankr Staked BNB", + "ANKRETH": "Ankr Staked ETH", + "ANKRFTM": "Ankr Staked FTM", + "ANKRMATIC": "Ankr Staked MATIC", + "ANLOG": "Analog", + "ANML": "Animal Concerts", + "ANN": "Annex Finance", + "ANNE": "ANNE", + "ANOA": "ANOA", + "ANOME": "Anome", + "ANON": "HeyAnon", + "ANONCOIN": "Anoncoin", + "ANONCRYPTO": "ANON", + "ANRX": "AnRKey X", + "ANS": "Apollo Name Service", + "ANSCRYPTO": "ANS Crypto Coin", + "ANSOM": "Ansom", + "ANSR": "Answerly", + "ANT": "Aragon", + "ANTC": "AntiLitecoin", + "ANTE": "ANTE", + "ANTEX": "Antex", + "ANTI": "Anti Bitcoin", + "ANTIS": "Antis Inu", + "ANTMONS": "Antmons", + "ANTS": "ANTS Reloaded", + "ANTT": "Antara Token", + "ANTV1": "Aragon v1", + "ANUBHAV": "Anubhav Trainings", + "ANUS": "URANUS", + "ANV": "Aniverse", + "ANVL": "Anvil", + "ANVLV1": "Anvil v1", + "ANW": "Anchor Neural World", + "ANY": "Anyswap", + "ANYONE": "ANyONe Protocol", + "ANZENUSD": "Anzen Finance", + "AO": "AO", + "AOC": "Alickshundra Occasional-Cortex", + "AOE": "Agentic Open Economy", + "AOG": "AgeOfGods", + "AOK": "AOK", + "AOL": "AOL (America Online)", + "AOP": "Ark Of Panda", + "AOPTWBTC": "Aave Optimism WBTC", + "AOS": "AOS", + "AOT": "Age of Tanks", + "AP": "America Party", + "AP3X": "Apex token", + "APAD": "Anypad", + "APC": "AlpaCoin", + "APCG": "ALLPAYCOIN", + "APD": "APD", + "APE": "ApeCoin", + "APED": "Aped", + "APEDEV": "The dev is an Ape", + "APEFUN": "Ape", + "APEMAN": "APEMAN", + "APEPE": "Ape and Pepe", + "APES": "APES", + "APETARDIO": "Apetardio", + "APEWIFHAT": "ApeWifHat", + "APEX": "ApeX Protocol", + "APEXA": "Apex AI", + "APEXCOIN": "ApexCoin", + "APEXT": "ApexToken", + "APFC": "APF coin", + "APH": "Aphelion", + "API": "Application Programming Interface", + "API3": "API3", + "APING": "aping", + "APIS": "APIS", + "APIX": "APIX", + "APL": "Apollo Currency", + "APM": "apM Coin", + "APN": "Apron", + "APO": "Apollo Caps ETF", + "APOD": "AirPod", + "APOL": "Apollo FTW", + "APOLL": "Apollon Limassol", + "APOLLO": "Apollo Crypto", + "APOLWBTC": "Aave Polygon WBTC", + "APP": "Moon App", + "APPA": "Dappad", + "APPC": "AppCoins", + "APPEALUSD": "Appeal dollar", + "APPLE": "AppleSwap", + "APPLESWAPAI": "AppleSwap AI", + "APPX": "AppLovin xStock", + "APR": "aPriori", + "APRCOIN": "APR Coin", + "APRICOT": "Apricot Finance", + "APRIL": "April", + "APRS": "Aperios", + "APS": "APRES", + "APT": "Aptos", + "APTCOIN": "Aptcoin", + "APTESG": "AppleTree Token", + "APTM": "Apertum", + "APTOGE": "Aptoge", + "APTOPAD": "Aptopad", + "APTR": "Aperture Finance", + "APU": "Apu Apustaja", + "APUAPU": "APU", + "APUGURL": "APU GURL", + "APW": "APWine", + "APX": "ApolloX", + "APXP": "APEX Protocol", + "APXT": "ApolloX", + "APXVENTURES": "Apx", + "APY": "APY.Finance", + "APYS": "APYSwap", + "APZ": "Alprockz", + "AQA": " AQA Token", + "AQDC": "AQDC", + "AQT": "Alpha Quark Token", + "AQTIS": "AQTIS", + "AQU": "aQuest", + "AQUA": "Aquarius", + "AQUAC": "Aquachain", + "AQUACITY": "Aquacity", + "AQUAGOAT": "Aqua Goat", + "AQUAGOATV1": "Aqua Goat v1", + "AQUAP": "Planet Finance", + "AQUARI": "Aquari", + "AR": "Arweave", + "ARA": "Ara Token", + "ARABCLUB": "The Arab Club Token", + "ARABIANDRAGON": "Arabian Dragon", + "ARACOIN": "Ara", + "ARARA": "Araracoin", + "ARATA": "Arata", + "ARAW": "Araw", + "ARB": "Arbitrum", + "ARBI": "Arbipad", + "ARBINU": "ArbInu", + "ARBIT": "Arbit Coin", + "ARBITROVE": "Arbitrove Governance Token", + "ARBP": "ARB Protocol", + "ARBS": "Arbswap", + "ARBT": "ARBITRAGE", + "ARBUZ": "ARBUZ", + "ARC": "AI Rig Complex", + "ARCA": "Legend of Arcadia", + "ARCAD": "Arcadeum", + "ARCADE": "ARCADE", + "ARCADECITY": "Arcade City", + "ARCADEF": "arcadefi", + "ARCADEN": "ArcadeNetwork", + "ARCAI": "ARCAI", + "ARCANE": "Arcane Token", + "ARCAS": "Arcas", + "ARCH": "Archway", + "ARCHA": "ArchAngel Token", + "ARCHAI": "ArchAI", + "ARCHCOIN": "ArchCoin", + "ARCHE": "Archean", + "ARCHETHIC": "Archethic Universal Coin", + "ARCHIVE": "Chainback", + "ARCINTEL": "Arc", + "ARCO": "AquariusCoin", + "ARCONA": "Arcona", + "ARCOS": "ArcadiaOS", + "ARCT": "ArbitrageCT", + "ARCTICCOIN": "ArcticCoin", + "ARCX": "ARC Governance", + "ARDR": "Ardor", + "ARDX": "ArdCoin", + "ARE": "Aurei", + "AREA": "Areon Network", + "AREN": "Arenon", + "ARENA": "Alpha Arena", + "ARENAT": "ArenaToken", + "AREPA": "Arepacoin", + "ARES": "ARES", + "ARESP": "Ares Protocol", + "ARG": "Argentine Football Association Fan Token", + "ARGENTUM": "Argentum", + "ARGO": "ArGoApp", + "ARGON": "Argon", + "ARGUS": "ArgusCoin", + "ARI": "AriCoin", + "ARI10": "Ari10", + "ARIA": "ARIA.AI", + "ARIA20": "Arianee", + "ARIAIP": "Aria", + "ARIO": "AR.IO Network", + "ARIT": "ArithFi", + "ARIX": "Arix", + "ARK": "ARK", + "ARKDEFAI": "ARK", + "ARKEN": "Arken Finance", + "ARKER": "Arker", + "ARKI": "ArkiTech", + "ARKM": "Arkham", + "ARKN": "Ark Rivals", + "ARKY": "Arky", + "ARKYS": "Arky Satoshi's Dog", + "ARM": "Armory Coin", + "ARMA": "Aarma", + "ARMOR": "ARMOR", + "ARMR": "ARMR", + "ARMS": "2Acoin", + "ARMY": "Army of Fortune Coin", + "ARNA": "ARNA Panacea", + "ARNC": "Arnoya classic", + "ARNM": "Arenum", + "ARNO": "ARNO", + "ARNOLD": "ARNOLD", + "ARNX": "Aeron", + "ARNXM": "Armor NXM", + "ARO": "Arionum", + "ARON": "Astronaut Aron", + "AROR": "Arora", + "AROS": "Aros", + "AROX": "OFFICIAL AROX", + "ARPA": "ARPA Chain", + "ARPAC": "ArpaCoin", + "ARQ": "ArQmA", + "ARQX": "ARQx AI", + "ARR": "ARROUND", + "ARRI": "Arris", + "ARRO": "Arro Social", + "ARROW": "Arrow Token", + "ARRR": "Pirate Chain", + "ARSL": "Aquarius Loan", + "ARSW": "ArthSwap", + "ART": "LiveArt", + "ARTC": "Artcoin", + "ARTDECO": "ARTDECO", + "ARTDRAW": "ArtDraw", + "ARTE": "Artemine", + "ARTEM": "Artem", + "ARTEON": "Arteon", + "ARTEQ": "artèQ", + "ARTEX": "Artex", + "ARTF": "Artfinity Token", + "ARTFI": "ARTFI", + "ARTG": "Goya Giant Token", + "ARTH": "ARTH", + "ARTHERA": "Arthera", + "ARTI": "Arti Project", + "ARTIF": "Artificial Intelligence", + "ARTII": "ARTII Token", + "ARTL": "ARTL", + "ARTM": "ARTM", + "ARTMETIS": "Staked Metis Token", + "ARTP": "ArtPro", + "ARTR": "Artery Network", + "ARTT": "ARTT Network", + "ARTX": "Ultiland", + "ARTY": "Artyfact", + "ARV": "Ariva", + "ARW": "Arowana Token", + "ARX": "ARCS", + "ARY": "Block Array", + "AS": "AmaStar", + "ASA": "ASA Coin", + "ASAFE2": "Allsafe", + "ASAN": "ASAN VERSE", + "ASAP": "Asap Sniper Bot", + "ASBNB": "Astherus Staked BNB", + "ASC": "All InX SMART CHAIN", + "ASCEND": "Ascend", + "ASCN": "AlphaScan", + "ASD": "AscendEX Token", + "ASDEX": "AstraDEX", + "ASEED": "aUSD SEED (Acala)", + "ASETQU": "AsetQu", + "ASF": "Asymmetry Finance Token", + "ASG": "Asgard", + "ASGC": "ASG", + "ASH": "ASH", + "ASHS": "AshSwap", + "ASI": "Sender AI Token", + "ASIA": "Asia Coin", + "ASIMI": "ASIMI", + "ASIX": "ASIX+", + "ASIXDEV": "ASIX", + "ASK": "Permission Coin", + "ASKAI": "ASKAI", + "ASKO": "Asko", + "ASM": "Assemble Protocol", + "ASMAT": "AsMatch", + "ASMLX": "ASML xStock", + "ASMO": "AS Monaco Fan Token", + "ASN": "Ascension Coin", + "ASNT": "Assent Protocol", + "ASP": "Aspecta", + "ASPC": "Astropup Coin", + "ASPIRE": "Aspire", + "ASPIRIN": "Aspirin", + "ASPO": "ASPO Shards", + "ASQT": "ASQ Protocol", + "ASR": "AS Roma Fan Token", + "ASRR": "Assisterr AI", + "ASS": "Australian Safe Shepherd", + "ASSA": "AssaPlay", + "ASSARA": "ASSARA", + "ASSDAQ": "ASSDAQ", + "ASSET": "iAssets", + "ASST": "AssetStream", + "AST": "AirSwap", + "ASTA": "ASTA", + "ASTER": "Aster", + "ASTERINU": "Aster INU", + "ASTHERUSUSDF": "Astherus USDF", + "ASTO": "Altered State Token", + "ASTON": "Aston", + "ASTONV": "Aston Villa Fan Token", + "ASTR": "Astar", + "ASTRA": "Astra Protocol", + "ASTRADAO": "Astra DAO", + "ASTRAFER": "Astrafer", + "ASTRAFERV1": "Astrafer v1", + "ASTRAL": "Astral", + "ASTRALAB": "Astra Labs", + "ASTRO": "Astroport", + "ASTROC": "Astroport Classic", + "ASTROLION": "AstroLion", + "ASTRONAUT": "Astronaut", + "ASTROO": "Astroon", + "ASTROP": "AstroPepeX", + "ASTROS": "AstroSwap", + "ASTX": "Asterix Labs", + "ASUNA": "Asuna Hentai", + "ASUSDF": "Astherus Staked USDF", + "ASUSHI": "Sushi (Arbitrum Bridge)", + "ASVA": "Asva", + "ASW": "AdaSwap", + "ASY": "ASYAGRO", + "AT": "APRO oracle Token", + "ATA": "Automata", + "ATB": "ATB coin", + "ATC": "AutoBlock", + "ATCC": "ATC Coin", + "ATD": "A2DAO", + "ATEC": "AnonTech", + "ATECH": "AvaxTech", + "ATEHUN": "ATEHUN", + "ATEM": "Atem Network", + "ATF": "Alion Tech Food", + "ATFI": "Atlantic Finance Token", + "ATFS": "ATFS Project", + "ATH": "Aethir", + "ATHCAT": "ATH CAT", + "ATHE": "Atheios", + "ATHEN": "Athenas AI", + "ATHENA": "Athena DexFi", + "ATHER": "Ather", + "ATHVODKA": "All Time High Vodka", + "ATID": "AstridDAO Token", + "ATK": "Attack Wagon", + "ATKN": "A-Token", + "ATL": "ATLANT", + "ATLA": "Atleta Network", + "ATLAS": "Star Atlas", + "ATLASD": "Atlas DEX", + "ATLASOFUSA": "Atlas", + "ATLX": "Atlantis Loans Polygon", + "ATM": "Atletico de Madrid Fan Token", + "ATMA": "ATMA", + "ATMBSC": "ATM", + "ATMC": "Autumncoin", + "ATMCHAIN": "ATMChain", + "ATMCOIN": "ATM", + "ATMI": "Atonomi", + "ATMOS": "Novusphere", + "ATN": "ATHENE NETWORK", + "ATNIO": "ATN", + "ATNT": "Artizen", + "ATO": "Atocha Protocol", + "ATOLO": "RIZON", + "ATOM": "Cosmos", + "ATON": "Further Network", + "ATOPLUS": "ATO+", + "ATOR": "ATOR Protocol", + "ATOS": "Atoshi", + "ATOZ": "Race Kingdom", + "ATP": "Atlas Protocol", + "ATPAY": "AtPay", + "ATR": "Artrade", + "ATRI": "Atari Token", + "ATRNO": "AETERNUS", + "ATROFA": "Atrofarm", + "ATRS": "Attarius Network", + "ATRV1": "Artrade v1", + "ATS": "Alltoscan", + "ATT": "Attila", + "ATTR": "Attrace", + "ATTRA": "Attractor", + "ATU": "Quantum", + "ATX": "ArtexCoin", + "AU": "AutoCrypto", + "AU79": "AU79", + "AUA": "ArubaCoin", + "AUC": "Auctus", + "AUCO": "Advanced United Continent", + "AUCTION": "Bounce", + "AUDC": "Aussie Digital", + "AUDD": "Australian Digital Dollar", + "AUDF": "Forte AUD", + "AUDIO": "Audius", + "AUDM": "Macropod Stablecoin", + "AUDT": "Auditchain", + "AUDX": "eToro Australian Dollar", + "AUK": "Aukcecoin", + "AUKI": "Auki Labs", + "AUN": "Authoreon", + "AUNIT": "Aunit", + "AUPC": "Authpaper", + "AUR": "AUREO", + "AURA": "aura", + "AURABAL": "Aura BAL", + "AURAF": "Aura Finance", + "AURANET": "Aura Network", + "AURO": "Aurora", + "AURORA": "Aurora", + "AURORAC": "Auroracoin", + "AUROS": "AurusGOLD", + "AURS": "Aureus", + "AURUM": "Aurum", + "AURY": "Aurory", + "AUSCM": "Auric Network", + "AUSD": "AUSD", + "AUSDC": "Aave USDC v1", + "AUSDT": "aUSDT", + "AUT": "Autoria", + "AUTHORSHIP": "Authorship", + "AUTISM": "autism", + "AUTISMTOKEN": "AUTISM", + "AUTO": "Auto", + "AUTOMATIC": "Automatic Treasury Machine", + "AUTONO": "Autonomi", + "AUTOS": "CryptoAutos", + "AUTUMN": "Autumn", + "AUVERSE": "AuroraVerse", + "AUX": "Auxilium", + "AV": "Avatar Coin", + "AVA": "Travala", + "AVAAI": "Ava AI", + "AVACN": "AVACOIN", + "AVAI": "Orca AVAI", + "AVAIL": "Avail", + "AVAL": "Avaluse", + "AVALON": "Avalon", + "AVALOX": "AVALOX", + "AVAO": "AvaOne Finance", + "AVAT": "AVATA Network", + "AVATAR": "Avatar", + "AVATLY": "Avatly", + "AVAV": "AVAV", + "AVAV1": "AVA v1", + "AVAX": "Avalanche", + "AVAXAI": "AIvalanche DeFAI Agents", + "AVAXIOU": "Avalanche IOU", + "AVB": "Autonomous Virtual Beings", + "AVC": "AVC", + "AVDO": "AvocadoCoin", + "AVE": "Avesta", + "AVEN": "Aventis AI", + "AVENT": "Aventa", + "AVEROPAY": "Averopay", + "AVERY": "Avery Games", + "AVG": "Avocado DAO", + "AVGOX": "Broadcom xStock", + "AVH": "Animation Vision Cash", + "AVI": "Aviator", + "AVICI": "Avici", + "AVINOC": "AVINOC", + "AVIVE": "Avive World", + "AVL": "AVL", + "AVM": "AVM (Atomicals)", + "AVME": "AVME", + "AVN": "AVNRich", + "AVNT": "Avantis", + "AVO": "Avoteo", + "AVR": "Avrora Metaverse", + "AVRK": "Avarik Saga", + "AVS": "Aves", + "AVT": "Aventus", + "AVTM": "Aventis Metaverse", + "AVXL": "Avaxlauncher", + "AVXT": "Avaxtars Token", + "AWARDCOIN": "Award", + "AWARE": "ChainAware.ai", + "AWARETOKEN": "AWARE", + "AWAX": "AWAX", + "AWBTC": "Aave interest bearing WBTC", + "AWC": "Atomic Wallet Coin", + "AWE": "AWE Network", + "AWK": "Awkward Monkey Base", + "AWM": "Another World", + "AWNEX": "AWNEX token", + "AWO": "AiWork", + "AWORK": "Aworker", + "AWP": "Ansem Wif Photographer", + "AWR": "All Will Retire", + "AWS": "Agentwood Studios", + "AWT": "Abyss World", + "AWX": "AurusX", + "AX": "AlphaX", + "AXC": "AXIA Coin", + "AXE": "Axe", + "AXEL": "AXEL", + "AXGT": "AxonDAO Governance Token", + "AXIAL": "AXiaL", + "AXIAV3": "Axia", + "AXIOM": "Axiom Coin", + "AXIS": "Axis DeFi", + "AXIST": "AXIS Token", + "AXL": "Axelar", + "AXLINU": "AXL INU", + "AXLUSDC": "Axelar Wrapped USDC", + "AXLW": "Axel Wrapped", + "AXM": "Axiome", + "AXN": "Axion", + "AXNT": "Axentro", + "AXO": "Axo", + "AXOL": "Axol", + "AXOME": "Axolotl Meme", + "AXON": "AxonDAO Governance Token", + "AXP": "aXpire v1", + "AXPR": "aXpire", + "AXPRV2": "aXpire v2", + "AXR": "AXRON", + "AXS": "Axie Infinity Shards", + "AXSV1": "Axie Infinity Shards v1", + "AXT": "AIX", + "AXYS": "Axys", + "AYA": "Aryacoin", + "AYFI": "Aave YFI", + "AYNI": "Ayni Gold", + "AZ": "Azbit", + "AZA": "Kaliza", + "AZART": "Azart", + "AZBI": "AZBI CORE", + "AZER": "Azerop", + "AZERO": "Aleph Zero", + "AZIT": "Azit", + "AZNX": "AstraZeneca xStock", + "AZR": "Azure", + "AZTEC": "AZTEC", + "AZU": "Azultec", + "AZUKI": "Azuki", + "AZUKI2": "AZUKI 2.0", + "AZUKIDAO": "AzukiDAO", + "AZUM": "Azuma Coin", + "AZUR": "Azuro Protocol", + "AZURE": "Azure Wallet", + "AZY": "Amazy", + "B": "BUILDon", + "B01": "b0rder1ess", + "B1P": "B ONE PAYMENT", + "B2": "B² Network", + "B20": "B20", + "B21": "B21", + "B26": "B26 Finance", + "B2G": "Bitcoiin2Gen", + "B2M": "Bit2Me", + "B2X": "SegWit2x", + "B3": "B3", + "B3COIN": "B3 Coin", + "B3TR": "VeBetterDAO", + "B3X": "Bnext Token", + "B91": "B91", + "BA": "BAHA", + "BAAS": "BaaSid", + "BAB": "Babacoin", + "BABI": "Babylons", + "BABL": "Babylon Finance", + "BABY": "Babylon", + "BABY4": "Baby 4", + "BABYANDY": "Baby Andy", + "BABYASTER": "Baby Aster", + "BABYB": "Baby Bali", + "BABYBI": "Baby Bitcoin", + "BABYBINANCE": "BABYBINANCE", + "BABYBITC": "BabyBitcoin", + "BABYBNB": "BabyBNB", + "BABYBNBBABY": "BabyBNB", + "BABYBNBTIGER": "BabyBNBTiger", + "BABYBO": "BabyBonk", + "BABYBOB": "Baby Bob", + "BABYBOME": "Book of Baby Memes", + "BABYBOMEOW": "Baby of BOMEOW", + "BABYBONK": "Baby Bonk", + "BABYBOOM": "BabyBoomToken", + "BABYBOSS": "Baby Boss", + "BABYBROC": "Baby Broccoli", + "BABYBROCCOL": "Baby Broccoli", + "BABYBROCCOLI": "BabyBroccoli", + "BABYBTC": "BABYBTC", + "BABYC": "Baby Cat", + "BABYCAT": "Baby Cat Coin", + "BABYCATE": "BabyCate", + "BABYCATS": "Baby Cat Coin", + "BABYCEO": "Baby Doge CEO", + "BABYCRASH": "BabyCrash", + "BABYCRAZYT": "BABY CRAZY TIGER", + "BABYCREPE": "BABY CREPE", + "BABYCUBAN": "Baby Cuban", + "BABYCZHAO": "Baby Czhao", + "BABYD": "Baby Dragon", + "BABYDENG": "Baby Moo Deng", + "BABYDOGE": "BabyDoge", + "BABYDOGE2": "Baby Doge 2.0", + "BABYDOGEINU": "BABY DOGE INU", + "BABYDOGEZILLA": "BabyDogeZilla", + "BABYDRAGON": "Baby Dragon", + "BABYELON": "BabyElon", + "BABYETH": "Baby Ethereum", + "BABYFB": "Baby Floki Billionaire", + "BABYFLOKI": "BabyFloki", + "BABYFLOKIZILLA": "BabyFlokiZilla", + "BABYG": "BabyGME", + "BABYGME": "Baby GameStop", + "BABYGOAT": "Baby Goat", + "BABYGOLDEN": "Baby Golden Coin", + "BABYGROK": "Baby Grok", + "BABYGUMMY": "BABY GUMMY", + "BABYHARRIS": "Baby Harris", + "BABYHIPPO": "BABY HIPPO", + "BABYHKTIGER": "BabyHkTiger", + "BABYHONK": "Baby Honk", + "BABYJERRY": "Baby Jerry", + "BABYJESUS": "BabyJesusCoin", + "BABYKABOSU": "Baby Kabosu", + "BABYKEKIUS": "Baby Kekius Maximus", + "BABYKITTY": "BabyKitty", + "BABYKOMA": "Baby Koma", + "BABYLABUBU": "BABY LABUBU", + "BABYLONG": "Baby Long", + "BABYM": "BabyMAGA", + "BABYMAGA": "Baby Maga", + "BABYMANYU": "Baby Manyu", + "BABYME": "Baby Meme Coin", + "BABYMEME": "Baby Memecoin", + "BABYMIGGLES": "Baby Miggles", + "BABYMO": "Baby Moon Floki", + "BABYMU": "Baby Musk", + "BABYMUB": "Baby Mubarak", + "BABYMUSK": "Baby Musk", + "BABYMYRO": "Babymyro", + "BABYNEIRO": "Baby Neiro", + "BABYNEIROB": "Baby Neiro", + "BABYOKX": "BABYOKX", + "BABYP": "BabyPepe", + "BABYPEIPEI": "Baby PeiPei", + "BABYPENGU": "BABY PENGU", + "BABYPEPE": "Babypepe (BSC)", + "BABYPNUT": "Baby Pnut", + "BABYPOPCAT": "Baby PopCat", + "BABYPORK": "Baby Pepe Fork", + "BABYRATS": "Baby Rats", + "BABYRWA": "BabyRWA", + "BABYS": "Baby Slerf", + "BABYSAITAMA": "Baby Saitama", + "BABYSHARK": "Baby Shark Meme", + "BABYSHARKBSC": "Baby Shark", + "BABYSHIB": "Baby Shiba Inu", + "BABYSHIBAINU": "Baby Shiba Inu", + "BABYSHIRO": "Baby Shiro Neko", + "BABYSHIV": "Baby Shiva", + "BABYSHREK": "Baby Shrek", + "BABYSLERF": "BabySlerf", + "BABYSNAKE": "Baby Snake BSC", + "BABYSOL": "Baby Solana", + "BABYSORA": "Baby Sora", + "BABYSPARK": "Baby Spark", + "BABYSWAP": "BabySwap", + "BABYSWEEP": "BabySweep", + "BABYT": "BABYTRUMP", + "BABYTK": "Baby Tiger King", + "BABYTOMCAT": "Baby Tomcat", + "BABYTOSHI": "Baby Toshi", + "BABYTR": "BABYTRUMP", + "BABYTROLL": "Baby Troll", + "BABYTRUMP": "BABYTRUMP", + "BABYU": "BabyUnicorn", + "BABYWIF": "babydogwifhat", + "BABYWLFI": "Baby World Liberty Financial", + "BABYWLFIN": "Baby WLFI", + "BABYX": "Baby X", + "BABYXRP": "Baby Ripple", + "BAC": "Basis Cash", + "BACHI": "Bachi on Base", + "BACK": "DollarBack", + "BACOIN": "BACoin", + "BACON": "BaconDAO (BACON)", + "BACX": "Bank of America xStock", + "BAD": "Bad Idea AI", + "BADA": "Bad Alien Division", + "BADAI": "BAD Coin", + "BADC": "BADCAT", + "BADCAT": "Andy’s Alter Ego", + "BADDEST": "Baddest Alpha Ape Bundle", + "BADGER": "Badger DAO", + "BADM": "Badmad Robots", + "BAFC": "BabyApeFunClub", + "BAG": "Bag", + "BAGS": "Basis Gold Share", + "BAGWORK": "Bagwork", + "BAHAMAS": "Bahamas", + "BAHIA": "Esporte Clube Bahia Fan Token", + "BAI": "BearAI", + "BAICA": "Baica", + "BAJU": "Bajun Network", + "BAK": "BaconCoin", + "BAKAC": "Baka Casino", + "BAKE": "BakeryToken", + "BAKED": "Baked", + "BAKEDB": "Baked Beans Token", + "BAKEDTOKEN": "Baked", + "BAKENEKO": "BAKENEKO", + "BAKSO": "Disney Sumatran Tiger", + "BAKT": "Backed Protocol", + "BAL": "Balancer", + "BALA": "Shambala", + "BALANCE": "Balance AI", + "BALD": "Bald", + "BALIN": "Balin Bank", + "BALKANCOIN": "Balkancoin", + "BALL": "BitBall", + "BALLTZE": "BALLTZE", + "BALLZ": "Wolf Wif", + "BALN": "Balanced", + "BALPHA": "bAlpha", + "BALT": "Brett's cat", + "BALTO": "Balto Token", + "BALVI": "Balvi", + "BAMA": "BabyAMA", + "BAMBIT": "BAMBIT", + "BAMBOO": "BambooDeFi", + "BAMF": "BAMF", + "BAMITCOIN": "Bamit", + "BAN": "Comedian", + "BANANA": "Banana Gun", + "BANANACHARITY": "BANANA", + "BANANAF": "Banana For Scale", + "BANANAGUY": "BananaGuy", + "BANANAS": "Monkey Peepo", + "BANANAS31": "Banana For Scale", + "BANANO": "Banano", + "BANC": "Babes and Nerds", + "BANCA": "BANCA", + "BAND": "Band Protocol", + "BANDEX": "Banana Index", + "BANDIT": "Bandit on Base", + "BANDO": "Bandot", + "BANG": "BANG", + "BANGY": "BANGY", + "BANK": "Lorenzo Protocol", + "BANKA": "Bank AI", + "BANKBRC": "BANK Ordinals", + "BANKC": "Bankcoin", + "BANKER": "BankerCoinAda", + "BANKETH": "BankEth", + "BANKSY": "BANKSY", + "BANNED": "BANNED", + "BANNER": "BannerCoin", + "BANUS": "Banus.Finance", + "BANX": "Banx.gg", + "BAO": "Bao Token V2", + "BAOBAO": "BaoBao", + "BAOE": "Business Age of Empires", + "BAOM": "Battle of Memes", + "BAOS": "BaoBaoSol", + "BAOV1": "BaoToken v1", + "BAP3X": "bAP3X", + "BAR": "FC Barcelona Fan Token", + "BARA": "Capybara", + "BARAKATUH": "Barakatuh", + "BARC": "The Blu Arctic Water Company", + "BARD": "Lombard", + "BAREBEARS": "BAREBEARS", + "BARIO": "Bario", + "BARK": "Bored Ark", + "BARRON": "Time Traveler", + "BARS": "Banksters Token", + "BARSIK": "Hasbulla's Cat", + "BART": "BarterTrade", + "BARTKRC": "BART Token", + "BARY": "Bary", + "BAS": "Basis Share", + "BASEAI": "BaseAI", + "BASEBEAR": "BBQ", + "BASECAT": "BASE CAT", + "BASECOIN": "BASECOIN", + "BASED": "Based Money", + "BASEDAI": "BasedAI", + "BASEDALF": "Based Alf", + "BASEDB": "Based Bonk", + "BASEDCHILL": "Based Chill Guy", + "BASEDCOPE": "COPE", + "BASEDFINANCE": "Based", + "BASEDHOPPY": "Based Hoppy (basedhoppy.vip)", + "BASEDP": "Based Pepe", + "BASEDR": "Based Rabbit", + "BASEDS": "BasedSwap", + "BASEDTURBO": "Based Turbo", + "BASEDV1": "Based Money v1", + "BASEHEROES": "Baseheroes", + "BASEPROTOCOL": "Base Protocol", + "BASESWAPX": "BaseX", + "BASEVE": "Base Velocimeter", + "BASEX": "Base Terminal", + "BASH": "LuckChain", + "BASHC": "BashCoin", + "BASHOS": "Bashoswap", + "BASIC": "BASIC", + "BASID": "Basid Coin", + "BASIL": "Basilisk", + "BASIS": "Basis", + "BASISCOIN": "Basis Coin", + "BASK": "BasketDAO", + "BAST": "Bast", + "BASTET": "Bastet Goddess", + "BAT": "Basic Attention Token", + "BATCH": "BATCH Token", + "BATH": "Battle Hero", + "BATMAN": "BATMAN", + "BATO": "Batonex Token", + "BATS": "Batcoin", + "BAVA": "Baklava", + "BAX": "BABB", + "BAXS": "BoxAxis", + "BAXV1": "BABB v1", + "BAY": "Marina Protocol", + "BAYSE": "coynbayse", + "BAZED": "Bazed Games", + "BB": "BounceBit", + "BB1": "Bitbond", + "BBADGER": "Badger Sett Badger", + "BBAION": "BigBear.ai Holdings (Ondo Tokenized)", + "BBANK": "BlockBank", + "BBB": "BitBullBot", + "BBBTC": "Big Back Bitcoin", + "BBC": "Bull BTC Club", + "BBCC": "BaseballCardCoin", + "BBCG": "BBC Gold Coin", + "BBCH": "Binance Wrapped BCH", + "BBCT": "TraDove B2BCoin", + "BBDC": "Block Beats Network", + "BBDOGITO": "BabyBullDogito", + "BBDT": "BBD Token", + "BBEER": "BABY BEERCOIN", + "BBF": "Bubblefong", + "BBFT": "Block Busters Tech Token", + "BBG": "BigBang", + "BBGC": "BigBang Game", + "BBI": "BelugaPay", + "BBITBTC": "BounceBit BTC", + "BBK": "BitBlocks", + "BBL": "beoble", + "BBN": "BBNCOIN", + "BBO": "Bigbom", + "BBOB": "BabyBuilder", + "BBONK": "BitBonk", + "BBOS": "Blackbox Foundation", + "BBP": "BiblePay", + "BBQ": "BBQ COIN", + "BBR": "Boolberry", + "BBRETT": "Baby Brett", + "BBROCCOLI": "Baby Broccoli", + "BBS": "BBSCoin", + "BBSNEK": "BabySNEK", + "BBSOL": "Bybit Staked SOL", + "BBT": "BurgerBlastToken", + "BBTC": "Binance Wrapped BTC", + "BBTF": "Block Buster Tech Inc", + "BBUSD": "BounceBit USD", + "BBYDEV": "The Dev is a Baby", + "BC": "Blood Crystal", + "BC3M": "Backed GOVIES 0-6 Months Euro Investment Grade", + "BC400": "Bitcoin Cultivator 400", + "BCA": "Bitcoin Atom", + "BCAC": "Business Credit Alliance Chain", + "BCAI": "Bright Crypto Ai", + "BCAP": "Blockchain Capital", + "BCAPV1": "Blockchain Capital v1", + "BCAT": "BitClave", + "BCAU": "BetaCarbon", + "BCB": "BCB Blockchain", + "BCCOIN": "BlackCardCoin", + "BCD": "Bitcoin Diamond", + "BCDN": "BlockCDN", + "BCDT": "EvidenZ", + "BCE": "bitcastle Token", + "BCEO": "bitCEO", + "BCF": "BitcoinFast", + "BCG": "BlockChainGames", + "BCH": "Bitcoin Cash", + "BCHA": "Bitcoin Cash ABC", + "BCHB": "Bitcoin Cash on Base", + "BCHC": "BitCherry", + "BCHT": "Blockchain Terminal", + "BCI": "Bitcoin Interest", + "BCIO": "Blockchain.io", + "BCITY": "Bitcoin City Coin", + "BCL": "Bitcoin Legend", + "BCLAT": "BOMBOCLAT", + "BCMC": "Blockchain Monster Hunt", + "BCMC1": "BeforeCoinMarketCap", + "BCN": "ByteCoin", + "BCNA": "BitCanna", + "BCNT": "Bincentive", + "BCNX": "BCNEX", + "BCO": "BridgeCoin", + "BCOIN": "Ball3", + "BCOINBNB": "Bombcrypto", + "BCOINM": "Bomb Crypto (MATIC)", + "BCOINSOL": "Bomb Crypto (SOL)", + "BCOINTON": "Bomb Crypto (TON)", + "BCONG": "BabyCong", + "BCOQ": "BLACK COQINU", + "BCP": "BlockChainPeople", + "BCPAY": "Bitcashpay", + "BCPT": "BlockMason Credit Protocol", + "BCPV1": "BitcashPay", + "BCR": "BitCredit", + "BCRO": "Bonded Cronos", + "BCS": "Business Credit Substitute", + "BCSPX": "Backed CSPX Core S&P 500", + "BCT": "Buy Coin Token", + "BCUBE": "B-cube.ai", + "BCUG": "Blockchain Cuties Universe Governance", + "BCUT": "bitsCrunch", + "BCV": "BitCapitalVendor", + "BCVB": "BCV Blue Chip", + "BCX": "BitcoinX", + "BCY": "BitCrystals", + "BCZERO": "Buggyra Coin Zero", + "BD": "BlastDEX", + "BD20": "BRC-20 DEX", + "BDAY": "Birthday Cake", + "BDB": "Big Data Block", + "BDC": "BILLION•DOLLAR•CAT", + "BDCA": "BitDCA", + "BDCC": "BDCC COIN", + "BDCLBSC": "BorderCollieBSC", + "BDG": "Beyond Gaming", + "BDID": "BDID", + "BDIN": "BendDAO BDIN", + "BDL": "Bitdeal", + "BDOG": "Bulldog Token", + "BDOGITO": "BullDogito", + "BDOT": "Binance Wrapped DOT", + "BDP": "Big Data Protocol", + "BDPI": "Interest Bearing Defi Pulse Index", + "BDR": "BlueDragon", + "BDRM": "Bodrumspor Fan Token", + "BDROP": "BlockDrop", + "BDSM": "BTC DOGE SOL MOON", + "BDTC": "BDTCOIN", + "BDX": "Beldex", + "BDXN": "Bondex Token", + "BDY": "Buddy DAO", + "BEA": "Beagle Inu", + "BEACH": "BeachCoin", + "BEAI": "BeNFT Solutions", + "BEAM": "Beam", + "BEAMMW": "Beam", + "BEAN": "Bean", + "BEANS": "SUNBEANS (BEANS)", + "BEAR": "3X Short Bitcoin Token", + "BEARIN": "Bear in Bathrobe", + "BEARINU": "Bear Inu", + "BEAST": "MrBeast", + "BEAT": "Beat Token", + "BEATAI": "eBeat AI", + "BEATLES": "JohnLennonC0IN", + "BEATS": "Sol Beats", + "BEATSWAP": "BeatSwap", + "BEATTOKEN": "BEAT Token", + "BEAVER": "beaver", + "BEB1M": "BeB", + "BEBE": "BEBE", + "BEBEETH": "BEBE", + "BEBEV1": "BEBE v1", + "BEC": "Betherchip", + "BECH": "Beauty Chain", + "BECKOS": "Beckos", + "BECN": "Beacon", + "BECO": "BecoSwap Token", + "BECX": "BETHEL", + "BED": "Bankless BED Index", + "BEDROCK": "Bedrock", + "BEE": "Herbee", + "BEEF": "PepeBull", + "BEEG": "Beeg Blue Whale", + "BEENZ": "BEENZ", + "BEEP": "BEEP", + "BEEPBOOP": "Boop", + "BEER": "BEERCOIN", + "BEER2": "Beercoin 2", + "BEERUSCAT": "BeerusCat", + "BEES": "BEEs", + "BEET": "BEETroot", + "BEETLE": "Beetle Coin", + "BEETOKEN": "Bee Token", + "BEETS": "Beethoven X", + "BEFE": "BEFE", + "BEFI": "BeFi Labs", + "BEFTM": "Beefy Escrowed Fantom", + "BEFX": "Belifex", + "BEFY": "Befy Protocol", + "BEG": "BEG", + "BEIBEI": "Chinese BEIBEI", + "BEL": "Bella Protocol", + "BELA": "Bela", + "BELG": "Belgian Malinois", + "BELIEVE": "Believe", + "BELL": "Bellscoin", + "BELLE": "Isabelle", + "BELLS": "Bellscoin", + "BELR": "Belrium", + "BELT": "Belt", + "BELUGA": "Beluga", + "BEM": "BEMIL Coin", + "BEMC": "BemChain", + "BEMD": "Betterment Digital", + "BEN": "Ben", + "BEND": "BendDao", + "BENDER": "BENDER", + "BENDOG": "Ben the Dog", + "BENG": "Based Peng", + "BENI": "Beni", + "BENJACOIN": "Benjacoin", + "BENJI": "Basenji", + "BENJIROLLS": "BenjiRolls", + "BENK": "BENK", + "BENT": "Bent Finance", + "BENTO": "Bento", + "BENV1": "Ben v1", + "BENX": "BlueBenx", + "BENZI": "Ben Zi Token", + "BEP": "Blucon", + "BEPE": "Blast Pepe", + "BEPR": "Blockchain Euro Project", + "BEPRO": "BEPRO Network", + "BERA": "Berachain", + "BERAETH": "Berachain Staked ETH", + "BERASTONE": "StakeStone Berachain Vault Token", + "BERF": "BERF", + "BERG": "Bloxberg", + "BERN": "BERNcash", + "BERNIE": "BERNIE SENDERS", + "BERRIE": "Berrie Token", + "BERRY": "Berry", + "BERRYS": "BerrySwap", + "BERT": "Bertram The Pomeranian", + "BES": "battle esports coin", + "BESA": "Besa Gaming", + "BESHARE": "Beshare Token", + "BEST": "Best Wallet Token", + "BESTC": "BestChain", + "BETA": "Beta Finance", + "BETACOIN": "BetaCoin", + "BETBOX": "betbox", + "BETF": "Betform", + "BETFI": "Betfin", + "BETH": "Beacon ETH", + "BETHER": "Bethereum", + "BETR": "BetterBetting", + "BETROCK": "Betrock", + "BETS": "BetSwirl", + "BETT": "Bettium", + "BETU": "Betu", + "BETURA": "BETURA", + "BETZ": "Bet Lounge", + "BEX": "BEX token", + "BEY": "NBX", + "BEYOND": "Beyond Protocol", + "BEZ": "Bezop", + "BEZOGE": "Bezoge Earth", + "BF": "BitForex Token", + "BFC": "Bifrost", + "BFCH": "Big Fun Chain", + "BFDT": "Befund", + "BFEX": "BFEX", + "BFG": "BFG Token", + "BFHT": "BeFaster Holder Token", + "BFI": "BlockFi-Ai", + "BFIC": "Bficoin", + "BFICGOLD": "BFICGOLD", + "BFK WARZONE": "BFK Warzone", + "BFLOKI": "BurnFloki", + "BFLY": "Butterfly Protocol", + "BFM": "BenefitMine", + "BFR": "Buffer Token", + "BFT": "BF Token", + "BFTB": "Brazil Fan Token", + "BFTC": "BITS FACTOR", + "BFTOKEN": "BOSS FIGHTERS", + "BFUSD": "BFUSD", + "BFWOG": "Based Fwog (basedfwog.info)", + "BFX": "BitFinex Tokens", + "BG": "BunnyPark Game", + "BGB": "Bitget token", + "BGBG": "BigMouthFrog", + "BGBP": "Binance GBP Stable Coin", + "BGBV1": "Bitget Token v1", + "BGC": "Bee Token", + "BGCI": "Bloomberg Galaxy Crypto Index", + "BGEO": "BGEO", + "BGG": "BGG Token", + "BGLD": "Based Gold", + "BGME": "Backed GameStop", + "BGONE": "BigONE Token", + "BGOOGL": "Backed Alphabet Class A", + "BGPT": "BlockGPT", + "BGR": "Bitgrit", + "BGS": "Battle of Guardians Share", + "BGSC": "BugsCoin", + "BGSOL": "Bitget SOL Staking", + "BGUY": "The Big Guy", + "BGVT": "Bit Game Verse Token", + "BHAO": "Bithao", + "BHAT": "BH Network", + "BHAX": "Bithashex", + "BHBD": "bHBD", + "BHC": "Billion Happiness", + "BHCV1": "Billion Happiness v1", + "BHEROES": "BombHeroes coin", + "BHIG": "BuckHathCoin", + "BHIGH": "Backed HIGH € High Yield Corp Bond", + "BHIRE": "BitHIRE", + "BHIVE": "Hive", + "BHO": "Bholdus Token", + "BHP": "Blockchain of Hash Power", + "BHPC": "BHPCash", + "BIAFRA": "Biafra Coin", + "BIAO": "BIAO", + "BIAOCOIN": "Biaocoin", + "BIB": "BIB Token", + "BIB01": "Backed IB01 $ Treasury Bond 0-1yr", + "BIBI": "Binance bibi", + "BIBI2025": "Bibi", + "BIBIBSC": "BIBI", + "BIBL": "Biblecoin", + "BIBO": "Bible of Memes", + "BIBTA": "Backed IBTA $ Treasury Bond 1-3yr", + "BIC": "Bikercoins", + "BICHO": "bicho", + "BICITY": "BiCity AI Projects", + "BICO": "Biconomy", + "BICS": "Biceps", + "BID": "CreatorBid", + "BIDAO": "Bidao", + "BIDCOM": "Bidcommerce", + "BIDEN": "Dark Brandon", + "BIDEN2024": "BIDEN 2024", + "BIDI": "Bidipass", + "BIDP": "BID Protocol", + "BIDR": "Binance IDR Stable Coin", + "BIDZ": "BIDZ Coin", + "BIDZV1": "BIDZ Coin v1", + "BIFI": "Beefy.Finance", + "BIFIF": "BiFi", + "BIFIV1": "Beefy v1", + "BIG": "Big Eyes", + "BIGBALLS": "Edward Coristine", + "BIGBANGCORE": "BigBang Core", + "BIGCOIN": "BigCoin", + "BIGDOG": "Big Dog", + "BIGFACTS": "BIGFACTS", + "BIGFOOT": "BigFoot Town", + "BIGGIE": "Biggie", + "BIGHAN": "BighanCoin", + "BIGJIM": "BIG JIM", + "BIGLEZ": "THE BIG LEZ SHOW", + "BIGMIKE": "Big Mike", + "BIGOD": "BinGold Token", + "BIGPUMP": "Big Pump", + "BIGSB": "BigShortBets", + "BIGTIME": "Big Time", + "BIGTOWN": "Burp", + "BIGUP": "BigUp", + "BIH": "BitHostCoin", + "BIHU": "Key", + "BIIS": "biis (Ordinals)", + "BIKE": "White Bike", + "BIKI": "BIKI", + "BILL": "TillBilly", + "BILLI": "Billi", + "BILLICAT": "BilliCat", + "BILLY": "Billy ", + "BILLYBSC": "BILLY", + "BIM": "BitminerCoin", + "BIN": "Binemon", + "BINA": "Binance Mascort Dog", + "BINAN": "Binance Mascot", + "BINANCED": "BinanceDog On Sol", + "BINANCEDOG": "Binancedog", + "BINANCIENS": "Binanciens", + "BIND": "Compendia", + "BINGO": "Tomorrowland", + "BINK": "Big Dog Fink", + "BINO": "Binopoly", + "BINS": "Bitsense", + "BINTEX": "Bintex Futures", + "BINU": "Blast Inu", + "BIO": "Bio Protocol", + "BIOB": "BioBar", + "BIOC": "BioCrypt", + "BIOCOIN": "Biocoin", + "BIOFI": "Biometric Financial", + "BIOP": "Biop", + "BIOS": "BiosCrypto", + "BIOT": "Bio Passport", + "BIP": "Minter", + "BIPC": "BipCoin", + "BIPX": "Bispex", + "BIR": "Birake", + "BIRB": "Moonbirds", + "BIRBV1": "Birb", + "BIRD": "BIRD", + "BIRDCHAIN": "Birdchain", + "BIRDD": "BIRD DOG", + "BIRDDOG": "Bird Dog", + "BIRDEI": "Birdei", + "BIRDMONEY": "Bird.Money", + "BIRDO": "Bird Dog", + "BIS": "Bismuth", + "BISKIT": "Biskit Protocol", + "BISO": "BISOSwap", + "BIST": "Bistroo", + "BISTOX": "Bistox Exchange Token", + "BIT": "BitDAO", + "BIT16": "16BitCoin", + "BITAIR": "Bitair", + "BITASEAN": "BitAsean", + "BITB": "BeanCash", + "BITBAY": "BitBay", + "BITBEDR": "Bitcoin EDenRich", + "BITBO": "BitBook", + "BITBOARD": "Bitboard", + "BITBOOST": "BitBoost", + "BITBOOSTTOKEN": "BitBoost", + "BITBULL": "Bitbull", + "BITBURN": "Bitburn", + "BITC": "BitCash", + "BITCAR": "BitCar", + "BITCARBON": "Bitcarbon", + "BITCAT": "Bitcat", + "BITCATONSOL": "Bitcat", + "BITCCA": "Bitcci Cash", + "BITCH": "OurBitch", + "BITCI": "Bitcicoin", + "BITCM": "Bitcomo", + "BITCNY": "bitCNY", + "BITCO": "Bitcoin Black Credit Card", + "BITCOINC": "Bitcoin Classic", + "BITCOINCONFI": "Bitcoin Confidential", + "BITCOINOTE": "BitcoiNote", + "BITCOINP": "Bitcoin Private", + "BITCOINSCRYPT": "Bitcoin Scrypt", + "BITCOINV": "BitcoinV", + "BITCONNECT": "BitConnect Coin", + "BITCORE": "BitCore", + "BITCRATIC": "Bitcratic Token", + "BITDEFI": "BitDefi", + "BITDEGREE": "BitDegree", + "BITE": "Bitether", + "BITF": "Bit Financial", + "BITFLIP": "BitFlip", + "BITG": "Bitcoin Green", + "BITGOLD": "bitGold", + "BITGRIN": "BitGrin", + "BITHER": "Bither", + "BITL": "BitLux", + "BITLAYER": "Bitlayer", + "BITM": "BitMoney", + "BITN": "Bitnet", + "BITNEW": "BitNewChain", + "BITO": "BitoPro Exchange Token", + "BITOK": "BitOKX", + "BITONE": "BITONE", + "BITORB": "BitOrbit", + "BITPANDA": "Bitpanda Ecosystem Token", + "BITRA": "Bitratoken", + "BITRADIO": "Bitradio", + "BITREWARDS": "BitRewards", + "BITROLIUM": "Bitrolium", + "BITRUE": "Bitrue Coin", + "BITS": "BitstarCoin", + "BITSD": "Bits Digit", + "BITSEEDS": "BitSeeds", + "BITSERIAL": "BitSerial", + "BITSILVER": "bitSilver", + "BITSPACE": "Bitspace", + "BITSZ": "Bitsz", + "BITT": "BiTToken", + "BITTO": "BITTO", + "BITTON": "Bitton", + "BITTY": "The Bitcoin Mascot", + "BITUPTOKEN": "BitUP Token", + "BITUSD": "bitUSD", + "BITV": "Bitvolt", + "BITVOLT": "BitVolt", + "BITWORLD": "Bit World Token", + "BITX": "BitScreener", + "BITXOXO": "Bitxoxo", + "BITZ": "MARBITZ", + "BITZBIZ": "Bitz Coin", + "BIUT": "Bit Trust System", + "BIVE": "BIZVERSE", + "BIX": "BiboxCoin", + "BIXB": "BIXBCOIN", + "BIXI": "Bixi", + "BIXV1": "BiboxCoin v1", + "BIZA": "BizAuto", + "BIZZ": "BIZZCOIN", + "BJ": "Blocjerk", + "BJC": "Bjustcoin", + "BJK": "Beşiktaş", + "BKBT": "BeeKan", + "BKC": "BKC Token", + "BKING": "King Arthur", + "BKK": "BKEX Token", + "BKN": "Brickken", + "BKOK": "BKOK FinTech", + "BKPT": "Biokript", + "BKR": "Balkari Token", + "BKRW": "Binance KRW", + "BKS": "Barkis Network", + "BKT": "Blocktrade token", + "BKX": "BANKEX", + "BL00P": "BLOOP", + "BLA": "BlaBlaGame", + "BLAC": "Blacksmith Token", + "BLACK": "BLACKHOLE PROTOCOL", + "BLACKD": "Blackder AI", + "BLACKDRAGON": "Black Dragon", + "BLACKP": "BlackPool Token", + "BLACKR": "BLACK ROCK", + "BLACKROCK": "BlackRock", + "BLACKSALE": "Black Sale", + "BLACKST": "Black Stallion", + "BLACKSWAN": "BlackSwan AI", + "BLACKWHALE": "The Black Whale", + "BLADE": "BladeGames", + "BLADEW": "BladeWarrior", + "BLAKEBTC": "BlakeBitcoin", + "BLANK": "Blank Token", + "BLAS": "BlakeStar", + "BLAST": "BLAST", + "BLASTA": "BlastAI", + "BLASTUP": "BlastUP", + "BLAUNCH": "B-LAUNCH", + "BLAZE": "StoryFire", + "BLAZECOIN": "Blaze", + "BLAZEX": "BlazeX", + "BLAZR": "BlazerCoin", + "BLBY": "Badluckbaby", + "BLC": "BlakeCoin", + "BLCT": "Bloomzed Loyalty Club Ticket", + "BLD": "Agoric", + "BLENDR": "Blendr Network", + "BLEPE": "Blepe", + "BLERF": "BLERF", + "BLES": "Blind Boxes", + "BLESS": "Bless Token", + "BLET": "Brainlet", + "BLF": "Baby Luffy", + "BLHC": "BlackholeCoin", + "BLI": "BALI TOKEN", + "BLID": "Bolide", + "BLIFFY": "BLIFFY", + "BLIN": "Blin Metaverse", + "BLIND": "Blindsight", + "BLING": "PLEB DREKE", + "BLINK": "BlockMason Link", + "BLINU": "Baby Lambo Inu", + "BLITZ": "BlitzCoin", + "BLITZP": "BlitzPredict", + "BLK": "BlackCoin", + "BLKC": "BlackHat Coin", + "BLKD": "Blinked", + "BLKS": "Blockshipping", + "BLM": "Blombard", + "BLN": "Bulleon", + "BLNM": "Bolenum", + "BLOB": "B.O.B the Blob", + "BLOBERC20": "Blob", + "BLOC": "Blockcloud", + "BLOCK": "Block", + "BLOCKASSET": "Blockasset", + "BLOCKB": "Block Browser", + "BLOCKBID": "Blockbid", + "BLOCKCHAINTRADED": "Blockchain Traded Fund", + "BLOCKF": "Block Farm Club", + "BLOCKG": "BlockGames", + "BLOCKIFY": "Blockify.Games", + "BLOCKMAX": "BLOCKMAX", + "BLOCKN": "BlockNet", + "BLOCKPAY": "BlockPay", + "BLOCKS": "BLOCKS", + "BLOCKSSPACE": "Blocks Space", + "BLOCKSTAMP": "BlockStamp", + "BLOCKSV1": "BLOCKS v1", + "BLOCKT": "Blocktools", + "BLOCKTRADE": "Blocktrade", + "BLOCKVAULT": "BLOCKVAULT TOKEN", + "BLOCKW": "Blockwise", + "BLOCM": "BLOC.MONEY", + "BLOCX": "BLOCX.", + "BLOGGE": "Bloggercube", + "BLOK": "Bloktopia", + "BLOO": "bloo foster coin", + "BLOOCYS": "BlooCYS", + "BLOODY": "Bloody Token", + "BLOOM": "BloomBeans", + "BLOOMT": "Bloom Token", + "BLOVELY": "Baby Lovely Inu", + "BLOX": "BLOX", + "BLOXT": "Blox Token", + "BLOXWAP": "BLOXWAP", + "BLP": "BullPerks", + "BLPAI": "BullPerks AI", + "BLPT": "Blockprompt", + "BLRY": "BillaryCoin", + "BLS": "BloodLoop", + "BLST": "Crypto Legions Bloodstone", + "BLT": "Blocto Token", + "BLTC": "BABYLTC", + "BLTG": "Block-Logic", + "BLTV": "BLTV Token", + "BLU": "BlueCoin", + "BLUAI": "Bluwhale AI", + "BLUB": "BLUB", + "BLUE": "Bluefin", + "BLUEBASE": "Blue", + "BLUEBUTT": "BLUE BUTT CHEESE", + "BLUEG": "Blue Guy", + "BLUEM": "BlueMove", + "BLUEN": "Blue Norva", + "BLUEPROTOCOL": "Blue Protocol", + "BLUES": "Blueshift", + "BLUESC": "BluesCrypto", + "BLUESPARROW": "BlueSparrow Token", + "BLUESPARROWOLD": "BlueSparrowToken", + "BLUEW": "Blue Whale", + "BLUEY": "BlueyonBase", + "BLUFF": "BluffCat", + "BLUI": "Blui", + "BLUM": "Blum", + "BLUR": "Blur", + "BLURT": "Blurt", + "BLUSD": "Boosted LUSD", + "BLUT": "Bluetherium", + "BLV": "Blockvest", + "BLV3": "Crypto Legions V3", + "BLWA": "BlockWarrior", + "BLX": "Balloon-X", + "BLXM": "bloXmove Token", + "BLY": "Blocery", + "BLZ": "Bluzelle", + "BLZD": "Blizzard.money", + "BLZE": "BLAZE TOKEN", + "BM": "BitMoon", + "BMAGA": "Baby Maga", + "BMARS": "Binamars", + "BMAX": "BMAX", + "BMB": "Beamable Network Token", + "BMBO": "Bamboo Coin", + "BMC": "Blackmoon Crypto", + "BMCHAIN": "BMChain", + "BMDA": "Bermuda", + "BME": "BitcoMine", + "BMEX": "BitMEX", + "BMF": "MetaFame", + "BMG": "Borneo", + "BMH": "BlockMesh", + "BMI": "Bridge Mutual", + "BMIC": "Bitmic", + "BMICKEY": "Baby Mickey", + "BMK": "Benchmark", + "BMNRX": "Bitmine xStock", + "BMON": "Binamon", + "BMONEY": "B-money", + "BMP": "Brother Music Platform", + "BMS": "BMS COIN", + "BMSFT": "Backed Microsoft", + "BMSTR": "Backed MicroStrategy", + "BMT": "Bubblemaps", + "BMTC": "Metabit", + "BMW": "BMW", + "BMWUKONG": "Black Myth WuKong", + "BMX": "BitMart Token", + "BMXT": "Bitmxittz", + "BMXX": "Multiplier", + "BN": "TNA Protocol", + "BNA": "BananaTok", + "BNANA": "Chimpion", + "BNB": "Binance Coin", + "BNBAI": "BNB Agents", + "BNBAICLUB": "BNB AI Agent", + "BNBBONK": "BNB BONK", + "BNBBUNNY": "BNB BUNNY", + "BNBCARD": "BNB Card", + "BNBCAT": "BNBcat", + "BNBCH": "BNB Cash", + "BNBD": "BNBDOG", + "BNBDOG": "BNB DOG INU", + "BNBDOGE": "BNBdoge", + "BNBDRGN": "BNBDragon", + "BNBE": "BNBEE", + "BNBETF": "BNB ETF", + "BNBFLOKI": "BNB FLOKI", + "BNBFROG": "BNBFROG", + "BNBH": "BnbHeroes Token", + "BNBHOLDER": "币安Holder", + "BNBLION": "BNB LION", + "BNBOLYMPIC": "BNB OLYMPIC", + "BNBP": "BNBPot", + "BNBPRINTER": "BNBPrinter", + "BNBSNAKE": "BNB SNAKE", + "BNBSONGOKU": "BNBsongoku", + "BNBTC": "BNbitcoin", + "BNBULL": "BNBULL", + "BNBVEGETA": "BNB VEGETA", + "BNBWHALES": "BNB Whales", + "BNBX": "Stader BNBx", + "BNBXBT": "BNBXBT", + "BNC": "Bifrost Native Coin", + "BND": "Bened", + "BNF": "BonFi", + "BNFT": "APENFT (BitTorrent Bridge)", + "BNIU": "Backed Niu Technologies", + "BNIX": "BNIX Token", + "BNK": "Bankera", + "BNKR": "BankrCoin", + "BNKV1": "Bankera v1", + "BNL": "BitNational Token", + "BNN": "Banyan Network", + "BNOM": "BitNomad", + "BNP": "BenePit", + "BNPL": "BNPL Pay", + "BNR": "BiNeuro", + "BNRTX": "BnrtxCoin", + "BNRY": "Binary Coin", + "BNS": "BNS token", + "BNSAI": "bonsAI Network", + "BNSD": "BNSD Finance", + "BNSOL": "Binance Staked SOL", + "BNSOLD": "BNS token ", + "BNSV1": "BNS token v1", + "BNSX": "Bitcoin Name Service System", + "BNT": "Bancor Network Token", + "BNTE": "Bountie", + "BNTN": "Blocnation", + "BNTY": "Bounty0x", + "BNU": "ByteNext", + "BNUSD": "Balanced Dollars", + "BNVDA": "Backed NVIDIA", + "BNX": "BinaryX", + "BNXV1": "BinaryX v1", + "BNY": "TaskBunny", + "BOA": "BOSAGORA", + "BOAI": "BOLICAI", + "BOAM": "BOOK OF AI MEOW", + "BOARD": "SurfBoard Finance", + "BOAT": "Doubloon", + "BOATKID": "Pacu Jalur", + "BOATKIDSITE": "BoatKid", + "BOBA": "Boba Network", + "BOBAI": "Bob AI", + "BOBAOPPA": "Bobaoppa", + "BOBBY": "Kennedy Coin", + "BOBBYM": "Bobby Moore", + "BOBC": "Bobcoin", + "BOBE": "BOOK OF BILLIONAIRES", + "BOBER": "BOBER", + "BOBFUN": "BOB", + "BOBL2": "BOB", + "BOBLS": "Boblles", + "BOBMARLEY": "Bob Marley Meme", + "BOBO": "BOBO", + "BOBOT": "Bobo The Bear", + "BOBR": "Based BOBR", + "BOBS": "Bob's Repair", + "BOBT": "BOB Token", + "BOBTHE": "Bob The Builder", + "BOBUKI": "Bobuki Neko", + "BOBY": "BOBY", + "BOC": "BOCOIN", + "BOCA": "BookOfPussyCats", + "BOCAC": "BocaChica token", + "BOCAT": "BOCAT", + "BOD": "Book of Donald Trump", + "BODA": "Based Yoda", + "BODAV2": "BODA Token", + "BODE": "Book of Derp", + "BODEN": "Jeo Boden", + "BODHI": "Bodhi Network", + "BODO": "BOOK OF DOGS", + "BODOG": "Book of Doge", + "BODYP": "Body Profile", + "BOE": "Bodhi", + "BOF": "Balls of Fate", + "BOG": "Bogged Finance", + "BOGCOIN": "Bogcoin", + "BOGD": "Bogdanoff", + "BOGE": "Boge", + "BOGEY": "Bogey", + "BOGGY": "Boggy Coin", + "BOHR": "BOHR", + "BOHRV1": "BOHR v1", + "BOJAK": "Based Wojak", + "BOJI": "BOJI Token", + "BOJIV1": "BOJI Token v1", + "BOK": "Blockium", + "BOKI": "BOOK OF KILLER", + "BOKU": "Boryoku Dragonz", + "BOLBOL": "BOLBOL", + "BOLD": "Bold", + "BOLI": "BolivarCoin", + "BOLT": "Bolt", + "BOLTAI": "Bolt AI", + "BOLTT": "BolttCoin", + "BOM": "Book Of Matt Furie", + "BOMA": "Book of Maga", + "BOMB": "Bombie", + "BOMBC": "BombCoin", + "BOMBLOONG": "Bombloong", + "BOMBM": "Bomb Money", + "BOMBO": "BOMBO", + "BOMBS": "Bomb Shelter Inu", + "BOMBTOKEN": "BOMB", + "BOME": "BOOK OF MEME", + "BOME2": "Book of Meme 2.0", + "BOMEDOGE": "BOOK OF DOGE MEMES", + "BOMEOW": "Book of Meow", + "BOMES": "BOOK OF MEMES", + "BOMET": "BOME TRUMP", + "BOMK": "BOMK", + "BOMO": "BOMO", + "BOMT": "Baby One More Time", + "BON": "Bonpay", + "BONA": "Bonafi", + "BOND": "BarnBridge", + "BONDAPPETIT": "BondAppetit", + "BONDLY": "Bondly", + "BONDLYV1": "Bondly Finance", + "BONDX": "BondX", + "BONE": "Bone ShibaSwap", + "BONEBONE": "Bone", + "BONES": "Moonshots Farm", + "BONESCOIN": "BonesCoin", + "BONESV1": "Squirrel Finance", + "BONFIRE": "Bonfire", + "BONG": "BonkWifGlass", + "BONGO": "BONGO CAT", + "BONIX": "Blockonix", + "BONK": "Bonk", + "BONKBNB": "Bonk BNB", + "BONKCON": "Bonkcon", + "BONKEA": "Bonk Earn", + "BONKEY": "Bonkey", + "BONKFA": "Bonk of America", + "BONKFORK": "BonkFork", + "BONKGROK": "Bonk Grok", + "BONKH": "BonkHoneyHNTMobileSOL", + "BONKIN": "Bonkinu", + "BONKKONG": "BONK KONG", + "BONKONBASE": "Bonk on Base", + "BONKONETH": "Bonk On ETH", + "BONKW": "bonkwifhat", + "BONO": "Bonorum Coin", + "BONTE": "Bontecoin", + "BONUS": "BonusBlock", + "BONUSCAKE": "Bonus Cake", + "BONZI": "Bonzi PFP Cult", + "BOO": "Spookyswap", + "BOOB": "BooBank", + "BOOCHIE": "Boochie by Matt Furie", + "BOOE": "Book of Ethereum", + "BOOF": "Boofus by Virtuals", + "BOOFI": "Boo Finance", + "BOOG": "BOOG base", + "BOOGIE": "Boogie", + "BOOK": "Solbook", + "BOOKIE": "BookieBot", + "BOOKO": "Book of Pets", + "BOOKOF": "BOOK OF NOTHING", + "BOOM": "Boomco", + "BOOMCOIN": "Boom Token", + "BOOMDAO": "BOOM DAO", + "BOOMER": "Boomer", + "BOONS": "BOONSCoin", + "BOOP": "BOOP", + "BOOPA": "Boopa", + "BOOS": "Boost Trump Campaign", + "BOOST": "Boost", + "BOOSTCO": "Boost", + "BOOSTO": "BOOSTO", + "BOOT": "Bostrom", + "BOP": "Boring Protocol", + "BOPB": "BIOPOP", + "BOPE": "Book of Pepe", + "BOPPY": "BOPPY", + "BOR": "BoringDAO", + "BORA": "BORA", + "BORAV1": "BORA v1", + "BORED": "Bored Museum", + "BORG": "SwissBorg", + "BORGY": "BORGY", + "BORING": "BoringDAO", + "BORK": "Bork", + "BORKIE": "Borkie", + "BORPA": "Borpa", + "BORUTO": "Boruto Inu", + "BOS": "BitcoinOS Token", + "BOSCOIN": "BOScoin", + "BOSE": "Bitbose", + "BOSHI": "Boshi", + "BOSOL": "Book of Solana", + "BOSON": "Boson Protocol", + "BOSONC": "BosonCoin", + "BOSS": "BitBoss", + "BOSSBABY": "BossBaby", + "BOSSBURGER": "Boss Burger", + "BOSSCOQ": "THE COQFATHER", + "BOST": "BoostCoin", + "BOSU": "Bosu Inu", + "BOT": "HyperBot", + "BOTC": "BotChain", + "BOTIFY": "BOTIFY", + "BOTPLANET": "Bot Planet", + "BOTS": "ArkDAO", + "BOTTO": "Botto", + "BOTX": "BOTXCOIN", + "BOU": "Boulle", + "BOUNCE": "Bounce Token", + "BOUNTY": "ChainBounty", + "BOUNTYK": "BOUNTYKINDS", + "BOUTS": "BoutsPro", + "BOW": "Archer Swap", + "BOWE": "Book of Whales", + "BOWSC": "BowsCoin", + "BOWSER": "Bowser", + "BOX": "DeBoxToken", + "BOXABL": "BOXABL", + "BOXCAT": "BOXCAT", + "BOXETH": "Cat-in-a-Box Ether", + "BOXT": "BOX Token", + "BOXX": "Blockparty", + "BOXY": "BoxyCoin", + "BOYS": "BOYSCLUB (boysclubonbase.com)", + "BOYSC": "Boy's club", + "BOYSCLUB": "Matt Furie's Boys Club", + "BOZO": "BOZO", + "BOZOH": "bozo Hybrid", + "BOZY": "Book of Crazy", + "BP": "BunnyPark", + "BPAD": "BlokPad", + "BPADA": "Binance-Peg Cardano (Binance Bridge)", + "BPAVAX": "Binance-Peg Avalanche (Binance Bridge)", + "BPAY": "BNBPay", + "BPBCH": "Binance-Peg Bitcoin Cash (Binance Bridge)", + "BPBTT": "Binance-Peg BitTorrent", + "BPD": "Beautiful Princess Disorder", + "BPDAI": "Binance-Peg Dai (Binance Bridge)", + "BPDOGE": "Binance-Peg DogeZilla (Binance Bridge)", + "BPEPE": "BABY PEPE", + "BPEPEF": "Baby Pepe Floki", + "BPET": "BPET", + "BPINKY": "BPINKY", + "BPL": "BlockPool", + "BPLC": "BlackPearl Token", + "BPLINK": "Binance-Peg Chainlink (Binance Bridge)", + "BPLTC": "Binance-Peg Litecoin", + "BPMATIC": "Binance-Peg Polygon (Binance Bridge)", + "BPN": "beepnow", + "BPNEAR": "Binance-Peg NEAR Protocol", + "BPOKO": "BabyPoko", + "BPRIVA": "Privapp Network", + "BPRO": "BitCloud Pro", + "BPS": "BitcoinPoS", + "BPSCRT": "Secret (Binance Bridge)", + "BPSHIB": "Binance-Peg Shiba Inu (Binance Bridge)", + "BPT": "Best Patent Token", + "BPTC": "Business Platform Tomato Coin", + "BPUNI": "Binance-Peg Uniswap Protocol Token (Binance Bridge)", + "BPUSDC": "Binance-Peg USD Coin (Binance Bridge)", + "BPX": "Black Phoenix", + "BPXL": "BombPixel", + "BQ": "Bitqy", + "BQC": "BQCoin", + "BQQQ": "Bitsdaq Token", + "BQT": "Blockchain Quotations Index Token", + "BQTX": "BQT", + "BR": "Bedrock", + "BR34P": "BR34P", + "BRACE": "Bitci Racing Token", + "BRAI": "Brain Frog", + "BRAIN": "BrainCoin", + "BRAINERS": "Brainers", + "BRAINLET": "Brainlet", + "BRAINROT": "AI Brainrot", + "BRAINZ": "Brainz Finance", + "BRAM": "Defibox bRAM", + "BRANA": "Branaverse", + "BRAND": "BrandProtect", + "BRANDY": "BRANDY", + "BRAT": "Peak Brat", + "BRATT": "Son of Brett", + "BRAWL": "BitBrawl", + "BRAZ": "Brazio", + "BRC": "Baer Chain", + "BRCG": "Bitcoin Roller Coaster Guy", + "BRCP": "BRCP Token", + "BRCST": "BRCStarter", + "BRCT": "BRC App", + "BRD": "Bread token", + "BRDD": "BeardDollars", + "BRDG": "Bridge Protocol", + "BREAD": "Breadchain Cooperative", + "BREE": "CBDAO", + "BREED": "BreederDAO", + "BREPE": "BREPE", + "BRETARDIO": "Bretardio", + "BRETT": "Brett Base", + "BRETTA": "Bretta", + "BRETTFYI": "Brett", + "BRETTGOLD": "Brett Gold", + "BRETTONETH": "Brett ETH", + "BRETTSUI": "Brett (brettsui.com)", + "BREV": "Brevis Token", + "BREW": "CafeSwap Token", + "BREWERY": "Brewery Consortium Coin", + "BREWLABS": "Brewlabs", + "BRG": "Bridge Oracle", + "BRGE": "OrdBridge", + "BRGX": "Bridge$", + "BRI": "Baroin", + "BRIA": "Briacoin", + "BRIAN": "Brian Arm Strong", + "BRIANWIF": "Brianwifhat", + "BRIBE": "Bribe Protocol", + "BRIC": "Redbrick", + "BRICK": "Brickchain FInance", + "BRICKS": "MyBricks", + "BRICS": "BRICS Chain", + "BRIDGE": "Bridge Bot", + "BRIGHT": "Bright Token", + "BRIGHTCOIN": "BrightCoin", + "BRIGHTU": "Bright Union", + "BRIK": "BrikBit", + "BRIL": "Brilliantcrypto", + "BRISE": "Bitgert", + "BRIT": "BritCoin", + "BRITT": "Britt", + "BRITTO": "Britto", + "BRIUM": "Bearium", + "BRIUN": "Briun Armstrung", + "BRIX": "OpenBrix", + "BRK": "BreakoutCoin", + "BRKBX": "Berkshire Hathaway xStock", + "BRKL": "Brokoli Token", + "BRL1": "BRL1", + "BRLV": "High Velocity BRLY", + "BRLY": "Yield Bearing BRL", + "BRM": "BullRun Meme", + "BRMV": "BRMV Token", + "BRN": "BRN Metaverse", + "BRNK": "Brank", + "BRNX": "Bronix", + "BRO": "Bro the cat", + "BROAK": "Broak on Base", + "BROC": "Broccoli (broc.wtf)", + "BROCC": "Broccoli", + "BROCCO": "Broccoli (firstbroccoli.com)", + "BROCCOL": "Broccoli (broccolibsc.com)", + "BROCCOLI": "CZ'S Dog (broccoli.gg)", + "BROCCOLIBNB": "BROCCOLI (broccolibnb.xyz)", + "BROCCOLICZ": "Broccoli (broccoli_cz)", + "BROCCOLIVIP": "Broccoli(broccoli.vip)", + "BROCK": "Bitrock", + "BROGG": "Brett's Dog", + "BROKE": "Broke Again", + "BROKIE": "Brokie", + "BRONZ": "BitBronze", + "BROOBEE": "ROOBEE", + "BROOT": "BROOT", + "BROTHER": "BROTHER", + "BROWN": "BrowniesSwap", + "BROZ": "Brozinkerbell", + "BRP": "BananaRepublic", + "BRRR": "Burrow", + "BRS": "Broovs Projects", + "BRT": "Bikerush", + "BRTR": "Barter", + "BRTX": "Bertinity", + "BRUH": "Bruh", + "BRUNE": "BitRunes", + "BRUSH": "PaintSwap", + "BRUV": "Bruv", + "BRWL": "Blockchain Brawlers", + "BRWS": "Browsr Ai", + "BRX": "Breakout Stake", + "BRY": "Berry Data", + "BRYLL": "Bryllite", + "BRZ": "Brazilian Digital Token", + "BRZE": "Breezecoin", + "BRZN": "Brayzin", + "BS": "BlackShadowCoin", + "BSAFE": "BlockSafe", + "BSAFU": "BlockSAFU", + "BSAI": "Bitcoin Silver AI", + "BSATOSHI": "BabySatoshi", + "BSB": "Based Street Bets", + "BSC": "BSC Layer", + "BSCAKE": "Bunscake", + "BSCBURN": "BSCBURN", + "BSCC": "BSCCAT", + "BSCGIRL": "Binance Smart Chain Girl", + "BSCH": "BitSchool", + "BSCM": "BSC MemePad", + "BSCPAD": "BSCPAD", + "BSCPAY": "BSC PAYMENTS", + "BSCS": "BSC Station", + "BSCST": "Starter", + "BSCV": "Bscview", + "BSDETH": "Based ETH", + "BSE": "base season", + "BSEN": "Baby Sen by Sentio", + "BSEND": "BitSend", + "BSFM": "BABY SAFEMOON", + "BSG": "Baby Squid Game", + "BSGG": "Betswap.gg", + "BSGS": "Basis Gold Share", + "BSHARE": "Bomb Money", + "BSHIB": "Based Shiba Inu", + "BSI": "Bali Social Integrated", + "BSK": "BTCSKR", + "BSKT": "BasketCoin", + "BSL": "BankSocial", + "BSOL": "BlazeStake Staked SOL", + "BSOP": "Bsop", + "BSOV": "BitcoinSoV", + "BSP": "BallSwap", + "BSPM": "Bitcoin Supreme", + "BSPT": "Blocksport", + "BSR": "BitSoar Coin", + "BSSB": "BitStable Finance", + "BST": "Blocksquare Token", + "BSTAR": "Blackstar", + "BSTC": "BST Chain", + "BSTER": "Bster", + "BSTK": "BattleStake", + "BSTN": "BitStation", + "BSTR": "BSTR", + "BSTS": "Magic Beasties", + "BSTY": "GlobalBoost", + "BSU": "Baby Shark Universe Token", + "BSV": "Bitcoin SV", + "BSVBRC": "BSVBRC", + "BSW": "Biswap", + "BSWAP": "BaseSwap", + "BSWT": "BaySwap", + "BSX": "BSX", + "BSY": "Bestay", + "BSYS": "BSYS", + "BT": "BT.Finance", + "BT1": "Bitfinex Bitcoin Future", + "BT2": "Bitcoin SegWit2X", + "BTA": "Bata", + "BTAD": "Bitcoin Adult", + "BTAF": "BTAF token", + "BTAMA": "Basetama", + "BTB": "BitBar", + "BTBL": "Bitball", + "BTBS": "BitBase Token", + "BTBTX": "Bit Digital xStock", + "BTC": "Bitcoin", + "BTC2": "Bitcoin 2", + "BTC2XFLI": "BTC 2x Flexible Leverage Index", + "BTC6900": "Bitcoin 6900", + "BTC70000": "BTC 70000", + "BTCA": "BITCOIN ADDITIONAL", + "BTCAB": "Bitcoin Avalanche Bridged", + "BTCACT": "BITCOIN Act", + "BTCAI": "BTC AI Agent", + "BTCAS": "BitcoinAsia", + "BTCAT": "Bitcoin Cat", + "BTCB": "Bitcoin BEP2", + "BTCBAM": "BitCoin Bam", + "BTCBASE": "Bitcoin on Base", + "BTCBR": "Bitcoin BR", + "BTCBRV1": "Bitcoin BR v1", + "BTCBULL": "BTC Bull", + "BTCC": "Bitcoin Core", + "BTCD": "BitcoinDark", + "BTCDRAGON": "BTC Dragon", + "BTCE": "EthereumBitcoin", + "BTCEX": "BtcEX", + "BTCF": "BitcoinFile", + "BTCGO": "BitcoinGo", + "BTCH": "Bitcoin Hush", + "BTCHD": "Bitcoin HD", + "BTCINU": "Bitcoin Inu", + "BTCIX": "BITCOLOJIX", + "BTCJ": "Bitcoin (JustCrypto)", + "BTCK": "Bitcoin Turbo Koin", + "BTCL": "BTC Lite", + "BTCM": "BTCMoon", + "BTCMT": "Minto", + "BTCN": "Bitcorn", + "BTCNOW": "Blockchain Technology Co.", + "BTCONETH": "bitcoin on Ethereum", + "BTCP": "Bitcoin Palladium", + "BTCPAY": "Bitcoin Pay", + "BTCPR": "Bitcoin Pro", + "BTCPT": "Bitcoin Platinum", + "BTCPX": "BTC Proxy", + "BTCR": "BitCurrency", + "BTCRED": "Bitcoin Red", + "BTCRY": "BitCrystal", + "BTCS": "BTCs", + "BTCSR": "BTC Strategic Reserve", + "BTCST": "BTC Standard Hashrate Token", + "BTCTOKEN": "Bitcoin Token", + "BTCUS": "Bitcoinus", + "BTCV": "Bitcoin Vault", + "BTCVB": "BitcoinVB", + "BTCZ": "BitcoinZ", + "BTD": "Bitcloud", + "BTDX": "Bitcloud 2.0", + "BTE": "Betero", + "BTECOIN": "BTEcoin", + "BTELEGRAM": "BetterTelegram Token", + "BTEV1": "Betero v1", + "BTEX": "BTEX", + "BTF": "Bitfinity Network", + "BTFA": "Banana Task Force Ape", + "BTG": "Bitcoin Gold", + "BTGON": "B2Gold (Ondo Tokenized)", + "BTH": "Bithereum", + "BTK": "Bostoken", + "BTL": "Bitlocus", + "BTLC": "BitLuckCoin", + "BTM": "Bytom", + "BTMETA": "BTCASH", + "BTMG": "Bitcademy Football", + "BTMI": "BitMiles", + "BTMK": "BitMark", + "BTMT": "BITmarkets Token", + "BTMXBULL": "3X Long BitMax Token Token", + "BTNT": "BitNautic Token", + "BTNTV2": "BitNautic Token", + "BTNYX": "BitOnyx Token", + "BTO": "Bottos", + "BTOP": "Botopia.Finance", + "BTORO": "Bitoro Network", + "BTP": "Bitpaid", + "BTPL": "Bitcoin Planet", + "BTQ": "BitQuark", + "BTR": "BTRIPS", + "BTRC": "Bitro Coin", + "BTRFLY": "Redacted Cartel", + "BTRL": "BitcoinRegular", + "BTRM": "Betrium Token", + "BTRN": "Biotron", + "BTRS": "Bitball Treasure", + "BTRST": "Braintrust", + "BTRU": "Biblical Truth", + "BTRUMP": "Baron Trump", + "BTS": "Bitshares", + "BTSC": "BTS Chain", + "BTSE": "BTSE Token", + "BTSG": "BitSong", + "BTSGV1": "BitSong v1", + "BTSLA": "Backed Tesla", + "BTT": "BitTorrent", + "BTTF": "Coin to the Future", + "BTTOLD": "BitTorrent", + "BTTR": "BitTiger", + "BTTY": "Bitcointry Token", + "BTU": "BTU Protocol", + "BTV": "Bitvote", + "BTW": "BitWhite", + "BTX": "Bitradex Token", + "BTXC": "Bettex coin", + "BTY": "Bityuan", + "BTYC": "BigTycoon", + "BTZ": "BitzCoin", + "BTZC": "BeatzCoin", + "BTZN": "Bitzon", + "BU": "BUMO", + "BUB": "BUBCAT", + "BUBB": "Bubb", + "BUBBA": "Bubba", + "BUBBLE": "Bubble", + "BUBBLES": "BUBBLES", + "BUBO": "Budbo", + "BUBU": "BUBU", + "BUBV1": "BUBCAT v1", + "BUC": "Beau Cat", + "BUCK": "GME Mascot", + "BUCKAZOIDS": "Buckazoids", + "BUCKS": "SwagBucks", + "BUCKY": "Bucky", + "BUD": "Buddy", + "BUDDHA": "Buddha", + "BUDDY": "alright buddy", + "BUDDYONSOL": "BUDDY", + "BUDG": "Bulldogswap", + "BUENO": "Bueno", + "BUF": "Buftoad", + "BUFF": "Buffalo Swap", + "BUFFDOGE": "Buff Doge", + "BUFFET": "Worried", + "BUFFI": "Bufficorn", + "BUGATTI": "BUGATTI", + "BUGG": "Bugg Inu", + "BUGS": "Bugs Bunny", + "BUI": "Build forward", + "BUIDL": "BlackRock USD Institutional Digital Liquidity Fund", + "BUIDLI": "BlackRock USD Institutional Digital Liquidity Fund - I Class", + "BUIL": "BUILD", + "BUILD": "BuildAI", + "BUILDIN": "Buildin Token", + "BUILDON": "Build On BNB", + "BUILDTEAM": "BuildTeam", + "BUILT": "Built Different", + "BUK": "CryptoBuk", + "BUL": "bul", + "BULDAK": "Buldak", + "BULEI": "Bulei", + "BULL": "Tron Bull", + "BULLA": "BULLA", + "BULLBEAR": "BullBear AI", + "BULLC": "BuySell", + "BULLF": "BULL FINANCE", + "BULLGOD": "Bull God", + "BULLI": "Bullish On Ethereum", + "BULLIEVERSE": "Bullieverse", + "BULLINU": "Bull inu", + "BULLIONFX": "BullionFX", + "BULLISH": "Bullish Degen", + "BULLISHCOIN": "bullish", + "BULLMOON": "Bull Moon", + "BULLPEPE": "Bull Pepe", + "BULLPEPEIO": "Bullpepe", + "BULLPEPENET": "Bull Pepe", + "BULLS": "Bull Coin", + "BULLSEYE": "bulls-eye", + "BULLSH": "Bullshit Inu", + "BULLY": "Dolos The Bully", + "BULLYINGCAT": "Bullying Cat", + "BULT": "Bullit", + "BUM": "WillyBumBum", + "BUMN": "BUMooN", + "BUMP": "Bumper", + "BUN": "BunnyCoin", + "BUND": "Bund V2.0", + "BUNDL": "Bundl Tools", + "BUNI": "Bunicorn", + "BUNKER": "BunkerCoin", + "BUNNI": "Bunni", + "BUNNIE": "Bunnie", + "BUNNIV1": "Timeless", + "BUNNY": "Pancake Bunny", + "BUNNYINU": "Bunny Inu", + "BUNNYM": "BUNNY MEV BOT", + "BUNNYROCKET": "BunnyRocket", + "BURG": "Burger", + "BURGER": "Burger Swap", + "BURN": "BurnedFi", + "BURNDOGE": "BurnDoge", + "BURNIFYAI": "BurnifyAI", + "BURNKING": "BurnKing", + "BURNNY": "Burnny Inu", + "BURNS": "Burnsdefi", + "BURNZ": "BURNZ", + "BURP": "CoinBurp", + "BURRRD": "BURRRD", + "BURT": "BURT", + "BUSD": "Binance USD", + "BUSD0": "Bond USD0", + "BUSDC": "BUSD", + "BUSY": "Busy DAO", + "BUT": "Bucket Token", + "BUTT": "Buttercat", + "BUTTC": "Buttcoin", + "BUTTCOIN": "The Next Bitcoin", + "BUTTHOLE": "Butthole Coin", + "BUTTPLUG": "fartcoin killer", + "BUTWHY": "ButWhy", + "BUX": "BUX", + "BUXCOIN": "Buxcoin", + "BUY": "Burency", + "BUYI": "Buying.com", + "BUYT": "Buy the DIP", + "BUZ": "BUZ", + "BUZZ": "Hive AI", + "BUZZCOIN": "BuzzCoin", + "BV3A": "Buccaneer V3 Arbitrum", + "BVC": "BeaverCoin", + "BVM": "BVM", + "BVND": "Binance VND", + "BVO": "BRAVO Pay", + "BVT": "BovineVerse Token", + "BWB": "Bitget Wallet Token", + "BWEN": "Baby Wen", + "BWF": "Beowulf", + "BWJ": "Baby WOJ", + "BWK": "Bulwark", + "BWLD": "Bowled.io", + "BWN": "BitWings", + "BWO": "Battle World", + "BWS": "BitcoinWSpectrum", + "BWT": "Bittwatt", + "BWT2": "Bitwin 2.0", + "BWULL": "Bwull", + "BWX": "Blue Whale", + "BX": "BlockXpress", + "BXA": "Blockchain Exchange Alliance", + "BXBT": "BoxBet", + "BXC": "BonusCloud", + "BXE": "Banxchange", + "BXF": "BlackFort Token", + "BXH": "BXH", + "BXK": "Bitbook Gambling", + "BXMI": "Bxmi Token", + "BXN": "BlackFort Exchange Network", + "BXR": "Blockster", + "BXT": "BitTokens", + "BXTB": "BXTB Foundation", + "BXX": "Baanx", + "BXXV1": "Baanx v1", + "BXY": "Beaxy", + "BYAT": "Byat", + "BYB": "BiorBank", + "BYC": "ByteCent", + "BYG": "Black Eye Galaxy", + "BYIN": "BYIN", + "BYT": "ByteAI", + "BYTE": "Byte", + "BYTES": "Neo Tokyo", + "BYTHER": "Bytether ", + "BYTS": "Bytus", + "BYTZ": "BYTZ", + "BZ": "Bit-Z", + "BZE": "BeeZee", + "BZENIQ": "Wrapped Zeniq (BNB)", + "BZET": "Bzetcoin", + "BZKY": "Bizkey", + "BZL": "BZLCoin", + "BZNT": "Bezant", + "BZR": "Bazaars", + "BZRX": "bZx Protocol", + "BZX": "Bitcoin Zero", + "BZZ": "Swarmv", + "BZZONE": "Bzzone", + "C": "Chainbase Token", + "C1USD": "Currency One USD", + "C1USDV1": "Currency One USD", + "C2": "Coin.2", + "C20": "Crypto20", + "C25": "C25 Coin", + "C2H6": "Ethane", + "C2X": "C2X", + "C3": "Charli3", + "C98": "Coin98", + "CA": "Coupon Assets", + "CAAVE": "cAAVE", + "CAB": "CabbageUnit", + "CABO": "CatBonk", + "CABS": "CryptoABS", + "CACAO": "Maya Protocol", + "CACH": "Cachecoin", + "CACHE": "Cache", + "CACHEGOLD": "CACHE Gold", + "CACTUS": "CACTUS", + "CACXT": "Convertible ACXT", + "CADAI": "CADAI", + "CADC": "CAD Coin", + "CADINU": "Canadian Inuit Dog", + "CADN": "Content and AD Network", + "CADX": "eToro Canadian Dollar", + "CAESAR": "Caesar's Arena", + "CAF": "Childrens Aid Foundation", + "CAG": "Change", + "CAGA": "Crypto Asset Governance Alliance", + "CAH": "Moon Tropica", + "CAI": "CharacterX", + "CAID": "ClearAid", + "CAILA": "Caila", + "CAIR": "Crypto-AI-Robo.com", + "CAITOKEN": "Cai Token", + "CAIV": "CARVIS", + "CAIX": "CAIx", + "CAIZ": "Caizcoin", + "CAKE": "PancakeSwap", + "CAKEBOT": "CakeBot", + "CAKEMOON": "CakeMoon", + "CAKESWAP": "CakeSwap", + "CAKEW": "CakeWSwap", + "CAL": "FitBurn", + "CALC": "CaliphCoin", + "CALCI": "Calcium", + "CALI": "CaliCoin", + "CALL": "Global Crypto Alliance", + "CALLISTO": "Callisto Network", + "CALLS": "OnlyCalls by Virtuals", + "CALO": "Calo", + "CALVIN": "CALVIN", + "CAM": "Consumption Avatar Matrix", + "CAMC": "Camcoin", + "CAMEL": "The Camel", + "CAMINO": "Camino Network", + "CAMLY": "Camly Coin", + "CAMP": "Camp Network", + "CAMPGLOBAL": "Camp", + "CAMT": "CAMELL", + "CAN": "Channels", + "CANCER": "Cancer", + "CAND": "Canary Dollar", + "CANDLE": "Candle TV", + "CANDLECAT": "Candle Cat", + "CANDY": "UnicornGo Candy", + "CANDYLAD": "Candylad", + "CANN": "CannabisCoin", + "CANNF": "CANNFINITY", + "CANTI": "Cantina Royale", + "CANTO": "CANTO", + "CANYA": "CanYaCoin", + "CAOCAO": "CaoCao", + "CAP": "Capverto", + "CAPA": "Cake Panda", + "CAPD": "Capdax", + "CAPO": "IL CAPO OF CRYPTO", + "CAPP": "Cappasity", + "CAPRI": "Caprisun Monkey", + "CAPRICOIN": "CapriCoin", + "CAPS": "Ternoa", + "CAPT": "Bitcoin Captain", + "CAPTAINBNB": "CaptainBNB", + "CAPTAINPLANET": "Captain Planet", + "CAPY": "Capybara", + "CAPYBARA": "Capybara", + "CAPYBARA1995": "Capybara", + "CAR": "Central African Republic Meme", + "CARAT": "AlaskaGoldRush", + "CARATSTOKEN": "Carats Token", + "CARBLOCK": "CarBlock", + "CARBO": "CleanCarbon", + "CARBON": "Carbon", + "CARBONCOIN": "Carboncoin", + "CARBONGEMS": "Carbon GEMS", + "CARBONUSD": "Carbon", + "CARD": "Cardstack", + "CARDS": "Collector Crypt", + "CARDSTARTER": "Cardstarter", + "CARDSWAP": "CardSwap", + "CARE": "CareCoin", + "CAREBIT": "Carebit", + "CARES": "CareCoin", + "CARL": "Carl", + "CARLO": "Carlo", + "CARO": "Meta Ricaro", + "CAROL": "CAROLToken", + "CARPE": "CarpeDiemCoin", + "CARR": "Carnomaly", + "CARROT": "Carrot by Puffer", + "CARROTSWAP": "CarrotSwap", + "CART": "CryptoArt.Ai", + "CARTAXI": "CarTaxi", + "CARTERCOIN": "CarterCoin", + "CARTIER": "Cartier", + "CARV": "CARV", + "CAS": "Cashaa", + "CASH": "CashCoin", + "CASHIO": "Cashio Dollar", + "CASHLY": "Cashly", + "CASHT": "Cash Tech", + "CASINU": "Casinu Inu", + "CASIO": "CasinoXMetaverse", + "CASPER": "Casper DeFi", + "CASPERTOKEN": "Casper Token", + "CASPUR": "Caspur Zoomies", + "CAST": "CAST ORACLES", + "CASTELLOCOIN": "Castello Coin", + "CASTLE": "bitCastle", + "CAT": "Simon's Cat", + "CATA": "CATAMOTO", + "CATABSC": "CATA BSC", + "CATAI": "Catgirl AI", + "CATALORIAN": "CATALORIAN", + "CATANA": "Catana", + "CATBA": "CATBA INU", + "CATBAL": "Catbal", + "CATBOY": "Catboy", + "CATC": "Catcoin", + "CATCEO": "CATCEO", + "CATCH": "SpaceCatch", + "CATCO": "CatCoin", + "CATCOIN": "CatCoin", + "CATCOINETH": "Catcoin", + "CATCOINIO": "Catcoin", + "CATCOINOFSOL": "Cat Coin", + "CATCOINV2": "CatCoin Cash", + "CATDOG": "Cat-Dog", + "CATDOGE": "CAT DOGE", + "CATE": "Cate on ETH", + "CATEC": "Cate Coin", + "CATECOIN": "CateCoin", + "CATELON": "CatElonMars", + "CATEX": "CATEX", + "CATFISH": "Catfish", + "CATG": "Crypto Agent Trading", + "CATGAME": "Cookie Cat Game", + "CATGIRL": "Catgirl", + "CATGOKU": "Catgoku", + "CATGOLD": "Cat Gold Miner", + "CATGPT": "CatGPT", + "CATHAT": "catwifhat", + "CATHEON": "Catheon Gaming", + "CATHERO": "Cat Hero", + "CATI": "Catizen", + "CATINU": "CAT INU", + "CATKING": "CAT KING", + "CATLIFE": "Cat Life", + "CATMAN": "Catman", + "CATME": "ELON’S CAT", + "CATO": "CATO", + "CATPAY": "CATpay", + "CATPEPE": "CAT PEPE", + "CATS": "Cats", + "CATSC": "Catscoin", + "CATSHIRA": "Shira Cat", + "CATSO": "Cats Of Sol", + "CATSON": "Catson", + "CATSV1": "CatCoin Token v1", + "CATSV2": "CatCoin Token", + "CATSY": "CAT SYLVESTER", + "CATT": "Catex", + "CATTO": "Cat Token", + "CATTON": "Catton AI", + "CATVAX": "Catvax", + "CATVILLS": "Catvills Coin", + "CATW": "Cat wif Hands", + "CATWARRIOR": "Cat warrior", + "CATWIF": "CatWifHat", + "CATWIFM": "catwifmask", + "CATX": "CAT.trade Protocol", + "CATZ": "CatzCoin", + "CAU": "Canxium", + "CAUSE": "Causecoin", + "CAV1": "Coupon Assets v1", + "CAVA": "Cavapoo", + "CAVADA": "Cavada", + "CAVE": "Deepcave", + "CAVO": "Excavo Finance", + "CAW": "A Hunters Dream", + "CAWCEO": "CAW CEO", + "CB": "COINBIG", + "CBAB": "CreBit", + "CBABY": "Cosmo Baby", + "CBANK": "Crypto Bank", + "CBAT": "Compound Basic Attention Token", + "CBBTC": "Coinbase Wrapped BTC", + "CBBTCBASE": "cbBTC", + "CBC": "Casino Betting Coin", + "CBD": "CBD Crystals", + "CBDAI": "Dai (Cronos Bridge)", + "CBDC": "CannaBCoin", + "CBDG": "CBD Global", + "CBE": "The Chain of Business Entertainment", + "CBET": "CryptoBet", + "CBETH": "Coinbase Wrapped Staked ETH", + "CBFT": "CoinBene Future Token", + "CBG": "Chainbing", + "CBIXP": "Cubiex Power", + "CBK": "Cobak Token", + "CBL": "Credbull", + "CBM": "CryptoBonusMiles", + "CBNB": "Community of BNB", + "CBNT": "Create Breaking News Together", + "CBOT": "C-BOT", + "CBP": "CashBackPro", + "CBPAY": "COINBAR PAY", + "CBRL": "Crypto BRL", + "CBRT": "Cybereits Token", + "CBS": "Cerberus", + "CBSL": "CeBioLabs", + "CBSN": "BlockSwap Network", + "CBT": "CommerceBlock Token", + "CBU": "Banque Universal", + "CBUCKS": "CRYPTOBUCKS", + "CBUK": "CurveBlock", + "CBX": "CropBytes", + "CBXRP": "Coinbase Wrapped XRP", + "CBY": "Carbify", + "CBYTE": "CBYTE", + "CC": "Canton Coin", + "CC10": "Cryptocurrency Top 10 Tokens Index", + "CCA": "CCA", + "CCAKE": "CheeseCake Swap", + "CCAR": "CryptoCars", + "CCASH": "C-cash", + "CCAT": "Crypto Cat", + "CCC": "CCCoin", + "CCCX": "Clipper Coin Capital", + "CCD": "Concordium", + "CCDOG": "Courage The Dog", + "CCDS": "CCDS INTERNATIONAL", + "CCE": "CloudCoin", + "CCGDS": "CCGDS", + "CCH": "Coinchase", + "CCHG": "C+Charge", + "CCI": "Cyber Capital Invest", + "CCIN": "Cryptocoin Insurance", + "CCL": "CyClean", + "CCN": "CannaCoin", + "CCO": "Ccore", + "CCO2": "Carbon Capture", + "CCOIN": "Creditcoin", + "CCOMM": "Crypto Commonwealth", + "CCOMP": "cCOMP", + "CCOS": "CrowdCoinage", + "CCP": "CryptoCoinPay", + "CCRB": "CryptoCarbon", + "CCT": "Carbon Credit", + "CCTN": "Connectchain", + "CCV2": "CelebrityCoinV2", + "CCX": "Conceal", + "CCXC": "CoolinDarkCoin", + "CCXX": "CounosX", + "CDAG": "CannDollar", + "CDAI": "Compound Dai", + "CDBIO": "CDbio", + "CDCETH": "Crypto.com Staked ETH", + "CDCSOL": "Crypto.com Staked SOL", + "CDEX": "Cryptodex", + "CDG": "CDG Project", + "CDL": "Creditlink", + "CDN": "Canada eCoin", + "CDOG": "Corn Dog", + "CDOGE": "cyberdoge", + "CDPT": "Creditor Data Platform", + "CDRX": "CDRX", + "CDT": "CheckDot", + "CDX": "CDX Network", + "CDY": "Bitcoin Candy", + "CDragon": "Clumsy Dragon", + "CEC": "Counterfire Economic Coin", + "CEDEX": "CEDEX Coin", + "CEEK": "CEEK Smart VR Token", + "CEFS": "CryptopiaFeeShares", + "CEICAT": "CEILING CAT", + "CEJI": "Ceji", + "CEL": "Celsius Network", + "CELA": "Cellula Token", + "CELB": "Celb Token", + "CELEB": "CELEBPLUS", + "CELL": "Cellframe", + "CELO": "Celo", + "CELR": "Celer Network", + "CELT": "Celestial", + "CEM": "Crypto Emergency", + "CEN": "Coinsuper Ecosystem Network", + "CENNZ": "Centrality Token", + "CENS": "Censored Ai", + "CENT": "CENTERCOIN", + "CENTA": "Centaurify", + "CENTRA": "Centra", + "CENTS": "Centience", + "CENX": "Centcex", + "CEO": "CEO", + "CEODOGE": "CEO DOGE", + "CERBER": "CERBEROGE", + "CERE": "Cere Network", + "CEREB": "Cerebrum", + "CERES": "Ceres", + "CES": "swap.coffee", + "CESC": "Crypto Escudo", + "CESS": "CESS Token", + "CET": "CoinEx Token", + "CETES": "Etherfuse CETES", + "CETH": "Compound Ethereum", + "CETI": "CETUS Coin", + "CETUS": "Cetus Protocol", + "CEUR": "Celo Euro", + "CEX": "Catena X", + "CF": "Californium", + "CFC": "CoinField Coin", + "CFD": "Confido", + "CFF": "Coffe", + "CFG": "Centrifuge", + "CFGV1": "Centrifuge", + "CFI": "CyberFi Token", + "CFL365": "CFL365 Finance", + "CFLASH": "Flash", + "CFLO": "Chain Flowers", + "CFN": "Cockfight Network", + "CFT": "CryptoForecast", + "CFTY": "Crafty", + "CFX": "Conflux Network", + "CFXQ": "CFX Quantum", + "CFXT": "Chainflix", + "CFun": "CFun", + "CGA": "Cryptographic Anomaly", + "CGAI": "GDAI Agent", + "CGAR": "CryptoGuards", + "CGG": "Chain Guardians", + "CGL": "Crypto Gladiator Shards", + "CGLD": "Celo Gold", + "CGN": "CYGNUS", + "CGO": "Comtech Gold", + "CGPT": "ChainGPT", + "CGPU": "ChainGPU", + "CGS": "Crypto Gladiator Shards", + "CGT": "Coin Gabbar Token", + "CGTV1": "Curio Governance", + "CGTV2": "Curio Gas Token", + "CGU": "Crypto Gaming United", + "CGV": "Cogito Finance", + "CGX": "Forkast", + "CHA": "Charity Coin", + "CHACHA": "Chacha", + "CHAD": "Chad Coin", + "CHADCAT": "CHAD CAT", + "CHADETTE": "Chadette", + "CHADS": "CHADS VC", + "CHAI": "Chroma AI", + "CHAIN": "Chain Games", + "CHAINCADE": "ChainCade", + "CHAINSOFWAR": "Chains of War", + "CHAL": "Chalice Finance", + "CHAM": "Champion", + "CHAMP": "Super Champs", + "CHAMPZ": "Champz", + "CHAN": "ChanCoin", + "CHANCE": "Ante Casino", + "CHANEL": "Chanel", + "CHANG": "Chang", + "CHANGE": "ChangeX", + "CHAO": "23 Skidoo", + "CHAOS": "chaos and disorder", + "CHAPZ": "Chappyz", + "CHARGED": "GoCharge Tech", + "CHARIZARD": "Charizard Inu", + "CHARL": "Charlie", + "CHARLI": "Charlie Trump Dog", + "CHARLIE": "Charlie Kirk", + "CHARM": "Charm Coin", + "CHARS": "CHARS", + "CHART": "BetOnChart", + "CHARTA": "CHARTAI", + "CHARTIQ": "ChartIQ", + "CHAS": "Chasm", + "CHASH": "CleverHash", + "CHAT": "Solchat", + "CHATAI": "ChatAI Token", + "CHATGPT": "AI Dragon", + "CHATOSHI": "chAtoshI", + "CHATTY": "ChatGPT's Mascot", + "CHB": "COINHUB TOKEN", + "CHBR": "CryptoHub", + "CHC": "ChainCoin", + "CHD": "CharityDAO", + "CHECK": "Checkmate", + "CHECKR": "CheckerChain", + "CHECOIN": "CheCoin", + "CHED": "Giggleched", + "CHEDDA": "Chedda", + "CHEEKS": "CHEEKS", + "CHEEL": "Cheelee", + "CHEEMS": "Cheems (cheems.pet)", + "CHEEMSCO": "Cheems", + "CHEEMSV1": "Cheems (cheems.pet) v1", + "CHEEPEPE": "CHEEPEPE", + "CHEERS": "DICAPRIO CHEERS", + "CHEESE": "Cheese", + "CHEESEBALL": "Cheeseball the Wizard", + "CHEESECOIN": "Cheesecoin", + "CHEESUS": "Cheesus", + "CHEF": "CoinChef", + "CHEFDOTFUN": "Chefdotfun", + "CHENG": "Chengshi", + "CHEQ": "CHEQD Network", + "CHER": "Cherry Network", + "CHERRY": "CherrySwap", + "CHESS": "Tranchess", + "CHESSCOIN": "ChessCoin", + "CHET": "ChetGPT", + "CHEW": "CHEWY", + "CHEWY": "Chewy", + "CHEX": "Chintai", + "CHEYENNE": "Cheyenne", + "CHFN": "NOKU CHF", + "CHFT": "Crypto Holding", + "CHFU": "Upper Swiss Franc", + "CHFX": "eToro Swiss Franc", + "CHH": "Chihuahua Token", + "CHI": "Chi Gastoken", + "CHIB": "Chiba Inu", + "CHICA": "CHICA", + "CHICKS": "SolChicks", + "CHIDO": "Chinese Doge Wow", + "CHIE": "Chief Pepe Officer", + "CHIEF": "TheChiefCoin", + "CHIEFD": "Chief D.O.G.E", + "CHIHUA": "Chihua Token", + "CHII": "Chiiper Chain", + "CHILD": "ChildCoin", + "CHILDAI": "Singularity's Child gonzoai", + "CHILI": "CHILI", + "CHILL": "ChillPill", + "CHILLAX": "Chillax", + "CHILLCAT": "Chillchat", + "CHILLGUY": "Chill Guy", + "CHILLHOUSE": "Chill House", + "CHIM": "Chimera", + "CHINA": "China Coin", + "CHINAU": "Chinau", + "CHINAZILLA": "ChinaZilla", + "CHINGON": "Mexico Chingon", + "CHINU": "Chubby Inu", + "CHIP": "Chip", + "CHIPI": "chipi", + "CHIPPY": "Chippy", + "CHIPS": "CHIPS", + "CHIRP": "Chirp Token", + "CHIRPFI": "Chirp", + "CHIRPY": "Chirpy Boy", + "CHITAN": "Chitan", + "CHITCAT": "ChitCAT", + "CHIWAWA": "Chiwawa", + "CHK": "Chek", + "CHKN": "Chickencoin", + "CHLOE": "Pnut's Sister", + "CHLT": "Chellitcoin", + "CHMB": "Chumbi Valley", + "CHMPZ": "Chimpzee", + "CHN": "Chain", + "CHNG": "Chainge Finance", + "CHO": "Choise", + "CHOKE": "Artichoke Protocol", + "CHOMP": "ChompCoin", + "CHON": "Chonk The Cat", + "CHONK": "Chonk", + "CHONKY": "CHONKY", + "CHOO": "Chooky", + "CHOOCH": "CHOOCH", + "CHOOF": "ChoofCoin", + "CHOPPER": "Chopper Inu", + "CHOPPY": "Choppy", + "CHORIZO": "Chorizo", + "CHORUZ": "Choruz AI", + "CHOW": "Chow Chow Finance", + "CHOY": "Bok Choy", + "CHP": "CoinPoker Token", + "CHPD": "Chirppad", + "CHR": "Chroma", + "CHRETT": "Chinese BRETT", + "CHRISPUMP": "Christmas Pump", + "CHRONOEFFE": "Chronoeffector", + "CHRP": "Chirpley", + "CHS": "Chainsquare", + "CHSB": "SwissBorg", + "CHT": "Countinghouse Fund", + "CHUANPU": "Chuan Pu", + "CHUB": "CallHub", + "CHUC": "CHUCK", + "CHUCHU": "CHUCHU", + "CHUCK": "Chuck Norris", + "CHUD": "Chudjak", + "CHULO": "Papichulo", + "CHUMP": "Donald J Chump", + "CHUMPC": "Chump Change", + "CHURRO": "CHURRO-The Jupiter Dog", + "CHVF": "Chives Finance", + "CHW": "Chrysalis Coin", + "CHWY": "CHEWY", + "CHX": "Own", + "CHY": "Concern Poverty Chain", + "CHZ": "Chiliz", + "CIC": "Crazy Internet Coin", + "CICHAIN": "CIChain", + "CIF": "Crypto Improvement Fund", + "CIFRON": "Cipher Mining (Ondo Tokenized)", + "CIG": "cig", + "CIM": "COINCOME", + "CIN": "CinderCoin", + "CIND": "Cindrum", + "CINNI": "CINNICOIN", + "CINU": "CHEEMS INU", + "CINUV1": "CHEEMS INU v1", + "CINX": "CINDX", + "CIOTX": "Crosschain IOTX", + "CIPHC": "Cipher Core Token", + "CIR": "CircuitCoin", + "CIRC": "CryptoCircuits", + "CIRCLE": "You Looked", + "CIRCUS": "Cirque Du Sol", + "CIRRUS": "Cirrus", + "CIRUS": "Cirus", + "CIRX": "Circular Protocol", + "CITADAIL": "Griffain New Hedge Fund", + "CITI": "CITI Fediverse", + "CITY": "Manchester City Fan Token", + "CIV": "Civilization", + "CIVIT": "Civitas Protocol", + "CIX": "Cryptonetix", + "CIX100": "Cryptoindex", + "CJ": "CryptoJacks", + "CJC": "CryptoJournal", + "CJL": "Cjournal", + "CJR": "Conjure", + "CJT": "ConnectJob Token", + "CKB": "Nervos Network", + "CKBTC": "Chain-key Bitcoin", + "CKC": "Clockcoin", + "CKEK": "CryptoKek", + "CKETH": "Chain-key Ethereum", + "CKP": "Cakepie", + "CKT": "Caketools", + "CKUSD": "CKUSD", + "CL": "CoinLancer", + "CLA": "ClaimSwap", + "CLAM": "CLAMS", + "CLANKER": "tokenbot", + "CLAP": "Clap Cat", + "CLAS": "Classic USDC", + "CLASH": "GeorgePlaysClashRoyale", + "CLASHUB": "Clashub", + "CLASS": "Class Coin", + "CLAWD": "clawd.atg.eth", + "CLAWNCH": "CLAWNCH", + "CLAY": "Clayton", + "CLAYN": "Clay Nation", + "CLB": "Cloudbric", + "CLBR": "Colibri Protocol", + "CLBTC": "clBTC", + "CLCT": "CollectCoin", + "CLD": "Cloud", + "CLDX": "Cloverdex", + "CLEAR": "Everclear", + "CLEARPOLL": "ClearPoll", + "CLEARWATER": "Clear Water", + "CLEG": "Chain of Legends", + "CLEO": "Cleo Tech", + "CLEV": "CLever Token", + "CLEVERCOIN": "CleverCoin", + "CLFI": "cLFi", + "CLH": "ClearDAO", + "CLICK": "Clickcoin", + "CLIFF": "Clifford Inu", + "CLIFFORD": "Clifford", + "CLIMB": "CLIMB TOKEN FINANCE", + "CLIN": "Clinicoin", + "CLINK": "cLINK", + "CLINT": "Clinton", + "CLIPPY": "Clippy", + "CLIPPYETH": "CLIPPY", + "CLIPS": "Clips", + "CLIQ": "DefiCliq", + "CLIST": "Chainlist", + "CLM": "CoinClaim", + "CLMRS": "Crolon Mars", + "CLN": "Colu Local Network", + "CLND": "COLEND", + "CLNX": "Coloniume Network", + "CLNY": "Colony", + "CLO": "Yei Finance", + "CLOA": "Cloak", + "CLOAK": "CloakCoin", + "CLOKI": "CATLOKI", + "CLOOTS": "CryptoLoots", + "CLORE": "Clore.ai", + "CLOUD": "Cloud", + "CLOUDCHAT": "CloudChat", + "CLOUDGPU": "CloudGPU", + "CLOUT": "BitClout", + "CLOUTIO": "Clout", + "CLOW": "Clown Pepe", + "CLPX": "Chynge.net", + "CLR": "CopperLark", + "CLRTY": "Clarity", + "CLS": "Coldstack", + "CLT": "CoinLoan", + "CLU": "CluCoin", + "CLUB": "ClubCoin", + "CLUD": "CludCoin", + "CLUSTR": "Clustr Labs", + "CLUTCH": "Clutch", + "CLV": "Clover Finance", + "CLVA": "Clever DeFi", + "CLVX": "Calvex", + "CLX": "Celeum", + "CLY": "Colony", + "CMA": "Crypto Market Ads", + "CMC": "CosmosCoin", + "CMCC": "CMC Coin", + "CMCSAX": "Comcast xStock", + "CMCT": "Crowd Machine", + "CMCX": "CORE MultiChain", + "CMDX": "Comdex", + "CMERGE": "CoinMerge", + "CMETH": "Mantle Restaked Ether", + "CMFI": "Compendium", + "CMINER": "ChainMiner", + "CMIT": "CMITCOIN", + "CMK": "Credmark", + "CMKR": "cMKR", + "CML": "Camelcoin", + "CMM": "Commercium", + "CMN": "Crypto Media Network", + "CMONK": "CRAZY MONKEY", + "CMOON": "CryptoMoonShot", + "CMOS": "CoinMerge OS", + "CMP": "Caduceus", + "CMPCO": "CampusCoin", + "CMPT": "Spatial Computing", + "CMPV2": "Caduceus Protocol", + "CMQ": "Communique", + "CMR": "U.S Critical Mineral Reserve", + "CMS": "COMSA", + "CMSN": "The Commission", + "CMT": "CyberMiles", + "CMTC": "CometCoin", + "CMZ": "CRYPTOMAGZ", + "CNAB": "Cannabium", + "CNAME": "Cloudname", + "CNB": "Coinsbit Token", + "CNBC": "Cash & Back Coin", + "CNC": "ChinaCoin", + "CNCL": "The Ordinals Council", + "CNCT": "CONNECT", + "CND": "Cindicator", + "CNDL": "Candle", + "CNETA": "AnetaBTC", + "CNF": "CryptoNeur Network foundation", + "CNFI": "Connect Financial", + "CNG": "Changer", + "CNHT": "Tether CNH", + "CNL": "ConcealCoin", + "CNMT": "Coinomat", + "CNN": "Content Neutrality Network", + "CNNS": "CNNS", + "CNO": "Coino", + "CNRG": "CryptoEnergy", + "CNS": "Centric Cash", + "CNT": "Centurion", + "CNTM": "Connectome", + "CNTR": "Centaur", + "CNUS": "CoinUs", + "CNX": "Cryptonex", + "CNYD": "Chinese NY Dragon", + "CNYT": "CNY Tether", + "CNYX": "eToro Chinese Yuan", + "CO": "Corite", + "CO2": "CO2 Token", + "COA": "Alliance Games", + "COAI": "ChainOpera AI", + "COAL": "BitCoal", + "COB": "Cobinhood", + "COBE": "Castle of Blackwater", + "COBY": "Coby", + "COC": "Coin of the champions", + "COCAINE": "THE GOOD STUFF", + "COCK": "Shibacock", + "COCO": "coco", + "COCOCOIN": "COCO COIN", + "COCONUT": "Coconut", + "COCOR": "Cocoro", + "COCORO": "Cocoro", + "COCOROBNB": "Cocoro", + "COCOROERC": "COCORO", + "COD": "Chief of Deswamp", + "CODA": "CODA", + "CODAI": "CODAI", + "CODE": "Code Token", + "CODEG": "CodeGenie", + "CODEMONG": "CodeMong Ai", + "CODEO": "Codeo Token", + "CODEXTOKEN": "CodexToken", + "CODI": "Codi Finance", + "CODY": "Coindy", + "COE": "CoEval", + "COFEEE": "COFEEE", + "COFFEE": "COFFEE", + "COFFEECOIN": "CoffeeCoin", + "COFI": "CoinFi", + "COFIX": "CoFIX", + "COFOUNDIT": "Cofound.it", + "COG": "Cognitio", + "COGE": "Cogecoin", + "COGEN": "Cogenero", + "COGI": "COGI", + "COGS": "Cogmento", + "COI": "Coinnec", + "COINAI": "Coinbase AI Agent", + "COINB": "Coinbidex", + "COINBANK": "CoinBank", + "COINBT": "CoinBot", + "COINBUCK": "Coinbuck", + "COINCOLLECT": "CoinCollect", + "COINDEALTOKEN": "CoinDeal Token", + "COINDEFI": "Coin", + "COINDEPO": "CoinDepo Token", + "COINEDELWEIS": "Coin Edelweis", + "COING": "Coingrid", + "COINH": "Coinhound", + "COINLION": "CoinLion", + "COINM": "CoinMarketPrime", + "COINONAT": "Coinonat", + "COINRADR": "CoinRadr", + "COINSCOPE": "Coinscope", + "COINSL": "CoinsLoot", + "COINVEST": "Coinvest", + "COINX": "Coinbase xStock", + "COINYE": "Coinye West", + "COJ": "Cojam", + "COK": "Cat Own Kimono", + "COKE": "Cocaine Cowboy Shards", + "COKEONS": "Coke on Sol", + "COL": "Clash of Lilliput", + "COLA": "Cola", + "COLISEUM": "Coliseum", + "COLL": "Collateral Pay", + "COLLAB": "Collab.Land", + "COLLAR": "PolyPup Finance", + "COLLAT": "Collaterize", + "COLLE": "Collective Care", + "COLLEA": "Colle AI", + "COLLECT": "Collect on Fanable", + "COLLG": "Collateral Pay Governance", + "COLON": "Colon", + "COLR": "colR Coin", + "COLS": "Cointel", + "COLT": "Collateral Network", + "COLX": "ColossusCoinXT", + "COM": ".com", + "COMAI": "Commune AI", + "COMB": "Combo", + "COMBO": "COMBO", + "COMBOX": "ComBox", + "COMC": "ComCrica Token", + "COME": "Community of Meme", + "COMEW": "Coin In Meme World", + "COMFI": "CompliFi", + "COMM": "Community Coin", + "COMMON": "COMMON", + "COMMS": "CallofMeme", + "COMMUNITYCOIN": "Community Coin", + "COMP": "Compound", + "COMPCOIN": "Compcoin", + "COMPD": "Compound Coin", + "COMPU": "Compute Network", + "COMT": "Community Token", + "CONAN": "Conan", + "CONC": "Concentrator", + "CONCHO": "Sapo Concho", + "CONDENSATE": "Condensate", + "CONDO": "CONDO", + "CONE": "BitCone", + "CONG": "The Conglomerate Capital", + "CONI": "CoinBene", + "CONJ": "Conjee", + "CONK": "ShibaPoconk", + "CONS": "ConSpiracy Coin", + "CONSCIOUS": "Conscious Token", + "CONSENTIUM": "Consentium", + "CONTENTBOX": "ContentBox", + "CONTROL": "Control Token", + "CONV": "Convergence", + "CONVO": "Prefrontal Cortex Convo Agent by Virtuals", + "CONX": "Connex", + "CONY": "Cony", + "COO": "Cool Cats MILK", + "COOCHIE": "Cucci", + "COOHA": "CoolHash", + "COOK": "COOK", + "COOKIE": "Cookie", + "COOKTOKEN": "Cook", + "COOL": "CoolCoin", + "COOP": "Coop Network", + "COPA": "COCO PARK", + "COPE": "Cope", + "COPI": "Cornucopias", + "COPIO": "Copiosa Coin", + "COPIUM": "Copium", + "COPPER": "COPPER", + "COPS": "Cops Finance", + "COPXON": "Global X Copper Miners ETF (Ondo Tokenized)", + "COPYCAT": "Copycat Finance", + "COQ": "Coq Inu", + "COR": "Coreto", + "CORA": "Cora by Virtuals", + "CORAL": "Coral Protocol", + "CORALPAY": "CoralPay", + "CORALSWAP": "Coral Swap", + "CORE": "Core", + "COREC": "CoreConnect", + "COREDAO": "coreDAO", + "COREG": "Core Group Asset", + "COREK": "Core Keeper", + "COREUM": "Coreum", + "CORGI": "Corgi Inu", + "CORGIAI": "CorgiAI", + "CORGIB": "The Corgi of PolkaBridge", + "CORION": "Corion", + "CORL": "Coral Finance", + "CORN": "Corn", + "CORNELLA": "CORNELLA", + "CORNFIELDFARM": "CORN", + "CORSI": "Cane Corso", + "CORTEX": "Cortex Protocol", + "CORX": "CorionX", + "CORXB": "CorionX BSC", + "COS": "Contentos", + "COSHI": "CoShi Inu", + "COSM": "CosmoChain", + "COSMI": "Cosmic FOMO", + "COSMIC": "CosmicSwap", + "COSMICN": "Cosmic Network", + "COSP": "Cosplay Token", + "COSS": "COS", + "COST": "Costco Hot Dog", + "COSX": "Cosmecoin", + "COT": "CoTrader", + "COTI": "COTI", + "COTS": "Children Of The Sky", + "COU": "Couchain", + "COUNOS": "Counos Coin", + "COUNOSBIT": "Counos Bit", + "COUNOSH": "Counos H", + "COUNOSU": "Counos U", + "COUP": "CouponBay", + "COURAGE": "Courage the Cowardly Dog", + "COV": "Covesting", + "COVA": "COVA", + "COVAL": "Circuits of Value", + "COVER": "Cover Protocol", + "COVERV1": "Cover Protocol (old)", + "COVEX": "CoVEX", + "COVIDTOKEN": "Covid Token", + "COVIR": "COVIR", + "COVN": "Covenant", + "COW": "CoW Protocol", + "COWRIE": "MYCOWRIE", + "COX": "CobraCoin", + "COY": "Coin Analyst", + "COZP": "COZPlus", + "COZY": "Cozy Pepe", + "CP": "CoPuppy", + "CPA": "CryptoPulse AdBot", + "CPAD": "Cronospad", + "CPAN": "CryptoPlanes", + "CPAY": "CryptoPay", + "CPC": "CPChain", + "CPCOIN": "CPCoin", + "CPD": "CoinsPaid", + "CPET": "Chain Pet", + "CPEX": "CoinPulseToken", + "CPH": "Cypherium", + "CPI": "Crypto Price Index", + "CPIGGY": "Vix Finance", + "CPL": "CoinPlace Token", + "CPLO": "Cpollo", + "CPM": "Crypto Pump Meme", + "CPN": "CompuCoin", + "CPNGON": "Coupang (Ondo Tokenized)", + "CPO": "Cryptopolis", + "CPOO": "Cockapoo", + "CPOOL": "Clearpool", + "CPOS": "Cpos Cloud Payment", + "CPR": "Cipher", + "CPROP": "CPROP", + "CPRX": "Crypto Perx", + "CPS": "Cryptostone", + "CPT": "Cryptaur", + "CPTN": "Captain Max", + "CPU": "CPUcoin", + "CPX": "Apex Token", + "CPXTB": "Coin Prediction Tool On Base", + "CPY": "COPYTRACK", + "CQST": "ConquestCoin", + "CQT": "Covalent", + "CR": "CryptoRiyal", + "CR8": "Crazy8Token", + "CRA": "Crabada", + "CRAB": "CrabCoin", + "CRACER": "Coinracer Reloaded", + "CRACK": "CrackCoin", + "CRADLE": "Cradle of Sins", + "CRAFT": "TaleCraft", + "CRAFTCOIN": "Craftcoin", + "CRAI": "Cryptify AI", + "CRAIG": "CraigsCoin", + "CRAMER": "Cramer Coin", + "CRANEPAY": "Cranepay", + "CRAPPY": "CrappyBird", + "CRASH": "Solana Crash", + "CRASHBOYS": "CRASHBOYS", + "CRAT": "CratD2C", + "CRAVE": "CraveCoin", + "CRAYRABBIT": "CrazyRabbit", + "CRAZ": "CRAZY FLOKI", + "CRAZE": "Craze", + "CRAZYB": "Crazy Bunny", + "CRAZYBONK": "CRAZY BONK", + "CRAZYBUNNY": "Crazy Bunny", + "CRAZYCAT": "CRAZY CAT", + "CRAZYDOGE": "CRAZY DOGE", + "CRAZYDRAGON": "CRAZY DRAGON", + "CRAZYMUSK": "CRAZY MUSK", + "CRAZYPEPE": "CrazyPepe", + "CRAZYT": "CRAZY TRUMP", + "CRAZYTIGER": "CRAZY TIGER", + "CRB": "Creditbit", + "CRBN": "Carbon", + "CRBRUS": "Cerberus", + "CRC": "CryCash", + "CRCL": "Circle", + "CRCLX": "Circle xStock", + "CRD": "CRD Network", + "CRDC": "Cardiocoin", + "CRDN": "Cardence", + "CRDNC": "Credence Coin", + "CRDS": "Credits", + "CRDT": "CRDT", + "CRDTS": "Credits", + "CRE": "Carry", + "CRE8": "Creaticles", + "CREA": "CreativeChain", + "CREAL": "Celo Brazilian Real", + "CREAM": "Cream", + "CREAML": "Creamlands", + "CREATIVE": "Creative Token", + "CRED": "Credia Layer", + "CREDI": "Credefi", + "CREDIT": "Credit", + "CREDITS": "Credits", + "CREDO": "Credo", + "CREED": "Thecreed", + "CREMAT": "Cremation Coin", + "CREMEPUFF": "Creme Puff", + "CREO": "Creo Engine", + "CREP": "Compound Augur", + "CREPE": "CREPE", + "CREPECOIN": "Crepe Coin", + "CRES": "Cresio", + "CRESV1": "Cresio v1", + "CRETA": "Creta World", + "CREV": "CryptoRevolution", + "CREVA": "Creva Coin", + "CREW": "CREW INU", + "CRF": "Crafting Finance", + "CRFI": "CrossFi", + "CRGO": "CargoCoin", + "CRGPT": "CryptoGPT", + "CRH": "Crypto Hunters Coin", + "CRHT": "CryptHub", + "CRI": "Criptodólar", + "CRI3X": "CRI3X", + "CRICKETS": "Kermit", + "CRIME": "Crime Gold", + "CRIMINGO": "Criminal Flamingo", + "CRIPPL": "Wheelchair Cat", + "CRIS": "CristianoRonaldoSpeedSmurf7Siu", + "CRISPR": "CRISPR", + "CRK": "Croking", + "CRL": "Cryptelo Coin", + "CRM": "Cream", + "CRMS": "Cryptomus", + "CRMX": "Salesforce xStock", + "CRNCHY": "Crunchy Network", + "CRNK": "CrankCoin", + "CRO": "Cronos", + "CROAK": "Croakey", + "CROAT": "Croat", + "CROB": "Crob Coin", + "CROCO": "Croco", + "CRODIE": "Crodie", + "CROGE": "Crogecoin", + "CROID": "Cronos ID", + "CRON": "Cryptocean", + "CRONA": "CronaSwap", + "CRONK": "CRONK", + "CROPPER": "CropperFinance", + "CROS": "Cros Token", + "CROSS": "Cross", + "CROW": "cr0w by Virtuals", + "CROWD": "CrowdCoin", + "CROWDWIZ": "Crowdwiz", + "CROWN": "Crown by Third Time Games", + "CROWWITH": "crow with knife", + "CROX": "CroxSwap", + "CRP": "Crypton", + "CRPS": "CryptoPennies", + "CRPT": "Crypterium", + "CRPTC": "CRPT Classic", + "CRS": "CYRUS", + "CRSP": "CryptoSpots", + "CRT": "Carr.Finance", + "CRTAI": "CRT AI Network", + "CRTB": "Coritiba F.C. Fan Token", + "CRTM": "Cryptum", + "CRTS": "Cratos", + "CRU": "Crust Network", + "CRUD": "CRUDE OIL BRENT", + "CRUIZ": "Cruiz", + "CRUMP": "Crypto Trump", + "CRUX": "CryptoMines Reborn", + "CRV": "Curve DAO Token", + "CRVE": "Curve DAO Token (Avalanche Bridge)", + "CRVUSD": "crvUSD", + "CRVY": "Curve Inu", + "CRW": "Crown Coin", + "CRWD": "CRWD Network", + "CRWDX": "CrowdStrike xStock", + "CRWNY": "Crowny Token", + "CRX": "ChronosCoin", + "CRY": "Crypto News Flash AI", + "CRYBB": "CryBaby", + "CRYN": "CRYN", + "CRYO": "CryoDAO", + "CRYP": "CrypticCoin", + "CRYPSURE": "CrypSure", + "CRYPT": "CryptCoin", + "CRYPTAL": "CrypTalk", + "CRYPTER": "Crypteriumcoin", + "CRYPTOA": "CryptoAI", + "CRYPTOAGENT": "CRYPTO AGENT TRUMP", + "CRYPTOAI": "CryptoAI", + "CRYPTOB": "Crypto Burger", + "CRYPTOBEAST": "CryptoBeast", + "CRYPTOBL": "CryptoBlades Kingdoms", + "CRYPTOBR": "Crypto Bro", + "CRYPTOBULLION": "CryptoBullion", + "CRYPTODELIVERY": "Crypto Delivery", + "CRYPTOE": "Cryptoenter", + "CRYPTOEM": "Crypto Emperor Trump", + "CRYPTOF": "CryptoFarmers", + "CRYPTOFIGHT": "Crypto Fight Club", + "CRYPTOH": "CryptoHunterTrading", + "CRYPTOJ": "Crypto Journey", + "CRYPTOJESUS": "Crypto Jesus Trump", + "CRYPTON": "CRYPTON", + "CRYPTONITE": "Cryptonite", + "CRYPTOOFFICIAL": "Crypto", + "CRYPTOPAL": "Pal", + "CRYPTOPING": "CryptoPing", + "CRYPTOPRO": "CryptoProfile", + "CRYPTOR": "CRYPTORG", + "CRYPTOS": "CryptoSoul", + "CRYPTOSDG": "Crypto SDG", + "CRYPTOT": "Crypto Trump", + "CRYPTOTANKS": "CryptoTanks", + "CRYPTOTR": "Crypto Trump", + "CRYPTOTYCOON": "CryptoTycoon", + "CRYPTOU": "CryptoUnity", + "CRYSTAL": "Crystal", + "CRYSTALCLEAR": "Crystal Clear Token", + "CRYSTALS": "CRYSTALS", + "CRYSTL": "Crystl Finance", + "CS": "Child Support", + "CSAC": "Credit Safe Application Chain", + "CSAI": "Compound SAI", + "CSAS": "csas (Ordinals)", + "CSC": "CasinoCoin", + "CSCOX": "Cisco xStock", + "CSEN": "Sentient Coin", + "CSH": "CashOut", + "CSI": "CSI888", + "CSIX": "Carbon Browser", + "CSM": "Crust Shadow", + "CSMIC": "Cosmic", + "CSNO": "BitDice", + "CSNP": "CrowdSale Network", + "CSOV": "Crown Sovereign", + "CSP": "Caspian", + "CSPN": "Crypto Sports", + "CSPR": "Casper Network", + "CSQ": "cosquare", + "CSR": "Cashera", + "CSS": "CoinSwap Token", + "CST": "Crypto Samurai", + "CSTAR": "COINSTAR", + "CSTC": "CryptosTribe", + "CSTL": "Castle", + "CSTR": "CoreStarter", + "CSUSDL": "Coinshift USDL Morpho Vault", + "CSUSHI": "cSUSHI", + "CSW": "Crosswalk", + "CSWAP": "ChainSwap", + "CSX": "Coinstox", + "CT": "CryptoTwitter", + "CTA": "Cross The Ages", + "CTAG": "CTAGtoken", + "CTASK": "CryptoTask", + "CTB": "Content Bitcoin", + "CTC": "Creditcoin", + "CTCN": "Contracoin", + "CTE": "Crypto Tron", + "CTEX": "Crypto tex", + "CTF": "CyberTime Finance", + "CTG": "City Tycoon Games", + "CTH": "Changcoin", + "CTI": "ClinTex CTi", + "CTIC": "Coinmatic", + "CTK": "Shentu", + "CTKN": "Curaizon", + "CTL": "Citadel", + "CTLS": "Chaintools", + "CTLX": "Cash Telex", + "CTM": "c8ntinuum", + "CTN": "Continuum Finance", + "CTO": "BaseCTO", + "CTOAI": "ClustroAI", + "CTOC": "CTOC", + "CTOK": "Codyfight", + "CTP": "Ctomorrow Platform", + "CTPL": "Cultiplan", + "CTPT": "Contents Protocol", + "CTR": "Creator Platform", + "CTRL": "Ctrl Wallet", + "CTRL2XY": "Control2XY", + "CTRT": "Cryptrust", + "CTS": "Citrus", + "CTSI": "Cartesi", + "CTT": "Castweet", + "CTW": "Citowise", + "CTX": "Cryptex", + "CTXC": "Cortex", + "CTY": "Connecty", + "CTYN": "Canyont", + "CU": "Crypto Unicorns", + "CUAN": "CuanSwap.com", + "CUB": "Cub Finance", + "CUBE": "Somnium Space CUBEs", + "CUBEAUTO": "Cube", + "CUBEB": "CubeBase", + "CUBENETWORK": "Cube Network", + "CUCCI": "Cat in Gucci", + "CUCK": "Cuckadoodledoo", + "CUDIS": "Cudis", + "CUDOS": "Cudos", + "CUE": "CUE Protocol", + "CUEX": "Cuex", + "CUFF": "Jail Cat", + "CULO": "CULO", + "CULOETH": "CULO", + "CULT": "Milady Cult Coin", + "CULTDAO": "Cult DAO", + "CULTUR": "Cultur", + "CUM": "Cumbackbears", + "CUMINU": "CumInu", + "CUMMIES": "CumRocket", + "CUNI": "Compound Uni", + "CURA": "Cura Network", + "CURE": "Curecoin", + "CURI": "Curium", + "CURLY": "Curly", + "CURR": "Curry", + "CURRY": "CurrySwap", + "CUSD": "Celo Dollar", + "CUSDC": "Compound USD Coin", + "CUSDO": "Compounding Open Dollar", + "CUSDT": "cUSDT", + "CUSDTBULL": "3X Long Compound USDT Token", + "CUST": "Custody Token", + "CUT": "CUTcoin", + "CUTE": "Blockchain Cuties Universe", + "CUUT": "CUTTLEFISHY", + "CUZ": "Cool Cousin", + "CV": "CarVertical", + "CVA": "Crypto Village Accelerator", + "CVAG": "Crypto Village Accelerator CVAG", + "CVAULT": "cVault.finance", + "CVC": "Civic", + "CVCC": "CryptoVerificationCoin", + "CVCOIN": "Crypviser", + "CVG": "Convergence", + "CVIP": "CVIP", + "CVN": "ConsciousDao", + "CVNC": "CovenCoin", + "CVNG": "Crave-NG", + "CVNT": "Conscious Value Network", + "CVP": "PowerPool Concentrated Voting Power", + "CVPT": "Concentrated Voting Power", + "CVR": "Polkacover", + "CVS": "CoinVisa", + "CVSHOT": "CV SHOTS", + "CVT": "CyberVein", + "CVTC": "CavatCoin", + "CVTX": "Carrieverse", + "CVX": "Convex Finance", + "CVXCRV": "Convex CRV", + "CVXFXS": "Convex FXS", + "CVXX": "Chevron xStock", + "CW": "CardWallet", + "CWA": "Chris World Asset", + "CWAR": "Cryowar Token", + "CWBTC": "Compound Wrapped BTC", + "CWD": "CROWD", + "CWDV1": "Linkflow", + "CWEB": "Coinweb", + "CWEX": "Crypto Wine Exchange", + "CWIF": "catwifhat", + "CWIS": "Crypto Wisdom Coin", + "CWN": "CryptoWorldNews", + "CWOIN": "cwoin", + "CWR": "Cowrium", + "CWS": "Crowns", + "CWT": "CrossWallet", + "CWV": "CryptoWave", + "CWX": "Crypto-X", + "CWXT": "CryptoWorldXToken", + "CX": "Crypto X", + "CXA": "CryptovationX", + "CXC": "CheckCoin", + "CXCELL": "CAPITAL X CELL", + "CXG": "Coinxes", + "CXO": "CargoX", + "CXP": "Caixa Pay", + "CXPAD": "CoinxPad", + "CXT": "Covalent X Token", + "CY97": "Cyclops97", + "CYB": "CYBERTRUCK", + "CYBA": "CYBRIA", + "CYBE": "Cyberlete", + "CYBER": "CyberConnect", + "CYBERA": "Cyber Arena", + "CYBERC": "CyberCoin", + "CYBERD": "Cyber Doge", + "CYBERTRUCK": "Cyber Truck", + "CYBERTRUMP": "CyberTrump", + "CYBERWAY": "CyberWay", + "CYBONK": "CYBONK", + "CYBR": "CYBR", + "CYBRO": "Cybro Token", + "CYC": "Cyclone Protocol", + "CYCAT": "Chi Yamada Cat", + "CYCE": "Crypto Carbon Energy", + "CYCEV1": "Crypto Carbon Energy v1", + "CYCLE": "Cycle Finance", + "CYCLUB": "Cyclub", + "CYCON": "CONUN", + "CYDER": "Cyder Coin", + "CYDX": "CyberDEX", + "CYFI": "cYFI", + "CYG": "Cygnus", + "CYL": "Crystal Token", + "CYM": "Cylum Finance", + "CYMT": "CyberMusic", + "CYOP": "CyOp Protocol", + "CYP": "CypherPunkCoin", + "CYPEPE": "CyPepe", + "CYPHER": "CYPHER•GENESIS (Runes)", + "CYPR": "Cypher", + "CYRS": "Cyrus Token", + "CYRUS": "Cyrus Exchange", + "CYS": "Cysic", + "CYT": "Cryptokenz", + "CZ": "CHANGPENG ZHAO (changpengzhao.club)", + "CZBOOK": "CZ BOOK", + "CZBROCCOLI": "Cz Broccoli", + "CZC": "Crazy Coin", + "CZDOG": "CZ Dog", + "CZF": "CZodiac Farming Token", + "CZGOAT": "CZ THE GOAT", + "CZKING": "CZKING", + "CZOL": "Czolana", + "CZR": "CanonChain", + "CZRX": "Compound 0x", + "CZSHARES": "CZshares", + "CZUSD": "CZUSD", + "CZZ": "ClassZZ", + "D": "Dar Open Network", + "D11": "DeFi11", + "D2O": "DAM Finance", + "D2T": "Dash 2 Trade", + "D3D": "D3D Social", + "D4RK": "DarkPayCoin", + "DAAPL": "Apple Tokenized Stock Defichain", + "DAB": "DABANKING", + "DABCAT": "Dabcat", + "DAC": "Davinci Coin", + "DACASH": "DACash", + "DACAT": "daCat", + "DACC": "Decentralized Accessible Content Chain", + "DACC2": "DACC2", + "DACH": "DACH Coin", + "DACKIE": "DackieSwap", + "DACS": "Dacsee", + "DACXI": "Dacxi", + "DAD": "DAD", + "DADA": "DADA", + "DADDY": "Daddy Tate", + "DADDYCHILL": "Daddy Chill", + "DADDYDOGE": "Daddy Doge", + "DADI": "Edge", + "DAETA": "DÆTA", + "DAF": "DaFIN", + "DAFI": "Dafi Protocol", + "DAFT": "DaftCoin", + "DAG": "Constellation", + "DAGESTAN": "Dagestan And Forget", + "DAGO": "Dago Mining", + "DAGS": "Dagcoin", + "DAGT": "Digital Asset Guarantee Token", + "DAI": "Dai", + "DAIE": "Dai (Avalanche Bridge)", + "DAIFUKU": "Daifuku", + "DAILY": "Coindaily", + "DAILYS": "DailySwap Token", + "DAIMO": "Diamond Token", + "DAIN": "Dain Token", + "DAIQ": "Daiquilibrium", + "DAISY": "Daisy Launch Pad", + "DAIWO": "D.A.I.Wo", + "DAK": "dak", + "DAKU": "Der Daku", + "DAL": "DAOLaunch", + "DALI": "Dalichain", + "DALMA": "Dalma Inu", + "DAM": "Reservoir", + "DAMEX": "DAMEX", + "DAMN": "Sol Killer", + "DAMO": "Coinzen", + "DAMOON": "Damoon Coin", + "DAN": "Daneel", + "DANA": "Ardana", + "DANCING": "Dancing Michi", + "DANG": "Guangdang", + "DANGEL": "dAngel Fund", + "DANJ": "Danjuan Cat", + "DANK": "DarkKush", + "DANKDOGE": "Dank Doge", + "DANKDOGEAI": "DankDogeAI", + "DANNY": "Degen Danny", + "DAO": "DAO Maker", + "DAO1": "DAO1", + "DAOACT": "ACT", + "DAOB": "DAOBet", + "DAOLITY": "Daolity", + "DAOP": "Dao Space", + "DAOSOL": "MonkeDAO", + "DAOSQUARE": "DAOSquare Governance Token", + "DAOVC": "DAO.VC", + "DAOX": "Daox", + "DAPP": "Pencils Protocol", + "DAPPSY": "Dappsy", + "DAPPT": "Dapp Token", + "DAPPTOKEN": "LiquidApps", + "DAPPX": "dAppstore", + "DAPS": "DAPS Coin", + "DAR": "Mines of Dalarnia", + "DARA": "Immutable", + "DARAM": "Daram", + "DARB": "Darb Token", + "DARC": "Konstellation", + "DARCRUS": "Darcrus", + "DARE": "The Dare", + "DARED": "Daredevil Dog", + "DARICO": "Darico", + "DARIK": "Darik", + "DARK": "Dark Eclipse", + "DARKCOIN": "Dark", + "DARKEN": "Dark Energy Crystals", + "DARKF": "Dark Frontiers", + "DARKMAGACOIN": "DARK MAGA", + "DARKSTAR": "DarkStar", + "DARKT": "Dark Trump", + "DARKTOKEN": "DarkToken", + "DART": "dART Insurance", + "DARWIN": "Darwin", + "DARX": "Bitdaric", + "DAS": "DAS", + "DASC": "DasCoin", + "DASH": "Dash", + "DASHD": "Dash Diamond", + "DASHG": "Dash Green", + "DASIAv": "DASIA", + "DAT": "Datum", + "DATA": "Streamr", + "DATAB": "Databot", + "DATAEC": "DATA Economy Index", + "DATAMALL": "Datamall Coin", + "DATAMINE": "Datamine", + "DATAO": "Data Ownership Protocol", + "DATAWALLET": "DataWallet", + "DATBOI": "Dat Boi", + "DATOM": "Drop Staked ATOM", + "DATP": "Decentralized Asset Trading Platform", + "DATX": "DATx", + "DAUMEN": "Daumenfrosch", + "DAV": "DAV", + "DAVE": "DAVE", + "DAVID": "David", + "DAVINC": "DaVinci Protocol", + "DAVINCI": "Davincigraph", + "DAVIS": "Davis Cup Fan Token", + "DAVP": "Davion", + "DAW": "DAWKOINS", + "DAWAE": "DaWae", + "DAWCURRENCY": "Daw Currency", + "DAWG": "Dawg Coin", + "DAWGS": "SpaceDawgs", + "DAWN": "Dawn Protocol", + "DAX": "DAEX", + "DAXX": "DaxxCoin", + "DAY": "Chronologic", + "DAYTA": "Dayta", + "DB": "DarkBit", + "DBA": "Digital Bank of Africa", + "DBC": "DeepBrain Chain", + "DBCCOIN": "Datablockchain", + "DBD": "Day By Day", + "DBEAR": "DBear Coin", + "DBET": "Decent.bet", + "DBI": "Don't Buy Inu", + "DBIC": "DubaiCoin", + "DBIX": "DubaiCoin", + "DBL": "Doubloon", + "DBOE": "DBOE", + "DBOX": "DefiBox", + "DBR": "deBridge", + "DBTC": "DebitCoin", + "DBTN": "Universa Native token", + "DBUND": "DarkBundles", + "DBUY": "Doont Buy", + "DBX": "DBX", + "DBY": "Dobuy", + "DBZ": "Diamond Boyz Coin", + "DC": "Dogechain", + "DCA": "AutoDCA", + "DCAR": "Dragon Crypto Argenti", + "DCARD": "DECENTRACARD", + "DCASH": "Diabolo", + "DCAU": "Dragon Crypto Aurum", + "DCB": "Decubate", + "DCC": "Distributed Credit Chain", + "DCCT": "DocuChain", + "DCD": "DecideAI", + "DCE": "Decentra Ecosystem", + "DCF": "Decentralized Finance", + "DCHEFSOL": "Degen Chef", + "DCHEWY": "Drop Chewy", + "DCHF": "DeFi Franc", + "DCI": "Decentralized Cloud Infrastructure", + "DCIP": "Decentralized Community Investment Protocol", + "DCK": "DexCheck AI", + "DCLOUD": "DecentraCloud", + "DCM": "Ducky City", + "DCN": "Dentacoin", + "DCNT": "Decanect", + "DCNTR": "Decentrahub Coin", + "DCOIN": "Dogcoin", + "DCR": "Decred", + "DCRE": "DeltaCredits", + "DCRN": "Decred-Next", + "DCS.": "deCLOUDs", + "DCT": "Decent", + "DCTO": "Decentralized Crypto Token", + "DCX": "DeCEX", + "DCY": "Dinastycoin", + "DD": "DuckDAO", + "DDAM": "DDAM", + "DDAO": "DDAO Hunters", + "DDBAM": "Didi Bam Bam", + "DDD": "Scry.info", + "DDDD": "People's Punk", + "DDF": "Digital Developers Fund", + "DDIM": "DuckDaoDime", + "DDK": "DDKoin", + "DDL": "Donocle", + "DDM": "DDM Deutsche Mark", + "DDMT": "Dongdaemun Token", + "DDN": "Den Domains", + "DDOS": "disBalancer", + "DDR": "Digi Dinar", + "DDRO": "D-Drops", + "DDRST": "DigiDinar StableToken", + "DDRT": "DigiDinar Token", + "DDS": "DDS.Store", + "DDUSDV1": "Decentralized USD", + "DDX": "DerivaDAO", + "DEA": "Degas Coin", + "DEAI": "Zero1 Lab", + "DEAL": "iDealCash", + "DEB": "Debitum Token", + "DEBASE": "Debase", + "DEBT": "DebtCoin", + "DEC": "Decentr", + "DECENTRALG": "Decentral Games ICE", + "DECENTRALIZED": "DECENTRALIZED", + "DECHAT": "Dechat", + "DECI": "Maximus DECI", + "DECL": "Decimal token", + "DECODE": "Decode Coin", + "DEDA": "DedaCoin", + "DEDE": "Dede", + "DEDI": "Dedium", + "DEDPRZ": "DEDPRZ", + "DEE": "Deep AI", + "DEEBO": "Deebo the Bear", + "DEED": "Deed (Ordinals)", + "DEEM": "iShares MSCI Emerging Markets ETF Defichain", + "DEEP": "DeepBook Protocol", + "DEEPCLOUD": "DeepCloud AI", + "DEEPG": "Deep Gold", + "DEEPS": "DeepSeek AI", + "DEEPSE": "DeepSeek AI Assistant", + "DEEPSEARCH": "Grok 3 DeepSearch", + "DEEPSEE": "DeepSeek AI", + "DEEPSEEK": "Global DePIN Chain", + "DEEPSEEKAI": "DeepSeek AI Agent", + "DEEPSEEKR1": "DeepSeek R1", + "DEEPSPACE": "DeepSpace", + "DEER": "ToxicDeer Finance", + "DEERSEIZED": "Deer Seized by US Government", + "DEESSE": "Deesse", + "DEEX": "DEEX", + "DEEZ": "DEEZ NUTS", + "DEFAI": "DEFAI", + "DEFAIDAO": "DeFAI", + "DEFC": "Defi Coin", + "DEFEND": "Blockdefend AI", + "DEFI": "DeFi", + "DEFI5": "DEFI Top 5 Tokens Index", + "DEFIDO": "DeFido", + "DEFIK": "DeFi Kingdoms JADE", + "DEFIL": "DeFIL", + "DEFILAB": "Defi", + "DEFISCALE": "DeFiScale", + "DEFISSI": "DEFI.ssi", + "DEFIT": "Digital Fitness", + "DEFLA": "Defla", + "DEFLCT": "Deflect", + "DEFLECT": "Deflect Harbor AI", + "DEFLY": "Deflyball", + "DEFROGS": "DeFrogs", + "DEFT": "DeFi Factory Token", + "DEFX": "DeFinity", + "DEFY": "DEFY", + "DEG": "Degis", + "DEGA": "Dega", + "DEGATE": "DeGate", + "DEGE": "DegeCoinc", + "DEGEN": "Degen", + "DEGENAI": "Degen Spartan AI", + "DEGENR": "DegenReborn", + "DEGO": "Dego Finance", + "DEGOD": "degod", + "DEGOV": "Degov", + "DEGOV1": "Dego Finance v1", + "DEHUB": "DeHub", + "DEI": "Deimos", + "DEK": "DekBox", + "DEL": "Decimal", + "DELABS": "Delabs Games", + "DELAY": "DegenLayer", + "DELCHAIN": "DelChain", + "DELFI": "DeltaFi", + "DELI": "NFTDeli", + "DELIGHTPAY": "DelightPay", + "DELON": "Dark Elon", + "DELOT": "DELOT.IO", + "DELTA": "Delta Financial", + "DELTAC": "DeltaChain", + "DEM": "eMark", + "DEMI": "DeMi", + "DEMIR": "Adana Demirspor Token", + "DEMOS": "DEMOS", + "DENARIUS": "Denarius", + "DENT": "Dent", + "DENTX": "DENTNet", + "DEO": "Demeter", + "DEOD": "Decentrawood", + "DEOR": "Decentralized Oracle", + "DEP": "DEAPCOIN", + "DEPAY": "DePay", + "DEPIN": "DEPIN", + "DEPINU": "Depression Inu", + "DEPO": "Depo", + "DEPTH": "Depth Token", + "DEQ": "Dequant", + "DER": "Deri Trade", + "DERC": "DeRace", + "DERI": "Deri Protocol", + "DERO": "Dero", + "DERP": "Derp", + "DES": "DeSpace Protocol", + "DESCI": "SUI Desci Agents", + "DESCIMEME": "DeSci Meme", + "DESI": "Desico", + "DESO": "Decentralized Social", + "DESTINY": "Destiny", + "DESU": "Dexsport", + "DESY": "Desy Duk", + "DETENSOR": "DeTensor", + "DETF": "Decentralized ETF", + "DETH": "DarkEther", + "DETO": "Delta Exchange", + "DEUR": "DigiEuro", + "DEURO": "DecentralizedEURO", + "DEUS": "DEUS Finance", + "DEUSD": "Elixir deUSD", + "DEV": "Deviant Coin", + "DEVAI": "DEV AI", + "DEVCOIN": "DevCoin", + "DEVE": "Develocity Finance", + "DEVI": "DEVITA", + "DEVO": "DeVolution", + "DEVT": "DeHorizon", + "DEVVE": "Devve", + "DEVX": "Developeo", + "DEW": "DEW", + "DEX": "DEX", + "DEX223": "DEX223", + "DEXA": "DEXA COIN", + "DEXC": "DexCoyote Legends", + "DEXE": "DeXe", + "DEXEV1": "DeXe v1", + "DEXG": "Dextoken Governance", + "DEXIO": "Dexioprotocol", + "DEXM": "Dexmex", + "DEXNET": "DexNet", + "DEXO": "DEXO", + "DEXSHARE": "dexSHARE", + "DEXT": "DEXTools", + "DEXTF": "DEXTF", + "DEXTV1": "DEXTools V1", + "DF": "dForce", + "DFA": "DeFine", + "DFB": "Facebook Tokenized Stock Defichain", + "DFBT": "DentalFix", + "DFC": "DeFinder Capital", + "DFD": "DefiDollar DAO", + "DFDVSOL": "DFDV Staked SOL", + "DFDVX": "DFDV xStock", + "DFG": "Defigram", + "DFGL": "DeFi Gold", + "DFH": "DeFiHorse", + "DFI": "DeFiChain", + "DFIAT": "DeFiato", + "DFIO": "DeFi Omega", + "DFIS": "DfiStarter", + "DFL": "DeFi Land", + "DFND": "dFund", + "DFNDR": "Defender Bot", + "DFP": "Digital Fund Coin", + "DFSG": "DFSocial Gaming", + "DFSM": "DFS MAFIA", + "DFSOCIAL": "DefiSocial (OLD)", + "DFSPORTS": "Digital Fantasy Sports", + "DFT": "DigiFinexToken", + "DFTV1": "DigiFinexToken v1", + "DFUN": "DashFun Coin", + "DFX": "DFX Finance", + "DFY": "Defi For You", + "DFYN": "Dfyn Network", + "DG": "Decentral Games", + "DGB": "DigiByte", + "DGC": "DecentralGPT", + "DGCL": "DigiCol Token", + "DGD": "Digix DAO", + "DGDC": "DarkGold", + "DGEN": "The MVP Society", + "DGH": "Digihealth", + "DGI": "DGI Game", + "DGLD": "Digital Gold", + "DGLN": "Dogelana", + "DGM": "DigiMoney", + "DGMA": "daGama", + "DGME": "GameStop Tokenized Stock Defichain", + "DGMS": "Digigems", + "DGMT": "DigiMax DGMT", + "DGMV": "DigiMetaverse", + "DGN": "Diagon", + "DGNX": "DegenX", + "DGOLD": "PolyDragon", + "DGORE": "DogeGoreCoin", + "DGP": "DGPayment", + "DGPT": "DigiPulse", + "DGRAM": "Datagram", + "DGTA": "Digitra.com Token", + "DGTX": "Digitex Token", + "DGV1": "Decentral Games v1", + "DGVC": "DegenVC", + "DGX": "Digix Gold token", + "DHLT": "DeHealth", + "DHN": "Dohrnii", + "DHP": "dHealth", + "DHR": "DeHR Network", + "DHRX": "Danaher xStock", + "DHS": "Dirham Crypto", + "DHT": "dHedge DAO", + "DHV": "DeHive", + "DHX": "DataHighway", + "DIA": "DIA", + "DIAB": "Diablo IV Solana", + "DIABLO": "Diablo IV", + "DIAM": "DIAM", + "DIAMND": "Projekt Diamond", + "DIAMO": "Diamond Launch", + "DIAMON": "Diamond", + "DIAMOND": "Diamond Coin", + "DIAMONDINU": "Diamond", + "DIBBLE": "Dibbles", + "DIBC": "DIBCOIN", + "DIC": "Daikicoin", + "DICE": "Klaydice", + "DICEM": "DICE Money", + "DICETRX": "TRONbetDice", + "DICK": "adDICKted", + "DICKBUTT": "Dickbutt", + "DICKCOIN": "DickCoin", + "DID": "Didcoin", + "DIDDY": "DIDDY", + "DIDID": "Didi Duck", + "DIE": "Die Protocol", + "DIEM": "Facebook Diem", + "DIESEL": "Diesel", + "DIFF": "Diffusion", + "DIFI": "Digital Files", + "DIFX": "Digital Financial Exchange", + "DIG": "DIEGO", + "DIGAU": "Dignity Gold", + "DIGEX": "Digex", + "DIGG": "DIGG", + "DIGGAI": "DIGGER AI", + "DIGI": "MineD", + "DIGIC": "DigiCube", + "DIGICOIN": "Digicoin", + "DIGIF": "DigiFel", + "DIGIMON": "Digimon", + "DIGIMONRABBIT": "Digimon Rabbit", + "DIGIT": "Digital Asset Rights Token", + "DIGITAL": "Digital Reserve Currency", + "DIGITALCOIN": "Digitalcoin", + "DIGITS": "Digits DAO", + "DIGIV": "Digiverse", + "DIGNITY": "Dignity", + "DIGS": "Diggits", + "DIK": "DikDok", + "DIKO": "Arkadiko", + "DILDO": "Green Dildo Coin", + "DILI": "D Community", + "DILIGENT": "Diligent Pepe", + "DILL": "dillwifit", + "DIM": "DIMCOIN", + "DIME": "DimeCoin", + "DIMO": "DIMO", + "DIN": "DIN", + "DINE": "Dinero", + "DINER": "TESLA DINER", + "DINERO": "Dinero", + "DINEROBET": "Dinerobet", + "DINGER": "Dinger Token", + "DINGO": "Dingocoin", + "DINNER": "Trump Dinner", + "DINO": "DINO", + "DINOLFG": "DinoLFG", + "DINOS": "Dinosaur Inu", + "DINOSOL": "DINOSOL", + "DINOSWAP": "DinoSwap", + "DINT": "DinarTether", + "DINU": "Dogey-Inu", + "DINW": "Dinowars", + "DIO": "Decimated", + "DIONE": "Dione", + "DIONEV1": "Dione v1", + "DIP": "Etherisc", + "DIPA": "Doge Ipa", + "DIRTY": "Dirty Street Cats", + "DIS": "DisChain", + "DISCO": "Disco By Matt Furie", + "DISCOVERY": "DiscoveryIoT", + "DISK": "Dark Lisk", + "DISPEPE": "Disabled Pepe", + "DISTR": "Distributed Autonomous Organization", + "DISTRIBUTE": "DISTRIBUTE", + "DIT": "Ditcoin", + "DITH": "Dither AI", + "DIVA": "DIVA Protocol", + "DIVER": "Divergence Protocol", + "DIVI": "Divi Project", + "DIVO": "DIVO Token", + "DIVX": "Divi Exchange Token", + "DIW": "DIWtoken", + "DIYAR": "Diyarbekirspor Token", + "DJED": "Djed", + "DJI": "Doge Jones Industrial Average", + "DJI6930": "DOWGE", + "DJT": "Save America", + "DK": "Dominant Kong", + "DKA": "dKargo", + "DKC": "DarkKnightCoin", + "DKD": "Dekado", + "DKEY": "DKEY Bank", + "DKKT": "DKK Token", + "DKNIGHT": "Dark Knight", + "DKP": "Dragginz", + "DKS": "DarkShield", + "DKT": "Duelist King", + "DKUMA": "KumaDex Token", + "DL": "Dill", + "DLA": "Dolla", + "DLANCE": "DeeLance", + "DLB": "DiemLibre", + "DLC": "DeepLink", + "DLCBTC": "DLC.Link", + "DLISK": "Dlisk", + "DLLR": "Sovryn Dollar", + "DLO": "Delio", + "DLORD": "DORK LORD", + "DLPD": "DLP Duck Token", + "DLPT": "Deliverers Power Token", + "DLR": "DollarOnline", + "DLT": "Agrello Delta", + "DLTA": "delta.theta", + "DLX": "DAppLinks", + "DLXV": "Delta-X", + "DLY": "Daily Finance", + "DLYCOP": "Daily COP", + "DM": "Dumb Money", + "DMA": "Dragoma", + "DMAGA": "Dark MAGA", + "DMAIL": "DMAIL Network", + "DMAR": "DMarket", + "DMC": "DeLorean", + "DMCC": "DiscoverFeed", + "DMCH": "DARMA Cash", + "DMCK": "Diamond Castle", + "DMD": "DMD", + "DMG": "DMM: Governance", + "DMGBULL": "3X Long DMM Governance Token", + "DMIND": "DecentraMind", + "DML": "Decentralized Machine Learning", + "DMLG": "Demole", + "DMOD": "Demodyfi Token", + "DMOON": "Dollarmoon", + "DMR": "dmr", + "DMS": "Documentchain", + "DMT": "Dream Machine Token", + "DMTC": "Demeter Chain", + "DMTR": "Dimitra", + "DMX": "Dymmax", + "DMZ": "DeMon Token", + "DN": "DeepNode", + "DN8": "Pldgr", + "DNA": "Metaverse", + "DNAPEPE": "DNA PEPE", + "DND": "Diamond DND", + "DNET": "DeNet", + "DNF": "DNFT Protocol", + "DNFLX": "Netflix Tokenized Stock Defichain", + "DNFT": "DareNFT", + "DNN": "DNN Token", + "DNNON": "Denison Mines (Ondo Tokenized)", + "DNO": "Denaro", + "DNODE": "DecentraNode", + "DNOTES": "Dnotes", + "DNOW": "DuelNow", + "DNS": "BitDNS", + "DNT": "district0x", + "DNTX": "DNAtix", + "DNVDA": "Nvidia Tokenized Stock Defichain", + "DNX": "Dynex", + "DNXC": "DinoX", + "DNY": "Dynasty Coin", + "DNZ": "Denizlispor Fan Token", + "DOAI": "DOJO Protocol", + "DOBBY": "Dobby", + "DOBEN": "dark boden", + "DOBO": "DogeBonk", + "DOBUY": "Just do buy", + "DOC": "Dochain", + "DOCAINEURON": "Doc.ai Neuron", + "DOCC": "Doc Coin", + "DOCCOM": "DOC.COM", + "DOCK": "Dock.io", + "DOCSWAP": "Dex on Crypto", + "DOCT": "DocTailor", + "DOCTO": "DoctorX", + "DOD": "Day Of Defeat 2.0", + "DOD100": "Day of Defeat Mini 100x", + "DODI": "DoubleDice", + "DODO": "DODO", + "DODOT": "Dodo the Black Swan", + "DOE": "Dogs Of Elon", + "DOFI": "Doge Floki Coin", + "DOG": " DOG•GO•TO•THE•MOON", + "DOGA": "Dogami", + "DOGACOIN": "DogaCoin", + "DOGAI": "Dogai", + "DOGALD": "dogald trump", + "DOGB": "DogeBoy", + "DOGBA": "DOGBA INU", + "DOGBOSS": "Dog Boss", + "DOGC": "Dogeclub", + "DOGCOIN": "Dogcoin", + "DOGCOLLAR": "Dog Collar", + "DOGDEFI": "DogDeFiCoin", + "DOGE": "Dogecoin", + "DOGE1SAT": "DOGE-1SATELLITE", + "DOGE2": "Dogecoin 2.0", + "DOGE20": "Doge 2.0", + "DOGEAI": "DOGEai", + "DOGEB": "DogeBonk", + "DOGEBASE": "Doge Base", + "DOGEBNB": "DogeBNB", + "DOGEC": "DogeCash", + "DOGECAST": "Dogecast", + "DOGECAUCUS": "Doge Caucus", + "DOGECEO": "Doge CEO", + "DOGECO": "Dogecolony", + "DOGECOIN": "Buff Doge Coin", + "DOGECOLA": "DogeCola", + "DOGECUBE": "DogeCube", + "DOGED": "DogeCoinDark", + "DOGEDAO": "DogeDao", + "DOGEDASH": "Doge Dash", + "DOGEDI": "Doge Dividends", + "DOGEFA": "DOGEFATHER", + "DOGEFATHER": "Dogefather", + "DOGEFORK": "DogeFork", + "DOGEGF": "DogeGF", + "DOGEGOV": "Department Of Government Efficiency (dogegov.com)", + "DOGEGROK": "Doge Grok", + "DOGEGROKAI": "Doge Of Grok AI", + "DOGEI": "Dogei", + "DOGEIN": "Doge In Glasses", + "DOGEINU": "Doge Inu", + "DOGEIUS": "DOGEIUS", + "DOGEJ": "Dogecoin (JustCrypto)", + "DOGEKING": "DogeKing", + "DOGELEGION": "DOGE LEGION", + "DOGEM": "Doge Matrix", + "DOGEMARS": "DOGE TO MARS", + "DOGEMETA": "Dogemetaverse", + "DOGEMOB": "DOGEMOB", + "DOGEMOON": "DOGE TO MOON", + "DOGENARII": "Dogenarii", + "DOGENFT": "The Doge NFT", + "DOGEP": "Doge Protocol", + "DOGEPAY": "Doge Payment", + "DOGEPEPE": "Doge Pepe", + "DOGEPR": "DOGE PRESIDENT", + "DOGER": "Robotic Doge", + "DOGERA": "Dogera", + "DOGES": "Dogeswap", + "DOGESWAP": "Dogeswap Token (HECO)", + "DOGETF": "DOGE ETF", + "DOGETH": "EtherDoge", + "DOGEVERSE": "DogeVerse", + "DOGEWHALE": "Dogewhale", + "DOGEX": "DogeHouse Capital", + "DOGEY": "Dogey", + "DOGEYIELD": "DogeYield", + "DOGEZILLA": "DogeZilla", + "DOGEZILLAV1": "DogeZilla v1", + "DOGG": "Doggo", + "DOGGO": "DOGGO", + "DOGGS": "Doggensnout", + "DOGGY": "Doggy", + "DOGGYCOIN": "DOGGY", + "DOGH": "a dog in a hoodie", + "DOGI": "dogi", + "DOGIMUS": "Tesla Dog", + "DOGIN": "Doginhood", + "DOGINC": "dog in cats world", + "DOGINME": "doginme", + "DOGINWOTAH": "doginwotah", + "DOGIRA": "Dogira", + "DOGK": "Dagknight Dog", + "DOGLAI": "Doglaikacoin", + "DOGMI": "DOGMI", + "DOGO": "DogemonGo", + "DOGONB": "Dog on Base", + "DOGPAD": "DogPad Finance", + "DOGPU": "DogeGPU", + "DOGRMY": "DogeArmy", + "DOGS": "Dogs", + "DOGSROCK": "Dogs Rock", + "DOGSS": "DOGS SOL", + "DOGSSO": "DOGS Solana", + "DOGSWAG": "DogSwaghat", + "DOGUN": "Dogun", + "DOGW": "DOGWIFHOOD", + "DOGWIFHAT": "dogwifhat", + "DOGWIFSEAL": "dogwifseal", + "DOGY": "Dogy", + "DOGZ": "Dogz", + "DOJO": "ProjectDojo", + "DOKI": "Doki Doki Finance", + "DOKY": "Donkey King", + "DOLA": "Dola USD Stablecoin", + "DOLAN": "Dolan Duck", + "DOLLAR": "Dollar", + "DOLLARCOIN": "DollarCoin", + "DOLLUR": "Dollur Go Brrr", + "DOLLY": "DOLLY", + "DOLO": "Dolomite", + "DOLPHY": "Dolphy", + "DOLZ": "DOLZ", + "DOM": "DomusAI", + "DOME": "Everdome", + "DOMI": "Domi", + "DOMO": "Dony Montana", + "DON": "TheDonato Token", + "DONA": "DONASWAP", + "DONAL": "Donald Pump", + "DONALD": "DONALD TRUMP", + "DONALDP": "Donald Pump", + "DONALDT": "Donald The Trump", + "DONATION": "DonationCoin", + "DONG": "DongCoin", + "DONGO": "Dongo AI", + "DONJR": "Don Jr.", + "DONK": "Don-key", + "DONKE": "DONKE", + "DONKEY": "donkey", + "DONNIEFIN": "Donnie Finance", + "DONS": "The Dons", + "DONT": "DisclaimerCoin", + "DONTCASH": "DONT", + "DONU": "Donu", + "DONUT": "Donut", + "DONUTS": "The Simpsons", + "DOOD": "Doodles", + "DOODI": "Doodipals", + "DOODOO": "Doodoo", + "DOOGLE": "Doogle", + "DOOH": "Bidooh", + "DOOMER": "Doomer", + "DOOR": "DOOR", + "DOPA": "DopaMeme", + "DOPE": "Dopamine App", + "DOPEC": "DOPE Coin", + "DOPECOIN": "DopeCoin", + "DOPEX": "DOPE", + "DOPU": "DOPU The Dog with A Purpose", + "DOR": "Dorado", + "DORA": "DORA", + "DORAEMON": "Doraemon", + "DORAV1": "Dora Factory v1", + "DORAV2": "Dora Factory", + "DORK": "DORK", + "DORKL": "DORK LORD", + "DORKVADER": "DorkVader", + "DORKY": "Dork Lord", + "DOS": "DOS Network", + "DOSE": "DOSE", + "DOSHIB": "DogeShiba", + "DOT": "Polkadot", + "DOTC": "Dotcoin", + "DOTF": "Dot Finance", + "DOTR": "Cydotori", + "DOUG": "Doug The Duck", + "DOUGH": "PieDAO v2 (DOUGH)", + "DOV": "DOVU", + "DOVI": "Dovi(Ordinals)", + "DOVIS": "Dovish Finance", + "DOVU": "DOVU", + "DOWS": "Shadows", + "DOYOUR": "Do Your Own Research", + "DOYR": "DOYR", + "DP": "DigitalPrice", + "DPAD": "Dpad Finance", + "DPAY": "Devour", + "DPCORE": "DeepCore AI", + "DPDBC": "PDBC Defichain", + "DPET": "My DeFi Pet", + "DPEX": "DPEX", + "DPI": "DeFiPulse Index", + "DPIE": "DeFiPie", + "DPIN": "DPIN", + "DPINO": "DarkPino", + "DPLAT": "zbyte", + "DPLN": "DePlan", + "DPLTR": "Palantir Tokenized Stock Defichain", + "DPN": "DIPNET", + "DPOOL": "Deadpool Inu", + "DPP": "Digital Assets Power Play", + "DPR": "Deeper Network", + "DPS": "DEEPSPACE", + "DPT": "Diamond Platform Token", + "DPX": "Dopex", + "DPY": "Delphy", + "DQQQ": "Invesco QQQ Trust Defichain", + "DRA": "Decentralized Retirement Account", + "DRAC": "Drac", + "DRACE": "DeathRoad", + "DRACO": "DT Token", + "DRACOO": "DracooMaster", + "DRACTOKEN": "DRAC Network", + "DRACULA": "Dracula", + "DRAFTC": "Draftcoin", + "DRAGGY": "Draggy", + "DRAGON": "Dragon", + "DRAGONGROK": "DragonGROK", + "DRAGONKING": "DragonKing", + "DRAGONMA": "Dragon Mainland Shards", + "DRAGONX": "DragonX", + "DRAGONZ": "Dragonz Land", + "DRAGU": "DRAGU", + "DRAGY": "Dragy", + "DRAKO": "Drako", + "DRAM": "DRAM", + "DRAW": "Drawshop Kingdom Reverse", + "DRB": "DebtReliefBot", + "DRBT": "DeFi-Robot", + "DRC": "DRC Mobility", + "DRCT": "Ally Direct", + "DRDR": "DRDR Token", + "DRE": "DoRen", + "DREAM": "DREAM", + "DREAM21": "Dream21", + "DREAMS": "Dreams Quest", + "DREP": "DREP", + "DRESS": "Dress", + "DRF": "Drife", + "DRG": "Dragon Coin", + "DRGN": "Dragonchain", + "DRIFT": "Drift protocol", + "DRINK": "DRINK", + "DRINKCHAIN": "DrinkChain", + "DRIP": "Metadrip", + "DRIPNET": "Drip Network", + "DRIV": "DRIVEZ", + "DRIVECRYPTO": "Drive Crypto", + "DRKC": "DarkCash", + "DRKT": "DarkTron", + "DRM": "DoDreamChain", + "DRM8": "Dream8Coin", + "DROGGY": "Droggy", + "DRONE": "Drone Coin", + "DROP": "DROP", + "DROPIL": "Dropil", + "DROPS": "Drops", + "DROVERS": "Drover Inu", + "DRP": "DCORP", + "DRPU": "DRP Utility", + "DRPXBT": "Hunter by Virtuals", + "DRS": "Digital Rupees", + "DRT": "DomRaider", + "DRUGS": "Big Pharmai", + "DRV": "Derive", + "DRX": "DRX Token", + "DRXNE": "Droxne", + "DRZ": "Droidz", + "DS": "DeStorage", + "DSAI": "DeSend Ai", + "DSB": "DarkShibe", + "DSC": "Dash Cash", + "DSCP": "Dreamscape", + "DSCVR": "DSCVR.Finance", + "DSD": "Dynamic Set Dollar", + "DSFR": "Digital Swiss Franc", + "DSG": "Dinosaureggs", + "DSH": "Dashcoin", + "DSHARE": "Dibs Share", + "DSHELL": "diamondshell", + "DSHIB": "DOLLAR SHIBA INU", + "DSK": "Darüşşafaka Spor Kulübü Token", + "DSLA": "DSLA Protocol", + "DSLV": "iShares Silver Trust Defichain", + "DSQ": "Dsquared.finance", + "DSR": "Desire", + "DSRUN": "Derby Stars", + "DST": "Double Swap Token", + "DSTAG": "deadstag", + "DSTNY": "Destinys Chicken", + "DSTR": "Dynamic Supply Tracker", + "DSUN": "DsunDAO", + "DSYNC": "Destra Network", + "DT": "Drift Zone", + "DT1": "Dollar Token 1", + "DTA": "Data", + "DTB": "Databits", + "DTC": "Data Transaction", + "DTCT": "DetectorToken", + "DTEC": "Dtec", + "DTEM": "Dystem", + "DTEP": "DECOIN", + "DTG": "Defi Tiger", + "DTH": "Dether", + "DTJR": "Donald Trump Jr.", + "DTLT": "iShares 20+ Year Treasury Bond ETF Defichain", + "DTN": "Datareum", + "DTO": "DotOracle", + "DTOP": "DTOP Token", + "DTORO": "DexToro", + "DTR": "Dotori", + "DTRC": "Datarius", + "DTRUMP": "Degen Trump", + "DTSLA": "Tesla Tokenized Stock Defichain", + "DTV": "DraperTV", + "DTX": "DataBroker DAO", + "DUA": "Brillion", + "DUAL": "Dual Finance", + "DUB": "DubCoin", + "DUBAICAT": "Dubai Cat", + "DUBBZ": "Dubbz", + "DUBER": "Düber", + "DUBI": "Decentralized Universal Basic Income", + "DUBX": "DUBXCOIN", + "DUC": "DucatusCoin", + "DUCAT": "Ducat", + "DUCATO": "Ducato Protocol Token", + "DUCK": "DuckChain Token", + "DUCKAI": "Duck AI", + "DUCKC": "DuckCoin", + "DUCKD": "DuckDuckCoin", + "DUCKER": "Ducker", + "DUCKIES": "Yellow Duckies", + "DUCKO": "Duck Off Coin", + "DUCKV1": "UNITPROV1", + "DUCKY": "Ducky Duck", + "DUCX": "DucatusX", + "DUDE": "DuDe", + "DUEL": "GameGPT", + "DUELERS": "Block Duelers", + "DUELN": "Duel Network", + "DUELV1": "Duel Network v1", + "DUET": "Duet Protocol", + "DUG": "DUG", + "DUGE": "DUGE", + "DUK": "DUKE COIN", + "DUK+": "Dukascoin", + "DUKE": "Duke Inu", + "DUKO": "DUKO", + "DUMMY": "Dummy", + "DUN": "Dune", + "DUNG": "Scarab Tools", + "DUO": "ParallelCoin", + "DUOLINGOAI": "DUOLINGO AI", + "DUOT": "DUO Network", + "DUPE": "Dupe", + "DUREV": "Povel Durev", + "DUROV": "FREE DUROV", + "DURTH": "iShares MSCI World ETF Tokenized Stock Defichain", + "DUSD": "StandX DUSD", + "DUSK": "Dusk Network", + "DUST": "Dust", + "DUSTPROTOCOL": "DUST Protocol", + "DUSTY": "Dusty", + "DUX": "DuxCoin", + "DUZCE": "Duzce Token", + "DV": "Dreamverse", + "DVC": "DragonVein", + "DVDX": "Derived", + "DVF": "Rhino.fi", + "DVG": "DAOventures", + "DVI": "Dvision Network", + "DVINCI": "Davinci Jeremie", + "DVK": "Devikins", + "DVL": "Develad", + "DVNQ": "Vanguard Real Estate Tokenized Stock Defichain ()", + "DVOO": "Vanguard S&P 500 ETF Tokenized Stock Defichain", + "DVP": "Decentralized Vulnerability Platform", + "DVRS": "DaoVerse", + "DVS": "Davies", + "DVT": "DeVault", + "DVTC": "DivotyCoin", + "DVX": "Derivex", + "DWAIN": "DWAIN", + "DWARFY": "Dwarfy", + "DWARS": "Dynasty Wars", + "DWC": "Digital Wallet", + "DWEB": "DecentraWeb", + "DWOG": "DWOG THE DOG", + "DWOLF": "Dark Wolf", + "DWT": "DiveWallet Token", + "DWZ": "DeFi Wizard", + "DX": "DxChain Token", + "DXA": "DEXART", + "DXB": "DefiXBet", + "DXC": "DixiCoin", + "DXCT": "DNAxCAT", + "DXD": "DXdao", + "DXF": "Dexfin", + "DXG": "DexAge", + "DXGM": "DEXGame", + "DXH": "Daxhund", + "DXL": "Dexlab", + "DXN": "DEXON", + "DXO": "Dextro", + "DXR": "DEXTER", + "DXS": "Dx Spot", + "DXT": "Dexit Finance", + "DXY": "US Degen Index 6900", + "DYAD": "Dyad Stable", + "DYC": "Dycoin", + "DYDX": "dYdX", + "DYDXV1": "dYdX v1", + "DYM": "Dymension", + "DYN": "Dynamic", + "DYNA": "Dynamix", + "DYNAM": "Dynamic Crypto Index", + "DYNAMICTRADING": "Dynamic Trading Rights", + "DYNASTYGLOB": "Dynasty Global Investments AG", + "DYNCOIN": "Dyncoin", + "DYNEX": "Dynex GPU", + "DYNMT": "Dynamite", + "DYNO": "DYNO", + "DYNOC": "DynoChain", + "DYOR": "DYOR Token", + "DYP": "Dypius", + "DYPV1": "Dypius v1", + "DYST": "Dystopia", + "DYT": "DoYourTip", + "DYZILLA": "DYZilla", + "DZA": "DZA", + "DZAR": "Digital Rand", + "DZCC": "DZCC", + "DZDD": "DZD", + "DZG": "Dinamo Zagreb Fan Token", + "DZI": "DeFinition", + "DZOO": "Degen Zoo", + "Dow": "DowCoin", + "E1INCH": "1inch (Energi Bridge)", + "E21": "E21 Coin", + "E2C": "Electronic Energy Coin", + "E4C": "E4C", + "E8": "Energy8", + "EA": "EagleCoin", + "EAC": "Education Assessment Cult", + "EADX": "EADX Token", + "EAFIN": "EAFIN", + "EAG": "Emerging Assets Group", + "EAGLE": "Eagle Token", + "EAGS": "EagsCoin", + "EAI": "Eagle AI", + "EARLY": "Early Risers", + "EARLYF": "EarlyFans", + "EARN": "Earn Network", + "EARNB": "Earn BTC", + "EARNGUILD": "EarnGuild", + "EARNM": "EARNM", + "EARTH": "Earth Token", + "EARTHCOIN": "EarthCoin", + "EARTHM": "Earthmeta", + "EASY": "EASY", + "EASYF": "EasyFeedback", + "EASYMINE": "EasyMine", + "EAT": "375ai", + "EATH": "Eartherium", + "EAURIC": "Eauric", + "EAVE": "EaveAI", + "EB3": "EB3coin", + "EBA": "Elpis Battle", + "EBASE": "EURBASE", + "EBC": "EBCoin", + "EBCH": "Bitcoin Cash (Energiswap)", + "EBEN": "Green Ben", + "EBET": "EthBet", + "EBIT": "eBit", + "EBITCOIN": "eBitcoin", + "EBK": "Ebakus", + "EBOX": "Ethbox Token", + "EBS": "EbolaShare", + "EBSC": "EarlyBSC", + "EBSHIB": "Wrapped Energy Shiba Inu (Energi Bridge)", + "EBSO": "eBlockStock", + "EBST": "eBoost", + "EBT": "ELON BUYS TWITTER", + "EBTC": "Ether.fi Staked BTC", + "EBULL": "ETHEREUM IS GOOD", + "EBYT": "EarthByt", + "EBZ": "Ebitz", + "EC": "Echoin", + "ECA": "Electra", + "ECASH": "Ethereum Cash", + "ECC": "Etherconnect", + "ECD": "Echidna", + "ECELL": "Consensus Cell Network", + "ECET": "Evercraft Ecotechnologies", + "ECG": "EcoSmart", + "ECH": "EthereCash", + "ECHELON": "Echelon Token", + "ECHO": "Echo", + "ECHOBOT": "ECHO BOT", + "ECHOD": "EchoDEX", + "ECHT": "e-Chat", + "ECI": "Euro Cup Inu", + "ECL": "ECLAT", + "ECLD": "Ethernity Cloud", + "ECLIP": "Eclipse Fi", + "ECLIPSE": "Eclipse", + "ECO": "Ormeus Ecosystem", + "ECOB": "EcoBit", + "ECOC": "ECOcoin", + "ECOCH": "ECOChain", + "ECOFI": "EcoFi", + "ECOIN": "Ecoin", + "ECOM": "Omnitude", + "ECOR": "Ecorpay token", + "ECOREAL": "Ecoreal Estate", + "ECOTERRA": "ecoterra", + "ECOX": "ECOx", + "ECP": "ECP+ Technology", + "ECR": "EcoVerse", + "ECS": "eCredits", + "ECT": "SuperEdge", + "ECTE": "EurocoinToken", + "ECU": "ECOSC", + "ECXX": "ECXX", + "EDAIN": "Edain", + "EDAT": "EnviDa", + "EDC": "EDC Blockchain", + "EDDA": "EDDASwap", + "EDDIE": "Eddie coin", + "EDE": "El Dorado Exchange", + "EDEL": "Edel", + "EDEN": "Eden Token", + "EDENA": "EDENA", + "EDENNETWORK": "EDEN", + "EDEXA": "edeXa Security Token", + "EDFI": "EdFi", + "EDG": "Edgeless", + "EDGE": "Definitive", + "EDGEACTIVITY": "EDGE Activity Token", + "EDGEAI": "EdgeAI", + "EDGEN": "LayerEdge", + "EDGENET": "EDGE", + "EDGESOL": "Edgevana Staked SOL", + "EDGEW": "Edgeware", + "EDGT": "Edgecoin", + "EDI": "Freight Trust & Clearing Network", + "EDLC": "Edelcoin", + "EDN": "EdenChain", + "EDNS": "EDNS Token", + "EDOG": "EDOG", + "EDOGE": "ElonDoge", + "EDOM": "EDOM", + "EDR": "Endor Protocol Token", + "EDRC": "EDRCoin", + "EDSE": "Eddie Seal", + "EDT": "E-Drive Token", + "EDU": "Open Campus", + "EDUC": "EducoinV", + "EDUCOIN": "EduCoin", + "EDUM": "EDUM", + "EDUX": "Edufex", + "EDWIN": "Edwin", + "EDX": "Equilibrium", + "EEFS": "Eefs", + "EEG": "EEG Token", + "EER": "Ethereum eRush", + "EETH": "ether fi", + "EEUR": "ARYZE eEUR", + "EFBAI": "EuroFootball AI", + "EFC": "Everton Fan Token", + "EFCR": "EFLANCER", + "EFFECT": "Effect AI", + "EFFT": "Effort Economy ", + "EFI": "Efinity", + "EFIL": "Ethereum Wrapped Filecoin", + "EFK": "ReFork", + "EFL": "E-Gulden", + "EFR": "End Federal Reserve", + "EFT": "ETH Fan Token Ecosystem", + "EFX": "The Effect.ai", + "EFYT": "Ergo", + "EG": "EG Token", + "EGAME": "Every Game", + "EGAS": "ETHGAS", + "EGAX": "Egochain", + "EGAZ": "EGAZ", + "EGC": "Eagle Coin", + "EGCC": "Engine", + "EGDC": "EasyGuide", + "EGEM": "EtherGem", + "EGG": "Goose Finance", + "EGGC": "EggCoin", + "EGGMAN": "Eggman Inu", + "EGGP": "Eggplant Finance", + "EGGT": "Egg N Partners", + "EGGY": "EGGY", + "EGI": "eGame", + "EGL": "The Eagle Of Truth", + "EGL1": "EGL1", + "EGLD": "eGold", + "EGO": "Paysenger EGO", + "EGOCOIN": "EGOcoin", + "EGOD": "EgodCoin", + "EGOLD": "EGOLD", + "EGOLDGG": "eGold", + "EGON": "EgonCoin", + "EGR": "Egoras Rights", + "EGRN": "Energreen", + "EGS": "EdgeSwap", + "EGT": "Egretia", + "EGX": "Enegra", + "EGY": "Egypt Cat", + "EHASH": "EHash", + "EHIVE": "eHive", + "EHRT": "Eight Hours Token", + "EICOIN": "EICOIN", + "EIFI": "EIFI FINANCE", + "EIGEN": "EigenLayer", + "EIGENP": "Eigenpie", + "EIM": "Expert Infra", + "EIQT": "IQ Prediction", + "EJAC": "EJA Coin", + "EJS": "Enjinstarter", + "EKG": "Ekon Gold", + "EKN": "Elektron", + "EKO": "EchoLink", + "EKOC": "Coke", + "EKS": "Elumia Krystal Shards", + "EKSM": "Synthetic Kusama (Energiswap)", + "EKT": "EDUCare", + "EKTA": "Ekta", + "EKTAV1": "Ekta v1", + "EKTAV2": "Ekta v2", + "EKUBO": "Ekubo Protocol", + "EL": "ELYSIA", + "ELA": "Elastos", + "ELAC": "ELA Coin", + "ELAD": "ELAD Network", + "ELAMA": "Elamachain", + "ELAND": "Etherland", + "ELC": "Elacoin", + "ELCASH": "Electric Cash", + "ELD": "Electrum Dark", + "ELDA": "Eldarune", + "ELDE": "Elderglade", + "ELE": "Elementrem", + "ELEC": "Electrify.Asia", + "ELECTRON": "Electron (Atomicals)", + "ELEMENTS": "Elements", + "ELEN": "Everlens", + "ELEPEPE": "ElephantPepe", + "ELEPHANT": "Elephant Money", + "ELES": "Elements Estates", + "ELET": "Elementeum", + "ELF": "aelf", + "ELFI": "ELYFI", + "ELFW": "ELF Wallet", + "ELG": "EscoinToken", + "ELGATO": "el gato", + "ELI": "GoCrypto", + "ELIC": "Elicoin", + "ELITE": "EthereumLite", + "ELIX": "Elixir Games", + "ELIXI": "Elixir", + "ELIXIR": "Starchi", + "ELIZ": "Eliza (ai16zeliza)", + "ELIZA": "Eliza (elizawakesup.ai)", + "ELIZAOS": "ElizaOS", + "ELK": "Elk Finance", + "ELLA": "Ellaism", + "ELLI": "ElliotCoin", + "ELM": "Elements Play", + "ELMO": "ELMOERC", + "ELMOCOIN": "Elmo", + "ELMON": "Elemon", + "ELMT": "Element", + "ELO": "ElonPark", + "ELON": "Dogelon Mars", + "ELON2024": "ELON 2024(BSC)", + "ELON404": "Elon404", + "ELON4AFD": "Elon for AfD", + "ELONCAT": "ELON CAT COIN", + "ELOND": "ELON DOGE", + "ELONDOGE": "ELON DOGE", + "ELONDRAGON": "ELON DRAGON", + "ELONGATE": "ElonGate", + "ELONGD": "Elongate Deluxe", + "ELONGT": "Elon GOAT", + "ELONIA": "Elonia Trump", + "ELONIUM": "Elonium", + "ELONM": "ELON MEME", + "ELONMA": "ELON MARS", + "ELONMARS": "ELON MARS", + "ELONMU": "Elon Musk", + "ELONONE": "AstroElon", + "ELONPEPE": "Elon Pepe Robot", + "ELONRWA": "ElonRWA", + "ELONTRUMP": "ELON TRUMP", + "ELP": "Ellerium", + "ELS": "Ethlas", + "ELSA": "Elsa", + "ELT": "Element Black", + "ELTC2": "eLTC", + "ELTCOIN": "ELTCOIN", + "ELTG": "Graphen", + "ELU": "Elumia", + "ELUSKMON": "Elusk Mon", + "ELV": "Elvantis", + "ELVIS": "ELVIS", + "ELVN": "11Minutes", + "ELX": "Elixir Network", + "ELY": "Elysium", + "ELYS": "Elys Network", + "ELYSIAN": "Elysian", + "ELYSIUM": "Elysium", + "EM": "Eminer", + "EMAID": "MaidSafeCoin", + "EMANATE": "EMANATE", + "EMAR": "EmaratCoin", + "EMATIC": "Wrapped Polygon (Energi Bridge)", + "EMAX": "EthereumMax", + "EMB": "Overline Emblem", + "EMBER": "Ember", + "EMBERCOIN": "EmberCoin", + "EMBR": "Embr", + "EMC": "Edge Matrix Computing", + "EMC2": "Einsteinium", + "EMD": "Emerald", + "EMDR": "Ethereum MDR", + "EMERCOIN": "Emercoin", + "EMIGR": "EmiratesGoldCoin", + "EMILY": "Emily", + "EMIT": "Time Machine NFTs", + "EML": "EML Protocol", + "EMMM": "emmm", + "EMN.CUR": "Eastman Chemical", + "EMOJI": "MOMOJI", + "EMON": "Ethermon", + "EMONEYEUR": "e-Money EUR", + "EMOT": "Sentigraph.io", + "EMOTI": "EmotiCoin", + "EMP": "Emp Money", + "EMPC": "EmporiumCoin", + "EMPH": "Emphy", + "EMPI": "Emperor", + "EMPIRE": "Empire Token", + "EMPR": "empowr", + "EMR": "Emorya Finance", + "EMRLD": "The Emerald Company", + "EMRX": "Emirex Token", + "EMT": "EMAIL Token", + "EMU": "eMusic", + "EMV": "Ethereum Movie Venture", + "EMX": "EMX", + "EMYC": "E Money", + "ENA": "Ethena", + "ENC": "Encores Token", + "ENCD": "Encircled", + "ENCN": "EndChain", + "ENCR": "ENCRYPT", + "ENCRYPG": "EncrypGen", + "ENCS": "ENCOINS", + "ENCX": "Encrybit", + "ENDCEX": "Endpoint CeX Fan Token", + "ENDLESS": "Endless Board Game", + "ENDOR": "Endor Protocol Token", + "ENE": "EneCoin", + "ENEAR": "Near (Energiswap)", + "ENEDEX": "Enedex", + "ENERGYLEDGER": "Energy Ledger", + "ENERGYX": "Safe Energy", + "ENEXSPACE": "ENEX", + "ENF": "enfineo", + "ENG": "Enigma", + "ENGT": "Engagement Token", + "ENIGMA": "ENIGMA", + "ENJ": "Enjin Coin", + "ENJV1": "Enjin Coin v1", + "ENK": "Enkidu", + "ENNO": "ENNO Cash", + "ENO": "Enotoken", + "ENOKIFIN": "Enoki Finance", + "ENQ": "Enecuum", + "ENQAI": "enqAI", + "ENRG": "EnergyCoin", + "ENRON": "Enron", + "ENRX": "Enrex", + "ENS": "Ethereum Name Service", + "ENSO": "Enso", + "ENT": "Eternity", + "ENTC": "EnterButton", + "ENTER": "EnterCoin", + "ENTR": "EnterDAO", + "ENTRC": "ENTER COIN", + "ENTROPY": "Entropy", + "ENTRP": "Hut34 Project", + "ENTRY": "ENTRY", + "ENTS": "Ents", + "ENTT": "Presale Ventures", + "ENU": "Enumivo", + "ENV": "ENVOY", + "ENVIENTA": "Envienta", + "ENVION": "Envion", + "ENVOY": "Envoy A.I", + "ENX": "Enigma", + "EOC": "EveryonesCoin", + "EON": "Exscudo", + "EONC": "Dimension", + "EOS": "EOS", + "EOSBLACK": "eosBLACK", + "EOSC": "EOSForce", + "EOSDAC": "eosDAC", + "EOSDT": "EOSDT", + "EOST": "EOS TRUST", + "EOTH": "Echo Of The Horizon", + "EOX": "EXTRA ORDINARY", + "EPAN": "Paypolitan Token", + "EPANUS": "Epanus", + "EPENDLE": "Equilibria Pendle", + "EPEP": "Epep", + "EPETS": "Etherpets", + "EPIC": "Epic Chain", + "EPICCASH": "Epic Cash", + "EPICV1": "Ethernity Chain", + "EPIK": "EPIK Token", + "EPIKO": "Epiko", + "EPIX": "Byepix", + "EPK": "EpiK Protocol", + "EPS": "Ellipsis (OLD)", + "EPSTAIN": "Jeffrey Epstain", + "EPT": "Balance", + "EPTT": "Evident Proof Transaction Token", + "EPX": "Ellipsis X", + "EPY": "Empyrean", + "EQ": "Equilibrium Games", + "EQ9": "EQ9", + "EQC": "Ethereum Qchain Token", + "EQL": "EQUAL", + "EQM": "Equilibrium Coin", + "EQO": "EQO", + "EQPAY": "EquityPay", + "EQT": "EquiTrader", + "EQTYX": "WisdomTree Siegel Global Equity Digital Fund", + "EQU": "Equation", + "EQUAD": "Quadrant Protocol", + "EQUAL": "Equalizer DEX", + "EQUALCOIN": "EqualCoin", + "EQUI": "EQUI", + "EQUIL": "Equilibrium", + "EQUITOKEN": "EQUI Token", + "EQX": "EQIFi", + "EQZ": "Equalizer", + "ERA": "Caldera", + "ERA7": "Era Token", + "ERASWAP": "Era Swap Token", + "ERB": "ERBCoin", + "ERBB": "Exchange Request for Bitbon", + "ERC": "EuropeCoin", + "ERC20": "ERC20", + "ERC20V1": "ERC20 v1", + "ERD": "Elrond", + "ERE": "Erecoin", + "EREAL": "eREAL", + "ERG": "Ergo", + "ERIC": "Elon's Pet Fish ERIC", + "ERIS": "Eristica", + "ERK": "Eureka Coin", + "ERO": "Eroscoin", + "ERON": "ERON", + "EROTICA": "Erotica", + "ERR": "Coinerr", + "ERROR": "484 Fund", + "ERROR404": "ERROR404 MEME", + "ERRORCOIN": "ErrorCoin", + "ERSDL": "UnFederalReserve", + "ERT": "Esports.com", + "ERTH": "Erth Point", + "ERTHA": "Ertha", + "ERW": "ZeLoop Eco Reward", + "ERY": "Eryllium", + "ERZ": "Erzurumspor Token", + "ES": "Eclipse", + "ESAI": "Ethscan AI", + "ESBC": "ESBC", + "ESCC": "Eos Stable Coin Chain", + "ESCE": "Escroco Emerald", + "ESCROW": "Cryptegrity DAO", + "ESCU": "EYESECU AI", + "ESD": "Empty Set Dollar", + "ESE": "Eesee", + "ESES": "Eskişehir Fan Tokens", + "ESG": "ESG", + "ESGC": "ESG Chain", + "ESH": "Switch", + "ESHIB": "Euro Shiba Inu", + "ESIM": "DEPINSIM Token", + "ESM": "EL SALVADOR MEME", + "ESN": "Ethersocial", + "ESNC": "Galaxy Arena Metaverse", + "ESOL": "Earn Solana", + "ESP": "Espers", + "ESPL": "ESPL ARENA", + "ESPORTS": "Yooldo Games", + "ESPR": "Espresso Bot", + "ESRC": "ESR Coin", + "ESS": "Essentia", + "EST": "ESports Chain", + "ESTATE": "AgentMile", + "ESTEE": "Kaga No Fuuka Go Sapporo Kagasou", + "ESW": "eSwitch®", + "ESX": "EstateX", + "ESZ": "EtherSportz", + "ET": "ENDO", + "ET4": "Eticket4", + "ETALON": "Etalon", + "ETAN": "Etarn", + "ETBS": "EthBits", + "ETBT": "Ethereum Black", + "ETC": "Ethereum Classic", + "ETE": "EXTRADECOIN", + "ETER": "Eternal AI", + "ETERNAL": "CryptoMines Eternal", + "ETERNALC": "Eternal Coin", + "ETERNALT": "Eternal Token", + "ETF500": "Elon Trump Fart", + "ETFETH": "ETFETH", + "ETG": "Ethereum Gold", + "ETGM": "ETGM", + "ETGP": "Ethereum Gold Project", + "ETH": "Ethereum", + "ETH2": "Eth 2.0 Staking by Pool-X", + "ETH2X-FLI": "ETH 2x Flexible Leverage Index", + "ETH6900": "ETH6900", + "ETHA": "ETHA Lend", + "ETHAX": "ETHAX", + "ETHB": "ETHEREUM ON BASE", + "ETHBN": "EtherBone", + "ETHD": "Ethereum Dark", + "ETHDOG": "Ethereumdog", + "ETHER": "Etherparty", + "ETHERBTC": "EtherBTC", + "ETHERDELTA": "EtherDelta", + "ETHERE": "Ethereal", + "ETHEREM": "Etherempires", + "ETHEREUMMEME": "Solana Ethereum Meme", + "ETHEREUMP": "ETHEREUMPLUS", + "ETHEREUMSCRYPT": "EthereumScrypt", + "ETHERINC": "EtherInc", + "ETHERKING": "Ether Kingdoms Token", + "ETHERNITY": "Ethernity Chain", + "ETHEROLL": "Etheroll", + "ETHERW": "Ether Wars", + "ETHF": "EthereumFair", + "ETHFAI": "ETHforestAI", + "ETHFI": "Ether.fi", + "ETHI": "Ethical Finance", + "ETHIX": "EthicHub", + "ETHJ": "Ethereum (JustCrypto)", + "ETHM": "Ethereum Meta", + "ETHO": "The Etho Protocol", + "ETHOS": "Ethos Project", + "ETHP": "ETHPlus", + "ETHPAD": "ETHPad", + "ETHPLO": "ETHplode", + "ETHPOS": "ETHPoS", + "ETHPOW": "ETHPoW", + "ETHPR": "Ethereum Premium", + "ETHPY": "Etherpay", + "ETHR": "Ethereal", + "ETHS": "Ethscriptions", + "ETHSHIB": "Eth Shiba", + "ETHV": "Ethverse", + "ETHW": "Ethereum PoW", + "ETHX": "Stader ETHx", + "ETHY": "Ethereum Yield", + "ETI": "Etica", + "ETK": "Energi Token", + "ETKN": "EasyToken", + "ETL": "EtherLite", + "ETM": "En-Tan-Mo", + "ETN": "Electroneum", + "ETNA": "ETNA Network", + "ETNY": "Ethernity", + "ETP": "Metaverse", + "ETPOS": "EtherPOS", + "ETR": "Electric Token", + "ETRL": "Ethereal", + "ETRNT": "Eternal Trusts", + "ETS": "ETH Share", + "ETSC": "Ether star blockchain", + "ETT": "EncryptoTel", + "ETX": "Ethrix", + "ETY": "Ethereum Cloud", + "ETZ": "EtherZero", + "EU24": "EURO2024", + "EUC": "Eurocoin", + "EUCOIN": "EU Coin", + "EUCX": "EUCX", + "EUD": "Eurodom", + "EUL": "Euler", + "EULER": "Euler Tools", + "EUM": "Elitium", + "EUNO": "EUNO", + "EURA": "EURA", + "EURAU": "AllUnity EUR", + "EURC": "Euro Coin", + "EURCV": "EUR CoinVertible", + "EURCVV1": "EUR CoinVertible v1", + "EURD": "Quantoz EURD", + "EURE": "Monerium EUR emoney", + "EUREV1": "Monerium EUR emoney v1", + "EURI": "Eurite", + "EURL": "LUGH", + "EURN": "NOKU EUR", + "EURO3": "EURO3", + "EUROB": "Etherfuse EUROB", + "EUROCUP": "EURO CUP INU", + "EUROE": "EUROe Stablecoin", + "EUROP": "EURØP", + "EUROPACOIN": "Europa Coin", + "EURQ": "Quantoz EURQ", + "EURR": "StablR Euro", + "EURRV1": "StablR Euro v1", + "EURS": "STASIS EURS", + "EURST": "EURO Stable Token", + "EURT": "Euro Tether", + "EURTV1": "Euro Tether v1", + "EURU": "Upper Euro", + "EURX": "eToro Euro", + "EUSD": "Egoras Dollar", + "EUSX": "eUSX", + "EUT": "EarnUp Token", + "EUTBL": "Spiko EU T-Bills Money Market Fund", + "EV": "EVAI", + "EVA": "Evadore", + "EVAA": "EVAA Protocol", + "EVAI": "EVA Intelligence", + "EVAL": "Chromia's EVAL by Virtuals", + "EVAN": "Evanesco Network", + "EVAULT": "EthereumVault", + "EVAV1": "Evadore v1", + "EVC": "Eventchain", + "EVCC": "Eco Value Coin", + "EVCOIN": "EverestCoin", + "EVDC": "Electric Vehicle Direct Currency", + "EVE": "Devery", + "EVEAI": "EVEAI", + "EVED": "Evedo", + "EVENC": "EvenCoin", + "EVENT": "Event Token", + "EVER": "Everscale", + "EVEREST": "Everest", + "EVERETH": "EverETH Reflect", + "EVERGREEN": "EverGreenCoin", + "EVERGROW": "EverGrowCoin", + "EVERLIFE": "EverLife.AI", + "EVERMOON": "EverMoon", + "EVERV": "EverValue Coin", + "EVERY": "Everyworld", + "EVIL": "EvilCoin", + "EVILPEPE": "Evil Pepe", + "EVIN": "Evin Token", + "EVMOS": "Evmos", + "EVN": "Evn Token", + "EVO": "Devomon", + "EVOAI": "EvolveAI", + "EVOC": "EVOCPLUS", + "EVOL": "EVOL NETWORK", + "EVOS": "EVOS", + "EVOSIM": "EvoSimGame", + "EVOVERSES": "EvoVerses", + "EVR": "Everus", + "EVRICE": "Evrice", + "EVRM": "Evrmore", + "EVRT": "Everest Token", + "EVRY": "Evrynet", + "EVT": "EveriToken", + "EVU": "Evulus Token", + "EVX": "Everex", + "EVY": "EveryCoin", + "EVZ": "Electric Vehicle Zone", + "EWC": "Erugo World Coin", + "EWIF": "elonwifcoin", + "EWON": "Ewon Mucks", + "EWT": "Energy Web Token", + "EWTT": "Ecowatt", + "EXA": "Exactly Protocol", + "EXB": "ExaByte (EXB)", + "EXC": "Eximchain", + "EXCC": "ExchangeCoin", + "EXCHANGEN": "ExchangeN", + "EXCL": "Exclusive Coin", + "EXCLUSIVEPL": "Exclusive Platform", + "EXCLUSIVEPLV1": "Exclusive Platform v1", + "EXD": "Exorde", + "EXE": "ExeCoin", + "EXEN": "Exen Coin", + "EXFI": "Flare Finance", + "EXGO": "EXGOLAND", + "EXIP": "EXIP", + "EXIT": "ExitCoin", + "EXLT": "ExtraLovers", + "EXM": "EXMO Coin", + "EXMR": "EXMR FDN", + "EXN": "Exeno", + "EXNT": "EXNT", + "EXO": "Exosis", + "EXOS": "Exobots", + "EXP": "Expanse", + "EXPAND": "Gems", + "EXPERIENCE": "Experience Points", + "EXPERT": "EXPERT_MONEY", + "EXPO": "Exponential Capital", + "EXRD": "Radix", + "EXRN": "EXRNchain", + "EXTN": "Extensive Coin", + "EXTP": "TradePlace", + "EXTRA": "Extra Finance", + "EXVG": "Exverse", + "EXY": "Experty", + "EXZO": "ExzoCoin 2.0", + "EYE": "MEDIA EYE", + "EYES": "Eyes Protocol", + "EYETOKEN": "EYE Token", + "EYWA": "EYWA", + "EZ": "EasyFi V2", + "EZC": "EZCoin", + "EZEIGEN": "Restaked EIGEN", + "EZETH": "Renzo Restaked ETH", + "EZI": "Ezillion", + "EZM": "EZMarket", + "EZPZ": "Eazy Peazy", + "EZSOL": "Renzo Restaked SOL", + "EZSWAP": "EZswap Protocol", + "EZSWAPV1": "EZswap Protocol v1", + "EZT": "EZToken", + "EZY": "EzyStayz", + "ElvishMagic": "EMAGIC", + "F": "SynFutures", + "F16": "F16Coin", + "F1C": "Future1coin", + "F2C": "Ftribe Fighters", + "F2K": "Farm2Kitchen", + "F3": "Friend3", + "F5": "F5-promoT5", + "F7": "Five7", + "F9": "Falcon Nine", + "FAB": "FABRK Token", + "FABA": "Faba Invest", + "FABIENNE": "Fabienne", + "FABRIC": "MetaFabric", + "FAC": "Flying Avocado Cat", + "FACEDAO": "FaceDAO", + "FACETER": "Faceter", + "FACT": "Orcfax", + "FACTOM": "Factom", + "FACTORY": "ChainFactory", + "FACTR": "Defactor", + "FACTRPAY": "FactR", + "FACY": "ArAIstotle Fact Checker", + "FADO": "FADO Go", + "FAFO": "FAFO", + "FAFOSOL": "Fafo", + "FAG": "PoorFag", + "FAH": "Falcons", + "FAI": "Freysa AI", + "FAIR": "FairCoin", + "FAIR3": "Fair and Free", + "FAIRC": "Faireum Token", + "FAIRG": "FairGame", + "FAIRUM": "Fairum", + "FAKE": "FAKE COIN", + "FAKEAI": "DeepFakeAI", + "FAKT": "Medifakt", + "FALCONS": "Falcon Swaps", + "FALX": "FalconX", + "FAM": "Family", + "FAME": "Fame MMA", + "FAMEC": "FameCoin", + "FAMILY": "The Bitcoin Family", + "FAML": "FAML", + "FAMOUSF": "Famous Fox Federation", + "FAN": "Fanadise", + "FAN360": "Fan360", + "FANC": "fanC", + "FANCYTHATTOKEN": "Fancy That", + "FAND": "Fandomdao", + "FANG": "FANG Token", + "FANS": "Fantasy Cash", + "FANT": "Phantasia", + "FANTC": "FANtium Tennis Coin", + "FANTOM": "Fantom Maker", + "FANV": "FanVerse", + "FANX": "FrontFanz", + "FANZ": "FanChain", + "FAPTAX": "Faptax", + "FAR": "Farmland Protocol", + "FARA": "FaraLand", + "FARCA": "Farcana", + "FARM": "Harvest Finance", + "FARMA": "FarmaTrust", + "FARMC": "FARM Coin", + "FARME": "Farmers Only", + "FARMING": "Farming Bad", + "FARMS": "Farmsent", + "FARTAI": "Fart AI", + "FARTBOY": "Fartboy", + "FARTCOIN": "Fartcoin", + "FARTDEV": "Fart Dev", + "FARTIMUS": "Fartimus Prime", + "FARTING": "Farting Unicorn", + "FARTLESS": "FARTLESS COIN", + "FAS": "fast construction coin", + "FAST": "Fastswap", + "FASTAI": "Fast And Ai", + "FASTMOON": "FastMoon", + "FASTUSD": "Sei fastUSD", + "FASTV1": "Fastswap v1", + "FAT": "Fatcoin", + "FATCAKE": "FatCake", + "FATH": "Father Of Meme: Origin", + "FATHER": "DogeFather", + "FATHOM": "Fathom", + "FATMICHI": "FATMICHI", + "FAV": "Football At AlphaVerse", + "FAVR": "FAVOR", + "FAYD": "Fayda", + "FAYRE": "Fayre", + "FAZZ": "FazzCoin", + "FB": "Fractal Bitcoin", + "FBA": "Firebird Aggregator", + "FBB": "FilmBusinessBuster", + "FBD": "Fiboard", + "FBG": "Fort Block Games", + "FBN": "Five balance", + "FBNB": "ForeverBNB", + "FBOMB": "fBomb", + "FBOMBV1": "fBomb v1", + "FBURN": "Forever Burn", + "FBX": "Finance Blocks", + "FC": "Facecoin", + "FC2": "Fuel2Coin", + "FCC": "Freechat", + "FCD": "FreshCut Diamond", + "FCF": "French Connection Finance", + "FCH": "Freecash", + "FCK": "Find & Check", + "FCK925": "FCK925", + "FCL": "Fractal", + "FCN": "FantomCoin", + "FCO": "Fanatico", + "FCOIN": "FCoin", + "FCON": "SpaceFalcon", + "FCP": "FILIPCOIN", + "FCQ": "Fortem Capital", + "FCS": "CryptoFocus", + "FCT": "FirmaChain", + "FCTC": "FaucetCoin", + "FCTR": "FactorDAO", + "FDC": "Fidance", + "FDGC": "FINTECH DIGITAL GOLD COIN", + "FDLS": "FIDELIS", + "FDM": "Fandom", + "FDO": "Firdaos", + "FDR": "French Digital Reserve", + "FDS": "Foodie Squirrel", + "FDT": "Frutti Dino", + "FDUSD": "First Digital USD", + "FDX": "fidentiaX", + "FDZ": "Friendz", + "FEAR": "Fear", + "FEARNOT": "FEAR NOT", + "FEATHER": "FeatherCoin", + "FECES": "FECES", + "FEED": "Feeder Finance", + "FEENIXV2": "ProjectFeenixv2", + "FEES": "UNIFEES", + "FEFE": "Fefe", + "FEG": "FEED EVERY GORILLA", + "FEGV1": "FEG Token v1", + "FEGV2": "FEG Token", + "FEI": "Fei Protocol", + "FELIS": "Felis", + "FELIX": "FelixCoin", + "FELIX2": "Felix 2.0 ETH", + "FELY": "Felysyum", + "FEN": "First Ever NFT", + "FENE": "Fenerbahçe Token", + "FENOMY": "Fenomy", + "FENTANYL": "Chinese Communist Dragon", + "FER": "Ferro", + "FERC": "FairERC20", + "FERMA": "Ferma", + "FERT": "Chikn Fert", + "FERZAN": "Ferzan", + "FESS": "Fesschain", + "FET": "Artificial Superintelligence Alliance", + "FETCH": "Fetch", + "FETS": "FE TECH", + "FETV1": "Fetch v1", + "FEUSD": "Felix feUSD", + "FEVR": "RealFevr", + "FEX": "FEX Token", + "FEY": "Feyorra", + "FF": "Falcon Finance", + "FF1": "Two Prime FF1 Token", + "FFA": "Cryptofifa", + "FFC": "FireflyCoin", + "FFCT": "FortFC", + "FFM": "Files.fm Library", + "FFN": "Fairy Forest", + "FFTP": "FIGHT FOR THE PEOPLE", + "FFUEL": "getFIFO", + "FFYI": "Fiscus FYI", + "FGC": "FantasyGold", + "FGD": "Freedom God DAO", + "FGM": "Feels Good Man", + "FGPT": "FurGPT", + "FGT": "Flozo Game Token", + "FGZ": "Free Game Zone", + "FHB": "FHB", + "FHE": "MindNetwork FHE Token", + "FHM": "FantOHM", + "FI": "Fideum", + "FIA": "FIA Protocol", + "FIATDAO": "FIAT DAO Token", + "FIBO": "FibSWAP DEx", + "FIBOS": "FIBOS", + "FIBRE": "FIBRE", + "FIC": "Filecash", + "FID": "Fidira", + "FIDA": "Bonfida", + "FIDD": "Fidelity Digital Dollar", + "FIDLE": "Fidlecoin", + "FIDO": "FIDO", + "FIDU": "Fidu", + "FIELD": "Fieldcoin", + "FIERO": "Fieres", + "FIF": "flokiwifhat", + "FIFA": "FIFA", + "FIFTY": "FIFTYONEFIFTY", + "FIG": "FlowCom", + "FIGH": "FIGHT FIGHT FIGHT", + "FIGHT2MAGA": "Fight to MAGA", + "FIGHTMAGA": "FIGHT MAGA", + "FIGHTPEPE": "FIGHT PEPE", + "FIGHTRUMP": "FIGHT TRUMP", + "FIH": "Fidelity House", + "FIII": "Fiii", + "FIL": "FileCoin", + "FILDA": "Filda", + "FILES": "Solfiles", + "FILEST": "FileStar", + "FILL": "Fillit", + "FILM": "Filmpass", + "FILST": "Filecoin Standard Hashrate Token", + "FIN": "DeFiner", + "FINA": "Defina Finance", + "FINALE": "Ben's Finale", + "FINAN": "FINANCIAL TRANSACTION SYSTEM", + "FINB": "Finblox", + "FINC": "Finceptor", + "FIND": "FindCoin", + "FINDER": "Finder AI", + "FINE": "Refinable", + "FINGER": "Finger Blast", + "FINK": "FINK", + "FINN": "Huckleberry", + "FINOM": "Finom FIN Token", + "FINOMNOM": "Finom NOM Token", + "FINS": "AutoShark DEX", + "FINT": "FintraDao", + "FINU": "Formula Inu", + "FINVESTA": "Finvesta", + "FIO": "FIO Protocol", + "FIONA": "Fiona", + "FIONABSC": "Fiona", + "FIR": "Fireverse", + "FIRA": "Defira", + "FIRE": "Matr1x Fire", + "FIRECOIN": "FireCoin", + "FIREP": "Fire Protocol", + "FIREW": "Fire Wolf", + "FIRO": "Firo", + "FIRSTHARE": "FirstHare", + "FIRU": "Firulais Finance", + "FIS": "Stafi", + "FISH": "Polycat Finance", + "FISH2": "FISH2", + "FISHK": "Fishkoin", + "FISHW": "Fishwar", + "FIST": "Fistbump", + "FISTBUMP": "FistBump", + "FIT": "Financial Investment Token", + "FITC": "Fitcoin", + "FITCOIN": "FITCOIN", + "FITFI": "Step App", + "FITT": "Fitmint", + "FIU": "beFITTER", + "FIUSD": "Sygnum FIUSD Liquidity Fund", + "FIWA": "Defi Warrior", + "FIX00": "FIX00", + "FJB": "Freedom. Jobs. Business.", + "FJC": "FujiCoin", + "FJO": "Fjord Foundry", + "FJT": "Fuji FJT", + "FK": "FK Coin", + "FKBIDEN": "Fkbiden", + "FKGARY": "Fuck Gary Gensler", + "FKH": "Flying Ketamine Horse", + "FKPEPE": "Fuck Pepe", + "FKR": "Flicker", + "FKRPRO": "FlickerPro", + "FKSK": "Fatih Karagümrük SK", + "FKX": "FortKnoxster", + "FL": "Freeliquid", + "FLA": "Flappy", + "FLAG": "Flag Network", + "FLAKY": "FLAKY", + "FLAME": "FireStarter", + "FLAP": "Flappy Coin", + "FLAPPY": "Flappy", + "FLAREF": "FlareFoxInu", + "FLAS": "Flas Exchange Token", + "FLASH": "Flashstake", + "FLASHC": "FLASH coin", + "FLASHT": "FlashToken", + "FLAVIA": "Flavia Is Online", + "FLAY": "Flayer", + "FLC": "FlowChainCoin", + "FLD": "FluidAI", + "FLDC": "Folding Coin", + "FLDT": "FairyLand", + "FLEA": "FLEABONE", + "FLEPE": "Floki VS Pepe", + "FLETA": "FLETA", + "FLEX": "FLEX Coin", + "FLEXUSD": "flexUSD", + "FLG": "Folgory Coin", + "FLIBERO": "Fantom Libero Financial", + "FLIC": "Skaflic", + "FLIGHT": "FLIGHTCLUPCOIN", + "FLIK": "FLiK", + "FLIKO": "Fliko Uni", + "FLINU": "FLOKI INU", + "FLIP": "Chainflip", + "FLIX": "OmniFlix Network", + "FLIXX": "Flixxo", + "FLK": "Fleek", + "FLL": "Feellike", + "FLLW": "Follow Coin", + "FLM": "Flamingo", + "FLMC": "FOLM coin", + "FLN": "Falcon", + "FLO": "Flo", + "FLOAT": "Float Protocol", + "FLOATBANK": "Float Protocol", + "FLOCHI": "Flochi", + "FLOCK": "FLock.io", + "FLOCKA": "Waka Flocka", + "FLOCKE": "Flockerz", + "FLOCO": "flocoin", + "FLOKA": "FLOKA", + "FLOKEI": "FLOKEI", + "FLOKI": "Floki Inu", + "FLOKIBURN": "FlokiBurn", + "FLOKICASH": "Floki Cash", + "FLOKIM": "Flokimooni", + "FLOKIMOON": "FLOKIMOON", + "FLOKINY": "Floki New Year", + "FLOKIPEPE": "FlokiPepe", + "FLOKITA": "FLOKITA", + "FLOKIV1": "Floki v1", + "FLOKIV2": "Floki v2", + "FLOKIV3": "Floki v3", + "FLOKIX": "FLOKI X", + "FLOOF": "FLOOF", + "FLOOR": "FloorDAO", + "FLOP": "Big Floppa", + "FLOPPA": "Floppa Cat", + "FLORK": "FLORK BNB", + "FLORKY": "Florky", + "FLOSHIDO": "FLOSHIDO INU", + "FLOT": "FireLotto", + "FLOTUS47": "Melania Trump", + "FLOURI": "Flourishing AI", + "FLOVI": "Flovi inu", + "FLOVM": "FLOV MARKET", + "FLOW": "Flow", + "FLOWER": "FlowerAI", + "FLOWM": "Flowmatic", + "FLOWP": "Flow Protocol", + "FLOYX": "Floyx", + "FLP": "Gameflip", + "FLR": "Flare", + "FLRBRG": "Floor Cheese Burger", + "FLRS": "Flourish Coin", + "FLS": "Flits", + "FLSH": "FlashWash", + "FLT": "Fluence", + "FLTTX": "WisdomTree Floating Rate Treasury Digital Fund", + "FLUFFI": "Fluffington", + "FLUFFY": "FLUFFY", + "FLUFFYS": "Fluffys", + "FLUI": "Fluidity", + "FLUID": "Fluid", + "FLUIDTRADE": "Fluid", + "FLURRY": "Flurry Finance", + "FLUT": "Flute", + "FLUTTERCOIN": "FlutterCoin", + "FLUX": "Flux", + "FLUXB": "Fluxbot", + "FLUXT": "Flux Token", + "FLUZ": "FluzFluz", + "FLVR": "FlavorCoin", + "FLX": "Reflexer Ungovernance Token", + "FLY": "Fly.trade", + "FLYBNB": "FlyBNB", + "FLYCOIN": "FlyCoin", + "FLZ": "Fellaz", + "FM": "Full Moon", + "FMA": "FLAMA", + "FMB": "FREEMOON BINANCE", + "FMC": "Fimarkcoin", + "FME": "FME", + "FMEX": "FMex", + "FMF": "Formosa Financial Token", + "FMG": "FM Gallery", + "FML": "FormulA", + "FMT": "Finminity", + "FN": "Filenet", + "FNA": "FinTech AI", + "FNB": "FNB protocol", + "FNC": "Fancy Games", + "FNCT": "Financie Token", + "FNCY": "FNCY", + "FND": "Rare FND", + "FNDZ": "FNDZ Token", + "FNF": "FunFi", + "FNK": "FunKeyPay", + "FNL": "Finlocale", + "FNLX": "Fignal X", + "FNO": "Fonero", + "FNP": "FlipNpik", + "FNS": "FAUNUS", + "FNSA": "FINSCHIA", + "FNTB": "FinTab", + "FNX": "FinNexus", + "FNXAI": "Finanx AI", + "FNZ": "Fanzee", + "FO": "Official FO", + "FOA": "Fragments of arker", + "FOAM": "Foam", + "FOC": "TheForce Trade", + "FOCAI": "focai.fun", + "FOCV": "FOCV", + "FODL": "Fodl Finance", + "FOF": "Future Of Fintech", + "FOFAR": "FoFar", + "FOFARBASE": "FOFAR", + "FOFARIO": "Fofar", + "FOFO": "FOFO", + "FOFOTOKEN": "FOFO Token", + "FOG": "FOGnet", + "FOGE": "Fat Doge", + "FOGO": "Fogo", + "FOGV1": "FOGnet v1", + "FOIN": "Foin", + "FOL": "Folder Protocol", + "FOLD": "Manifold Finance", + "FOLGORYUSD": "FolgoryUSD", + "FOLGORYUSDV1": "FolgoryUSD", + "FOLKS": "Folks Finance", + "FOLO": "Alpha Impact", + "FOM": "FOMO BULL CLUB", + "FOMO": "Fomo", + "FOMON": "FOMO Network", + "FOMOSOL": "FOMOSolana", + "FON": "INOFI", + "FONE": "Fone", + "FONS": "FONSmartChain", + "FONT": "Font", + "FONZ": "FonzieCoin", + "FOOD": "FoodCoin", + "FOODC": "Food Club", + "FOOM": "FOOM", + "FOOX": "Foox", + "FOPA": "Fopacoin", + "FOR": "ForTube", + "FORA": "UFORIKA", + "FORCE": "TriForce Tokens", + "FORCEC": "Force Coin", + "FORDON": "Ford Motor (Ondo Tokenized)", + "FORE": "FORE Protocol", + "FOREFRONT": "Forefront", + "FOREST": "Forest", + "FORESTPLUS": "The Forbidden Forest", + "FOREVER": "Forever Coin", + "FOREVERFOMO": "ForeverFOMO", + "FOREVERPUMP": "Forever Pump", + "FOREVERUP": "ForeverUp", + "FOREX": "handle.fi", + "FOREXCOIN": "FOREXCOIN", + "FORK": "Gastro Advisor Token", + "FORM": "Four", + "FORMATION": "Formation FI", + "FORMNET": "Form", + "FORS": "Forus", + "FORT": "Forta", + "FORTH": "Ampleforth Governance Token", + "FORTHB": "ForthBox", + "FORTKNOX": "Fort Knox", + "FORTUNA": "Fortuna", + "FORTUNE": "Fortune", + "FORWARD": "Forward Protocol", + "FOTA": "Fight Of The Ages", + "FOTO": "Unique Photo", + "FOTTIE": "Fottie", + "FOU": "Four", + "FOUND": "ccFound", + "FOUNDER": "Founder", + "FOUNTAIN": "Fountain", + "FOUR": "4", + "FOX": "ShapeShift FOX Token", + "FOXAI": "FOXAI", + "FOXD": "Foxdcoin", + "FOXE": "Foxe", + "FOXF": "Fox Finance", + "FOXGIRL": "FoxGirl", + "FOXI": "Foxify", + "FOXSY": "Foxsy AI", + "FOXT": "Fox Trading", + "FOXV2": "FoxFinanceV2", + "FOXXY": "FOXXY", + "FOXY": "Foxy", + "FP": "Forgotten Playland", + "FPAD": "FantomPAD", + "FPC": "Futurepia", + "FPEPE": "Based Father Pepe", + "FPFT": "Peruvian National Football Team Fan Token", + "FPI": "Frax Price Index", + "FPIBANK": "FPIBANK", + "FPIS": "Frax Price Index Share", + "FPS": "WEB3WAR Token", + "FQS": "FQSwap V2", + "FR": "Freedom Reserve", + "FRA": "Findora", + "FRAC": "FractalCoin", + "FRAG": "Fragmetric", + "FRANK": "Frank", + "FRANKLIN": "Franklin", + "FRATT": "Frogg and Ratt", + "FRAX": "Frax Share", + "FRAXLEGACY": "Frax", + "FRAZ": "FrazCoin", + "FRBK": " FreeBnk", + "FRC": "FireRoosterCoin", + "FRD": "Farad", + "FRDX": "Frodo Tech", + "FRE": "FreeCoin", + "FREAK": "Freakoff", + "FREC": "Freyrchain", + "FRECNX": "FreldoCoinX", + "FRED": "First Convicted Raccon Fred", + "FREDDY": "FREDDY", + "FREDE": "FREDEnergy", + "FREE": "FREE coin", + "FREED": "FreedomCoin", + "FREEDO": "Freedom", + "FREEDOG": "Freedogs", + "FREEDOM": "Freedom Protocol Token", + "FREELA": "DecentralFree", + "FREEPAVEL": "Free Pavel", + "FREEROSS": "FreeRossDAO", + "FREET": "FreeTrump", + "FREL": "Freela", + "FREN": "FREN", + "FRENC": "Frencoin", + "FRENCH": "French On Base", + "FRENLY": "Frenly", + "FRENPET": "Fren Pet", + "FRENS": "Farmer Friends", + "FRESCO": "Fresco", + "FRF": "France REV Finance", + "FRGB": "Pepe's Frogbar", + "FRGST": "Froggies Token", + "FRGX": "FRGX", + "FRIC": "Fric", + "FRICTION": "Frictionless", + "FRIEND": "Friend.tech", + "FRIES": "Soltato FRIES", + "FRIN": "Fringe Finance", + "FRK": "Franko", + "FRKT": "FRAKT Token", + "FRLONG": "FRLONGTOKEN", + "FRM": "Ferrum Network", + "FRN": "Francs", + "FRNT": "Final Frontier", + "FROC": "Based Froc", + "FROG": "FrogSwap", + "FROGB": "Frog Bsc", + "FROGCEO": "Frog Ceo", + "FROGE": "Froge Finance", + "FROGEX": "FrogeX", + "FROGGER": "FROGGER", + "FROGGIE": "Froggie", + "FROGGY": "Froggy", + "FROGLIC": "Pink Hood Froglicker", + "FROGO": "Frogo", + "FROK": "Frok.ai", + "FROKAI": "FrokAI", + "FRONK": "Fronk", + "FRONT": "Frontier", + "FROP": "Popo The Frog", + "FROSTY": "Frosty the Polar Bear", + "FROX": "Frox", + "FROYO": "Froyo Games", + "FROZE": "FrozenAi", + "FRP": "Fame Reward Plus", + "FRR": "Frontrow", + "FRSP": "Forkspot", + "FRST": "FirstCoin", + "FRT": "FORT Token", + "FRTC": "FART COIN", + "FRTN": "EbisusBay Fortune", + "FRTS": "Fruits", + "FRV": "Fitrova", + "FRWC": "Frankywillcoin", + "FRXETH": "Frax Ether", + "FRXUSD": "Frax USD", + "FRZ": "Frozy Inu", + "FRZSS": "Frz Solar System", + "FRZSSCOIN": "FRZ Solar System Coin", + "FS": "FantomStarter", + "FSBT": "Forty Seven Bank", + "FSC": "FriendshipCoin", + "FSCC": "Fisco Coin", + "FSHN": "Fashion Coin", + "FSM": "Floki SafeMoon", + "FSN": "Fusion", + "FSNV1": "Fusion v1", + "FSO": "FSociety", + "FST": "FreeStyle Token", + "FSTC": "FastCoin", + "FSTR": "Fourth Star", + "FSW": "Falconswap", + "FT": "Fracton Protocol", + "FTB": "Fit&Beat", + "FTC": "Futurex", + "FTD": "42DAO", + "FTG": "fantomGO", + "FTH": "Fintyhub Token", + "FTHM": "Fathom Protocol", + "FTI": "FansTime", + "FTK": "FToken", + "FTM": "Fantom", + "FTMO": "Fantom Oasis", + "FTMX": "FUCK THE MATRIX", + "FTN": "Fasttoken", + "FTO": "FuturoCoin", + "FTON": "Fanton", + "FTP": "FuturePoints", + "FTPY": "FTPY TOKEN", + "FTR": "Fautor", + "FTRB": "Faith Tribe", + "FTRC": "FutureCoin", + "FTS": "Fortress Lending", + "FTT": "FTX Token", + "FTTOKEN": "Finance Token", + "FTTT": "FTT Token", + "FTUM": "Fatum", + "FTVT": "FashionTV Token", + "FTW": "FutureWorks", + "FTX": "FintruX", + "FTXAI": "FTX AI Agent", + "FTXT": "FUTURAX", + "FU": "FU Money", + "FUBAO": "FUBAO", + "FUCK": "Fuck Token", + "FUCKTRUMP": "FUCK TRUMP", + "FUD": "Fud the Pug", + "FUDFINANCE": "FUD.finance", + "FUEGO": "FUEGO", + "FUEL": "Fuel Network", + "FUELX": "Fuel", + "FUFU": "Fufu Token", + "FUG": "FUG", + "FUJIN": "Fujinto", + "FUKU": "FUKU-KUN", + "FUL": "Fulcrom Finance", + "FULLSEND": "Fullsend Community Coin", + "FUMO": "Alien Milady Fumo", + "FUN": "FUN Token", + "FUNASSYI": "Funassyi", + "FUNC": "FunCoin", + "FUNCH": "FUNCH", + "FUND": "Unification", + "FUNDC": "FUNDChains", + "FUNDP": "Fund Platform", + "FUNDREQUEST": "FundRequest", + "FUNDX": "Funder One Capital", + "FUNDYOUR": "FundYourselfNow", + "FUNDZ": "FundFantasy", + "FUNG": "Fungify", + "FUNGI": "Fungi", + "FUNK": "Cypherfunks Coin", + "FUR": "Furio", + "FURIE": "Matt Furie", + "FURM": "Furmula", + "FURO": "Furo", + "FURU": "Furucombo", + "FURUKURU": "Furukuru", + "FURY": "Engines of Fury", + "FURYX": "Metafury", + "FUS": "Fus", + "FUSAKA": "Fusaka", + "FUSD": "Fantom USD", + "FUSDC": "Fluidity", + "FUSE": "Fuse Network Token", + "FUSIO": "FUSIO", + "FUSION": "FusionBot", + "FUSO": "Fusotao", + "FUT": "FuturesAI", + "FUTC": "FutCoin", + "FUTUR": "Future Token", + "FUTURE": "FutureCoin", + "FUTUREAI": "Future AI", + "FUTURESWAP": "Futureswap", + "FUZE": "FUZE Token", + "FUZEX": "FuzeX", + "FUZN": "Fuzion", + "FUZZ": "Fuzzballs", + "FVT": "Finance Vote", + "FWATCH": "Foliowatch", + "FWB": "Friends With Benefits Pro", + "FWBV1": "Friends With Benefits Pro v1", + "FWC": "Qatar 2022", + "FWCL": "Legends", + "FWH": "FigureWifHat", + "FWOG": "Fwog", + "FWT": "Freeway Token", + "FWW": "Farmers World Wood", + "FX": "Function X", + "FXAKV": "Akiverse Governance", + "FXB": "FxBox", + "FXC": "Flexacoin", + "FXD": "Fathom Dollar", + "FXDX": "FXDX", + "FXF": "Finxflo", + "FXI": "FX1 Sports", + "FXN": "FXN", + "FXP": "FXPay", + "FXST": "FX Stock Token", + "FXT": "Frog X Toad 6900", + "FXUSD": "f(x) Protocol fxUSD", + "FXY": "Floxypay", + "FYD": "FYDcoin", + "FYDE": "Fyde", + "FYDO": "Fly Doge", + "FYN": "Affyn", + "FYP": "FlypMe", + "FYZ": "Fyooz", + "FYZNFT": "Fyooz NFT", + "G": "Gravity", + "G1X": "GoldFinX", + "G3": "GAM3S.GG", + "G50": "G50", + "G7": "Game7", + "G8C": "ONEG8.ONE", + "G999": "G999", + "GAC": "Green Art Coin", + "GAD": "Green App Development", + "GAFA": "Gafa", + "GAFI": "GameFi", + "GAGA": "Gaga", + "GAI": "GraphAI", + "GAIA": "Gaia Token", + "GAIAE": "Gaia Everworld", + "GAIAPLATFORM": "GAIA Platform", + "GAIB": "GAIB", + "GAIN": "GriffinAI", + "GAINFY": "Gainfy", + "GAINS": "Gains", + "GAINSV1": "Gains v1", + "GAIX": "GaiAI Token", + "GAJ": "Gaj Finance", + "GAKH": "GAKHcoin", + "GAL": "Galxe", + "GALA": "Gala", + "GALATA": "Galatasaray Fan Token", + "GALAV1": "Gala v1", + "GALAX": "Galaxy Finance", + "GALAXIS": "Galaxis", + "GALAXY": "GalaxyCoin", + "GALEON": "Galeon", + "GALI": "Galilel", + "GALO": "Clube Atlético Mineiro Fan Token", + "GALT": "Galtcoin", + "GAM": "Gambit coin", + "GAMA": "GAMA Coin", + "GAMB": "GAMB", + "GAMBI": "Gambi Fi", + "GAMBIT": "Gambit", + "GAMBL": "Metagamble", + "GAME": "GameBuild", + "GAME5BALL": "Game 5 BALL", + "GAMEBUD": "GAMEBUD", + "GAMEBYV": "GAME by Virtuals", + "GAMEC": "Game", + "GAMECO": "Game.com", + "GAMECOIN": "Game Coin", + "GAMECRED": "GameCredits", + "GAMEF": "Game Fantasy Token", + "GAMEFI": "GameFi Token", + "GAMEFORK": "GameFork", + "GAMEIN": "Game Infinity", + "GAMER": "GameStation", + "GAMERFI": "GamerFI", + "GAMES": "Gamestarter", + "GAMEST": "GameStop Coin", + "GAMESTARS": "Game Stars", + "GAMESTO": "GameStop", + "GAMESTOP": "GameStop", + "GAMESTUMP": "GAMESTUMP", + "GAMET": "GAME Token", + "GAMETA": "Gameta", + "GAMEX": "GameX", + "GAMEXCOIN": "Game X Coin", + "GAMI": "GAMI World", + "GAMIN": "Gaming Stars", + "GAMINGDOGE": "GAMINGDOGE", + "GAMINGSHIBA": "GamingShiba", + "GAMMA": "Gamma Strategies", + "GAN": "Galactic Arena: The NFTverse", + "GANA": "GANA", + "GAP": "Gaps Chain", + "GAPC": "Gapcoin", + "GARD": "Hashgard", + "GARFIELD": "Garfield Cat", + "GARI": "Gari Network", + "GARK": "Game Ark", + "GART": "Griffin Art", + "GARTS": "Glink Arts Share", + "GARU": "Garuda Coin", + "GARUDA": "GarudaSwap", + "GARWIF": "Garfield Wif Hat", + "GARY": "Gary", + "GAS": "Gas", + "GASDAO": "Gas DAO", + "GASG": "Gasgains", + "GASP": "GASP", + "GASPCOIN": "gAsp", + "GASS": "Gasspas", + "GAST": "Gas Town", + "GASTRO": "GastroCoin", + "GAT": "Gather", + "GATA": "Gata", + "GATCOIN": "GATCOIN", + "GATE": "GATENet", + "GATEUSD": "GUSD", + "GATEWAY": "Gateway Protocol", + "GATHER": "Gather", + "GATSBY": "Gatsby Inu", + "GAU": "Gamer Arena", + "GAUSS": "Gauss0x", + "GAY": "GAY", + "GAYPEPE": "Gay Pepe", + "GAYSLER": "Gaysler", + "GAZE": "GazeTV", + "GB": "GoldBlocks", + "GBA": "Geeba", + "GBC": "Green Blue Coin", + "GBCK": "GoldBrick", + "GBCR": "Gold BCR", + "GBD": "Great Bounty Dealer", + "GBE": "Godbex", + "GBEX": "Globiance Exchange", + "GBG": "Golos Gold", + "GBIT": "GravityBit", + "GBK": "Goldblock", + "GBL": "Global Token", + "GBNB": "GOLD BNB", + "GBO": "Gabro.io", + "GBOT": "GBOT", + "GBOY": "GameBoy", + "GBPT": "poundtoken", + "GBPU": "Upper Pound", + "GBRC": "GBR Coin", + "GBSK": "Gençlerbirliği Fan Token", + "GBT": "GameBetCoin", + "GBTC": "GigTricks", + "GBURN": "GBURN", + "GBX": "GoByte", + "GBXT": "Globitex Token", + "GBYTE": "Obyte", + "GC": "Gric Coin", + "GCAKE": "Pancake Games", + "GCAT": "Giga Cat on Base", + "GCB": "Global Commercial Business", + "GCC": "GuccioneCoin", + "GCCO": "GCCOIN", + "GCME": "GoCryptoMe", + "GCN": "gCn Coin", + "GCOIN": "Galaxy Fight Club", + "GCOTI": "COTI Governance Token", + "GCR": "Global Currency Reserve", + "GCRE": "Gluwa Creditcoin Vesting Token", + "GCW": "GCWine", + "GDAO": "Governor DAO", + "GDC": "Global Digital Content", + "GDCC": "GLOBAL DIGITAL CLUSTER COIN", + "GDDY": "Giddy", + "GDE": "Golden Eagle", + "GDIGIT": "GoldDigitStandart", + "GDL": "GodlyCoin", + "GDO": "GroupDao", + "GDOG": "GDOG", + "GDOGE": "Golden Doge", + "GDR": "Guider.Travel", + "GDRT": "Good Driver Reward Token", + "GDS": "Grat Deal Coin", + "GDSC": "Golden Safety Coin", + "GDT": "Globe Derivative Exchange", + "GDX": "VanEck Vectors Gold Miners Etf", + "GE": "GEchain", + "GEA": "Goldea", + "GEAR": "Gearbox Protocol", + "GEC": "Gecko Inu", + "GECKO": "Gecko Coin", + "GECKY": "Gecky", + "GECO": "GECOIN", + "GEEK": "De:Lithe Last Memories", + "GEEQ": "Geeq", + "GEF": "GemFlow", + "GEGE": "Gege", + "GEIST": "Geist Finance", + "GEKKO": "Gekko HQ", + "GELATO": "Gelato", + "GELO": "Grok Elo", + "GEM": "Gemie", + "GEMA": "Gemera", + "GEME": "GEME", + "GEMG": "GemGuardian", + "GEMI": "Gemini Inu", + "GEMINI": "Gemini Ai", + "GEMINIT": "Gemini", + "GEMO": "Gemo", + "GEMS": "Gems VIP", + "GEMSTON": "GEMSTON", + "GEMZ": "Gemz Social", + "GEN": "DAOstack", + "GENAI": "Gen AI BOT", + "GENE": "Genopets", + "GENECTO": "Gene", + "GENESIS": "Genesis Worlds", + "GENI": "Genius", + "GENIE": "The Genie", + "GENIEC": "GenieCoin", + "GENIESWAP": "GenieSwap", + "GENIESWAPV1": "GenieSwap v1", + "GENIFYART": "Genify ART", + "GENIX": "Genix", + "GENO": "GenomeFi", + "GENOME": "GenomesDao", + "GENS": "Genshiro", + "GENSLR": "Good Gensler", + "GENSTAKE": "Genstake", + "GENT": "Gentleman", + "GENX": "Genx Token", + "GENXNET": "Genesis Network", + "GENZ": "GENZ Token", + "GENZAI": "GENZAI", + "GEO": "GeoCoin", + "GEOD": "GEODNET", + "GEODB": "GeoDB", + "GEOJ": "Geojam", + "GEOL": "GeoLeaf", + "GEON": "Geon", + "GEORGE": "GEORGE", + "GEP": "Gaia", + "GER": "GermanCoin", + "GERA": "Gera Coin", + "GERMANY": "Germany Rabbit Token", + "GERO": "GeroWallet", + "GES": "Galaxy eSolutions", + "GESE": "Gese", + "GET": "Global Entertainment Token", + "GETA": "Getaverse", + "GETH": "Guarded Ether", + "GETLIT": "LIT", + "GETX": "Guaranteed Ethurance Token Extra", + "GEX": "Gexan", + "GEZY": "EZZY GAME GEZY", + "GF": "GuildFi", + "GFAL": "Games for a Living", + "GFARM2": "Gains V2", + "GFCE": "GFORCE", + "GFCS": "Global Funeral Care", + "GFI": "Goldfinch", + "GFLY": "BattleFly", + "GFM": "GoFundMeme", + "GFN": "Graphene", + "GFOX": "Galaxy Fox", + "GFT": "Gifto", + "GFUN": "GoldFund", + "GFX": "GamyFi Token", + "GFY": "go fu*k yourself", + "GG": "Reboot", + "GGAVAX": "GoGoPool AVAX", + "GGB": "GGEBI", + "GGBR": "Goldfish", + "GGC": "Global Game Coin", + "GGCM": "Gold Guaranteed Coin", + "GGEZ1": "GGEZ1", + "GGG": "Good Games Guild", + "GGGG": "Good Game Gary Gensler", + "GGH": "Green Grass Hopper", + "GGM": "Monster Galaxy", + "GGMT": "GG MetaGame", + "GGOLD": "GramGold Coin", + "GGPT": "Generative GPT", + "GGR": "GGRocket", + "GGS": "Gilgam", + "GGT": "Goat Gang", + "GGTK": "GGDApp", + "GGTKN": "GG Token", + "GGX": "GG3", + "GHA": "Ghast", + "GHC": "Galaxy Heroes Coin", + "GHCV1": "Galaxy Heroes Coin v1", + "GHCV2": "Galaxy Heroes Coin v2", + "GHCV3": "Galaxy Heroes Coin v3", + "GHD": "Giftedhands", + "GHDV1": "Giftedhands v1", + "GHE": "GHETTO PEPE", + "GHHS": "GHHS Healthcare", + "GHI": "Ghibli HeYi", + "GHIB": "GhibliCZ", + "GHIBL": "Ghibli Zao", + "GHIBLI": "Ghiblification", + "GHIBLIAI": "Ghibli AI Agent", + "GHIBLIDOGE": "Ghibli Doge", + "GHIBLIELON": "Ghibli Elon", + "GHNY": "Grizzly Honey", + "GHO": "GHO", + "GHOAD": "GhoadCoin", + "GHOST": "GhostwareOS", + "GHOSTBY": "GhostbyMcAfee", + "GHOSTCOIN": "GhostCoin", + "GHOSTM": "GhostMarket", + "GHOUL": "Ghoul Coin", + "GHST": "Aavegotchi", + "GHSY": "Ghosty Cash", + "GHT": "Global Human Trust", + "GHUB": "GemHUB", + "GHX": "GamerCoin", + "GIA": "Gamia", + "GIAC": "Gorilla In A Coupe", + "GIB": "Bible Coin", + "GIC": "Giant", + "GICT": "GICTrade", + "GIF": "Gift Token", + "GIFT": "GiftNet", + "GIG": "GigaCoin", + "GIGA": "Gigachad", + "GIGABRAIN": "Gigabrain by virtuals", + "GIGACAT": "GIGACAT", + "GIGACHAD": "GigaChad", + "GIGAG": "GIGAGEEK", + "GIGASWAP": "GigaSwap", + "GIGGLE": "Giggle Fund", + "GIGGLEACADEMY": "Giggle Academy", + "GIGL": "GIGGLE PANDA", + "GIGS": "Climate101", + "GIGX": "GigXCoin", + "GIKO": "Giko Cat", + "GILOEX": "Gilo", + "GILTS": "Etherfuse GILTS", + "GIM": "Gimli", + "GIMMER": "Gimmer", + "GIMMERV1": "Gimmer v1", + "GIN": "GINcoin", + "GINGER": "GINGER", + "GINI": "Kalp", + "GINNAN": "Ginnan The Cat", + "GINOA": "Ginoa", + "GINU": "Green Shiba Inu", + "GINUX": "Green Shiba Inu", + "GINZA": "GINZA NETWORK", + "GIO": "Graviocoin", + "GIOT": "Giotto Coin", + "GIOVE": "GIOVE", + "GIR": "Girlfriend", + "GIRLS": "Girls Club", + "GITH": "GitHub's Mascot Octocat", + "GIV": "Giveth", + "GIVE": "GiveCoin", + "GIX": "GoldFinX", + "GIZ": "GIZMOcoin", + "GIZA": "Giza", + "GIZMO": "GIZMO•IMAGINARY• KITTEN (Runes)", + "GJC": "Global Jobcoin", + "GKAPPA": "Golden Kappa", + "GKF": "Galatic Kitty Fighters", + "GKI": "GKi", + "GL": "Lemmings", + "GLA": "Gladius", + "GLASS": "Glass Chain", + "GLAX": "BLOCK GALAXY NETWORK", + "GLAZE": "Glaze", + "GLB": "Golden Ball", + "GLC": "GoldCoin", + "GLCH": "Glitch", + "GLD": "Goldario", + "GLDGOV": "Gold DAO", + "GLDR": "Golder Coin", + "GLDS": "Glades", + "GLDX": "Gold xStock", + "GLDY": "Buzzshow", + "GLE": "Green Life Energy", + "GLEEC": "Gleec Coin", + "GLF": "Galaxy Finance", + "GLFT": "Global Fan Token", + "GLI": "GLI TOKEN", + "GLIDE": "Glide Finance", + "GLIDR": "Glidr", + "GLIESE": "GlieseCoin", + "GLINK": "Gemlink", + "GLINT": "BeamSwap", + "GLIZZY": "GLIZZY", + "GLM": "Golem Network Token", + "GLMR": "Moonbeam", + "GLMV1": "Golem Network Token v1", + "GLN": "Galion Token", + "GLO": "Global Innovation Platform", + "GLOBAL": "GlobalCoin", + "GLOBALTOUR": "Global Tour Club", + "GLOBE": "Global", + "GLORP": "Glorp", + "GLORY": "SEKAI GLORY", + "GLOS": "GLOS", + "GLOWSHA": "GlowShares", + "GLP1": "GLP1", + "GLQ": "GraphLinq Protocol", + "GLR": "Glory Finance", + "GLS": "Glacier", + "GLT": "GlobalToken", + "GLUE": "Glue", + "GLX": "GalaxyCoin", + "GLYPH": "GlyphCoin", + "GM": "GOMBLE", + "GMA": "Goldchip Mining Asset", + "GMAC": "Gemach", + "GMAT": "GoWithMi", + "GMB": "GMB", + "GMBL": "GMBL Computer", + "GMC": "Gridmaster", + "GMCN": "GambleCoin", + "GMCOIN": "GMCoin", + "GMDP": "GMD Protocol", + "GME": "GameStop", + "GMEE": "GAMEE", + "GMEON": "GameStop (Ondo Tokenized)", + "GMEPEPE": "GAMESTOP PEPE", + "GMETHERFRENS": "GM", + "GMETRUMP": "GME TRUMP", + "GMEX": "Gamestop xStock", + "GMFAM": "GMFAM", + "GMFI": "Golden Magfi", + "GMI": "GamiFi", + "GML": "GameLeagueCoin", + "GMM": "Gamium", + "GMMT": "Giant Mammoth", + "GMNG": "Global Gaming", + "GMNT": "Gmining", + "GMON": "gMON", + "GMPD": "GamesPad", + "GMR": "GAMER", + "GMRT": "Gamertag Token", + "GMRV1": "GAMER v1", + "GMRV2": "GAMER v2", + "GMRX": "Gaimin", + "GMS": "Gemstra", + "GMT": "STEPN", + "GMTO": "Game Meteor Coin", + "GMTT": "GMT Token", + "GMUBARAK": "Ghibli Mubarak", + "GMUSD": "GND Protocol", + "GMWAGMI": "GM", + "GMX": "GMX", + "GN": "GN", + "GNBT": "Genebank Token", + "GNC": "Greenchie", + "GND": "GND Protoco", + "GNFT": "GNFT", + "GNG": "GreenGold", + "GNJ": "GanjaCoin V2", + "GNNX": "Gennix", + "GNO": "Gnosis", + "GNOME": "GNOME", + "GNOMY": "Gnomy", + "GNON": "Numogram", + "GNR": "Gainer", + "GNS": "Gains Network", + "GNT": "GreenTrust", + "GNTO": "GoldeNugget Token", + "GNUS": "GENIUS TOKEN", + "GNX": "Genaro Network", + "GNY": "GNY", + "GO": "GoChain", + "GO4": "GameonForge", + "GOA": "GoaCoin", + "GOAL": "TopGoal Token", + "GOALBON": "Goal Bonanza", + "GOALS": "UnitedFans", + "GOALTOKEN": "GOAL token", + "GOAT": "Goatseus Maximus", + "GOATAI": "GOAT AI", + "GOATCOIN": "Goat", + "GOATED": "Goat Network", + "GOATS": "GOATS", + "GOATSE": "GOATSE", + "GOB": "gob", + "GOBL": "GOBL", + "GOC": "GoCrypto", + "GOCHU": "Gochujangcoin", + "GOD": "Bitcoin God", + "GODC": "Godcoin", + "GODCAT": "GodcatExplodingKittens", + "GODE": "Gode Chain", + "GODEX": "GUARD OF DECENT", + "GODL": "RoOLZ", + "GODLAPP": "GODL", + "GODS": "Gods Unchained", + "GODZ": "Cryptogodz", + "GOETH": "Algomint", + "GOF": "Golff", + "GOFF": "Gift Off Token", + "GOFINDXR": "Gofind XR", + "GOFX": "GooseFX", + "GOG": "Guild of Guardians", + "GOGE": "GOLD DOGE", + "GOGLZ": "GOGGLES", + "GOGLZV1": "GOGGLES v1", + "GOGO": "GOGO Finance", + "GOGU": "GOGU Coin", + "GOHOME": "GOHOME", + "GOIN": "GOinfluencer", + "GOJOCOIN": "Gojo Coin", + "GOKU": "Goku Super Saiyan", + "GOKUINU": "Goku (gokuinu.io)", + "GOL": "GogolCoin", + "GOLC": "GOLCOIN", + "GOLD": "CyberDragon Gold", + "GOLDCAT": "GOLD CAT", + "GOLDCOINETH": "Gold", + "GOLDE": "GOLDEN AGE", + "GOLDEN": "Golden Inu", + "GOLDENC": "GoldenCat", + "GOLDENG": "Golden Goose", + "GOLDEX": "Goldex", + "GOLDF": "Gold Fever", + "GOLDMIN": "GoldMiner", + "GOLDN": "Goldn", + "GOLDPIECES": "GoldPieces", + "GOLDS": "Gold Standard", + "GOLDSECURED": "Gold Secured Currency", + "GOLDX": "eToro Gold", + "GOLDY": "DeFi Land Gold", + "GOLF": "GolfCoin", + "GOLFI": "Golf is Boring", + "GOLONDON": "GoLondon", + "GOLOS": "Golos", + "GOLOSBLOCKCHAIN": "Golos Blockchain", + "GOM": "Gomics", + "GOM2": "GoMoney2", + "GOMA": "GOMA Finance", + "GOMAV1": "GOMA Finance v1", + "GOMAV2": "GOMA Finance v2", + "GOMD": "GOMDori", + "GOME": "Game of Memes", + "GOMT": "GoMeat", + "GOMV1": "GoMoney", + "GONDOLA": "Gondola", + "GONE": "GONE", + "GONG": "GONG", + "GOO": "Gooeys", + "GOOCH": "Gooch", + "GOOD": "Goodomy", + "GOODBOY": "GoodBoy", + "GOODM": "Good Morning!", + "GOODMO": "Good Morning", + "GOOG": "Googly Cat", + "GOOGLE": "Deepmind Ai", + "GOOGLX": "Alphabet xStock", + "GOOGLY": "Googly Cat", + "GOOMPY": "Goompy by Matt Furie", + "GOON": "Goonies", + "GOONC": "gooncoin", + "GOONS": "Goons of Balatroon", + "GOP": "The Republican Party", + "GOPX": "GOPX Token", + "GOR": "Gorbagana", + "GORA": "Gora", + "GOREC": "GoRecruit", + "GORGONZOLA": "Heroes 3 Foundation", + "GORGONZOLAV1": "Heroes 3 Foundation v1", + "GORILLA": "Gorilla", + "GORILLAD": "Gorilla Diamond", + "GORILLAINU": "Gorilla Inu", + "GORK": "New XAI gork", + "GORPLE": "GorplesCoin", + "GORTH": "Gorth", + "GOS": "Gosama", + "GOSS": "GOSSIP-Coin", + "GOST": "SoulCoin", + "GOT": "GOLDEN PACT", + "GOTEM": "gotEM", + "GOTG": "Got Guaranteed", + "GOTTI": "Gotti Token", + "GOTX": "GothicCoin", + "GOU": "Gou", + "GOUT": "GOUT", + "GOV": "SubDAO", + "GOVI": "Govi", + "GOVT": "The Government Network", + "GOW39": "God Of Wealth", + "GOYOO": "GoYoo", + "GOZ": "Göztepe S.K. Fan Token", + "GP": "Wizards And Dragons", + "GPAWS": "Golden Paws", + "GPBP": "Genius Playboy Billionaire Philanthropist", + "GPCX": "Good Person Coin", + "GPECTRA": "Pectra Giraffe", + "GPKR": "Gold Poker", + "GPL": "Gold Pressed Latinum", + "GPLX": "Gplx", + "GPN": "Gamepass Network", + "GPO": "GoldPesa Option", + "GPPT": "Pluto Project Coin", + "GPRO": "GoldPro", + "GPS": "GoPlus Security", + "GPSTOKEN": "GPS Token", + "GPT": "QnA3.AI", + "GPT4O": "GPT-4o", + "GPTG": "GPT Guru", + "GPTON": "GPTON", + "GPTPLUS": "GPTPlus", + "GPTV": "GPTVerse", + "GPU": "Node AI", + "GPUCOIN": "GPU Coin", + "GPUINU": "GPU Inu", + "GPUNET": "GPUnet", + "GPX": "GPEX", + "GQ": "Galactic Quadrant", + "GR": "GROM", + "GRAB": "GRABWAY", + "GRABON": "Grab Holdings (Ondo Tokenized)", + "GRACY": "Gracy", + "GRAI": "Gravita Protocol", + "GRAIL": "Camelot Token", + "GRAIN": "Granary", + "GRAM": "Gram", + "GRAND": "Grand Theft Ape", + "GRANDCOIN": "GrandCoin", + "GRANDMA": "Grandma", + "GRANT": "GrantiX Token", + "GRAPE": "GrapeCoin", + "GRAPHGRAIAI": "GraphGrail AI", + "GRASS": "Grass", + "GRAV": "Graviton", + "GRAVITAS": "Gravitas", + "GRAVITYF": "Gravity Finance", + "GRAYLL": "GRAYLL", + "GRBE": "Green Beli", + "GRBT": "Grinbit", + "GRC": "GreenCoin.AI", + "GRDM": "GridiumAI", + "GRE": "GreenCoin", + "GREARN": "GrEarn", + "GREE": "Green God Candle", + "GREEN": "GreenX", + "GREENCOIN": "Greencoin", + "GREENH": "Greenheart CBD", + "GREENMMT": "Green Mining Movement Token", + "GREENPOWER": "GreenPower", + "GREENT": "Greentoken", + "GREG": "greg", + "GRELF": "GRELF", + "GREMLY": "Gremly", + "GREMLYART": "Gremly", + "GREXIT": "GrexitCoin", + "GREY": "Grey Token", + "GRFT": "Graft Blockchain", + "GRG": "RigoBlock", + "GRID": "Grid+", + "GRIDCOIN": "GridCoin", + "GRIDZ": "GridZone.io", + "GRIFFAIN": "GRIFFAIN", + "GRIFT": "ORBIT", + "GRIM": "GRIMREAPER", + "GRIMACE": "Grimace", + "GRIMEVO": "Grim EVO", + "GRIMEX": "SpaceGrime", + "GRIN": "Grin", + "GRIND": "Self Improving", + "GRIPPY": "GRIPPY", + "GRL": "Greelance", + "GRLC": "Garlicoin", + "GRM": "GridMaster", + "GRMD": "GreenMed", + "GRN": "GRN Grid", + "GRND": "SuperWalk", + "GRNV1": "GRN Grid v1", + "GRO": "Gro DAO Token", + "GROGGO": "Groggo By Matt Furie", + "GROK": "Grok", + "GROK2": "GROK 2.0", + "GROK3": "Grok 3", + "GROKAI": "Grok AI Agent", + "GROKBANK": "Grok Bank", + "GROKBOY": "GrokBoy", + "GROKCAT": "Grok Cat", + "GROKCEO": "GROK CEO", + "GROKCOIN": "Grok Coin", + "GROKFATHER": "Grok Father", + "GROKGIRL": "Grok Girl", + "GROKGROW": "GrokGrow", + "GROKHEROES": "GROK heroes", + "GROKI": "Grok Imagine", + "GROKIM": "Grok Imagine Penguin", + "GROKINU": "Grok Inu", + "GROKKING": "GrokKing", + "GROKKY": "GroKKy", + "GROKMOON": "Grok Moon", + "GROKOLAUS": "GROKolaus", + "GROKQUEEN": "Grok Queen", + "GROKSORAX": "GROKSORAX", + "GROKVANCE": "GROK VANCE", + "GROKX": "GROKX", + "GROKXAI": "Grok X Ai", + "GRON": "Gron Digital", + "GROOOOOK": "Groooook", + "GROOVE": "GROOVE", + "GROW": "Grow Token", + "GROWAI": "SocialGrowAI", + "GROWNCOIN": "GrownCoin", + "GROWTH": "GROWTH DeFi", + "GROYPER": "Groyper", + "GRP": "Grape", + "GRPH": "Soul Graph", + "GRPL": "Golden Ratio Per Liquidity", + "GRS": "Groestlcoin", + "GRT": "The Graph", + "GRUM": "Grumpy (Ordinals)", + "GRUMPY": "Grumpy Finance", + "GRV": "GroveCoin", + "GRVE": "Grave", + "GRW": "GrowthCoin", + "GRWI": "Growers International", + "GRX": "Gold Reward Token", + "GS": "Genesis Shards", + "GS1": "NFTGamingStars", + "GSC": "Global Social Chain", + "GSE": "GSENetwork", + "GSHIBA": "Gambler Shiba", + "GSI": "Globex SCI", + "GSKY": "SKY FRONTIER", + "GSM": "GSM Coin", + "GSPI": "GSPI", + "GSR": "GeyserCoin", + "GST": "CoinGhost", + "GSTBSC": "Green Satoshi Token (BSC)", + "GSTC": "GSTCOIN", + "GSTETH": "Green Satoshi Token (ETH)", + "GSTOP": "GameStop", + "GSTS": "Gunstar Metaverse", + "GSTSOL": "Green Satoshi Token (SOL)", + "GSTT": "GSTT", + "GSWAP": "Gameswap", + "GSWIFT": "GameSwift", + "GSX": "Goldman Sachs xStock", + "GSY": "GenesysCoin", + "GSYS": "Genesys", + "GT": "Gatechain Token", + "GTA": "GTA Token", + "GTA6": "GTA VI", + "GTAI": "GT Protocol", + "GTAN": "Giant Token", + "GTAVI": "GTAVI", + "GTBOT": "Gaming-T-Bot", + "GTBTC": "Gate Wrapped BTC", + "GTC": "Gitcoin", + "GTCC": "GTC COIN", + "GTCOIN": "Game Tree", + "GTE": "GreenTek", + "GTF": "GLOBALTRUSTFUND TOKEN", + "GTFO": "DumpBuster", + "GTH": "Gath3r", + "GTIB": "Global Trust Coin", + "GTK": "GoToken", + "GTN": "GlitzKoin", + "GTO": "Gifto", + "GTON": "GTON Capital", + "GTR": "Gturbo", + "GTRUMP": "Giga Trump", + "GTSE": "Global Tourism Sharing Ecology", + "GTTM": "Going To The Moon", + "GTX": "GALLACTIC", + "GTY": "G-Agents AI", + "GUA": "GUA", + "GUAC": "Guacamole", + "GUAMEME": "GUA", + "GUAN": "Guanciale by Virtuals", + "GUAP": "Guapcoin", + "GUAR": "Guarium", + "GUARD": "Guardian", + "GUARDAI": "GuardAI", + "GUC": "Green Universe Coin", + "GUCCI": "GUCCI", + "GUDTEK": "ai16zterminalfartARCzereLLMswarm", + "GUE": "GuerillaCoin", + "GUESS": "Peerguess", + "GUGU": "gugu", + "GUI": "Gui Inu", + "GUILD": "BlockchainSpace", + "GUISE": "GUISE", + "GULF": "GulfCoin", + "GULL": "GULL", + "GUM": "Gourmet Galaxy", + "GUMMIES": "GUMMIES", + "GUMMY": "GUMMY", + "GUMSHOOS": "GUMSHOOS TRUMP", + "GUN": "GUNZ", + "GUNCOIN": "GunCoin", + "GUNS": "GeoFunders", + "GUP": "Guppy", + "GURL": "Gently Used Girl", + "GURU": "Guru Network", + "GUS": "Gus", + "GUSD": "Gemini Dollar", + "GUSDT": "Global Utility Smart Digital Token", + "GUT": "Genesis Universe", + "GUUFY": "Guufy", + "GUZUTA": "CLYDE", + "GVC": "Global Virtual Coin", + "GVE": "Globalvillage Ecosystem", + "GVL": "Greever", + "GVNR": "GVNR", + "GVR": "Grove [OLD]", + "GVRV1": "Grove v1", + "GVT": "Genesis Vision", + "GW": "Gyrowin", + "GWD": "GreenWorld", + "GWEI": "ETHGas", + "GWGW": "GoWrap", + "GWT": "Galaxy War", + "GX": "GameX", + "GX3": "GX3ai", + "GXA": "Galaxia", + "GXC": "GXChain", + "GXE": "XENO Governance", + "GXT": "Gem Exchange And Trading", + "GYAT": "Gyat Coin", + "GYEN": "GYEN", + "GYM": "GYM Token", + "GYMNET": "Gym Network", + "GYMREW": "Gym Rewards", + "GYR": "Gyre Token", + "GYRO": "Gyro", + "GYROS": "Gyroscope GYD", + "GYSR": "GYSR", + "GZB": "Gigzi", + "GZE": "GazeCoin", + "GZIL": "governance ZIL", + "GZLR": "Guzzler", + "GZONE": "GameZone", + "GZT": "Golden Zen Token", + "GZX": "GreenZoneX", + "Glo Dollar": "USDGLO", + "H": "Humanity", + "H1": "Haven1", + "H1DR4": "H1DR4 by Virtuals", + "H2O": "H2O Dao", + "H2ON": "H2O Securities", + "H3O": "Hydrominer", + "H4TOKEN": "Hold Ignore Fud", + "HABIBI": "The Habibiz", + "HAC": "Hackspace Capital", + "HACD": "Hacash Diamond", + "HACH": "Hachiko", + "HACHI": "Hachi", + "HACHIK": "Hachiko", + "HACHIKO": "Hachiko Inu Token", + "HACHIONB": "Hachi On Base", + "HACK": "HACK", + "HADES": "Hades", + "HAEDAL": "Haedal Protocol", + "HAGGIS": "New Born Haggis Pygmy Hippo", + "HAHA": "Hasaki", + "HAI": "Hacken Token", + "HAIO": "HAiO", + "HAIR": " HairDAO", + "HAJIMI": "哈基米", + "HAKA": "TribeOne", + "HAKKA": "Hakka Finance", + "HAKU": "HakuSwap", + "HAL": "Halcyon", + "HALF": "0.5X Long Bitcoin Token", + "HALFP": "Half Pizza", + "HALFSHIT": "0.5X Long Shitcoin Index Token", + "HALIS": "Halis", + "HALLO": "Halloween Coin", + "HALLOWEEN": "HALLOWEEN", + "HALO": "Halo Coin", + "HALOPLATFORM": "Halo Platform", + "HAM": "Hamster", + "HAMBURG": "Hamburg Eyes", + "HAMI": "Hamachi Finance", + "HAMMY": "SAD HAMSTER", + "HAMS": "HamsterCoin", + "HAMSTER": "Hamster", + "HAMSTERB": "HamsterBase", + "HAMSTR": "Hamster Coin", + "HAN": "HanChain", + "HANA": "Hana Token", + "HANACOIN": "Hanacoin", + "HANAETH": "Hana", + "HANAETHCTO": "HANA", + "HAND": "ShowHand", + "HANDY": "Handy", + "HANK": "Hank", + "HANU": "Hanu Yokia", + "HAO": "HistoryDAO", + "HAP": "Happy Train", + "HAPI": "HAPI", + "HAPPY": "Happy Cat", + "HAPPYC": "HappyCoin", + "HAR": "Harambe Coin", + "HARAM": "HARAM", + "HARAMBE": "Harambe on Solana", + "HARD": "Kava Lend", + "HARE": "Hare Token", + "HAREPLUS": "Hare Plus", + "HARIKO": "Inu Hariko", + "HAROLD": "Harold", + "HAROLDDUCK": "Harold", + "HARPER": "Harper", + "HARR": "HARRIS DOGS", + "HARRIS": "KAMALA HARRIS", + "HARRISV": "Harris V Trump", + "HARRYBOLZ": "Harry Bolz", + "HARRYP": "HarryPotterObamaSonic10Inu (ERC20)", + "HARRYPO": "HarryPotterObamaPacMan8Inu", + "HART": "HARA", + "HASBIK": "Hasbulla", + "HASH": "Provenance Blockchain", + "HASHAI": "HashAI", + "HASHNET": "HashNet BitEco", + "HASHT": "HASH Token", + "HASUI": "Haedal", + "HAT": "TOP HAT", + "HATAY": "Hatayspor Token", + "HATCHY": "Hatchyverse", + "HATI": "Hati", + "HAUS": "DAOhaus", + "HAVEN": "Haven", + "HAVOC": "Havoc", + "HAVY": "Havy", + "HAW": "Hawk Tuah", + "HAWALA": "HAWALA", + "HAWK": "Hawksight", + "HAWKCITY": "Hawk", + "HAWKPTAH": "Hawk Ptah", + "HAWKTUAH": "Hawk Tuah", + "HAXS": "Axie Infinity Shards (Harmony One Bridge)", + "HAYYA": "GO HAYYA", + "HAZ": "Hazza", + "HAZE": "HazeCoin", + "HB": "HeartBout", + "HBAR": "Hedera Hashgraph", + "HBARBARIAN": "HBARbarian", + "HBARX": "HBARX", + "HBB": "Hubble", + "HBC": "HBTC Captain Token", + "HBCH": "Huobi BCH", + "HBD": "Hive Dollar", + "HBDC": "Happy Birthday Coin", + "HBE": "healthbank", + "HBIT": "HashBit", + "HBN": "HoboNickels", + "HBO": "Hash Bridge Oracle", + "HBOT": "Hummingbot", + "HBRS": "HubrisOne", + "HBSV": "Huobi BSV", + "HBT": "Habitat", + "HBTC": "Huobi BTC", + "HBX": "Hyperbridge", + "HBZ": "HBZ Coin", + "HC": "HyperCash", + "HCAT": "Hover Cat", + "HCC": "HappyCreatorCoin", + "HCT": "HurricaneSwap Token", + "HCXP": "HCX PAY", + "HD": "HubDao", + "HDAC": "Hdac", + "HDAO": "Hkd.com Dao", + "HDG": "Hedge Token", + "HDN": "Hydranet", + "HDRN": "Hedron", + "HDRO": "Hydro Protocol", + "HDV": "Hydraverse", + "HDX": "Home Depot xStock", + "HE": "Heroes & Empires", + "HEA": "Healium", + "HEAL": "Etheal", + "HEALT": "Healthmedi", + "HEART": "Humans", + "HEARTBOUT": "HeartBout Pay", + "HEARTN": "Heart Number", + "HEARTR": "Heart Rate", + "HEAT": "Heat Ledger", + "HEAVEN": "Heaven Token", + "HEC": "Hector Finance", + "HECT": "Hectic Turkey", + "HECTA": "Hectagon", + "HEDG": "HedgeTrade", + "HEDGE": "Hedgecoin", + "HEEL": "HeelCoin", + "HEFI": "HeFi", + "HEGE": "Hege", + "HEGG": "Hummingbird Egg", + "HEGIC": "Hegic", + "HEHE": "hehe", + "HEI": "Heima", + "HEL": "Hello Puppy", + "HELA": "Science Cult Mascot", + "HELI": "Helion", + "HELINK": "Chainlink (Huobi Exchange)", + "HELIOS": "Mission Helios", + "HELIOSAI": "HeliosAI", + "HELL": "HELL COIN", + "HELLO": "HELLO", + "HELMET": "Helmet Insure", + "HELPS": "HelpSeed", + "HEM": "Hemera", + "HEMAN": "HE-MAN", + "HEMI": "Hemi", + "HEMULE": "Hemule", + "HENAI": "HenjinAI Token", + "HENG": "HengCoin", + "HENL": "henlo", + "HENLO": "Henlo", + "HENLOV1": "Henlo v1", + "HEP": "Health Potion", + "HER": "Her.AI", + "HERA": "Hero Arena", + "HERAF": "Hera Finance", + "HERB": "HerbCoin", + "HERBE": "Herbee", + "HERME": "Hermes DAO", + "HERMES": "Hermes Protocol", + "HERMIONE": "Hermione", + "HERMY": "Hermy The Stallion", + "HERO": "Metahero", + "HEROC": "HEROcoin", + "HEROES": "Dehero Community Token", + "HEROESAI": "HEROES AI", + "HEROESC": "HeroesChained", + "HEROI": "Heroic Saga Shiba", + "HERONODE": "Hero Node", + "HEST": "Hash Epoch Sports Token", + "HET": "HavEther", + "HETA": "HetaChain", + "HETH": "Huobi Ethereum", + "HEU": "Heurist AI", + "HEWE": "Health & Wealth", + "HEX": "HEX", + "HEXC": "HexCoin", + "HEZ": "Hermez Network Token", + "HF": "Have Fun", + "HFI": "Holder Finance", + "HFIL": "Huobi Fil", + "HFT": "Hashflow", + "HFUN": "Hypurr Fun", + "HGEN": "HGEN DAO", + "HGET": "Hedget", + "HGHG": "HUGHUG Coin", + "HGO": "HireGo", + "HGOLD": "HollyGold", + "HGPT": "HyperGPT", + "HGS": "HashGains", + "HGT": "Hello Gold", + "HH": "Holyheld", + "HHEM": "Healthureum", + "HHGTTG": "Douglas Adams", + "HI": "hi Dollar", + "HIAZUKI": "hiAZUKI", + "HIBAKC": "hiBAKC", + "HIBAYC": "hiBAYC", + "HIBEANZ": "hiBEANZ", + "HIBIKI": "Hibiki Finance", + "HIBS": "Hiblocks", + "HICLONEX": "hiCLONEX", + "HICOOLCATS": "hiCOOLCATS", + "HID": "Hypersign Identity", + "HIDE": "Hide Coin", + "HIDOODLES": "hiDOODLES", + "HIDU": "H-Education World", + "HIENS3": "hiENS3", + "HIENS4": "hiENS4", + "HIFI": "Hifi Finance", + "HIFIDENZA": "hiFIDENZA", + "HIFLUF": "hiFLUF", + "HIFRIENDS": "hiFRIENDS", + "HIGAZERS": "hiGAZERS", + "HIGH": "Highstreet", + "HIGHER": "Higher", + "HIGHKEY": "HighKey", + "HIH": "HiHealth", + "HIKARI": "Hikari Protocol", + "HILL": "President Clinton", + "HILO": "HILO", + "HIM": "Human Intelligence Machine", + "HIMAYC": "hiMAYC", + "HIME": "Phantom of the Kill", + "HIMEEBITS": "hiMEEBITS", + "HIMFERS": "hiMFERS", + "HIMO": "Himo World", + "HIMOONBIRDS": "hiMOONBIRDS", + "HINA": "Hina Inu", + "HINAGI": "Hinagi", + "HINT": "Hive Intelligence", + "HINTCH": "Hintchain", + "HINU": "HajiIni", + "HIOD": "hiOD", + "HIODBS": "hiODBS", + "HIP": "HIPPOP", + "HIPENGUINS": "hiPENGUINS", + "HIPP": "El Hippo", + "HIPPO": "sudeng", + "HIPUNKS": "hiPUNKS", + "HIRE": "HireMatch", + "HIRENGA": "hiRENGA", + "HISAND33": "hiSAND33", + "HISEALS": "hiSEALS", + "HISQUIGGLE": "hiSQUIGGLE", + "HISS": "Snake of Solana", + "HIT": "HitChain", + "HITBTC": "HitBTC Token", + "HITOP": "Hitop", + "HIUNDEAD": "hiUNDEAD", + "HIVALHALLA": "hiVALHALLA", + "HIVE": "Hive", + "HIVP": "HiveSwap", + "HIX": "HELIX Orange", + "HK": "Hongkong", + "HKB": "HongKong BTC bank", + "HKC": "HK Coin", + "HKDOGE": "HongKong Doge", + "HKDX": "eToro Hong Kong Dollar", + "HKFLOKI": "hong kong floki", + "HKG": "Hacker Gold", + "HKN": "Hacken", + "HKU5": "New Coronavirus", + "HLC": "HalalChain", + "HLD": "HyperLending", + "HLDY": "HOLIDAY", + "HLG": "Holograph", + "HLINK": "Chainlink (Harmony One Bridge)", + "HLM": "Helium", + "HLN": "Ēnosys", + "HLO": "Halo", + "HLOV1": "Halo v1", + "HLP": "Purpose Coin", + "HLPR": "HELPER COIN", + "HLPT": "HLP Token", + "HLS": "Helios", + "HLT": "HyperLoot", + "HLTC": "Huobi LTC", + "HLX": "Helex", + "HMC": "Hi Mutual Society", + "HMD": "Homelend", + "HMKR": "Hitmakr", + "HMM": "HMM", + "HMN": "Harvest Masternode Coin", + "HMND": "Humanode", + "HMNG": "HummingBirdFinance", + "HMP": "HempCoin", + "HMQ": "Humaniq", + "HMR": "Homeros", + "HMRN": "Homerun", + "HMST": "Hamster Marketplace Token", + "HMSTR": "Hamster Kombat", + "HMT": "HUMAN Token", + "HMTT": "Hype Meme Token", + "HMU": "hit meeee upp", + "HMX": "HMX", + "HNB": "HNB Protocol", + "HNC": "Hellenic Coin", + "HNCN": "Huncoin", + "HND": "Hundred Finance", + "HNS": "Handshake", + "HNST": "Honest", + "HNT": "Helium", + "HNTR": "Hunter", + "HNTV1": "Helium v1", + "HNX": "HeartX Utility Token", + "HNY": "Honey", + "HNZO": "Hanzo Inu", + "HO": "HALO network", + "HOA": "Hex Orange Address", + "HOBA": "Honey Badger", + "HOBBES": "Hobbes", + "HOBO": "HOBO THE BEAR", + "HOCAI": "Heroes of Crypto AI", + "HOD": "HoDooi.com", + "HODLC": "HOdlcoin", + "HODLV1": "HODL v1", + "HODLV2": "HODL", + "HOG": "Hog", + "HOGE": "Hoge Finance", + "HOGONSOLANA": "HOG", + "HOHOHO": "Santa Floki v2.0", + "HOICHI": "Hoichi", + "HOKA": "Hokkaido Inu", + "HOKK": "Hokkaidu Inu", + "HOL": "Hololoot", + "HOLA": "Hola Token", + "HOLD": "Holdcoin", + "HOLDCO": "HOLD", + "HOLDEX": "Holdex Finance", + "HOLDFUN": "Hold.fun", + "HOLDON4": "HoldOn4DearLife", + "HOLDS": "Holdstation", + "HOLO": "Holoworld", + "HOLON": "Holonus", + "HOLY": "Holy Trinity", + "HOM": "Homeety", + "HOME": "Home", + "HOMEBREW": "Homebrew Robotics Club", + "HOMER": "Homer Simpson", + "HOMERB": "Homer BSC", + "HOMERO": "Homer Of Meme", + "HOMERS": "Homer", + "HOMI": "HOMIHELP", + "HOMIECOIN": "Homie Wars", + "HOMMIES": "HOMMIES", + "HOMS": "Heroes of memes", + "HON": "SoulSociety", + "HONESTCOIN": "HonestCoin", + "HONEY": "Hivemapper", + "HONEYCOIN": "Honey", + "HONG": "HongKongDAO", + "HONK": "Honk", + "HONKLER": "Honkler", + "HONOR": "HonorLand", + "HONX": "Honeywell xStock", + "HOODOG": "Hoodog", + "HOODON": "Robinhood Markets (Ondo Tokenized)", + "HOODRAT": "Hoodrat Coin", + "HOODX": "Robinhood xStock", + "HOOF": "Metaderby Hoof", + "HOOK": "Hooked Protocol", + "HOOP": "Chibi Dinos", + "HOOPS": "Hoops", + "HOOT": "HOOT", + "HOP": "Hop Protocol", + "HOPECOIN": "Hopecoin", + "HOPPY": "Hoppy", + "HOPPYTOKEN": "Hoppy", + "HOPR": "HOPR", + "HOR": "HorizonDEX", + "HORD": "Hord", + "HORSE": "Ethorse", + "HORUS": "HorusPay", + "HOS": "Hotel of Secrets", + "HOSHI": "Dejitaru Hoshi", + "HOSICO": "Hosico Cat", + "HOSKY": "Hosky", + "HOSTAI": "Host AI", + "HOT": "Holo", + "HOTCROSS": "Hot Cross", + "HOTDOGE": "Hot Doge", + "HOTKEY": "HotKeySwap", + "HOTMOON": "HotMoon Token", + "HOTN": "HotNow", + "HOTT": "HOT Token", + "HOUND": "BaseHoundBot by Virtuals", + "HOUSE": "Housecoin", + "HOW": "HowInu", + "HOWL": "Coyote", + "HP": "HeroPark", + "HPAD": "HarmonyPad", + "HPAY": "HedgePay", + "HPB": "High Performance Blockchain", + "HPC": "Helal Para Coin", + "HPL": "HappyLand (HPL)", + "HPN": "HyperonChain", + "HPO": "Hippocrat", + "HPOWSB10I": "HarryPotterObamaWallStreetBets10Inu", + "HPP": "House Party Protocol", + "HPT": "Huobi Pool Token", + "HPX": "HUPAYX", + "HPY": "Hyper Pay", + "HPYPEPE": "Happy Pepe Token", + "HQ": "Metaverse HQ", + "HQR": "Hayya Qatar", + "HQT": "HyperQuant", + "HQX": "HOQU", + "HRB": "Harbour DAO", + "HRBE": "Harambee Token", + "HRCC": "HRC Crypto", + "HRD": "Hoard", + "HRDG": "HRDGCOIN", + "HRM": "Honorarium", + "HRO": "HEROIC.com", + "HRSE": "The Winners Circle", + "HRT": "HIRO", + "HRTS": "YellowHeart Protocol", + "HRX": "HorusLayer", + "HSAI": "HealthSci.AI", + "HSC": "HashCoin", + "HSF": "Hillstone Finance", + "HSK": "HashKey Platform Token", + "HSN": "Hyper Speed Network", + "HSOL": "Helius Staked SOL", + "HSP": "Horse Power", + "HSR": "Hshare", + "HSS": "Hashshare", + "HST": "Decision Token", + "HSUI": "Suicune", + "HSUITE": "HbarSuite", + "HSUSDC": "Holdstation USDC", + "HT": "Huobi Token", + "HTA": "Historia", + "HTB": "Hotbit", + "HTC": "Hitcoin", + "HTD": "HeroesTD", + "HTDF": "Orient Walt", + "HTE": "Hepton", + "HTER": "Biogen", + "HTERM": "Hiero Terminal", + "HTK": "Hard To Kill", + "HTM": "Hatom", + "HTML": "HTML Coin", + "HTMOON": "HTMOON", + "HTN": "Hoosat Network", + "HTO": "Heavenland HTO", + "HTR": "Hathor", + "HTS": "Home3", + "HTT": "Hello Art", + "HTX": "HTX", + "HTZ": "Hertz Network", + "HUAHUA": "Chihuahua Chain", + "HUB": "Hub Token", + "HUBII": "Hubii Network", + "HUBSOL": "SolanaHub staked SOL", + "HUC": "HunterCoin", + "HUDI": "Hudi", + "HUE": "Huebel Bolt", + "HUGE": "HugeWin", + "HUGO": "Hugo Inu", + "HUH": "HUH Token", + "HUHCAT": "huhcat", + "HULEZHI": "HU LE ZHI", + "HUM": "Humanscape", + "HUMA": "Huma Finance", + "HUMAI": "Humanoid AI", + "HUMP": "Hump", + "HUMV1": "Humanscape v1", + "HUND": "HUND MEME COIN", + "HUNDRED": "HUNDRED", + "HUNNY": "Pancake Hunny", + "HUNT": "HUNT", + "HUR": "Hurify", + "HUS": "HUSSY", + "HUSBY": "HUSBY", + "HUSD": "HUSD", + "HUSH": "Hush", + "HUSHR": "hushr", + "HUSKY": "Husky", + "HUSL": "Hustle Token", + "HUSTLE": "Agent Hustle", + "HUSTLEV1": "Tensorium", + "HUT": "Hibiki Run", + "HVC": "HeavyCoin", + "HVCO": "High Voltage Coin", + "HVE": "UHIVE", + "HVE2": "Uhive", + "HVH": "HAVAH", + "HVI": "Hungarian Vizsla Inu", + "HVLO": "Hivello", + "HVN": "Hiveterminal Token", + "HVNT": "HiveNet Token", + "HVT": "HyperVerse", + "HWC": "HollyWoodCoin", + "HWL": "Howl City", + "HWT": "Honor World Token", + "HXA": "HXAcoin", + "HXC": "HexanCoin", + "HXD": "Honeyland", + "HXRO": "Hxro", + "HXT": "HextraCoin", + "HXX": "HexxCoin", + "HXXH": "Pioneering D. UTXO-Based NFT Social Protocol", + "HYB": "Hybrid Block", + "HYBN": "Hey Bitcoin", + "HYBRID": "Hybrid Bank Cash", + "HYBUX": "HYBUX", + "HYC": "HYCON", + "HYCO": "HYPERCOMIC", + "HYD": "HYDRA", + "HYDRA": "Hydra", + "HYDRADX": "HydraDX", + "HYDRO": "Hydro", + "HYDROMINER": "Hydrominer", + "HYDROP": "Hydro Protocol", + "HYDX": "Hydrex", + "HYGH": "HYGH", + "HYN": "Hyperion", + "HYP": "HyperX", + "HYPC": "HyperCycle", + "HYPE": "Hyperliquid", + "HYPER": "Hyperlane", + "HYPERAI": "HyperHash AI", + "HYPERC": "HyperChainX", + "HYPERCOIN": "HyperCoin", + "HYPERD": "HyperDAO", + "HYPERFLY": "HyperFly", + "HYPERIONX": "HyperionX", + "HYPERLEND": "HyperLend", + "HYPERS": "HyperSpace", + "HYPERSKIDS": "HYPERSKIDS", + "HYPERSTAKE": "HyperStake", + "HYPES": "Supreme Finance", + "HYPEV1": "Hype v1", + "HYPR": "Hypr", + "HYPRNETWORK": "Hypr Network", + "HYS": "Heiss Shares", + "HYT": "HoryouToken", + "HYVE": "Hyve", + "HZ": "Horizon", + "HZD": "HorizonDollar", + "HZM": "HZM Coin", + "HZMV1": "HZM Coin v1", + "HZN": "Horizon Protocol", + "HZT": "HazMatCoin", + "I0C": "I0coin", + "I3D": "i3D Protocol", + "I7": "ImpulseVen", + "I9C": "i9 Coin", + "IAG": "IAGON", + "IAGV1": "IAGON v1", + "IAI": "inheritance Art", + "IAM": "IAME Identity", + "IAOMIN": "Yao Ming", + "IAUON": "iShares Gold Trust (Ondo Tokenized)", + "IB": "Iron Bank", + "IBANK": "iBankCoin", + "IBAT": "Battle Infinity", + "IBERA": "Infrared Bera", + "IBETH": "Interest Bearing ETH", + "IBEUR": "Iron Bank EURO", + "IBFK": "İstanbul Başakşehir Fan Token", + "IBFN": "IBF Net", + "IBFR": "iBuffer Token", + "IBG": "iBG Token", + "IBGT": "Infrared BGT", + "IBIT": "InfinityBit Token", + "IBMX": "International Business Machines xStock", + "IBNB": "iBNB", + "IBP": "Innovation Blockchain Payment", + "IBS": "Irbis Network", + "IC": "Ignition", + "ICA": "Icarus Network", + "ICAP": "ICAP Token", + "ICASH": "ICASH", + "ICB": "IceBergCoin", + "ICBX": "ICB Network", + "ICC": "Insta Cash Coin", + "ICE": "Ice Open Network", + "ICEC": "IceCream", + "ICECR": "Ice Cream Sandwich", + "ICECREAM": "IceCream AI", + "ICELAND": "ICE LAND", + "ICETH": "Interest Compounding ETH Index", + "ICG": "Invest Club Global", + "ICH": "IdeaChain", + "ICHI": "ICHI", + "ICHN": "i-chain", + "ICHX": "IceChain", + "ICL": "ICLighthouse DAO", + "ICLICK": "Iclick inu", + "ICN": "Iconomi", + "ICNT": "Impossible Cloud Network Token", + "ICNX": "Icon.X World", + "ICOB": "Icobid", + "ICOM": "iCommunity", + "ICON": "Iconic", + "ICONS": "SportsIcon", + "ICOO": "ICO OpenLedger", + "ICOS": "ICOBox", + "ICP": "Internet Computer", + "ICPX": "Icrypex token", + "ICS": " ICPSwap Token", + "ICSA": "Icosa", + "ICST": "ICST", + "ICT": "Intrachain", + "ICX": "ICON Project", + "ID": "SPACE", + "IDAC": "IDAC", + "IDAP": "IDAP", + "IDC": "IdealCoin", + "IDEA": "Ideaology", + "IDEAL": "Ideal Opportunities", + "IDEFI": "Inverse DeFi Index", + "IDEX": "IDEX", + "IDH": "IndaHash", + "IDHUB": "IDHUB", + "IDIA": "Impossible Finance Launchpad", + "IDICE": "iDice", + "IDK": "IDK", + "IDLE": "IDLE", + "IDM": "IDM", + "IDNA": "Idena", + "IDNG": "IDNGold", + "IDO": "Idexo", + "IDOL": "MEET48 Token", + "IDOLINU": "IDOLINU", + "IDOODLES": "IDOODLES", + "IDORU": "Vip2Fan", + "IDRISS": "IDRISS", + "IDRT": "Rupiah Token", + "IDRX": "IDRX", + "IDT": "InvestDigital", + "IDTT": "Identity", + "IDV": "Idavoll DAO", + "IDVV1": "Idavoll DAO v1", + "IDX": "Index Chain", + "IDXM": "IDEX Membership", + "IDXS": "In-Dex Sale", + "IDYP": "iDypius", + "IEC": "IvugeoEvolutionCoin", + "IETH": "iEthereum", + "IF": "Impossible Finance", + "IFAI": "InfluxAI Token", + "IFBTC": "Ignition FBTC", + "IFC": "Infinite Coin", + "IFIT": "CALO INDOOR", + "IFLT": "InflationCoin", + "IFOR": "iFortune", + "IFR": "Inferium", + "IFT": "InvestFeed", + "IFUM": "Infleum", + "IFUND": "Unifund", + "IFX": "IdeaFeX", + "IG": "IG Token ", + "IGCH": "IG-Crypto Holding", + "IGG": "IG Gold", + "IGI": "Igi", + "IGNIS": "Ignis", + "IGT": "Infinitar", + "IGTT": "IGT", + "IGU": "IguVerse", + "IGUP": "IguVerse", + "IHC": "Inflation Hedging Coin", + "IHF": "Invictus Hyperion Fund", + "IHT": "I-House Token", + "IIC": "Intelligent Investment Chain", + "IJC": "IjasCoin", + "IJZ": "iinjaz", + "IJZV1": "iinjaz v1", + "IKA": "IKA Token", + "IKI": "ikipay", + "IKIGAI": "Ikigai", + "ILA": "Infinite Launch", + "ILC": "ILCOIN", + "ILCT": "ILCoin Token", + "ILK": "Inlock", + "ILLUMINAT": "Illuminat", + "ILT": "iOlite", + "ILV": "Illuvium", + "IMAGE": "Imagen AI", + "IMAGINE": "IMAGINE", + "IMARO": "IMARO", + "IMAYC": "IMAYC", + "IMBREX": "Imbrex", + "IMBTC": "The Tokenized Bitcoin", + "IMC": "i Money Crypto", + "IME": "Imperium Empires", + "IMG": "ImageCoin", + "IMGN": "IMGN Labs", + "IMGNAI": "Image Generation AI", + "IMGX10": "IMGx10", + "IMGZ": "Imigize", + "IMI": "Influencer", + "IML": "IMMLA", + "IMMIGRATION": "Immigration Customs Enforcement", + "IMMO": "ImmortalDAO Finance", + "IMMORTAL": "IMMORTAL.COM", + "IMO": "IMO", + "IMOV": "IMOV", + "IMP": "CoinIMP", + "IMPACT": "Impact", + "IMPACTXP": "ImpactXP", + "IMPCH": "Impeach", + "IMPCN": "Brain Space", + "IMPCOIN": "IMPERIUM", + "IMPER": "Impermax", + "IMPS": "Impulse Coin", + "IMPT": "IMPT", + "IMPULSE": "IMPULSE by FDR", + "IMS": "Independent Money System", + "IMST": "Imsmart", + "IMT": "Immortal Token", + "IMU": "Immunefi", + "IMUSIFY": "imusify", + "IMVR": "ImmVRse", + "IMX": "Immutable X", + "IN": "INFINIT", + "INA": "pepeinatux", + "INARI": "Inari", + "INB": "Insight Chain", + "INC": "WAT Income token", + "INCAKE": "InfinityCAKE", + "INCEPT": "Incept", + "INCNT": "Incent", + "INCO": "InfinitiCoin", + "INCORGNITO": "Incorgnito", + "INCP": "InceptionCoin", + "INCREMENTUM": "Incrementum", + "INCX": "INCX Coin", + "IND": "Indorse", + "INDAY": "Independence Day", + "INDEPENDENCEDAY": "Independence Day", + "INDEX": "Index Cooperative", + "INDI": "IndiGG", + "INDIA": "Indiacoin", + "INDIAN": "Indian Call Center", + "INDICOIN": "IndiCoin", + "INDIGOBTC": "Indigo Protocol - iBTC", + "INDU": "INDU4.0", + "INDUSTRIAL": "Industrial", + "INDX": "CryptoIndex", + "INDY": "Indigo Protocol", + "INE": "IntelliShare", + "INEDIBLE": "INEDIBLE", + "INERY": "Inery", + "INES": "Inescoin", + "INET": "Insure Network", + "INETH": "Inception Restaked ETH", + "INEX": "Inex Project", + "INF": "Infinium", + "INFC": "Influence Chain", + "INFI": "Infinite", + "INFINI": "Infinity Economics", + "INFINITUS": "InfinitusTokens", + "INFLR": "Inflr", + "INFO": "Infomatix", + "INFOFI": "WAGMI HUB", + "INFR": "infraX", + "INFRA": "Bware", + "INFT": "Infinito", + "INFTT": "iNFT Token", + "INFX": "Influxcoin", + "ING": "Infinity Games", + "INI": "InitVerse", + "INIT": "Initia", + "INJ": "Injective", + "INK": "Ink", + "INN": "Innova", + "INNBC": "Innovative Bioresearch Coin", + "INNOU": "Innou", + "INO": "Ino Coin", + "INOVAI": "INOVAI", + "INP": "Ionic Pocket Token", + "INRT": "INRToken", + "INRX": "INRx", + "INRXV1": "INRx v1", + "INS": "Insolar (Old Chain)", + "INSANE": "InsaneCoin", + "INSANECOIN": "InsaneCoin", + "INSANITY": "Insanity Coin", + "INSC": "INSC (Ordinals)", + "INSE": "INSECT", + "INSN": "Industry Sonic", + "INSP": "Inspect", + "INSPI": "InspireAI", + "INSR": "Insurabler", + "INSTAMINE": "Instamine Nuggets", + "INSTANTSPONSOR": "Instant Sponsor Token", + "INSTAR": "Insights Network", + "INSUR": "InsurAce", + "INSURANCE": "insurance", + "INSURC": "InsurChain Coin", + "INSUREDFIN": "Insured Finance", + "INT": "Internet Node token", + "INTCON": "Intel (Ondo Tokenized)", + "INTCX": "Intel xStock", + "INTD": "INTDESTCOIN", + "INTE": "InteractWith", + "INTELLIQUE": "KARASOU", + "INTER": "Inter Milan Fan Token", + "INTERN": "intern", + "INTL": "Intelly", + "INTO": "Influ Token", + "INTR": "Interlay", + "INTRO": "1INTRO", + "INTX": "Intexcoin", + "INU": "INU Token", + "INUGA": "INUGAMI", + "INUINU": "Inu Inu", + "INUKO": "Inuko Finance", + "INUS": "MultiPlanetary Inus", + "INUYASHA": "Inuyasha", + "INV": "Inverse Finance", + "INVC": "Invacio", + "INVESTEL": "Investelly token", + "INVI": "INVI Token", + "INVIC": "Invictus", + "INVITE": "INVITE Token", + "INVOX": "Invox Finance", + "INVX": "Investx", + "INX": "Insight Protocol", + "INXM": "InMax", + "INXT": "Internxt", + "INXTOKEN": "INX Token", + "IO": "io.net", + "IOC": "IOCoin", + "IOEN": "Internet of Energy Network", + "IOETH": "ioETH", + "IOEX": "ioeX", + "IOI": "IOI Token", + "ION": "Ionic", + "IONC": "IONChain", + "IONOMY": "Ionomy", + "IONP": "Ion Power Token", + "IONX": "Charged Particles", + "IONZ": "IONZ", + "IOP": "Internet of People", + "IOSHIB": "IoTexShiba", + "IOST": "IOS token", + "IOSTV1": "IOSToken V1", + "IOT": "Helium IOT", + "IOTAI": "IoTAI", + "IOTW": "IOTW", + "IOTX": "IoTeX Network", + "IOU": "IOU1", + "IOUX": "IOU", + "IOV": "Starname", + "IOVT": "IOV", + "IOWN": "iOWN Token", + "IP": "Story", + "IP3": "Cripco", + "IPAD": "Infinity Pad", + "IPAX": "Icopax", + "IPC": "IPChain", + "IPDN": "IPDnetwork", + "IPL": "VouchForMe", + "IPMB": "IPMB", + "IPOR": "IPOR", + "IPSX": "IP Exchange", + "IPT": "Crypt-ON", + "IPU": "iPulse", + "IPUX": "IPUX", + "IPV": "IPVERSE", + "IPVOLD": "IPVERSE (Klaytn)", + "IPX": "InpulseX", + "IPXV1": "InpulseX v1", + "IQ": "IQ", + "IQ50": "IQ50", + "IQ6900": "IQ6900", + "IQC": "IQ.cash", + "IQG": "IQ Global", + "IQN": "IQeon", + "IQQ": "Iqoniq", + "IQT": "IQ Protocol", + "IR": "Infrared Governance Token", + "IRA": "Diligence", + "IRC": "IRIS", + "IRENA": "Irena Coin Apps", + "IRENON": "IREN (Ondo Tokenized)", + "IRIS": "IRIS Network", + "IRISTOKEN": "Iris Ecosystem", + "IRL": "IrishCoin", + "IRO": "Iro-Chan", + "IRON": "Iron Fish", + "IRONBSC": "Iron BSC", + "IRONCOIN": "IRONCOIN", + "IRT": "Infinity Rocket", + "IRWA": "IncomRWA", + "IRYDE": "iRYDE COIN", + "IRYS": "Irys", + "ISA": "Islander", + "ISDT": "ISTARDUST", + "ISEC": "IntelliSecure Systems", + "ISG": "ISG", + "ISH": "Interstellar Holdings", + "ISHI": "Ishi", + "ISHND": "StrongHands Finance", + "ISIKC": "Isiklar Coin", + "ISKR": "ISKRA Token", + "ISKY": "Infinity Skies", + "ISL": "IslaCoin", + "ISLAMI": "ISLAMICOIN", + "ISLAND": "ISLAND Token", + "ISLM": "Islamic Coin", + "ISME": "Root Protocol", + "ISP": "Ispolink", + "ISR": "Insureum", + "ISRG.CUR": "Intuitive Surgical, Inc.", + "ISSOU": "Risitas", + "ISSP": "ISSP", + "IST": "Inter Stable Token", + "ISTEP": "iSTEP", + "ITA": "Italian National Football Team Fan Token", + "ITALIANROT": "Italian Brainrot", + "ITALOCOIN": "Italocoin", + "ITAM": "ITAM Games", + "ITAMCUBE": "CUBE", + "ITC": "IoT Chain", + "ITE": "Idle Tribe Era", + "ITEM": "ITEMVERSE", + "ITF": "Intelligent Trading", + "ITG": "iTrust Governance", + "ITGR": "Integral", + "ITHACA": "Ithaca Protocol", + "ITHEUM": "Itheum", + "ITL": "Italian Lira", + "ITLR": "MiTellor", + "ITM": "intimate.io", + "ITO": "Ito-chan", + "ITOC": "ITOChain", + "ITR": "INTRO", + "ITSB": "ITSBLOC", + "ITU": "iTrue", + "ITX": "Intellix", + "ITZ": "Interzone", + "IUNGO": "Iungo", + "IUS": "Iustitia Coin", + "IUSD": "Indigo Protocol - iUSD", + "IUX": "GeniuX", + "IVANKA": "IVANKA TRUMP", + "IVAR": "Ivar Coin", + "IVC": "Investy Coin", + "IVEX": "IVEX Financial", + "IVFUN": "Invest Zone", + "IVI": "IVIRSE", + "IVIP": "iVipCoin", + "IVN": "IVN Security", + "IVPAY": "ivendPay", + "IVY": "IvyKoin", + "IVZ": "InvisibleCoin", + "IW": "iWallet", + "IWFT": "İstanbul Wild Cats", + "IWMON": "iShares Russell 2000 ETF (Ondo Tokenized)", + "IWT": "IwToken", + "IX": "X-Block", + "IXC": "IXcoin", + "IXFI": "IXFI", + "IXIR": "IXIR", + "IXORA": "IXORAPAD", + "IXP": "IMPACTXPRIME", + "IXS": "IX Swap", + "IXT": "iXledger", + "IYKYK": "IYKYK", + "IZA": "Inzura", + "IZE": "IZE", + "IZER": "IZEROIUM", + "IZI": "Izumi Finance", + "IZICHAIN": "IZIChain", + "IZKY": "IZAKAYA", + "IZX": "IZX", + "IZZY": "Izzy", + "InBit": "PrepayWay", + "J": "Jambo", + "J8T": "JET8", + "J9BC": "J9CASINO", + "JACK": "Jack Token", + "JACKPOT": "Solana Jackpot", + "JACKSON": "Jackson", + "JACS": "JACS", + "JACY": "JACY", + "JADE": "Jade Protocol", + "JADEC": "Jade Currency", + "JAE": "JaeCoin", + "JAGER": "Jager Hunter", + "JAGO": "Jagotrack", + "JAI": "Japanese Akita Inu", + "JAIHO": "Jaiho Crypto", + "JAIHOZ": "Jaihoz by Virtuals", + "JAILSTOOL": "Stool Prisondente", + "JAKE": "Jake The Dog", + "JAM": "Tune.Fm", + "JAN": "Storm Warfare", + "JANE": "JaneCoin", + "JANET": "Janet", + "JANI": "JANI", + "JANITOR": "Janitor", + "JANRO": "Janro The Rat", + "JAPAN": "Japan Open Chain", + "JAPANCONTENTT": "Japan Content Token", + "JAR": "Jarvis+", + "JARED": "Jared From Subway", + "JARVIS": "Jarvis AI", + "JARY": "JeromeAndGary", + "JASMY": "JasmyCoin", + "JASON": "Jason Derulo", + "JAV": "Javsphere", + "JAWN": "Long Jawn Silvers", + "JAWS": "AutoShark", + "JAY": "Jaypeggers", + "JBC": "Japan Brand Coin", + "JBO": "JBOX", + "JBOT": "JACKBOT", + "JBS": "JumBucks Coin", + "JBX": "Juicebox", + "JC": "JesusCoin", + "JCB": "Wine Chain", + "JCC": "Junca Cash", + "JCG": "JustCarbon Governance", + "JCO": "JennyCo", + "JCR": "JustCarbon Removal", + "JCT": "Janction", + "JDAI": "Dai (TON Bridge)", + "JDC": "JustDatingSite", + "JDO": "JINDO", + "JDV": "JD Vance", + "JED": "JEDSTAR", + "JEDALS": "Yoda Coin Swap", + "JEET": "Jeet", + "JEETOLAX": "Jeetolax", + "JEETS": "I'm a Jeet", + "JEFE": "JEFE TOKEN", + "JEFF": "JEFF", + "JEFFINSPACE": "Jeff in Space", + "JEFFRY": "jeffry", + "JEJUDOGE": "Jejudoge", + "JELLI": "JELLI", + "JELLY": "Jelly eSports", + "JELLYAI": "jelly ai agent", + "JELLYJELLY": "Jelly-My-Jelly", + "JEM": "Jem", + "JEN": "JEN COIN", + "JENNER": "Caitlyn Jenner", + "JENSEN": "Jensen Huang", + "JERRY": "jerry", + "JERRYINU": "JERRYINU", + "JERRYINUCOM": "Jerry Inu", + "JES": "Jesus", + "JESSE": "jesse", + "JESSECOIN": "jesse", + "JEST": "Jester", + "JESUS": "Jesus Coin", + "JET": "Jet Protocol", + "JETCAT": "Jetcat", + "JETCOIN": "Jetcoin", + "JETFUEL": "Jetfuel Finance", + "JETTON": "JetTon Game", + "JETUSD": "JETUSD", + "JEUR": "Jarvis Synthetic Euro", + "JEW": "Shekel", + "JEWEL": "DeFi Kingdoms", + "JEWELRY": "Jewelry Token", + "JEX": "JEX Token", + "JF": "Jswap.Finance", + "JFI": "JackPool.finance", + "JFIN": "JFIN Coin", + "JFIVE": "Jonny Five", + "JFOX": "JuniperFox AI", + "JFP": "JUSTICE FOR PEANUT", + "JGGL": "JGGL Token", + "JGLP": "Jones GLP", + "JGN": "Juggernaut", + "JHH": "Jen-Hsun Huang", + "JIAOZI": "Jiaozi", + "JIB": "Jibbit", + "JIF": "JiffyCoin", + "JIG": "Jigen", + "JIM": "Jim", + "JIN": "JinPeng", + "JIND": "JINDO INU", + "JINDO": "JINDOGE", + "JINDOGE": "Jindoge", + "JIO": "JIO Token", + "JITOSOL": "Jito Staked SOL", + "JIZZ": "JizzRocket", + "JIZZLORD": "JizzLord", + "JIZZUS": "JIZZUS CHRIST", + "JJ": "JEJE", + "JK": "JK Coin", + "JKC": "JunkCoin", + "JKL": "Jackal Protocol", + "JLP": "Jupiter Perps LP", + "JLY": "Jellyverse", + "JM": "JustMoney", + "JMC": "Junson Ming Chan Coin", + "JMPT": "JumpToken", + "JMT": "JMTIME", + "JMZ": "Jimizz", + "JNB": "Jinbi Token", + "JNFTC": "Jumbo Blockchain", + "JNGL": "Jungle Labz", + "JNJX": "Johnson & Johnson xStock", + "JNS": "Janus", + "JNT": "Jibrel Network Token", + "JNX": "Janex", + "JNY": "JNY", + "JOB": "Jobchain", + "JOBCOIN": "buy instead of getting a job", + "JOBIESS": "JobIess", + "JOBS": "JobsCoin", + "JOBSEEK": "JobSeek AI", + "JOC": "Speed Star JOC", + "JOE": "JOE", + "JOEB": "Joe Biden", + "JOEBIDEN2024 ": "JOEBIDEN2024", + "JOECOIN": "Joe Coin", + "JOEY": "Joey Inu", + "JOGECO": "Jogecodog", + "JOHM": "Johm lemmon", + "JOHN": "John Tsubasa Rivals", + "JOHNNY": "Johnny The Bull", + "JOINCOIN": "JoinCoin", + "JOINT": "Joint Ventures", + "JOJO": "JOJOWORLD", + "JOJOSCLUB": "JOJO", + "JOJOTOKEN": "JOJO", + "JOK": "JokInTheBox", + "JOKER": "JOKER", + "JOKERCOIN": "JokerCoin", + "JOKERERC": "Joker", + "JOL": "Jolofcoin", + "JOLT": "Joltify", + "JOMA": "Joma", + "JONES": "Jones DAO", + "JONESUSDC": "Jones USDC", + "JOOPS": "JOOPS", + "JOPER": "Joker Pepe", + "JOS": "JuliaOS", + "JOSE": "Jose", + "JOTCHUA": "Perro Dinero", + "JOULE": "Joule", + "JOWNES": "Alux Jownes", + "JOY": "Joystream", + "JOYCAT": "JoyCat Coin", + "JOYS": "JOYS", + "JOYT": "JoyToken", + "JOYTOKEN": "Joycoin", + "JP": "JP", + "JPAW": "Jpaw Inu", + "JPD": "JackpotDoge", + "JPEG": "JPEG'd", + "JPGC": "JPGold Coin", + "JPMORGAN": "JPMorgan", + "JPMX": "JPMorgan Chase xStock", + "JPYC": "JPYC", + "JPYX": "eToro Japanese Yen", + "JRIT": "JERITEX", + "JRT": "Jarvis Reward Token", + "JSE": "JSEcoin", + "JSET": "Jsetcoin", + "JSM": "Joseon Mun", + "JSOL": "JPool Staked SOL", + "JST": "JUST", + "JT": "Jubi Token", + "JTC": "Jurat", + "JTO": "Jito", + "JTS": "Jetset", + "JTT": "Justus", + "JTX": "Project J", + "JU": "JuChain", + "JUDGE": "JudgeCoin", + "JUGNI": "JUGNI", + "JUI": "Juiice", + "JUIC": "Juice", + "JUICE": "Juice Finance", + "JUICEB": "Juice", + "JUICET": "Juice Town", + "JUL": "Joule", + "JULB": "JustLiquidity Binance", + "JULD": "JulSwap", + "JUM": "Jumoney", + "JUMBO": "Jumbo Exchange", + "JUMP": "Jumpcoin", + "JUN": "Jun \"M\" Coin", + "JUNGLE": "JUNGLEDOGE", + "JUNGLEKING": "JungleKing TigerCoin", + "JUNIOR": "Junior", + "JUNKIE": "Junkie Cats", + "JUNO": "JUNO", + "JUP": "Jupiter", + "JUPI": "Jupiter", + "JUPSOL": "Jupiter Staked SOL", + "JUR": "Jur", + "JUS": "Just The Tip", + "JUSD": "JUSD Stable Token", + "JUSDC": "USD Coin (TON Bridge)", + "JUSDT": "TON Bridged USDT", + "JUST": "just a cat", + "JUSTI": "Justin MEME", + "JUSTICE": "AssangeDAO", + "JUV": "Juventus Fan Token", + "JVL": "Javelin", + "JVT": "JVault", + "JVY": "Javvy", + "JW": "Jasan Wellness", + "JWBTC": "Wrapped Bitcoin (TON Bridge)", + "JWIF": "Jerrywifhat", + "JWL": "Jewels", + "JWT": "JW Token", + "JYAI": "Jerry The Turtle By Matt Furie", + "JYC": "Joe-Yo Coin", + "K": "Sidekick", + "K21": "K21", + "K2G": "Kasko2go", + "KAAI": "KanzzAI", + "KAAS": "KAASY.AI", + "KAB": "KABOSU", + "KABOSU": "X Meme Dog", + "KABOSUCOIN": "Kabosu", + "KABOSUCOM": "Kabosu", + "KABOSUFAMILY": "Kabosu Family", + "KABOSUTOKEN": "Kabosu", + "KABOSUTOKENETH": "KABOSU", + "KABUTO": "Kabuto", + "KABY": "Kaby Arena", + "KAC": "KACO Finance", + "KACY": "markkacy", + "KADYROV": "Ramzan", + "KAF": "KAIF Platform", + "KAG": "Silver", + "KAGE": "Kage Network", + "KAI": "KardiaChain", + "KAIA": "Kaia", + "KAID": "KAIDEX", + "KAIJU": "KAIJUNO8", + "KAIK": "KAI KEN", + "KAIKEN": "Kaiken Shiba", + "KAILY": "Kailith", + "KAIM": "Kai Meme", + "KAINET": "KAINET", + "KAIRO": "Kairo", + "KAITO": "KAITO", + "KAKA": "KAKA NFT World", + "KAKAXA": "KAKAXA", + "KAKI": "Doge KaKi", + "KAL": "Kaleido", + "KALA": "Kalata Protocol", + "KALAM": "Kalamint", + "KALDI": "Kaldicoin", + "KALI": "Kalissa", + "KALIS": "KALICHAIN", + "KALLY": "Polkally", + "KALM": "KALM", + "KALYCOIN": "KalyCoin", + "KAM": "BitKAM", + "KAMA": "Kamala Horris", + "KAMAL": "Kamala Harris", + "KAMALA": "Kamala Harris", + "KAMALAHARRIS": "KAMALA HARRIS", + "KAMB": "Kambria", + "KAMLA": "KAMALAMA (kamalama.org)", + "KAMPAY": "KamPay", + "KAN": "Bitkan", + "KANG": "Kangamoon", + "KANG3N": "Kang3n", + "KANGAL": "Kangal", + "KANGO": "KANGO", + "KAON": "Kaon", + "KAP": "KAP Games", + "KAPPA": "Kappa", + "KAPPY": "Kappy", + "KAPU": "Kapu", + "KAR": "Karura", + "KARA": "KarateCat", + "KARAT": "KARAT Galaxy", + "KARATE": "Karate Combat", + "KAREN": "KarenCoin", + "KARMA": "Karma", + "KARMAD": "Karma DAO", + "KARRAT": "KARRAT", + "KART": "Dragon Kart", + "KARUM": "Karum Coin", + "KAS": "Kaspa", + "KASBOT": "KASBOT THE GUARDIAN OF 𐤊ASPA", + "KASHIN": "KASHIN", + "KASPER": "Kasper the ghost of Kaspa", + "KASPY": "KASPY", + "KASSANDRA": "Kassandra", + "KASSIAHOME": "Kassia Home", + "KASTA": "Kasta", + "KASTER": "King Aster", + "KAT": "Karat", + "KATA": "Katana Inu", + "KATANA": "Katana Finance", + "KATANANET": "Katana Network", + "KATCHU": "Katchu Coin", + "KATT": "Katt Daddy", + "KATYCAT": "Katy Perry Fans", + "KATZ": "KATZcoin", + "KAU": "Kinesis Gold", + "KAVA": "Kava", + "KAWA": "Kawakami Inu", + "KAWS": "Kaws", + "KAYI": "Kayı", + "KBBB": "KILL BIG BEAUTIFUL BILL", + "KBC": "Karatgold coin", + "KBD": "Kyberdyne", + "KBOND": "Klondike Bond", + "KBOT": "Korbot", + "KBOX": "The Killbox", + "KBR": "Kubera Coin", + "KBT": "Kartblock", + "KBTC": "Klondike BTC", + "KBX": "KuBitX", + "KC": "Kernalcoin", + "KCAKE": "KittyCake", + "KCAL": "KCAL Token", + "KCALV2": "Phantasma Energy", + "KCASH": "Kcash", + "KCAT": "KING OF CATS", + "KCATS": "KASPA CATS", + "KCCM": "KCC MemePad", + "KCCPAD": "KCCPad", + "KCH": "Keep Calm and Hodl", + "KCS": "KuCoin Token", + "KCT": "Konnect", + "KDA": "Kadena", + "KDAG": "King DAG", + "KDC": "Klondike Coin", + "KDG": "Kingdom Game 4.0", + "KDIA": "KDIA COIN", + "KDK": "Kodiak Token", + "KDOE": "Kudoe", + "KDOGE": "KingDoge", + "KDT": "Kenyan Digital Token", + "KDX": "eckoDAO", + "KEANU": "Keanu Inu", + "KEC": "KEYCO", + "KED": "Klingon Empire Darsek", + "KEEMJONG": "KEEM JONG UNN", + "KEEP": "Keep Network", + "KEES": "Korea Entertainment Education & Shopping", + "KEETARD": "Keetard", + "KEI": "Keisuke Inu", + "KEIRA": "Keira", + "KEK": "KekCoin", + "KEKARMY": "Kek", + "KEKE": "KEK", + "KEKEC": "THE BALKAN DWARF", + "KEKIUS": "Kekius Maximus", + "KEL": "KelVPN", + "KELP": "KELP", + "KELPE": "Kelp Earned Points", + "KELPIE": "Kelpie Inu", + "KEM": "Kem Jeng Un", + "KEMA": "Kemacoin", + "KEN": "Ken", + "KENCOIN": "Kencoin", + "KENDU": "Kendu Inu", + "KENKA": "KENKA METAVERSE", + "KENNEL": "Kennel Locker", + "KENOBI": "Obi PNut Kenobi", + "KENSHI": "Kenshi", + "KEP": "Kepler", + "KEPT": "KeptChain", + "KERMIT": "KermitTheCoin", + "KERN": "Kernel", + "KERNEL": "KernelDAO", + "KEROSENE": "Kerosene", + "KET": "Ket", + "KETAMINE": "Ketamine", + "KETAN": "Ketan", + "KETCOIN": "KET", + "KEVIN": "Kevin (kevinonbase.xyz)", + "KEVINTOKENME": "KEVIN (kevintoken.me)", + "KEVINTOKENNET": "Kevin", + "KEX": "Kira Network", + "KEXCOIN": "KexCoin", + "KEY": "SelfKey", + "KEYC": "KeyCoin", + "KEYCAT": "Keyboard Cat", + "KEYFI": "KeyFi", + "KEYS": "KEYS", + "KEYT": "REBIT", + "KFC": "Chicken", + "KFI": "Klever Finance", + "KFR": "KING FOREVER", + "KFT": "Knit Finance", + "KFX": "KnoxFS", + "KGB": "KGB protocol", + "KGC": "Krypton Galaxy Coin", + "KGEN": "KGeN", + "KGO": "Kiwigo", + "KGST": "KGST", + "KGT": "Kaby Gaming Token", + "KHAI": "khai", + "KHEOWZOO": "khaokheowzoo", + "KHM": "Kohima", + "KHYPE": "Kinetiq Staked HYPE", + "KI": "Genopets KI", + "KIAN": "Porta", + "KIBA": "Kiba Inu", + "KIBAV1": "Kiba Inu v1", + "KIBSHI": "KiboShib", + "KICK": "Kick", + "KICKS": "GetKicks", + "KIDEN": "RoboKiden", + "KIF": "KittenFinance", + "KIKI": "KIKICat", + "KIKIF": "Kiki Flaminki", + "KIKO": "KIKO", + "KILLA": "The Bitcoin Killa", + "KILLER": "Fat Cat Killer", + "KILLSOLANA": "KillSolana", + "KILO": "KiloEx", + "KILT": "KILT Protocol", + "KIM": "KIM Token", + "KIMA": "Kima", + "KIMBA": "The White Lion", + "KIMBO": "Kimbo", + "KIMCHI": "KIMCHI.finance", + "KIMIAI": "Kimi AI Agent", + "KIN": "Kin", + "KIND": "Kind Ads", + "KINE": "Kine Protocol", + "KINET": "KinetixFi", + "KING": "LRT Squared", + "KING93": "King93", + "KINGB": "King Bean", + "KINGBONK": "King Bonk", + "KINGCAT": "King Cat", + "KINGCOIN": "KING", + "KINGD": "Kingdom of Ants", + "KINGDOG": "King Dog Inu", + "KINGDOM": "KING", + "KINGDOMQUEST": "Kingdom Quest", + "KINGF": "King Finance", + "KINGGROK": "King Grok", + "KINGMONEY": "King Money", + "KINGNEIRO": "King Neiro", + "KINGO": "King of memes", + "KINGOF": "King Of Memes", + "KINGPEPE": "KING PEPE", + "KINGSHIB": "King Shiba", + "KINGSLERF": "King Slerf", + "KINGSORA": "King Sora", + "KINGSWAP": "KingSwap", + "KINGTRUMP": "King Trump", + "KINGU": "KINGU", + "KINGWIF": "King WIF", + "KINGY": "KINGYTON", + "KINIC": "Kinic", + "KINK": "Kinka", + "KINT": "Kintsugi", + "KINTO": "Kinto", + "KINU": "Kragger Inu", + "KIP": "KIP", + "KIRA": "Kira the Injective Cat", + "KIRBY": "Kirby Inu", + "KIRBYCEO": "Kirby CEO", + "KIRBYINU": "Kirby Inu", + "KIRBYRELOADED": "Kirby Reloaded", + "KIRO": "Kirobo", + "KISC": "Kaiser", + "KISHIMOTO": "Kishimoto Inu", + "KISHU": "Kishu Inu", + "KIT": "Kitsune", + "KITA": "KITA INU", + "KITE": "Kite", + "KITEAI": "KITEAI", + "KITEHAI": "Kite", + "KITKAT": "Remember KitKat", + "KITSU": "Kitsune Inu", + "KITTE": "Kittekoin", + "KITTENS": "Kitten Coin", + "KITTENWIF": "KittenWifHat", + "KITTI": "KITTI TOKEN", + "KITTY": "Roaring Kitt", + "KITTYCOIN": "Kitty Coin", + "KITTYINU": "Kitty Inu", + "KITTYINUV1": "Kitty Inu v1", + "KITTYS": "KITTY Sol", + "KITTYSOL": "Kitty Solana", + "KIWI": "kiwi", + "KIZUNA": "KIZUNA", + "KKO": "Kineko", + "KKT": "Kingdom Karnage", + "KLAP": "Klap Finance", + "KLAUS": "Klaus", + "KLAYMORE": "Klaymore Stakehouse", + "KLC": "KiloCoin", + "KLD": "Koduck", + "KLEE": "KleeKai", + "KLEVA": "KLEVA Protocol", + "KLICKZIE": "Klickzie", + "KLIMA": "KlimaDAO", + "KLINK": "Klink Finance", + "KLIP": "KLIP AI", + "KLK": "Klickl Token", + "KLKS": "Kalkulus", + "KLKSYNC": "KLK Sync Protocol", + "KLO": "Kalao", + "KLON": "Klondike Finance", + "KLP": "Kulupu", + "KLS": "Karlsen", + "KLT": "Kamaleont", + "KLUB": "KlubCoin", + "KLV": "Klever", + "KLY": "Klayr", + "KMA": "Calamari Network", + "KMC": "Kitsumon", + "KMD": "Komodo", + "KML": "KinkyMilady", + "KMNO": "Kamino", + "KMON": "Kryptomon", + "KMX": "KiMex", + "KNB": "Kronobit Networks Blockchain", + "KNC": "Kyber Network Crystal v2", + "KNCH": "Kaanch Network", + "KNCL": "Kyber Network Crystal Legacy", + "KNDC": "KanadeCoin", + "KNDM": "Kingdom", + "KNDX": "Kondux", + "KNEKTED": "Knekted", + "KNFT": "KStarNFT", + "KNG": "BetKings", + "KNGN": "KingN Coin", + "KNI": "Knights of Cathena", + "KNIGHT": "Forest Knight", + "KNINE": "K9 Finance", + "KNJ": "Kunji Finance", + "KNOB": "KNOB", + "KNOT": "Karmaverse", + "KNOW": "KNOW", + "KNOX": "KnoxDAO", + "KNS": "Kenshi", + "KNT": "KayakNet", + "KNTO": "Kento", + "KNTQ": "Kinetiq Governance Token", + "KNU": "Keanu", + "KNUT": "Knut From Zoo", + "KNUXX": "Knuxx Bully of ETH", + "KNW": "Knowledge", + "KO": "Kyuzo's Friends", + "KOAI": "KOI", + "KOALA": "KOALA", + "KOBAN": "KOBAN", + "KOBE": "Shabu Shabu", + "KOBO": "KoboCoin", + "KOBUSHI": "Kobushi", + "KODA": "Koda Cryptocurrency", + "KODACHI": "Kodachi Token", + "KOGE": "BNB48 Club Token", + "KOGECOIN": "KogeCoin.io", + "KOGIN": "Kogin by Virtuals", + "KOI": "Koi", + "KOII": "Koii", + "KOIN": "Koinos", + "KOINB": "KoinBülteni Token", + "KOINDEX": "KOIN", + "KOINETWORK": "Koi Network", + "KOIP": "KoiPond", + "KOJI": "Koji", + "KOK": "KOK Coin", + "KOKO": "KOALA AI", + "KOKOK": "KoKoK The Roach", + "KOKOSWAP": "KokoSwap", + "KOL": "Kollect", + "KOLANA": "KOLANA", + "KOLION": "Kolion", + "KOLT": "Kolt", + "KOLZ": "KOLZ", + "KOM": "Kommunitas", + "KOMA": "Koma Inu", + "KOMO": "Komoverse", + "KOMP": "Kompass", + "KOMPETE": "KOMPETE", + "KON": "KonPay", + "KONAN": "Konan of Kaspa", + "KONET": "KONET", + "KONG": "KONG", + "KONO": "Konomi Network", + "KORA": "Kortana", + "KORC": "King of Referral Coin", + "KORE": "KORE Vault", + "KOREC": "Kore", + "KORI": "Kori The Pom", + "KORRA": "KORRA", + "KOS": "KONTOS", + "KOSS": "Koss", + "KOTARO": "KOTARO", + "KOTO": "Koto", + "KOX": "Coca-Cola xStock", + "KOY": "Koyo", + "KOZ": "Kozjin", + "KP3R": "Keep3rV1", + "KP4R": "Keep4r", + "KPAD": "KickPad", + "KPAPA": "KPAPA", + "KPAW": "KasPaw", + "KPC": "KEEPs Coin", + "KPHI": "Kephi Gallery", + "KPK": "ParkCoin", + "KPL": "Kepple", + "KPN": "KonnektVPN", + "KPOP": "OFFICIAL K-POP", + "KPOPCOIN": "KPOP Coin", + "KPOPFUN": "KPOP (kpop.fun)", + "KRAK": "Kraken", + "KRATOS": "KRATOS", + "KRAV": "Krav", + "KRAZY": "krazy n.d.", + "KRAZYKAMALA": "KRAZY KAMALA", + "KRB": "Karbo", + "KRC": "KRCoin", + "KRD": "Krypton DAO", + "KREDS": "KREDS", + "KREST": "krest Network", + "KRIDA": "KridaFans", + "KRIPTO": "Kripto", + "KRL": "Kryll", + "KRM": "Karma", + "KRN": "KRYZA Network", + "KRO": "Kroma", + "KROM": "Kromatika", + "KROME": "KROME Shares", + "KRONE": "Kronecoin", + "KRP": "Kryptoin", + "KRRX": "Kyrrex", + "KRS": "Kingdom Raids", + "KRT": "TerraKRW", + "KRU": "Kingaru", + "KRUGERCOIN": "KrugerCoin", + "KRWQ": "KRWQ", + "KRX": "RAVN Korrax", + "KRY": "Krypdraw", + "KRYP": "Krypto Trump", + "KS": "kittyspin", + "KS2": "Kingdomswap", + "KSC": "KStarCoin", + "KSH": "Kahsh", + "KSHIB": "Kilo Shiba Inu", + "KSK": "Karsiyaka Taraftar Token", + "KSM": "Kusama", + "KSN": "KISSAN", + "KSP": "KlaySwap Protocol", + "KSS": "Krosscoin", + "KST": "StarKST", + "KSTT": "Kocaelispor Fan Token", + "KSWAP": "KyotoSwap", + "KSYS": "K-Systems", + "KT": "KingdomX", + "KTA": "Keeta", + "KTC": "KTX.Finance", + "KTK": "KryptCoin", + "KTN": "Kattana", + "KTO": "Kounotori", + "KTON": "Darwinia Commitment Token", + "KTR": "Kitty Run", + "KTS": "Klimatas", + "KTT": "K-Tune", + "KTX": "KwikTrust", + "KUAI": "Kuai Token", + "KUB": "KUB Coin", + "KUBE": "KubeCoin", + "KUBO": "KUBO", + "KUBOS": "KubosCoin", + "KUDAI": "Kudai", + "KUE": "Kuende", + "KUJI": "Kujira", + "KUKU": "KuKu", + "KULA": "Kula", + "KUMA": "Kuma Inu", + "KUMU": "Kumu Finance", + "KUNAI": "KunaiKash", + "KUNCI": "Kunci Coin", + "KUNDALINI": "Kundalini is a real girl", + "KUR": "Kuro", + "KURO": "Kurobi", + "KURT": "Kurrent", + "KUS": "KuSwap", + "KUSA": "Kusa Inu", + "KUSD": "Kowala", + "KUSH": "KushCoin", + "KUSUNOKI": "Kusunoki Samurai", + "KUV": "Kuverit", + "KVERSE": "KEEPs Coin", + "KVI": "KVI Chain", + "KVNT": "KVANT", + "KVT": "Kinesis Velocity Token", + "KWAI": "KWAI", + "KWATT": "4New", + "KWD": "KIWI DEFI", + "KWEEN": "KWEEN", + "KWENTA": "Kwenta", + "KWH": "KWHCoin", + "KWIK": "KwikSwap", + "KWS": "Knight War Spirits", + "KWT": "Kawaii Island", + "KXA": "Kryxivia", + "KXC": "KingXChain", + "KXUSD": "kxUSD", + "KYCC": "KYCCOIN", + "KYL": "Kylin Network", + "KYO": "Kayyo", + "KYOKO": "Kyoko", + "KYRA": "KYRA", + "KYSOL": "Kyros Restaked SOL", + "KYTE": "Kambria Yield Tuning Engine", + "KYUB": "Kyuubi", + "KYVE": "KYVE Network", + "KZC": "KZCash", + "KZEN": "Kaizen", + "L": "L inu", + "L1": "Lamina1", + "L1X": "Layer One X", + "L2": "Leverj Gluon", + "L2DAO": "Layer2DAO", + "L3": "Layer3", + "L3P": "Lepricon", + "L3USD": "L3USD", + "L7": "L7", + "LA": "Lagrange", + "LAB": "LAB", + "LABORCRYPTO": "LaborCrypto", + "LABRA": "LabraCoin", + "LABRYS": "Labrys", + "LABS": "LABS Group", + "LABUBUORG": "Labubu", + "LABUBUSOL": "LABUBU", + "LABX": "Stakinglab", + "LABZ": "Insane Labz", + "LABZBASE": "Insane Labz (Base)", + "LACCOIN": "LocalAgro", + "LACE": "Lovelace World", + "LAD": "LADA", + "LADA": "LadderCaster", + "LADYF": "Milady Wif Hat", + "LADYS": "Milady Meme Coin", + "LAEEB": "LaEeb", + "LAELAPS": "Laelaps", + "LAFFIN": "Laffin Kamala", + "LAI": "LayerAI", + "LAIKA": "LAIKA", + "LAIKAPROTOCOL": "Laika Protocol", + "LAINESOL": "Laine Staked SOL", + "LAIR": "Lair", + "LAKE": "Data Lake", + "LALA": "LaLa World", + "LAMB": "Lambda", + "LAMBO": "LAMBO", + "LAMPIX": "Lampix", + "LAN": "Lanify", + "LANA": "LanaCoin", + "LANC": "Lanceria", + "LAND": "Landshare", + "LANDB": "LandBox", + "LANDLORD": "LANDLORD RONALD", + "LANDS": "Two Lands", + "LANDV1": "Landshare v1", + "LANDW": "LandWolf", + "LANDWOLF": "LANDWOLF", + "LANDWOLFAVAX": "LANDWOLF (AVAX)", + "LANDWOLFETH": "Landwolf", + "LANDWU": "LandWu", + "LANE": "LaneAxis", + "LANLAN": "LanLan Cat", + "LAO": "LC Token", + "LAOS": "LAOS Network", + "LAPI": "Lapis Inu", + "LAPTOP": "Hunter Biden's Laptop", + "LAPUPU": "Lapupu", + "LAR": "LinkArt", + "LARIX": "Larix", + "LARO": "Anito Legends", + "LARR": "larrywifhat", + "LARRY": "LarryCoin", + "LAS": "LNAsolution Coin", + "LASOL": "LamaSol", + "LAT": "PlatON Network", + "LATINA": "Latina", + "LATOKEN": "LATOKEN", + "LATOM": "Liquid ATOM", + "LATTE": "LatteSwap", + "LATX": "Latium", + "LAUGHCOIN": "Laughcoin", + "LAUNCH": "Launchblock.com", + "LAUNCHCOIN": "Launch Coin on Believe", + "LAUNCHMOBY": "Moby", + "LAVA": "Lava Network", + "LAVASWAP": "Lavaswap", + "LAVAX": "LavaX Labs", + "LAVE": "Lavandos", + "LAVITA": "Lavita AI", + "LAW": "Law Token", + "LAWO": "Law Of Attraction", + "LAX": "LAPO", + "LAY3R": "AutoLayer", + "LAYER": "Solayer", + "LAZ": "Lazarus", + "LAZHUZHU": "LAZHUZHU", + "LAZIO": "Lazio Fan Token", + "LAZYCAT": "LAZYCAT", + "LB": "LoveBit", + "LBA": "Cred", + "LBAI": "Lemmy The Bat", + "LBC": "LBRY Credits", + "LBK": "LBK", + "LBL": "LABEL Foundation", + "LBLOCK": "Lucky Block", + "LBM": "Libertum", + "LBR": "Lybra Finance", + "LBRV1": "Lybra Finance v1", + "LBT": "Law Blocks", + "LBTC": "Lombard Staked BTC", + "LBXC": "LUX BIO EXCHANGE COIN", + "LC": "Lotus Capital", + "LC4": "LEOcoin", + "LCASH": "LitecoinCash", + "LCAT": "Lion Cat", + "LCC": "LitecoinCash", + "LCD": "Lucidao", + "LCG": "LCG", + "LCI": "LOVECHAIN", + "LCK": "Luckbox", + "LCMG": "ElysiumG", + "LCMS": "LCMS", + "LCP": "Litecoin Plus", + "LCR": "Lucro", + "LCRO": "Liquid CRO", + "LCS": "LocalCoinSwap", + "LCSH": "LC SHIB", + "LCSN": "Lacostoken", + "LCT": "LendConnect", + "LCWP": "LiteCoinW Plus", + "LCX": "LCX", + "LD": "Long Dragon", + "LDC": "LeadCoin", + "LDFI": "LenDeFi Token", + "LDM": "Ludum token", + "LDN": "Ludena Protocol", + "LDO": "Lido DAO", + "LDOGE": "LiteDoge", + "LDX": "Litedex", + "LDXG": "LondonCoinGold", + "LDZ": "Voodoo Token", + "LEA": "LeaCoin", + "LEAD": "Lead Wallet", + "LEAF": "LeafCoin", + "LEAG": "LeagueDAO Governance Token", + "LEAN": "Lean Management", + "LEASH": "Doge Killer", + "LED": "LEDGIS", + "LEDGER": "Ledger Ai", + "LEDU": "Education Ecosystem", + "LEE": "Love Earn Enjoy", + "LEET": "LeetSwap", + "LEG": "Legia Warsaw Fan Token", + "LEGAL": "LegalX", + "LEGEND": "Legend", + "LEGENDSOFARIA": "Legends of Aria", + "LEGION": "LEGION", + "LEGIT": "LEGIT", + "LEGO": "Lego Coin", + "LEI": "Leia Games", + "LEIA": "Leia", + "LELE": "Lelecoin", + "LEMC": "LemonChain", + "LEMD": "Lemond", + "LEMN": "LEMON", + "LEMO": "LemoChain", + "LEMON": "LemonCoin", + "LEMX": "LEMON", + "LEN": "Liqnet", + "LENARD": "Lenard", + "LEND": "Aave", + "LENDA": "Lenda", + "LENDS": "Lends", + "LENFI": "Lenfi", + "LENIN": "LeninCoin", + "LENS": "Len Sassaman (len-sassaman.vip)", + "LEO": "LEO Token", + "LEOCOIN": "LEO", + "LEOPARD": "Leopard", + "LEOS": "Leonicorn Swap", + "LEOX": "Galileo", + "LEPA": "Lepasa", + "LEPEN": "LePenCoin", + "LEPER": "Leper", + "LESBIAN": "Lesbian Inu", + "LESLIE": "Leslie", + "LESS": "Less Network", + "LESSF": "LessFnGas", + "LESTE": "LESTER by Virtuals", + "LESTER": "Litecoin Mascot", + "LET": "LinkEye", + "LETIT": "Letit", + "LETS": "Let's WIN This", + "LETSBONK": "Let's BONK", + "LETSGETHAI": "Let's Get HAI", + "LETSGO": "Lets Go Brandon", + "LEU": "CryptoLEU", + "LEV": "Levante U.D. Fan Token", + "LEVE": "Leve Invest", + "LEVELG": "LEVELG", + "LEVER": "LeverFi", + "LEVERA": "LeverageInu", + "LEVERJ": "Leverj", + "LEVL": "Levolution", + "LEX": "Elxis", + "LEXI": "LEXIT", + "LEZ": "Peoplez", + "LEZGI": "LEZGI Token", + "LF": "LF", + "LFC": "BigLifeCoin", + "LFDOG": "lifedog", + "LFG": "Gamerse", + "LFGO": "Lets Fuckin Go", + "LFI": "LunaFi", + "LFIT": "LFIT", + "LFNTY": "Lifinity", + "LFT": "Lend Flare Dao", + "LFW": "Linked Finance World", + "LGBT": "Let's Go Brandon Token", + "LGBTQ": "LGBTQoin", + "LGC": "LiveGreen Coin", + "LGCT": "Legacy Token", + "LGCY": "LGCY Network", + "LGD": "Legends Cryptocurrency", + "LGG": "Let's Go Gambling", + "LGNDX": "LegendX", + "LGNS": "Longinus", + "LGO": "Legolas Exchange", + "LGOLD": "LYFE GOLD", + "LGOT": "LGO Token", + "LGR": "Logarithm", + "LGX": "Legion Network", + "LHB": "Lendhub", + "LHC": "LHCoin", + "LHD": "LitecoinHD", + "LHINU": "Love Hate Inu", + "LHT": "LHT Coin", + "LIB": "Libellum", + "LIBERA": "Libera Financial", + "LIBERO": "Libero Financial", + "LIBERTA": "The Libertarian Dog", + "LIBERTADPROJECT": "Libra", + "LIBERTY": "Torch of Liberty", + "LIBFX": "Libfx", + "LIBRA": "FUCK LIBRA", + "LIBRAP": "Libra Protocol", + "LIBRE": "Libre", + "LIC": "Ligercoin", + "LICK": "PetLFG", + "LICKER": "LICKER", + "LICKO": "LICKO", + "LICO": "Liquid Collectibles", + "LID": "Liquidity Dividends Protocol", + "LIDER": "Lider Token", + "LIE": "it’s all a lie", + "LIEN": "Lien", + "LIF": "Winding Tree", + "LIF3": "LIF3", + "LIFE": "Life Crypto", + "LIFEBIRD": "LIFEBIRD", + "LIFET": "LifeTime", + "LIFETOKEN": "LIFE", + "LIFT": "Uplift", + "LIGER": "Ligercoin", + "LIGHT": "LIGHT", + "LIGHTCHAIN": "LightChain", + "LIGHTHEAVEN": "Light", + "LIGHTSPEED": "LightSpeedCoin", + "LIGMA": "Ligma Node", + "LIGO": "Ligo", + "LIHUA": "LIHUA", + "LIKE": "Only1", + "LIKEC": "LikeCoin", + "LILA": "LiquidLayer", + "LILB": "Lil Brett", + "LILFLOKI": "Lil Floki", + "LILO": "Lilo", + "LILPUMP": "lilpump", + "LILY": "LILY-The Gold Digger", + "LIMBO": "Limbo", + "LIME": "iMe Lab", + "LIMEX": "Limestone Network", + "LIMITEDCOIN": "Limited Coin", + "LIMO": "Limoverse", + "LIMX": "LimeCoinX", + "LINA": "Linear", + "LINANET": "Lina", + "LINDA": "Metrix", + "LINDACEO": "LindaYacc Ceo", + "LINEA": "Linea", + "LING": "Lingose", + "LINGO": "Lingo", + "LINK": "Chainlink", + "LINKA": "LINKA", + "LINKCHAIN": "LINK", + "LINKFI": "LinkFi", + "LINQ": "LINQ", + "LINSPIRIT": "linSpirit", + "LINU": "Luna Inu", + "LINX": "Linde xStock", + "LIO": "Lio", + "LION": "Loaded Lions", + "LIONT": "Lion Token", + "LIORA": "Liora", + "LIPC": "LIpcoin", + "LIPS": "LipChain", + "LIQ": "LIQ Protocol", + "LIQD": "LiquidLaunch", + "LIQR": "Topshelf Finance", + "LIQUI": "Liquidus", + "LIQUID": "Liquid Finance", + "LIQUIDIUM": "LIQUIDIUM•TOKEN", + "LIR": "Let it Ride", + "LIS": "Realis Network", + "LISA": "LISA Token", + "LISAS": "Lisa Simpson", + "LISASIMPSONCLUB": "Lisa Simpson", + "LIST": "KList Protocol", + "LISTA": "Lista DAO", + "LISTEN": "Listen", + "LISUSD": "lisUSD", + "LIT": "Lighter", + "LITE": "Lite USD", + "LITEBTC": "LiteBitcoin", + "LITENETT": "Litenett", + "LITENTRY": "Litentry", + "LITH": "Lithium Finance", + "LITHIUM": "Lithium", + "LITHO": "Lithosphere", + "LITION": "Lition", + "LITT": "LitLab Games", + "LITTLEGUY": "just a little guy", + "LITTLEMANYU": "Little Manyu", + "LIV": "LiviaCoin", + "LIVE": "SecondLive", + "LIVENCOIN": "LivenPay", + "LIVESEY": "Dr. Livesey", + "LIVESTARS": "Live Stars", + "LIXX": "Libra Incentix", + "LIY": "Lily", + "LIZ": "Lizus Payment", + "LIZA": "Liza", + "LIZARD": "LIZARD", + "LIZD": "Dancing Lizard Coin", + "LK": "Liker", + "LK7": "Lucky7Coin", + "LKC": "LuckyCoin", + "LKD": "LinkDao", + "LKI": "Laika AI", + "LKN": "LinkCoin Token", + "LKSM": "Liquid KSM", + "LKT": "Locklet", + "LKU": "Lukiu", + "LKY": "LuckyCoin", + "LL": "LightLink", + "LLAND": "Lyfe Land", + "LLD": "Liberland dollar", + "LLG": "Loligo", + "LLION": "Lydian Lion", + "LLM": "Large Language Model Based", + "LLT": "LILLIUS", + "LLYX": "Eli Lilly xStock", + "LM": "LeisureMeta", + "LMAO": "LMAO Finance", + "LMC": "LomoCoin", + "LMCH": "Latamcash", + "LMCSWAP": "LimoCoin SWAP", + "LMEOW": "lmeow", + "LMF": "Lamas Finance", + "LMQ": "Lightning McQueen", + "LMR": "Lumerin", + "LMT": "LIMITUS", + "LMTOKEN": "LM Token", + "LMTS": "Limitless Official Token", + "LMWR": "LimeWire Token", + "LMXC": "LimonX", + "LMY": "Lunch Money", + "LN": "Lnfi Network", + "LNC": "Blocklancer", + "LNCHM": "Launchium", + "LND": "Lendingblock", + "LNDRR": "Lendr Network", + "LNDRY": "LNDRY", + "LNDX": "LandX Finance", + "LNGVX": "WisdomTree Siegel Longevity Digital Fund", + "LNK": "Ethereum.Link", + "LNKC": "Linker Coin", + "LNL": "LunarLink", + "LNQ": "LinqAI", + "LNR": "LNR", + "LNRV2": "Lunar", + "LNT": "Lottonation", + "LNX": "Lunox Token", + "LOA": "League of Ancients", + "LOAF": "LOAF CAT", + "LOAFCAT": "LOAFCAT", + "LOAN": "Lendoit", + "LOBO": "LOBO•THE•WOLF•PUP", + "LOBS": "Lobstex", + "LOC": "LockTrip", + "LOCAT": "LOVE CAT", + "LOCC": "Low Orbit Crypto Cannon", + "LOCG": "LOCGame", + "LOCI": "LociCoin", + "LOCK": "Contracto", + "LOCKIN": "LOCK IN", + "LOCO": "Loco", + "LOCOM": "Locomotir", + "LOCUS": "Locus Chain", + "LODE": "Lodestar", + "LOE": "Legends of Elysium", + "LOF": "Land of Fantasy", + "LOFI": "LOFI", + "LOFIBUZZ": "LOFI", + "LOG": "Wood Coin", + "LOGO": "LOGOS", + "LOGOS": "LOGOSAI", + "LOGT": "Lord of Dragons Governance Token", + "LOGX": "LogX Network", + "LOIS": "Lois Token", + "LOKA": "League of Kingdoms", + "LOKR": "Polkalokr", + "LOKY": "Loky by Virtuals", + "LOL": "EMOGI Network", + "LOLA": "Lola", + "LOLATHECAT": "Lola", + "LOLC": "LOL Coin", + "LOLCOIN": "Worlds First Memecoin", + "LOLLY": "Lollipop", + "LOLLYBOMB": "LollyBomb", + "LOLO": "Lolo", + "LON": "Tokenlon", + "LONG": "Longdrink Finance", + "LONGEVITY": "longevity", + "LONGFU": "LONGFU", + "LONGM": "Long Mao", + "LONGSHINE": "LongShine", + "LOOBY": "Looby by Stephen Bliss", + "LOOK": "LOOK", + "LOOKS": "LooksRare", + "LOOM": "Loom Network", + "LOOMV1": "Loom Network v1", + "LOON": "Loon Network", + "LOONG": "PlumpyDragons", + "LOOP": "LOOP", + "LOOPIN": "LooPIN Network", + "LOOPY": "Loopy", + "LOOT": "LootBot", + "LOOTEX": "Lootex", + "LOPES": "Leandro Lopes", + "LORD": "MEMELORD", + "LORDS": "LORDS", + "LORDZ": "Meme Lordz", + "LORGY": "Memeolorgy", + "LORY": "Yield Parrot", + "LOS": "Lord Of SOL", + "LOST": "Lost Worlds", + "LOT": "League of Traders", + "LOTES": "Loteo", + "LOTEU": "Loteo", + "LOTT": "Beauty bakery lott", + "LOTTO": "LottoCoin", + "LOTTY": "Lotty", + "LOTUS": "The White Lotus", + "LOUD": "Loud Market", + "LOULOU": "LOULOU", + "LOV": "LoveChain", + "LOVE": "Love Monster", + "LOVELY": "Lovely finance", + "LOVELYV1": "Lovely Inu Finance", + "LOVESNOOPY": "I LOVE SNOOPY", + "LOWB": "Loser Coin", + "LOWQ": "lowq frends", + "LOX": "Lox Network", + "LOYAL": "Loyalty Labs", + "LP": "Liquid Protocol", + "LPC": "Little Phil", + "LPENGU": "Lil Pudgys", + "LPI": "LPI DAO", + "LPK": "Kripton", + "LPL": "LinkPool", + "LPM": "Love Power Market", + "LPNT": "Luxurious Pro Network Token", + "LPOOL": "Launchpool", + "LPT": "Livepeer", + "LPTV1": "Livepeer v1", + "LPV": "Lego Pepe Vision", + "LPY": "LeisurePay", + "LQ": "Liqwid Finance", + "LQ8": "Liquid8", + "LQBTC": "Liquid Bitcoin", + "LQD": "Liquid", + "LQDN": "Liquidity Network", + "LQDR": "LiquidDriver", + "LQDX": "Liquid Crypto", + "LQNA": "The Queen of Hyperliquid", + "LQR": "Laqira Protocol", + "LQT": "Lifty", + "LQTY": "Liquity", + "LRC": "Loopring", + "LRCV1": "Loopring v1", + "LRDS": "BLOCKLORDS", + "LRG": "Largo Coin", + "LRN": "Loopring [NEO]", + "LRT": "LandRocker", + "LSC": "LS Coin", + "LSD": "LSD", + "LSDOGE": "LSDoge", + "LSETH": "Liquid Staked ETH", + "LSHARE": "LSHARE", + "LSILVER": "Lyfe Silver", + "LSK": "Lisk", + "LSKV1": "Lisk v1", + "LSP": "Lumenswap", + "LSPHERE": "Lunasphere", + "LSR": "LaserEyes", + "LSS": "Lossless", + "LST": "Lendroid Support Token", + "LSTAR": "Learning Star", + "LSTV1": "Lovely Swap Token", + "LSV": "Litecoin SV", + "LSWAP": "LoopSwap", + "LT": "Loctite Assets Token", + "LTA": "Litra", + "LTAI": "LibertAI", + "LTB": "Litebar", + "LTBC": "LTBCoin", + "LTBTC": "Lightning Bitcoin", + "LTBX": "Litbinex Coin", + "LTC": "Litecoin", + "LTCC": "Listerclassic Coin", + "LTCD": "LitecoinDark", + "LTCH": "Litecoin Cash", + "LTCJ": "Litecoin (JustCrypto)", + "LTCP": "LitecoinPro", + "LTCR": "LiteCreed", + "LTCU": "LiteCoin Ultra", + "LTCX": "LitecoinX", + "LTD": "Living the Dream", + "LTE": "Local Token Exchange", + "LTEX": "Ltradex", + "LTG": "LiteCoin Gold", + "LTH": "Lathaan", + "LTHN": "Lethean", + "LTK": "LinkToken", + "LTNM": "Bitcoin Latinum", + "LTO": "LTO Network", + "LTOV1": "LTO Network v1", + "LTOV2": "LTO Network v2", + "LTP": "Listapie", + "LTPC": "Lightpaycoin", + "LTR": "LogiTron", + "LTRBT": "Little Rabbit", + "LTRBTV1": "Little Rabbit v1", + "LTS": "Litestar Coin", + "LTT": "LocalTrade", + "LTX": "Lattice Token", + "LTZ": "Litecoinz", + "LUA": "Lua Token", + "LUAUSD": "Lumi Finance", + "LUBE": "Joe Lube Coin", + "LUC": "Play 2 Live", + "LUCA": "LUCA", + "LUCE": "Luce", + "LUCHOW": "LunaChow", + "LUCI": "LUCI", + "LUCIC": "Lucidum Coin", + "LUCK": "LUCK", + "LUCKY": "Lucky Lion", + "LUCKYB": "LuckyBlocks", + "LUCKYCAT": "Lucky Cat", + "LUCKYS": "LuckyStar", + "LUCKYSLP": "LuckysLeprecoin", + "LUCRE": "Lucre", + "LUCY": "Lucy", + "LUCYAI": "Pitch Lucy AI", + "LUDO": "Ludo", + "LUDUS": "Ludus", + "LUFC": "Leeds United Fan Token", + "LUFFY": "Luffy", + "LUFFYG": "Luffy G5", + "LUFFYOLD": "Luffy", + "LUFFYV1": "Luffy v1", + "LUIGI": "Luigi Inu", + "LUIS": "Tongue Cat", + "LUKKI": "Lukki Operating Token", + "LULU": "LULU", + "LUM": "Luminous", + "LUMA": "LUMA Token", + "LUMI": "LUMI Credits", + "LUMIA": "Lumia", + "LUMIO": "Solana Mascot", + "LUMO": "Lumo-8B-Instruct", + "LUMOS": "Lumos", + "LUN": "Lunyr", + "LUNA": "Terra", + "LUNAB": "Luna by Virtuals", + "LUNAR": "Lunar", + "LUNARLENS": "Lunarlens", + "LUNAT": "Lunatics", + "LUNC": "Terra Classic", + "LUNCARMY": "LUNCARMY", + "LUNCH": "LunchDAO", + "LUNE": "Luneko", + "LUNES": "Lunes", + "LUNG": "LunaGens", + "LUNR": "Lunr Token", + "LUPIN": "LUPIN", + "LUR": "Lumera", + "LUS": "Luna Rush", + "LUSD": "Liquity USD", + "LUSH": "Lush AI", + "LUT": "Cinemadrom", + "LUTETIUM": "Lutetium Coin", + "LUX": "Luxxcoin", + "LUXAI": "Lux Token", + "LUXCOIN": "LUXCoin", + "LUXO": "Luxo", + "LUXTOKEN": "Lux Token", + "LUXU": "Luxury Travel Token", + "LUXY": "Luxy", + "LVG": "Leverage Coin", + "LVIP": "Limitless VIP", + "LVL": "Level", + "LVLUSD": "Level USD", + "LVLY": "LyvelyToken", + "LVM": "LakeViewMeta", + "LVN": "Levana Protocol", + "LVVA": "Levva Protocol Token", + "LVX": "Level01", + "LWA": "LUMIWAVE", + "LWC": "Linework Coin", + "LWF": "Local World Forwarders", + "LWFI": "Liberty world financial", + "LX": "Moonlight", + "LXC": "LibrexCoin", + "LXF": "LuxFi", + "LXT": "LITEX", + "LXTO": "LuxTTO", + "LYA": "Huralya", + "LYB": "LyraBar", + "LYC": "LycanCoin", + "LYDI": "Lydia Finance", + "LYF": "Lillian Token", + "LYFE": "Lyfe", + "LYK": "Loyakk Vega", + "LYL": "LoyalCoin", + "LYM": "Lympo", + "LYMPO": "Lympo Market Token", + "LYN": "Everlyn Token", + "LYNCHPIN": "LYNCHPIN Token", + "LYNK": "Lynked.World", + "LYNX": "Lynex", + "LYNXCOIN": "Lynx", + "LYO": "LYO Credit", + "LYP": "Lympid Token", + "LYQD": "eLYQD", + "LYR": "Lyra", + "LYRA": "Lyra", + "LYTX": "LYTIX", + "LYUM": "Layerium", + "LYVE": "Lyve Finance", + "LYX": "LUKSO", + "LYXE": "LUKSO", + "LYZI": "Lyzi", + "LZ": "LaunchZone", + "LZM": "LoungeM", + "LZUSDC": "LayerZero Bridged USDC (Fantom)", + "M": "MemeCore", + "M0": "M by M^0", + "M1": "SupplyShock", + "M2O": "M2O Token", + "M3H": "MehVerseCoin", + "M3M3": "M3M3", + "M87": "MESSIER", + "MA": "Mind-AI", + "MAAL": "Maal Chain", + "MABA": "Make America Based Again", + "MAC": "MachineCoin", + "MACHO": "macho", + "MACRO": "Macro Millions", + "MACROPROTOCOL": "Macro Protocol", + "MADA": "MilkADA", + "MADAGASCARTOKEN": "Madagascar Token", + "MADANA": "MADANA", + "MADC": "MadCoin", + "MADCOIN": "MAD", + "MADH": "Madhouse", + "MADOG": "MarvelDoge", + "MADP": "Mad Penguin", + "MADPEPE": "Mad Pepe", + "MADU": "Nicolas Maduro", + "MADURO": "MADURO", + "MAECENAS": "Maecenas", + "MAEP": "Maester Protocol", + "MAF": "MetaMAFIA", + "MAG": "Magnify Cash", + "MAG7SSI": "MAG7.ssi", + "MAGA": "MAGA", + "MAGA2024": "MAGA2024", + "MAGA47": "MAGA 47", + "MAGAA": "MAGA AGAIN", + "MAGABRO": "M.A.G.A. Bro", + "MAGAC": "MAGA CAT", + "MAGACA": "MAGA CAT", + "MAGACAT": "MAGACAT", + "MAGADOGE": "MAGA DOGE", + "MAGAF": "MAGA FRENS", + "MAGAHAT": "MAGA Hat", + "MAGAIBA": "Magaiba", + "MAGAL": "Magallaneer", + "MAGAN": "Maganomics On Solana", + "MAGANOMICS": "Maganomics", + "MAGAP": "MAGA PEPE", + "MAGAPEPE": "MAGA PEPE", + "MAGASHIB": "MAGA SHIB", + "MAGASOL": "MAGA", + "MAGATRUMP": "MAGA Trump", + "MAGE": "MetaBrands", + "MAGIC": "Magic", + "MAGICF": "MagicFox", + "MAGICK": "Cosmic Universe Magick", + "MAGICV": "Magicverse", + "MAGIK": "Magik Finance", + "MAGMA": "MAGMA", + "MAGN": "Magnate Finance", + "MAGNE": "Magnetix", + "MAGNET": "Yield Magnet", + "MAGNET6900": "MAGNET6900", + "MAGNETWORK": "Magnet", + "MAGOA": "Make America Great Once Again", + "MAGPAC": "MAGA Meme PAC", + "MAH": "Mahabibi Bin Solman", + "MAHA": "MahaDAO", + "MAI": "MAI", + "MAIA": "Maia", + "MAID": "MaidSafe Coin", + "MAIGA": "MAIGA Token", + "MAIL": "CHAINMAIL", + "MAINSTON": "Ston", + "MAIV": "MAIV", + "MAJ": "Major Frog", + "MAJO": "Majo", + "MAJOR": "Major", + "MAK": "MetaCene", + "MAKA": "MAKA", + "MAKE": "MAKE", + "MAKEA": "Make America Healthy Again", + "MAKEE": "Make Ethereum Great Again", + "MAKI": "MakiSwap", + "MALGO": "milkALGO", + "MALL": "Metamall", + "MALLY": "Malamute Finance", + "MALOU": "MALOU Token", + "MAMAI": "MammothAI", + "MAMBA": "Mamba", + "MAMBO": "Mambo", + "MAMO": "Mamo", + "MAN": "Matrix AI Network", + "MANA": "Decentraland", + "MANA3": "MANA3", + "MANC": "Mancium", + "MAND": "Mandala Exchange Token", + "MANDALA": "Mandala Exchange Token", + "MANDOX": "MandoX", + "MANDY": "MANDY COIN", + "MANE": "MANE", + "MANEKI": "MANEKI", + "MANEKINEKO": "MANEKI-NEKO", + "MANGA": "Manga Token", + "MANIA": "ScapesMania", + "MANIFEST": "Manifest", + "MANNA": "Manna", + "MANORUKA": "ManoRuka", + "MANSONCOIN": "Manson Coin", + "MANT": "Mantle USD", + "MANTA": "Manta Network", + "MANTI": "Mantis", + "MANTLE": "Mantle", + "MANUSAI": "Manus AI Agent", + "MANYU": "Manyu", + "MANYUDOG": "MANYU", + "MAO": "Mao", + "MAOW": "MAOW", + "MAP": "MAP Protocol", + "MAPC": "MapCoin", + "MAPE": "Mecha Morphing", + "MAPO": "MAP Protocol", + "MAPR": "Maya Preferred 223", + "MAPS": "MAPS", + "MAPU": "MatchAwards Platform Utility Token", + "MAR3": "Mar3 AI", + "MARAON": "MARA Holdings (Ondo Tokenized)", + "MARCO": "MELEGA", + "MARCUS": "Marcus Cesar Inu", + "MARE": "Mare Finance", + "MARGA": "Margaritis", + "MARGE": "Marge Simpson", + "MARGINLESS": "Marginless", + "MARI": "MarijuanaCoin", + "MARIC": "Maricoin", + "MARIE": "Marie Rose", + "MARIEROSE": "Marie", + "MARIO": "MARIO CEO", + "MARK": "Benchmark Protocol", + "MARKE": "Market Ledger", + "MARKETMOVE": "MarketMove", + "MARLEY": "Marley Token", + "MARMAJ": "marmaj", + "MAROV1": "TTC PROTOCOL", + "MAROV2": "Maro", + "MARS": "Mars", + "MARS4": "MARS4", + "MARSC": "MarsCoin", + "MARSCOIN": "MarsCoin", + "MARSH": "Unmarshal", + "MARSMI": "MarsMi", + "MARSO": "Marso.Tech", + "MARSRISE": "MarsRise", + "MARSTOKEN": "Mars Token", + "MARSUPILAMI": "MARSUPILAMI INU", + "MARSW": "Marswap", + "MART": "ArtMeta", + "MARTIA": "Colonize Mars", + "MARTK": "Martkist", + "MARTY": "Marty Inu", + "MARU": "marumaruNFT", + "MARV": "Marvelous", + "MARVIN": "Marvin", + "MARVINB": "Marvin on Base", + "MARX": "MarX", + "MARXCOIN": "MarxCoin", + "MARYJ": "MaryJane Coin", + "MAS": "Midas Protocol", + "MASA": "Masa", + "MASHA": "Masha", + "MASK": "Mask Network", + "MASP": "Market.space", + "MASQ": "MASQ", + "MASS": "MASS", + "MASSA": "Massa", + "MASTER": "Mastercoin", + "MASTERCOIN": "MasterCoin", + "MASTERMINT": "MasterMint", + "MASTERMIX": "Master MIX Token", + "MASTERTRADER": "MasterTraderCoin", + "MASYA": "MASYA", + "MAT": "Matchain", + "MATA": "Ninneko", + "MATAR": "MATAR AI", + "MATCH": "Matching Game", + "MATE": "Mate", + "MATES": "MATES", + "MATH": "MATH", + "MATIC": "Polygon", + "MATICX": "Stader MaticX", + "MATPAD": "MaticPad", + "MATR1X": "Matr1x", + "MATRIX": "Matrix One", + "MATRIXLABS": "Matrix Labs", + "MATT": "Matt Furie", + "MATTER": "AntiMatter", + "MATTLE": "MattleFun", + "MAU": "MAU", + "MAUW": "MAUW", + "MAV": "Maverick Protocol", + "MAVAX": "Avalanche (Multichain)", + "MAVIA": "Heroes of Mavia", + "MAW": "Mountain Sea World", + "MAWA": "Kumala Herris", + "MAWC": "Magawincat", + "MAX": "Mastercard xStock", + "MAXAIAGENT": "MAX", + "MAXCOIN": "MaxCoin", + "MAXETH": "Max on ETH", + "MAXI": "Maximus", + "MAXIMUSA": "Kekius Maximusa", + "MAXL": "Maxi protocol", + "MAXR": "Max Revive", + "MAXX": "MAXX Finance", + "MAY": "Mayflower", + "MAYA": "Maya", + "MAYACOIN": "MayaCoin", + "MAYILONG": "Yi long ma", + "MAYO": "Mr Mayonnaise the Cat", + "MAYP": "Maya Preferred", + "MAZC": "MyMazzu", + "MAZI": "MaziMatic", + "MAZZE": "Mazze", + "MB": "MineBee", + "MB28": "MBridge28", + "MB4": "Matthew Box 404", + "MB8": "MB8 Coin", + "MBAG": "MoonBag", + "MBANK": "MetaBank", + "MBAPEPE": "MBAPEPE", + "MBASE": "Minebase", + "MBC": "MicroBitcoin", + "MBCASH": "MBCash", + "MBCC": "Blockchain-Based Distributed Super Computing Platform", + "MBD": "MBD Financials", + "MBE": "MxmBoxcEus Token", + "MBET": "MoonBet", + "MBF": "MoonBear.Finance", + "MBG": "MBG Token", + "MBGA": "MBGA", + "MBI": "Monster Byte Inc", + "MBID": "myBID", + "MBILLY": "MAMA BILLY", + "MBIT": "Mbitbooks", + "MBL": "MovieBloc", + "MBLC": "Mont Blanc", + "MBLK": "Magical Blocks", + "MBLV1": "MovieBloc v1", + "MBM": "MobileBridge Momentum", + "MBN": "Mobilian Coin", + "MBNB": "Binance Coin (Multichain)", + "MBONK": "megaBonk", + "MBOT": "MoonBot", + "MBOX": "MOBOX", + "MBOYS": "MoonBoys", + "MBP": "MobiPad", + "MBRS": "Embers", + "MBS": "MonkeyBall", + "MBT": "Metablackout", + "MBTCS": "MBTCs", + "MBTX": "MinedBlock", + "MBX": "Marblex", + "MC": "Merit Circle", + "MCA": "Mcashchain", + "MCADE": "Metacade", + "MCAKE": "EasyCake", + "MCAP": "MCAP", + "MCAR": "MasterCar", + "MCASH": "Monsoon Finance", + "MCAT20": "Wrapped Moon Cats", + "MCAU": "Meld Gold", + "MCB": "MCDEX", + "MCC": "Magic Cube Coin", + "MCD": "McDonald's Job Application", + "MCDAI": "Dai (Multichain)", + "MCDULL": "McDull", + "MCDX": "McDonald’s xStock", + "MCELO": "Moola Celo", + "MCEN": "Main Character Energy", + "MCEUR": "Moola Celo EUR", + "MCF": "MCFinance", + "MCG": "MicroChains Gov Token", + "MCGA": "Make CRO Great Again", + "MCH": "Meconcash", + "MCHC": "My Crypto Heroes", + "MCI": "Musiconomi", + "MCIV": "Mars Civ Project", + "MCL": "McLaren F1", + "MCLB": "Millennium Club Coin", + "MCM": "Mochimo", + "MCN": "mCoin", + "MCO": "Crypto.com", + "MCO2": "Moss Carbon Credit", + "MCOI": "MCOIN", + "MCOIN": "MCOIN", + "MCONTENT": "MContent", + "MCP": "My Crypto Play", + "MCPC": "Mobile Crypto Pay Coin", + "MCQ": "Mecha Conquest", + "MCRC": "MyCreditChain", + "MCRN": "MacronCoin", + "MCRT": "MagicCraft", + "MCS": "MCS Token", + "MCT": "MyConstant", + "MCTO": "McToken", + "MCTP": "Metacraft", + "MCU": "MediChain", + "MCUSD": "Moola Celo USD", + "MCV": "MCV Token", + "MCX": "MachiX Token", + "MD": "MetaDeck", + "MDA": "Moeda", + "MDAI": "MindAI", + "MDAO": "MarsDAO", + "MDB": "Million Dollar Baby", + "MDC": "MedicCoin", + "MDCL": "Medicalchain", + "MDDN": "Modden", + "MDF": "MatrixETF", + "MDH": "Telemedicoin", + "MDI": "Medicle", + "MDICE": "Multidice", + "MDM": "Medium", + "MDN": "Modicoin", + "MDOGE": "First Dog In Mars", + "MDOGS": "Money Dogs", + "MDR": "Mudra MDR", + "MDS": "MediShares", + "MDT": "Measurable Data Token", + "MDTK": "MDtoken", + "MDTX": "Medtronic xStock", + "MDU": "MDUKEY", + "MDUS": "MEDIEUS", + "MDX": "Mdex (BSC)", + "MDXH": "Mdex (HECO)", + "ME": "Magic Eden", + "MEA": "MECCA", + "MEAN": "Meanfi", + "MEB": "Meblox Protocol", + "MEC": "MegaCoin", + "MECH": "Mech Master", + "MECHA": "Mechanium", + "MECI": "Meta Game City", + "MED": "Medibloc", + "MEDAMON": "Medamon", + "MEDI": "MediBond", + "MEDIA": "Media Network", + "MEDIC": "MedicCoin", + "MEDICO": "Mediconnect", + "MEDIT": "MediterraneanCoin", + "MEDUSA": "MEDUSA", + "MEE": "Medieval Empires", + "MEED": "Meeds DAO", + "MEER": "Qitmeer Network", + "MEET": "CoinMeet", + "MEETONE": "MEET.ONE", + "MEETPLE": "Meetple", + "MEF": "MEFLEX", + "MEFA": "Metaverse Face", + "MEFAI": "META FINANCIAL AI", + "MEFI": "Meo Finance", + "MEGA": "MegaFlash", + "MEGABOT": "Megabot", + "MEGAD": "Mega Dice Casino", + "MEGAHERO": "MEGAHERO", + "MEGALAND": "Metagalaxy Land", + "MEGALANDV1": "Metagalaxy Land v1", + "MEGATECH": "Megatech", + "MEGAX": "Megahex", + "MEGE": "MEGE", + "MEH": "meh", + "MEI": "Mei Solutions", + "MEIZHU": "GUANGZHOU ZOO NEW BABY PANDA", + "MEL": "MELX", + "MELANIA": "Melania Meme", + "MELANIATRUMP": "Melania Trump", + "MELB": "Minelab", + "MELD": "MetaElfLand Token", + "MELDV1": "MELD v1", + "MELDV2": "MELD", + "MELI": "Meli Games", + "MELLO": "Mello Token", + "MELLOW": "Mellow Man", + "MELLSTROY": "MELLSTROY", + "MELO": "Melo Token", + "MELODITY": "Melodity", + "MELON": "cocomELON", + "MELOS": "Melos Studio", + "MELT": "Defrost Finance", + "MEM": "Memecoin", + "MEMAGX": "Meta Masters Guild Games", + "MEMBERSHIP": "Membership Placeholders", + "MEMD": "MemeDAO", + "MEMDEX": "Memdex100", + "MEME": "Memecoin", + "MEMEA": "MEME AI", + "MEMEAI": "Meme Ai", + "MEMEBRC": "MEME", + "MEMECOIN": "just memecoin", + "MEMECOINDAOAI": "MemeCoinDAO", + "MEMECUP": "Meme Cup", + "MEMEETF": "Meme ETF", + "MEMEFI": "MemeFi", + "MEMEFICASH": "MemeFi", + "MEMEINU": "Meme Inu", + "MEMEM": "Meme Man", + "MEMEME": "MEMEME", + "MEMEMINT": "MEME MINT", + "MEMEMUSK": "MEME MUSK", + "MEMENTO": "MEMENTO•MORI (Runes)", + "MEMERUNE": "MEME•ECONOMICS", + "MEMES": "memes will continue", + "MEMESAI": "Memes AI", + "MEMESQUAD": "Meme Squad", + "MEMET": "MEMETOON", + "MEMETIC": "Memetic", + "MEMEX": "Meme Index", + "MEMHASH": "Memhash", + "MEMORYCOIN": "MemoryCoin", + "MEMUSIC": "MeMusic", + "MEN": "METAHUB FINANCE", + "MENDI": "Mendi Finance", + "MENGO": "Flamengo Fan Token", + "MENLO": "Menlo One", + "MEO": "Meow Of Meme", + "MEOW": "Zero Tech", + "MEOWCAT": "MeowCat", + "MEOWETH": "Meow", + "MEOWG": "MeowGangs", + "MEOWIF": "Meowifhat", + "MEOWM": "Meow Meow Coin", + "MEOWME": "MEOW MEOW", + "MEPAD": "MemePad", + "MER": "Mercurial Finance", + "MERCE": "MetaMerce", + "MERCU": "Merculet", + "MERCURY": "Mercury", + "MEREDITH": "Taylor Swift's Cat MEREDITH", + "MERG": "Merge Token", + "MERGE": "Merge", + "MERI": "Merebel", + "MERIDIAN": "Meridian Network LOCK", + "MERKLE": "Merkle Network", + "MERL": "Merlin Chain", + "MERLIN": "Oldest Raccoon", + "MERY": "Mistery On Cro", + "MESA": "MetaVisa", + "MESG": "MESG", + "MESH": "MeshBox", + "MESSI": "MESSI COIN", + "MESSU": "Loinel Messu", + "MET": "Meteora", + "META": "MetaDAO", + "METAA": "META ARENA", + "METABOT": "Robot Warriors", + "METABRAW": "Metabrawl", + "METAC": "Metacoin", + "METACA": "MetaCash", + "METACAT": "MetaCat", + "METACLOUD": "Metacloud", + "METACR": "Metacraft", + "METAD": "MetaDoge", + "METADIUM": "Metadium", + "METADOGE": "MetaDoge", + "METADOGEV1": "MetaDoge V1", + "METADOGEV2": "MetaDoge V2", + "METAF": "MetaFastest", + "METAFIGHTER": "MetaFighter", + "METAG": "MetagamZ", + "METAGEAR": "MetaGear", + "METAIVERSE": "MetAIverse", + "METAL": "Metal Blockchain", + "METALCOIN": "MetalCoin", + "METAMEME": "met a meta metameme", + "METAMUSK": "Musk Metaverse", + "METAN": "Metan Evolutions", + "METANIA": "METANIA V2", + "METANIAV1": "METANIAGAMES", + "METANO": "Metano", + "METAPK": "Metapocket", + "METAPLACE": "Metaplace", + "METAQ": "MetaQ", + "METAS": "Metaseer", + "METAT": "MetaTrace", + "METATI": "Metatime Coin", + "METATR": "MetaTrace Utility Token", + "METAUFO": "MetaUFO", + "METAV": "METAVERSE", + "METAV1": "META v1", + "METAVE": "Metaverse Convergence", + "METAVERSEM": "MetaVerse-M", + "METAVERSEX": "MetaverseX", + "METAVIE": "Metavie", + "METAVPAD": "MetaVPad", + "METAW": "MetaWorth", + "METAX": "Meta xStock", + "METEOR": "Meteorite Network", + "METFI": "MetFi", + "METH": "Mantle Staked Ether", + "METI": "Metis", + "METIS": "Metis Token", + "METM": "MetaMorph", + "METO": "Metafluence", + "METRO": "Metropoly", + "METRON": "Metronome", + "METRONV1": "Metronome", + "METYA": "Metya Token", + "MEU": "MetaUnit", + "MEV": "MEVerse", + "MEVETH": "mevETH", + "MEVR": "Metaverse VR", + "MEW": "cat in a dogs world", + "MEWC": "Meowcoin", + "MEWING": "MEWING", + "MEWSWIFHAT": "cats wif hats in a dogs world", + "MEWTWO": "Mewtwo Inu", + "MEX": "MEX", + "MEXC": "MEXC Token", + "MEXP": "MOJI Experience Points", + "MEY": "Mey Network", + "MEZZ": "MEZZ Token", + "MF": "Moonwalk Fitness", + "MF1": "Meta Finance", + "MFAM": "Moonwell Apollo", + "MFC": "MFCoin", + "MFER": "mfercoin", + "MFERS": "MFERS", + "MFET": "MultiFunctional Environmental Token", + "MFG": "SyncFab", + "MFI": "Marginswap", + "MFO": "Moonfarm Finance", + "MFPS": "Meta FPS", + "MFS": "Moonbase File System", + "MFT": "Hifi Finance (Old)", + "MFTM": "Fantom (Multichain)", + "MFTU": "Mainstream For The Underground", + "MFUN": "MemeMarket", + "MFUND": "Memefund", + "MFX": "MFChain", + "MG": "MinerGate Token", + "MG8": "Megalink", + "MGAMES": "Meme Games", + "MGAR": "Metagame Arena", + "MGC": "Meta Games Coin", + "MGD": "MassGrid", + "MGG": "MetaGaming Guild", + "MGGT": "Maggie Token", + "MGKL": "MAGIKAL.ai", + "MGLC": "MetaverseMGL", + "MGLD": "Metallurgy", + "MGN": "MagnaCoin", + "MGO": "Mango Network", + "MGOD": "MetaGods", + "MGP": "MangoChain", + "MGPT": "MotoGP Fan Token", + "MGT": "Moongate", + "MGUL": "Mogul Coin", + "MGX": "MargiX", + "MHAM": "Metahamster", + "MHC": "MetaHash", + "MHLX": "HelixNetwork", + "MHP": "MedicoHealth", + "MHRD": "MacroHard", + "MHT": "Mouse Haunt", + "MHUNT": "MetaShooter", + "MI": "XiaoMiCoin", + "MIA": "MiamiCoin", + "MIAO": "MIAOCoin", + "MIB": "Mobile Integrated Blockchain", + "MIBO": "miBoodle", + "MIBR": "MIBR Fan Token", + "MIC": "Mithril Cash", + "MICE": "Mice", + "MICHI": "michi", + "MICK": "Mickey Meme", + "MICKEY": "Steamboat Willie", + "MICRO": "Micro GPT", + "MICRODOGE": "MicroDoge", + "MICROMINES": "Micromines", + "MICROVISION": "MicroVisionChain", + "MIDAI": "Midway AI", + "MIDAS": "Midas", + "MIDASDOLLAR": "Midas Dollar Share", + "MIDLE": "Midle", + "MIDN": "Midnight", + "MIDNIGHT": "Midnight", + "MIDNIGHTVIP": "Midnight", + "MIE": "MIE Network", + "MIF": "monkeywifhat", + "MIG": "Migranet", + "MIGGLEI": "Migglei", + "MIGGLES": "Mr Miggles", + "MIGMIG": "MigMig Swap", + "MIH": "MINE COIN", + "MIHARU": "Smiling Dolphin", + "MIHV1": "MINE COIN v1", + "MIIDAS": "Miidas NFT", + "MIININGNFT": "MiningNFT", + "MIKE": "Mike", + "MIKS": "MIKS COIN", + "MIL": "Mil", + "MILA": "MILADY MEME TOKEN", + "MILC": "Micro Licensing Coin", + "MILE": "milestoneBased", + "MILEI": "MILEI", + "MILK": "MilkyWay", + "MILK2": "Spaceswap MILK2", + "MILKBAG": "MILKBAG", + "MILKSHAKE": "Milkshake Swap", + "MILKYWAY": "MilkyWayZone", + "MILLI": "Million", + "MILLIM": "Millimeter", + "MILLIMV1": "Millimeter v1", + "MILLLIONAIRECOIN": "Milllionaire Coin", + "MILLY": "milly", + "MILO": "Milo Inu", + "MILOCEO": "Milo CEO", + "MILOCOIN": "MiloCoin", + "MILODOG": "MILO DOG", + "MILOP": "MILO Project", + "MIM": "Magic Internet Money", + "MIMATIC": "MAI", + "MIMI": "MIMI Money", + "MIMIR": "Mimir", + "MIMO": "MIMO Parallel Governance Token", + "MIN": "MINDOL", + "MINA": "Mina Protocol", + "MINAR": "Miner Arena", + "MINC": "MinCoin", + "MIND": "Morpheus Labs", + "MINDBODY": "Mind Body Soul", + "MINDC": "MindCoin", + "MINDCOIN": "MindCoin", + "MINDEX": "Mindexcoin", + "MINDFAK": "Mindfak By Matt Furie", + "MINDGENE": "Mind Gene", + "MINDS": "Minds", + "MINDSYNC": "Mindsync", + "MINE": "SpaceMine", + "MINEA": "Mine AI", + "MINER": "MINER", + "MINERALS": "Minerals Coin", + "MINES": "MINESHIELD", + "MINETTE": "Vibe Cat", + "MINEX": "Minex", + "MINGO": "Mingo", + "MINI": "mini", + "MINIAPPS": "MiniApps", + "MINIBNBTIGER": "MiniBNBTiger", + "MINID": "Mini Donald", + "MINIDO": "MiniDoge", + "MINIDOGE": "MiniDOGE", + "MINIFOOTBALL": "Minifootball", + "MINIMYRO": "Mini Myro", + "MININEIRO": "Mini Neiro", + "MININGWATCHDOG": "Miningwatchdog Smartchain", + "MINION": "Minions INU", + "MINIONS": "Minions", + "MINIP": "MiniPepe Coin", + "MINIPEPE": "MiniPepe", + "MINIS": "Mini", + "MINISHIB": "miniSHIB ETH", + "MINO": "MINO INU", + "MINOCOINCTO": "MINO", + "MINS": "Minswap", + "MINT": "Mintify", + "MINTCHAIN": "Mint", + "MINTCOIN": "MintCoin", + "MINTE": "Minter HUB", + "MINTME": "MintMe.com Coin", + "MINTO": "The AI Mascot", + "MINTYS": "MintySwap", + "MINU": "Minu", + "MINUTE": "MINUTE Vault (NFTX)", + "MINX": "InnovaMinex", + "MIO": "Miner One token", + "MIODIO": "MIODIOCOIN", + "MIOTA": "IOTA", + "MIR": "Mirror Protocol", + "MIRA": "Mira", + "MIRACLE": "MIRACLE", + "MIRACLETELE": "Miracle Tele", + "MIRAI": "Project MIRAI", + "MIRAIBUILD": "MIRAI", + "MIRC": "MIR COIN", + "MIRROR": "Black Mirror", + "MIRT": "MIR Token", + "MIRX": "Mirada AI", + "MIS": "Mithril Share", + "MISA": "Sangkara", + "MISCOIN": "MIScoin", + "MISHA": "Vitalik's Dog", + "MISHKA": "Mishka Token", + "MISS": "MISS", + "MISSION": "MissionPawsible", + "MISSK": "Miss Kaka", + "MIST": "Mist", + "MISTCOIN": "MistCoin", + "MISTE": "Mister Miggles", + "MISTRAL": "Mistral AI", + "MIT": "Galaxy Blitz", + "MITC": "MusicLife", + "MITH": "Mithril", + "MITHRIL": "CLIMBERS", + "MITO": "Mitosis", + "MITTENS": "Mittens", + "MITX": "Morpheus Infrastructure Token", + "MIU": "MIU", + "MIUONSOL": "Miu", + "MIV": "MakeItViral", + "MIVA": "Minerva Wallet", + "MIVRS": "Minionverse", + "MIX": "MIXMARVEL", + "MIXAI": "Mixcash AI", + "MIXCOIN": "Mixaverse", + "MIXER": "TON Mixer", + "MIXIE": "Mixie", + "MIY": "Icel Idman Yurdu Token", + "MIZ": "Mizar", + "MJT": "MojitoSwap", + "MK": "Meme Kombat", + "MKC": "Meta Kongz", + "MKEY": "MEDIKEY", + "MKL": "Merkle Trade", + "MKONG": "MEME KONG", + "MKR": "Maker", + "MKT": "MikeToken", + "MKUSD": "Prisma mkUSD", + "ML": "Mintlayer", + "MLA": "Moola", + "MLC": "My Lovely Planet", + "MLD": "MonoLend", + "MLEO": "LEO Token (Multichain)", + "MLG": "360noscope420blazeit", + "MLGC": "Marshal Lion Group Coin", + "MLINK": "Chainlink (Multichain)", + "MLITE": "MeLite", + "MLK": "MiL.k", + "MLMX": "MLM X", + "MLN": "Enzyme", + "MLNK": "Malinka", + "MLOKY": "MLOKY", + "MLP": "Matrix Layer Protocol", + "MLS": "CPROP", + "MLT": "MIcro Licensing Coin", + "MLTC": "Litecoin (Multichain)", + "MLTPX": "MoonLift Capital", + "MLXC": "Marvellex Classic", + "MM": "MOMO.FUN", + "MMA": "Meme Alliance", + "MMAI": "MetamonkeyAi", + "MMAON": "MMAON", + "MMAPS": "MapMetrics", + "MMATIC": "Wrapped Polygon (Multichain)", + "MMC": "Monopoly Millionaire Control", + "MMDAO": "MMDAO", + "MMETA": "Duckie Land Multi Metaverse", + "MMF": "MMFinance", + "MMG": "Monopoly Millionaire Game", + "MMIP": "Memes Make It Possible", + "MMIT": "MangoMan Intelligent", + "MMNXT": "MMNXT", + "MMO": "MMOCoin", + "MMON": "Multiverse Monkey", + "MMPRO": "Market Making Pro", + "MMS": "Marsverse", + "MMSC": "MMSC PLATFORM", + "MMSS": "MMSS (Ordinals)", + "MMT": "Momentum", + "MMTM": "Momentum", + "MMUI": "MetaMUI", + "MMULTI": "Multichain (via Multichain Cross-Chain Router)", + "MMVG": "MEMEVENGERS", + "MMX": "MMX", + "MMXIV": "MaieutiCoin", + "MMXVI": "MMXVI", + "MMY": "Mummy Finance", + "MN": "Cryptsy Mining Contract", + "MNB": "MoneyBag", + "MNBR": "MN Bridge", + "MNC": "MainCoin", + "MND": "Mind", + "MNDCC": "Mondo Community Coin", + "MNDE": "Marinade", + "MNE": "Minereum", + "MNEE": "MNEE USD Stablecoin ", + "MNEMO": "Mnemonics", + "MNET": "MINE Network", + "MNFT": "Mongol NFT", + "MNFTS": "Marvelous NFTs", + "MNG": "Moon Nation Game", + "MNGO": "Mango protocol", + "MNI": "Map Node", + "MNM": "Mineum", + "MNR": "Mineral", + "MNRB": "MoneyRebel", + "MNRCH": "Monarch", + "MNRY": "Moonray", + "MNS": "Monnos", + "MNSRY": "MANSORY", + "MNST": "MoonStarter", + "MNTA": "MantaDAO", + "MNTC": "Manet Coin", + "MNTG": "Monetas", + "MNTIS": "Mantis", + "MNTL": "AssetMantle", + "MNTO": "Minato", + "MNTP": "GoldMint", + "MNTX": "Minutes Network Token", + "MNV": "MonetaVerde", + "MNVM": "Novam", + "MNW": "Morpheus Network", + "MNX": "MinexCoin", + "MNY": "MoonieNFT", + "MNZ": "Menzy", + "MO": "Morality", + "MOAC": "MOAC", + "MOAI": "MOAI", + "MOANER": "Moaner by Matt Furie", + "MOAR": "Moar Finance", + "MOAT": "Mother Of All Tokens", + "MOB": "MobileCoin", + "MOBI": "Mobius", + "MOBIC": "Mobility Coin", + "MOBIE": "MobieCoin", + "MOBILE": "Helium Mobile", + "MOBILEGO": "MobileGo", + "MOBIU": "Mobius Money", + "MOBU": "MOBU", + "MOBX": "MOBIX", + "MOBY": "Moby AI", + "MOBYONBASE": "Moby", + "MOBYONBASEV1": "Moby v1", + "MOC": "Mossland", + "MOCA": "Moca Coin", + "MOCHI": "Mochiswap", + "MOCHICAT": "MochiCat", + "MOCHIINU": "Mochi Inu", + "MOCK": "Mock Capital", + "MOCO": "MoCo", + "MOD": "Modefi", + "MODA": "MODA DAO", + "MODAI": "Modai", + "MODC": "Modclub", + "MODE": "Mode", + "MODEL": "Model Labs", + "MODERN": "bitcoin-modern", + "MODEX": "Modex", + "MODRX": "WisdomTree Siegel Moderate Digital Fund", + "MODU": "Modular Wallet", + "MODUM": "Modum", + "MODX": "MODEL-X-coin", + "MOE": "Merchant Moe", + "MOETA": "Moeta", + "MOEW": "donotfomoew", + "MOF": "Molecular Future (TRC20)", + "MOFI": "MobiFi", + "MOFOLD": "Molecular Future (ERC20)", + "MOG": "Mog Coin", + "MOGC": "MOG CAT", + "MOGCO": "Mog Coin (mogcoinspl.com)", + "MOGE": "Moge", + "MOGGO": "MOGGO", + "MOGP": "MOG PEPE", + "MOGT": "MOG TRUMP", + "MOGU": "Mogu", + "MOGUL": "Mogul Productions", + "MOGULV1": "Mogul Productions v1", + "MOGUT": "Mogutou", + "MOGX": "Mogu", + "MOH": "Medal of Honour", + "MOI": "MyOwnItem", + "MOIN": "MoinCoin", + "MOJI": "Moji", + "MOJO": "Planet Mojo", + "MOJOB": "Mojo on Base", + "MOJOCOIN": "Mojocoin", + "MOK": "MocktailSwap", + "MOL": "Molecule", + "MOLA": "MoonLana", + "MOLI": "Mobile Liquidity", + "MOLK": "Mobilink Token", + "MOLLARS": "MollarsToken", + "MOLLY": "Molly", + "MOLT": "Moltbook", + "MOM": "Mother of Memes", + "MOMA": "Mochi Market", + "MOMIJI": "MAGA Momiji", + "MOMO": "Momo", + "MOMO2": "MOMO 2.0", + "MOMO2025": "momo", + "MON": "Monad", + "MONA": "MonaCoin", + "MONAI": "MONAI", + "MONAIZE": "Monaize", + "MONARCH": "TRUEMONARCH", + "MONART": "Monart", + "MONAV": "Monavale", + "MONB": "MonbaseCoin", + "MONDO": "mondo", + "MONEROAI": "Monero AI", + "MONEROCHAN": "Monerochan", + "MONET": "Claude Monet Memeory Coin", + "MONETA": "Moneta", + "MONEY": "MoneyCoin", + "MONEYBEE": "MONEYBEE", + "MONEYBYTE": "MoneyByte", + "MONEYGOD": "Money God One", + "MONEYTOKEN": "MoneyToken", + "MONF": "Monfter", + "MONG": "MongCoin", + "MONG20": "Mongoose 2.0", + "MONGBNB": "MongBNB", + "MONGOOSE": "Mongoose", + "MONGY": "Mongy", + "MONI": "Monsta Infinite", + "MONIE": "Infiblue World", + "MONK": "Monkey Project", + "MONKAS": "Monkas", + "MONKE": "Monkecoin", + "MONKEY": "Monkey", + "MONKEYS": "Monkeys Token", + "MONKU": "Monku", + "MONKY": "Wise Monkey", + "MONO": "MonoX", + "MONOLITH": "Monolith", + "MONONOKEINU": "Mononoke Inu", + "MONOPOLY": "Meta Monopoly", + "MONPRO": "MON Protocol", + "MONS": "Monsters Clan", + "MONST": "Monstock", + "MONSTA": "Cake Monster", + "MONSTE": "Monster", + "MONT": "Monarch Token", + "MONTE": "Monte", + "MOO": "MooMonster", + "MOOBIFI": "Staked BIFI", + "MOOCAT": "MooCat", + "MOODENG": "Moo Deng (moodengsol.com)", + "MOODENGBNB": "MOODENG (moodengbnb.com)", + "MOODENGSBS": "Moo Deng (moodeng.sbs)", + "MOODENGSPACE": "MOO DENG", + "MOODENGVIP": "MOO DENG (moodeng.vip)", + "MOODENGWIF": "MOODENGWIF", + "MOOI": "Moonai", + "MOOLA": "Degen Forest", + "MOOLAH": "Moolah", + "MOOLYA": "moolyacoin", + "MOOMEME": "MOO MOO", + "MOOMOO": "MOOMOO THE BULL", + "MOON": "r/CryptoCurrency Moons", + "MOONARCH": "Moonarch", + "MOONB": "Moon Base", + "MOONBEANS": "Moonbeans", + "MOONBI": "Moonbix", + "MOONBIX": "MOONBIX MEME", + "MOONC": "MoonCoin", + "MOONCAT": "Mooncat", + "MOONCOIN": "Mooncoin", + "MOOND": "Dark Moon", + "MOONDAY": "Moonday Finance", + "MOONDO": "MOON DOGE", + "MOONDOGE": "MOONDOGE", + "MOONED": "MoonEdge", + "MOONER": "CoinMooner", + "MOONEY": "Moon DAO", + "MOONI": "MOON INU", + "MOONION": "Moonions", + "MOONKIZE": "MoonKize", + "MOONLIGHT": "Moonlight Token", + "MOONPIG": "Moonpig", + "MOONR": "PulseMoonR", + "MOONS": "Sailor Moons", + "MOONSHOT": "Moonshot", + "MOONSTAR": "MoonStar", + "MOONW": "moonwolf.io", + "MOOO": "Hashtagger", + "MOOR": "MOOR TOKEN", + "MOOV": "dotmoovs", + "MOOX": "Moox Protocol", + "MOOXV1": "Moox Protocol v1", + "MOPS": "Mops", + "MOR": "Morpheus", + "MORA": "Meliora", + "MORE": "Moonveil", + "MORECOIN": "More Coin", + "MOREGEN": "MoreGen FreeMoon", + "MORFEY": "Morfey", + "MORI": "MORI COIN", + "MOROS": "MOROS NET", + "MORPH": "Morpheus Token", + "MORPHIS": "MorphIS", + "MORPHO": "Morpho", + "MORRA": "Morra", + "MORSE": "Morse", + "MORTY": "Morty", + "MOS": "MOS Coin", + "MOSS": "MOSS AI", + "MOST": "MOST Global", + "MOT": "Mobius Token", + "MOTA": "MotaCoin", + "MOTG": "MetaOctagon", + "MOTH": "MOTH", + "MOTHER": "Mother Iggy", + "MOTI": "Motion", + "MOTION": "motion", + "MOTIONCOIN": "Motion", + "MOTO": "Motocoin", + "MOUND": "Mound Token", + "MOUNTA": "Mountain Protocol", + "MOUTAI": "Moutai", + "MOV": "MovieCoin", + "MOVD": "MOVE Network", + "MOVE": "Movement", + "MOVER": "Mover", + "MOVEUSD": "MoveMoney USD", + "MOVEY": "Movey", + "MOVEZ": "MoveZ", + "MOVON": "MovingOn Finance", + "MOVR": "Moonriver", + "MOW": "mouse in a cats world", + "MOWA": "Moniwar", + "MOXIE": "Moxie", + "MOYA": "MOYA", + "MOZ": "Lumoz token", + "MOZA": "Mozaic", + "MOZIK": "Mozik", + "MP": "MerlinSwap Token", + "MP3": "MP3", + "MPAA": "MPAA", + "MPAD": "MultiPad", + "MPAY": "Menapay", + "MPC": "Partisia Blockchain", + "MPD": "Metapad", + "MPG": "Max Property Group", + "MPH": "Morpher", + "MPI": "MetaPioneers", + "MPIX": "Megapix", + "MPL": "Maple", + "MPLUS": "M+Plus", + "MPLX": "Metaplex", + "MPM": "Monopoly Meta", + "MPRO": "MediumProject", + "MPS": "Mt Pelerin Shares", + "MPT": "Miracleplay Token", + "MPTV1": "Miracleplay Token v1", + "MPWR": "Empower", + "MPX": "Morphex", + "MPXT": "Myplacex", + "MQL": "MiraQle", + "MQST": "MonsterQuest", + "MR": "Meta Ruffy", + "MRB": "MoonRabbits", + "MRBASED": "MrBased", + "MRBEAST": "X Super Official CEO", + "MRBOB": "MR BOB COIN", + "MRCH": "MerchDAO", + "MRCR": "Mercor Finance", + "MRDN": "Meridian", + "MRF": "Moonradar.finance", + "MRFOX": "Mr.FOX Token", + "MRHB": "MarhabaDeFi", + "MRI": "Marshall Inu", + "MRK": "MARK.SPACE", + "MRKX": "Merck xStock", + "MRLIGHTSPEED": "Mr. Lightspeed Creator Coin", + "MRLN": "Merlin Token", + "MRM": "Mr Mint", + "MRN": "Mercoin", + "MRNA": "Moderna", + "MRP": "MorpheusCoin", + "MRPEPE": "Pepe Potato", + "MRS": "Metars Genesis", + "MRSA": "MrsaCoin", + "MRSMIGGLES": "Mrs Miggles", + "MRST": "Mars Token", + "MRT": "MinersReward", + "MRUN": "Metarun", + "MRV": "Macroverse", + "MRVLX": "Marvell xStock", + "MRX": "Metrix Coin", + "MRXB": "Wrapped BNB Metrix", + "MRXE": "Wrapped ETH Metrix", + "MRY": "MurrayCoin", + "MSA": "My Shiba Academia", + "MSB": "Misbloc", + "MSC": "Matrix SmartChain", + "MSCP": "Moonscape", + "MSCT": "MUSE ENT NFT", + "MSD": "MSD", + "MSFT": "Microsoft 6900", + "MSFTON": "Microsoft (Ondo Tokenized)", + "MSFTX": "Microsoft xStock", + "MSG": "MsgSender", + "MSGO": "MetaSetGO", + "MSHD": "MASHIDA", + "MSHEESHA": "Sheesha Finance Polygon", + "MSHIB": "Magic Shiba Starter", + "MSHIP": "MetaShipping", + "MSIA": "Messiah", + "MSN": "Meson.Network", + "MSOL": "Marinade Staked SOL", + "MSOT": "BTour Chain", + "MSP": "Mothership", + "MSPC": "MeowSpace", + "MSQ": "MSquare Global", + "MSR": "Masari", + "MST": "Idle Mystic", + "MSTABLEUSD": "mStable USD", + "MSTAR": "MerlinStarter", + "MSTETH": "Eigenpie mstETH", + "MSTO": "Millennium Sapphire", + "MSTRX": "MicroStrategy xStock", + "MSU": "MetaSoccer", + "MSUSHI": "Sushi (Multichain)", + "MSVP": "MetaSoilVerseProtocol", + "MSWAP": "MoneySwap", + "MT": "Mint Token", + "MTA": "Meta", + "MTB": "MetaBridge", + "MTBC": "Metabolic", + "MTC": "Matrix Chain", + "MTCMN": "MTC Mesh", + "MTCN": "Multiven", + "MTD": "Minted", + "MTEL": "MEDoctor", + "MTG": "MagnetGold", + "MTGT": "MTG Token", + "MTGX": "Montage Token", + "MTH": "Monetha", + "MTHB": "MTHAIBAHT", + "MTHD": "Method Finance", + "MTHN": "MTH Network", + "MTHT": "MetaHint", + "MTIK": "MatikaToken", + "MTIX": "Matrix Token", + "MTK": "Moya Token", + "MTL": "Metal", + "MTLM3": "Metal Music v3", + "MTLS": "eMetals", + "MTLV1": "Metal v1", + "MTLX": "Mettalex", + "MTMS": "MTMS Network", + "MTN": "TrackNetToken", + "MTO": "Merchant Token", + "MTOS": "MomoAI", + "MTP": "Multiple Network Token", + "MTPLF": "Metaplanet", + "MTR": "Meter Stable", + "MTRA": "MetaRare", + "MTRC": "ModulTrade", + "MTRG": "Meter", + "MTRK": "Matrak Fan Token", + "MTRM": "Materium", + "MTRX": "Metarix", + "MTS": "Metastrike", + "MTSH": "Mitoshi", + "MTSP": "Metasphere", + "MTT": "MulTra", + "MTTCOIN": "Money of Tommorow, Today", + "MTV": "MultiVAC", + "MTV1": "Mint Club", + "MTVT": "Metaverser", + "MTW": "Meta Space 2045", + "MTX": "Matryx", + "MTXLT": "Tixl", + "MTY": "Viddli", + "MTZ": "Monetizr", + "MU": "Miracle Universe", + "MUA": "MUA DAO", + "MUB": "Mubarak on Base", + "MUBA": "mubarak", + "MUBAR": "mubarak", + "MUBARAK": "mubarak", + "MUBARAKAH": "Mubarakah", + "MUBI": "Multibit", + "MUC": "Multi Universe Central", + "MUDOL2": "Hero Blaze: Three Kingdoms", + "MUDRA": "MudraCoin", + "MUE": "MonetaryUnit", + "MUES": "MuesliSwap MILK", + "MULTI": "Multichain", + "MULTIBOT": "Multibot", + "MULTIGAMES": "MultiGames", + "MULTIV": "Multiverse", + "MULTIWALLET": "MultiWallet Coin", + "MUMU": "Mumu", + "MUN": "MUNcoin", + "MUNCAT": "MUNCAT", + "MUNCH": "Munch Token", + "MUNCHY": "Boys Club Munchy", + "MUNDI": "Salvator Mundi", + "MUNI": "Uniswap Protocol Token (Multichain)", + "MUNITY": "Metahorse Unity", + "MUNK": "Dramatic Chipmunk", + "MUNSUN": "MUNSUN", + "MUON": "Micron Technology (Ondo Tokenized)", + "MURA": "Murasaki", + "MURATIAI": "MuratiAI", + "MUSCAT": "MusCat", + "MUSD": "MetaMask USD", + "MUSDC": "USD Coin (Multichain)", + "MUSDCOIN": "MUSDcoin", + "MUSE": "Muse DAO", + "MUSIC": "Gala Music", + "MUSICAI": "MusicAI", + "MUSICOIN": "Musicoin", + "MUSK": "Musk", + "MUSKAI": "Musk AI Agent", + "MUSKIT": "Musk It", + "MUSKMEME": "MUSK MEME", + "MUSKVSZUCK": "Cage Match", + "MUST": "MUST Protocol", + "MUSTANGC": "MustangCoin", + "MUT": "Mutual Coin", + "MUTE": "Mute", + "MUU": "MilkCoin", + "MUZKI": "Muzki", + "MUZZ": "MuzzleToken", + "MV": "GensoKishi Metaverse", + "MVC": "MileVerse", + "MVD": "Metavault", + "MVDG": "MetaVerse Dog", + "MVEDA": "MedicalVeda", + "MVERSE": "MindVerse", + "MVG": "Mad Viking Games", + "MVI": "Metaverse Index", + "MVL": "MVL", + "MVOYA": "VOYA (Merlin Bridge)", + "MVP": "MAGA VP", + "MVPC": "MVP Coin", + "MVRS": "Meta MVRS", + "MVS": "Multiverse", + "MVU": "meVu", + "MVX": "Metavault Trade", + "MW": "MasterWin Coin", + "MWAR": "MemeWars (MWAR)", + "MWAT": "RED MegaWatt", + "MWAVE": "MeshWave", + "MWC": "MimbleWimbleCoin", + "MWCC": "Metaworld", + "MWD": "MEW WOOF DAO", + "MWETH": "Moonwell Flagship ETH (Morpho Vault)", + "MWH": "Melania Wif Hat", + "MWT": "Mountain Wolf Token", + "MWXT": "MWX Token", + "MX": "MX Token", + "MXC": "MXC Token", + "MXCV1": "Machine Xchange Coin v1", + "MXD": "Denarius", + "MXGP": "MXGP Fan Token", + "MXM": "Maximine", + "MXNA": "Machina", + "MXNB": "MXNB", + "MXNBC": "Rekt Burgundy by Virtuals", + "MXNT": "Tether MXNt", + "MXRP": "Monsta XRP", + "MXT": "MixTrust", + "MXTC": "MartexCoin", + "MXW": "Maxonrow", + "MXX": "Multiplier", + "MXZ": "Maximus Coin", + "MYB": "MyBit", + "MYC": "Mycelium", + "MYCE": "MY Ceremonial Event", + "MYCELIUM": "Mycelium Token", + "MYDFS": "MyDFS", + "MYID": "My Identity Coin", + "MYL": "MyLottoCoin", + "MYLINX": "Linx", + "MYLO": "MYLOCAT", + "MYMASTERWAR": "My Master Wa", + "MYNE": "ITSMYNE", + "MYO": "Mycro", + "MYOBU": "Myōbu", + "MYRA": "Mytheria", + "MYRC": "MYRC", + "MYRE": "Myre", + "MYRIA": "Myria", + "MYRO": "Myro", + "MYRODRAGON": "MYRO DRAGON", + "MYROO": "Myro Dog", + "MYROWIF": "MYROWIF", + "MYST": "Mysterium", + "MYSTERY": "Mystery", + "MYT": "Mytrade", + "MYTH": "Mythos", + "MYTHTOKEN": "Myth Token", + "MYTOKEN": "MyToken", + "MYTV": "MyTVchain", + "MYX": "MYX Finance", + "MZC": "MazaCoin", + "MZERO": "MetaZero", + "MZG": "Moozicore", + "MZK": "Muzika Network", + "MZM": "MetaZooMee", + "MZR": "Mazuri GameFi", + "MZX": "Mosaic Network", + "Medu": "Medusa", + "N0031": "nYFI", + "N1": "NFTify", + "N3": "Network3", + "N3DR": "NeorderDAO ", + "N3ON": "N3on", + "N4T": "Nobel For Trump", + "N64": "N64", + "N7": "Number7", + "N8V": "NativeCoin", + "NABOX": "Nabox", + "NAC": "Nirvana Chain", + "NACHO": "Nacho the 𐤊at", + "NADA": "NADA Protocol Token", + "NAFT": "Nafter", + "NAGANO": "nagano", + "NAH": "Strayacoin", + "NAI": "Nuklai", + "NAIIVE": "Naiive", + "NAILONG": "Nailong", + "NAIT": "Node AI Token", + "NAKA": "Nakamoto Games", + "NAKAV1": "Nakamoto Games v1", + "NALA": "NALA", + "NALS": "NALS (Ordinals)", + "NAM": "Namacoin", + "NAME": "PolkaDomain", + "NAMEC": "Name Change Token", + "NAMI": "Tsunami finance", + "NAMO": "NamoCoin", + "NAN": "NanoToken", + "NANA": "Bananace", + "NANAS": "BananaBits", + "NANJ": "NANJCOIN", + "NANO": "Nano", + "NAO": "Nettensor", + "NAORIS": "Naoris Protocol", + "NAOS": "NAOS Finance", + "NAP": "Napoli Fan Token", + "NARCO": "Mr. Narco", + "NARS": "Num ARS v2", + "NAS": "Nebulas", + "NAS2": "Nas2Coin", + "NASADOGE": "Nasa Doge", + "NASDAQ420": "Nasdaq420", + "NASH": "NeoWorld Cash", + "NASSR": "Alnassr FC Fan Token", + "NASTR": "Liquid ASTR", + "NAT": "Natmin", + "NATI": "IlluminatiCoin", + "NATION": "Nation3", + "NATIX": "NATIX Network", + "NATO": "The Nation Token", + "NATOR": "Pepenator", + "NAUSICAA": "Nausicaa-Inu", + "NAUT": "Nautilus Coin", + "NAV": "NavCoin", + "NAVAL": "NAVAL AI", + "NAVC": "NavC token", + "NAVI": "Atlas Navi", + "NAVIA": "NaviAddress", + "NAVIB": "Navibration", + "NAVX": "NAVI Protocol", + "NAVY": "BoatPilot Token", + "NAWA": "Narwhale.finance", + "NAWS": "NAWS.AI", + "NAX": "NextDAO", + "NAYM": "NAYM", + "NAYUTA": "Nayuta Coin", + "NAZ": "NAZDAQ", + "NAZA": "NAZA", + "NAZAR": "NAZAR PROTOCOL", + "NAZIELON": "NAZI ELON", + "NB": "Nubila Network", + "NBABSC": "NBA BSC", + "NBAI": "Nebula AI", + "NBAR": "NOBAR", + "NBC": "Niobium", + "NBD": "Never Back Down", + "NBIT": "NetBit", + "NBL": "Nobility", + "NBLU": "NuriTopia", + "NBNG": "Nobunaga Token", + "NBOT": "Naka Bodhi Token", + "NBOX": "Unboxed", + "NBP": "NFTBomb", + "NBR": "Niobio Cash", + "NBS": "New BitShares", + "NBT": "NanoByte", + "NBXC": "Nibble", + "NC": "Nodecoin", + "NCA": "NeuroCrypto Ads", + "NCASH": "Nucleus Vision", + "NCAT": "Neuracat", + "NCC": "NeuroChain", + "NCDT": "Nuco.Cloud", + "NCN": "NeurochainAI", + "NCO": "Nexacore", + "NCOIN": "NatronZ", + "NCOP": "NCOP", + "NCOR": "NovaCore", + "NCORAI": "NeoCortexAI", + "NCOV": "CoronaCoin", + "NCP": "Newton Coin", + "NCR": "Neos Credits", + "NCT": "PolySwarm", + "NCTR": "Nectar", + "ND": "Nemesis Downfall", + "NDAU": "ndau", + "NDB": "NDB", + "NDC": "NeverDie", + "NDLC": "NeedleCoin", + "NDN": "NDN Link", + "NDOGE": "NinjaDoge", + "NDQ": "Nasdaq666", + "NDR": "Node Runners", + "NDS": "NodeStation AI", + "NDX": "Indexed Finance", + "NEADRAM": "The Ennead", + "NEAL": "Coineal Token", + "NEAR": "Near", + "NEARK": "NearKat", + "NEARX": "Stader NearX", + "NEAT": "NEAT", + "NEBL": "Neblio", + "NEBNB": "Neuro BNB", + "NEBU": "Nebuchadnezzar", + "NEC": "Nectar", + "NEER": "Metaverse.Network Pioneer", + "NEET": "Not in Employment, Education, or Training", + "NEETCOIN": "Neetcoin", + "NEETFINANCE": "NEET Finance", + "NEF": "NefariousCoin", + "NEFTIPEDIA": "NEFTiPEDiA", + "NEFTY": "NeftyBlocks", + "NEGED": "Neged", + "NEI": "Neurashi", + "NEILUO": "CHINESE NEIRO", + "NEINEI": "Chinese Neiro", + "NEIREI": "NeiRei", + "NEIRO": "Neiro", + "NEIROC": "Neirocoin (neirocoin.club)", + "NEIROCOIN": "Neiro Ethereum", + "NEIROH": "NeiroWifHat", + "NEIROINU": "Neiro Inu", + "NEIROLIVE": "Neiro", + "NEIROLOL": "Neiro", + "NEIROONB": "Neiro on Base", + "NEKI": "Neki Token", + "NEKO": "NeonNeko", + "NEKOARC": "Neko Arc", + "NEKOIN": "Nekoin", + "NEKOS": "Nekocoin", + "NEKTAR": "Nektar Token", + "NEMO": "NEMO", + "NEMS": "The Nemesis", + "NEO": "NEO", + "NEOG": "NEO Gold", + "NEOK": "NEOKingdom DAO", + "NEOM": "New Earth Order Money", + "NEON": "Neon EVM", + "NEONAI": "NeonAI", + "NEOS": "NeosCoin", + "NEOX": "Neoxa", + "NEPT": "Metanept", + "NERD": "Nerd Bot", + "NERDS": "NERDS", + "NERF": "Neural Radiance Field", + "NERO": "NERO Chain", + "NEROTOKEN": "Nero Token", + "NERVE": "NERVE", + "NES": "Nest AI", + "NESS": "Ness LAB", + "NEST": "Nest Protocol", + "NESTREE": "Nestree", + "NESTV1": "Nest Protocol v1", + "NET": "NET", + "NETA": "Negative Tax", + "NETC": "NetworkCoin", + "NETCOI": "NetCoin", + "NETCOIN": "Netcoincapital", + "NETCOINV1": "Netcoincapital v1", + "NETK": "Netkoin", + "NETKO": "Netko", + "NETRUM": "Netrum", + "NETT": "Netswap", + "NETVR": "Netvrk", + "NETX": "NetX Token", + "NETZ": "MainnetZ", + "NETZ1": "NETZERO", + "NEU": "Neumark", + "NEUR": "neur.sh", + "NEURA": "Neurahub", + "NEURAL": "NeuralAI", + "NEURALINK": "Neuralink", + "NEURO": "NeuroWeb", + "NEURON": "Cerebrum DAO", + "NEURONI": "Neuroni AI", + "NEUROS": "Shockwaves", + "NEUTR": "Neutrinos", + "NEUTRO": "Neutro Protocol", + "NEUTRON": "Neutron", + "NEV": "NEVER SURRENDER", + "NEVA": "NevaCoin", + "NEVANETWORK": "Neva", + "NEVE": "NEVER SURRENDER", + "NEVER": "neversol", + "NEWB": "Newbium", + "NEWBV1": "Newbium v1", + "NEWC": "New Cat", + "NEWERASOL": "New Era AI", + "NEWG": "NewGold", + "NEWM": "NEWM", + "NEWO": "New Order", + "NEWOS": "NewsToken", + "NEWP": "New Peon", + "NEWS": "PUBLISH", + "NEWSL": "Newsly", + "NEWSTOKENS": "NewsTokens", + "NEWT": "Newton Protocol", + "NEWTON": "Newtonium", + "NEWYORKCOIN": "NewYorkCoin", + "NEX": "Nash Exchange", + "NEXA": "Nexa", + "NEXAI": "NexAI", + "NEXBOX": "NexBox", + "NEXBT": "Native XBTPro Exchange Token", + "NEXD": "Nexade", + "NEXEA": "NEXEA", + "NEXG": "NexGami", + "NEXM": "Nexum", + "NEXMI": "NexMillionaires", + "NEXMS": "NexMillionaires", + "NEXO": "NEXO", + "NEXOR": "Nexora", + "NEXT": "Connext Network", + "NEXTEX": "Next.exchange Token", + "NEXTEXV1": "Next.exchange Token v1", + "NEXTV1": "Connext Network", + "NEXUS": "Nexus", + "NEXUSAI": "NexusAI", + "NEXXO": "Nexxo", + "NEZHA": "NEZHA", + "NEZHATOKEN": "NezhaToken", + "NFAI": "Not Financial Advice", + "NFAIV1": "Not Financial Advice v1", + "NFCR": "NFCore", + "NFD": "Feisty Doge NFT", + "NFE": "Edu3Labs", + "NFLXON": "Netflix (Ondo Tokenized)", + "NFLXX": "Netflix xStock", + "NFM": "NFMart", + "NFN": "Nafen", + "NFNT": "NFINITY AI", + "NFP": "NFPrompt", + "NFPV1": "Token NFPrompt Token v1", + "NFT": "APENFT", + "NFT11": "NFT11", + "NFTART": "NFT Art Finance", + "NFTB": "NFTb", + "NFTBS": "NFTBooks", + "NFTCHAMPIONS": "NFT Champions", + "NFTD": "NFTrade", + "NFTE": "NFTEarthOFT", + "NFTFI": "NFTfi", + "NFTI": "NFT Index", + "NFTL": "NFTLaunch", + "NFTLOOT": "NFTLootBox", + "NFTM": "NFTMart Token", + "NFTN": "NFTNetwork", + "NFTPROTOCOL": "NFT", + "NFTS": "NFT STARS", + "NFTSTYLE": "NFTStyle", + "NFTX": "NFTX", + "NFTXHI": "NFTX Hashmasks Index", + "NFTY": "NFTY Token", + "NFTYP": "NFTY DeFi Protocol", + "NFUP": "Natural Farm Union Protocol", + "NFX": "Nova Fox", + "NFXC": "NFX Coin", + "NFY": "Non-Fungible Yearn", + "NGA": "NGA Tiger", + "NGC": "NagaCoin", + "NGIN": "Ngin", + "NGL": "Entangle", + "NGM": "e-Money", + "NGMI": "NGMI Coin", + "NGNT": "Naira Token", + "NGTG": "NUGGET TRAP", + "NHCT": "Nano Healthcare Token", + "NHI": "Non Human Intelligence", + "NHT": "Neighbourhoods", + "NIANNIAN": "NianNian", + "NIAO": "NIAO", + "NIBBLES": "Nibbles", + "NIBI": "Nibiru Chain", + "NIC": "NewInvestCoin", + "NICE": "Nice", + "NICEC": "NiceCoin", + "NIETZSCHEAN": "Nietzschean Penguin", + "NIF": "Unifty", + "NIFT": "Niftify", + "NIFTSY": "Envelop", + "NIFTY": "Nifty Wizards Dust", + "NIFTYL": "Nifty League", + "NIGELLA": "Nigella coin", + "NIGHT": "Midnight", + "NIGI": "Nigi", + "NIH": "Nihao coin", + "NIHAO": "NiHao", + "NII": "nahmii", + "NIIFI": "NiiFi", + "NIK": "NIKPLACE", + "NIKO": "NikolAI", + "NIL": "Nillion", + "NILA": "MindWave", + "NILE": "Nile", + "NIM": "Nimiq", + "NIMBUS": "Nimbus AI", + "NIMFA": "Nimfamoney", + "NIN": "Next Innovation", + "NINA": "NINA", + "NINJ": "Ninja Protocol", + "NINJA": "Dog Wif Nunchucks", + "NINJACAT": "NinjaCat", + "NINJAZ": "Danketsu", + "NINKY": "Ninky", + "NINO": "Ninneko", + "NINU": "Nvidia Inu", + "NIOB": "Niob Finance", + "NIOCTIB": "nioctiB", + "NIOON": "NIO (Ondo Tokenized)", + "NIOX": "Autonio", + "NIOXV1": "Autonio v1", + "NIOXV2": "Autonio v2", + "NIPPY": "Cat On Catnip", + "NIQAB": "NIQAB WORLD ORDER", + "NIRV": "Nirvana NIRV", + "NIRVA": "Nirvana", + "NIT": "Nesten", + "NITEFEEDER": "Nitefeeder", + "NITO": "Nitroken", + "NITRO": "Nitro League", + "NITROE": "NitroEX", + "NITROFROG": "Nitro", + "NITROG": "Nitro", + "NIX": "NIX", + "NIZA": "Niza Global", + "NKA": "IncaKoin", + "NKC": "Nukecoinz", + "NKCLC": "NKCL Classic", + "NKN": "NKN", + "NKT": "NakomotoDark", + "NKYC": "NKYC Token", + "NLC": "Nelore Coin", + "NLC2": "NoLimitCoin", + "NLG": "Gulden", + "NLINK": "Neuralink", + "NLK": "NuLink", + "NLS": "Nolus", + "NLX": "Nullex", + "NMB": "Nimbus Coin", + "NMBTC": "NanoMeter Bitcoin", + "NMC": "Namecoin", + "NMD": "Nexusmind", + "NMH": "Namahe", + "NMK": "Namek", + "NMKR": "NMKR", + "NML": "No Mans Land", + "NMR": "Numeraire", + "NMS": "Numus", + "NMSP": "Nemesis PRO", + "NMT": "NetMind Token", + "NMX": "Nominex Token", + "NNB": "NNB Token", + "NNC": "NEO Name Credit", + "NNI": "NeoNomad Exchange", + "NNN": "Novem Gold", + "NNT": "Nunu Spirits", + "NOA": "NOA PLAY", + "NOAH": "NOAHCOIN", + "NOBIKO": "Longcat", + "NOBL": "NobleCoin", + "NOBODY": "Nobody Sausage", + "NOBS": "No BS Crypto", + "NOC": "Nono Coin", + "NOCHILL": "AVAX HAS NO CHILL", + "NOCK": "Nockchain", + "NODE": "NodeOps", + "NODELYAI": "NodelyAI", + "NODESYNAPSE": "NodeSynapse", + "NODIDDY": "NODIDDY", + "NODIS": "Nodis", + "NODL": "Nodle Network", + "NOEL": "AskNoel", + "NOGS": "Noggles", + "NOHAT": "DogWifNoHat", + "NOIA": "Syntropy", + "NOICE": "noice", + "NOIS": "Nois Network", + "NOIZ": "NOIZ", + "NOKA": "Noka Solana AI", + "NOKU": "NOKU Master token", + "NOKUV1": "NOKU Master token v1", + "NOL": "NORDO MILE", + "NOLA": "Nola", + "NOM": "Nomina", + "NOMAI": "nomAI by Virtuals", + "NOMNOM": "nomnom", + "NOMOX": "NOMOEX Token", + "NONE": "None Trading", + "NOO": "Noocoin", + "NOOB": "Blast Royale", + "NOODS": "Noods", + "NOOOO": "NOOOO", + "NOOT": "NOOT (Ordinals)", + "NOPAIN": "No Pain No Gain", + "NOR": "Noir", + "NORA": "SnowCrash Token", + "NORD": "Nord Finance", + "NORDO": "Greenland Rare Bear", + "NORMIE": "Normie", + "NORMUS": "NORMUS", + "NOS": "Nosana", + "NOSN": "nOS", + "NOSO": "Noso", + "NOT": "Notcoin", + "NOTAI": "NOTAI", + "NOTALION": "Not a lion, a...", + "NOTC": "NOT", + "NOTDOG": "NOTDOG", + "NOTE": "Republic Note", + "NOTECANTO": "Note", + "NOTHING": "Youll own nothing & be happy", + "NOTHINGCASH": "NOTHING", + "NOTIFAI": "NotifAi News", + "NOTINU": "NOTCOIN INU", + "NOTIONAL": "Notional Finance", + "NOV": "Novara Calcio Fan Token", + "NOVA": "Nova Finance", + "NOVAAI": "Nova AI", + "NOW": "NOW Token", + "NOX": "NITRO", + "NOXB": "Noxbox", + "NPAS": "New Paradigm Assets Solution", + "NPC": "Non-Playable Coin", + "NPCC": "NPCcoin", + "NPCS": "Non-Playable Coin Solana", + "NPER": "NPER", + "NPICK": "NPICK BLOCK", + "NPLC": "Plus Coin", + "NPLCV1": "PlusCoin v1", + "NPM": "Neptune Mutual", + "NPRO": "NPRO", + "NPT": "Neopin", + "NPTX": "NeptuneX", + "NPX": "Napoleon X", + "NPXS": "Pundi X", + "NPXSXEM": "Pundi X NEM", + "NR1": "Number 1 Token", + "NRB": "NoirBits", + "NRC": "Neurocoin", + "NRCH": "EnreachDAO", + "NRFB": "NuriFootBall", + "NRG": "Energi", + "NRGE": "New Resources Generation Energy", + "NRGY": "NRGY Defi", + "NRK": "Nordek", + "NRM": "Neuromachine", + "NRN": "Neuron", + "NRO": "Neuro", + "NRP": "Neural Protocol", + "NRS": "NoirShares", + "NRV": "Nerve Finance", + "NRVE": "Narrative", + "NRX": "Neironix", + "NS": "SuiNS Token", + "NS2DRP": "New Silver Series 2 DROP", + "NSBT": "Neutrino Token", + "NSD": "Nasdacoin", + "NSDX": "NASDEX", + "NSFW": "xxxNifty", + "NSH": "NOSHIT", + "NSI": "nSights DeFi Trader", + "NSIMPSON": "NeuraSimpson", + "NSK": "NSKSwap", + "NSO": "NeverSurrenderOne's", + "NSP": "NOMAD.space", + "NSR": "NuShares", + "NSS": "NSS Coin", + "NST": "Ninja Squad Token", + "NSTE": "NewSolution 2.0", + "NSTK": "Unstake", + "NSTR": "Nostra", + "NSUR": "NSUR Coin", + "NSURE": "Nsure Network", + "NT": "NEXTYPE Finance", + "NTB": "TokenAsset", + "NTBC": "Note Blockchain", + "NTC": "NineElevenTruthCoin", + "NTCC": "NeptuneClassic", + "NTD": "Neural Tensor Dynamics", + "NTG": "NEWTOWNGAMING", + "NTK": "Neurotoken", + "NTM": "NetM", + "NTMPI": "Neutaro", + "NTO": "Neton", + "NTR": "Nether", + "NTRN": "Neutron", + "NTS": "Notarised", + "NTV": "NativToken", + "NTVRK": "Netvrk", + "NTWK": "Network Token", + "NTX": "NuNet", + "NTY": "Nexty", + "NU": "NuCypher", + "NUA": "Neulaut Token", + "NUB": " nubcat", + "NUBIS": "NubisCoin", + "NUC": "NuCoin", + "NUDE": "0xNude", + "NUDES": "NUDES", + "NUGGET": "Gegagedigedagedago", + "NUKE": "NukeCoin", + "NULS": "Nuls", + "NUM": "Numbers Protocol", + "NUMBERS": "NumbersCoin", + "NUMI": "NUMINE Token", + "NUMITOR": "Numitor", + "NUNU": "nunu", + "NUR": "Nurcoin", + "NURA": "Nura Labs", + "NUSA": "Nusa", + "NUSD": "Nomin USD", + "NUT": "Native Utility Token", + "NUTC": "Nutcash", + "NUTGV2": "NUTGAIN", + "NUTS": "Thetanuts Finance", + "NUTSDAO": "NutsDAO", + "NUTZ": "NUTZ", + "NUUM": "MNet", + "NUX": "Peanut", + "NVA": "Neeva Defi", + "NVB": "NovaBank", + "NVC": "NovaCoin", + "NVDAON": "NVIDIA (Ondo Tokenized)", + "NVDAX": "NVIDIA xStock", + "NVDX": "Nodvix", + "NVG": "NightVerse Game", + "NVG8": "Navigate", + "NVIR": "NvirWorld", + "NVL": "Nevula", + "NVOX": "Novo Nordisk xStock", + "NVOY": "Envoy", + "NVS": "Navis", + "NVST": "NVO", + "NVT": "NerveNetwork", + "NVX": "Novax Coin", + "NVZN": "INVIZION", + "NWC": "Numerico", + "NWCN": "NowCoin", + "NWG": "NotWifGary", + "NWIF": "neirowifhat", + "NWP": "NWPSolution", + "NWS": "Nodewaves", + "NXA": "NEXA Agent", + "NXC": "Nexium", + "NXD": "Nexus Dubai", + "NXDT": "NXD Next", + "NXE": "NXEcoin", + "NXM": "Nexus Mutual", + "NXMC": "NextMindCoin", + "NXN": "Naxion", + "NXPC": "NXPC", + "NXQ": "NexQloud", + "NXRA": "AllianceBlock Nexera", + "NXS": "Nexus", + "NXT": "Nxt", + "NXTI": "NXTI", + "NXTT": "Next Earth", + "NXTTY": "NXTTY", + "NYA": "Nya", + "NYAN": "NYAN", + "NYANCOIN": "NyanCoin", + "NYANDOGE": "NyanDOGE International", + "NYANTE": "Nyantereum International", + "NYBBLE": "Nybble", + "NYC": "NYC", + "NYCREC": "NYCREC", + "NYE": "NewYork Exchange", + "NYEX": "Nyerium", + "NYM": "Nym Token", + "NYN": "NYNJA", + "NYS": "node.sys", + "NYX": "NYXCOIN", + "NYXC": "Nyxia AI", + "NYZO": "Nyzo", + "NZC": "NewZealandCoin", + "NZDX": "eToro New Zealand Dollar", + "NZE": "Nagezeni", + "NZL": "Zealium", + "NZO": "NonZero", + "O": "Childhoods End", + "O1INCH": "1inch (Optimism Bridge)", + "O3": "O3 Swap", + "O4DX": "O4DX", + "OAK": "Acorn Collective", + "OAS": "Oasis City", + "OASC": "Oasis City", + "OASI": "Oasis Metaverse", + "OASIS": "OASIS", + "OASISPLATFORM": "Oasis", + "OAT": "OAT Network", + "OATH": "OATH Protocol", + "OAX": "Oax", + "OB1INCH": "1inch (OmniBridge)", + "OBABYTRUMP": "Official Baby Trump", + "OBEMA": "burek obema", + "OBI": "Orbofi AI", + "OBICOIN": "OBI Real Estate", + "OBITS": "Obits Coin", + "OBOL": "Obol Network", + "OBOT": "Obortech", + "OBROK": "OBRok", + "OBS": "One Basis Cash", + "OBSCURE": "Obscurebay", + "OBSI": "Obsidium", + "OBSR": "OBSERVER Coin", + "OBSUSHI": "Sushi (OmniBridge)", + "OBT": "Orbiter Token", + "OBTC": "Obitan Chain", + "OBVIOUS": "OBVIOUS COIN", + "OBX": "OpenBlox", + "OC": "OrangeCoin", + "OCADA": "OCADA.AI", + "OCAI": "Onchain AI", + "OCAVU": "Ocavu Network Token", + "OCB": "OneCoinBuy", + "OCC": "OccamFi", + "OCD": "On-Chain Dynamics", + "OCE": "OceanEX Token", + "OCEAN": "Ocean Protocol", + "OCEANT": "Poseidon Foundation", + "OCEANV1": "Ocean Protocol v1", + "OCH": "Orchai", + "OCICAT": "OciCat", + "OCL": "Oceanlab", + "OCN": "Odyssey", + "OCNEST": "OcNest AI", + "OCO": "Owners Casino Online", + "OCP": "Omni Consumer Protocols", + "OCPR": "OC Protocol", + "OCRV": "Curve DAO Token (OmniBridge)", + "OCT": "Octopus Network", + "OCTA": "OctaSpace", + "OCTAGON": "POLYDeFI", + "OCTAVUS": "Octavus Prime", + "OCTAX": "OctaX", + "OCTI": "Oction", + "OCTO": "OctonetAI", + "OCTOCOIN": "Octocoin", + "OCTOF": "OctoFi", + "OCTOIN": "Octoin Coin", + "OCW": "Online Cold Wallet", + "OCX": "Original Crypto Coin", + "ODAI": "Dai (Optimism Bridge)", + "ODC": "Overseas Direct Certification", + "ODDZ": "Oddz", + "ODE": "ODEM", + "ODGN": "OrdiGen", + "ODIN": "Odin Protocol", + "ODMC": "ODMCoin", + "ODN": "Obsidian", + "ODNT": "Old Dogs New Tricks", + "ODOS": "Odos", + "ODS": "Odesis", + "ODX": "ODX Token", + "ODYS": "OdysseyWallet", + "OETHER": "Origin Ether", + "OEX": "OEX", + "OF": "OFCOIN", + "OFBC": "OneFinBank Coin", + "OFC": "$OFC Coin", + "OFCR": "CryptoPolice", + "OFE": "Ofero", + "OFF": "BlastOff", + "OFFI": "Official Elon Coin", + "OFFIC": "OFFICIAL SIMPSON", + "OFFICI": "OFFICIAL BARRON", + "OFFICIA": "Official Elon Coin", + "OFFICIALUSA": "Official USA Token", + "OFINTOKEN": "OFIN Token", + "OFN": "Openfabric AI", + "OFT": "ONFA", + "OG": "OG Fan Token", + "OGC": "OGCommunity", + "OGCINU": "The OG Cheems Inu", + "OGD": "OLYMPIC GAMES DOGE", + "OGGIE": "Oggie", + "OGGY": "Oggy Inu", + "OGLG": "OGLONG", + "OGM": "OG Mickey", + "OGN": "Origin Protocol", + "OGO": "Origo", + "OGOD": "GOTOGOD", + "OGPU": "OPEN GPU", + "OGSM": "OGSMINEM", + "OGSP": "OriginSport", + "OGT": "One Game", + "OGV": "Origin Dollar Governance", + "OGY": "ORIGYN", + "OGZ": "OGzClub", + "OH": "Oh! Finance", + "OHANDY": "Orbit Bridge Klaytn Handy", + "OHM": "Olympus", + "OHMV2": "Olympus v2", + "OHNO": "Oh no", + "OHNOGG": "OHNHO (ohno.gg)", + "OHO": "OHO", + "OICOIN": "Osmium Investment Coin", + "OIIAOIIA": "spinning cat", + "OIK": "Space Nation", + "OIL": "Oiler", + "OILD": "OilWellCoin", + "OILX": "OilX Token", + "OIN": "OIN Finance", + "OIO": "Online", + "OJA": "Ojamu", + "OJX": "Ojooo", + "OK": "OKCash", + "OKANE": "OKANE", + "OKAYEG": "Okayeg", + "OKB": "OKB", + "OKG": "Ookeenga", + "OKINAMI": "Kanagawa Nami", + "OKLP": "OkLetsPlay", + "OKOIN": "OKOIN", + "OKS": "Oikos", + "OKSE": "Okse", + "OKT": "OKT Chain", + "OL": "Open Loot", + "OLA": "Ola", + "OLAF": "Olaf Token", + "OLAND": "Oceanland", + "OLAS": "Autonolas", + "OLD": "Old Trump", + "OLDSF": "OldSafeCoin", + "OLE": "OpenLeverage", + "OLEA": "Olea Token", + "OLEV1": "OpenLeverage v1", + "OLIVE": "Olive", + "OLIVIA": "AIGOV", + "OLOID": "OLOID", + "OLT": "OneLedger", + "OLV": "OldV", + "OLXA": "OLXA", + "OLY": "Olyseum", + "OLYMP": "OlympCoin", + "OLYMPE": "OLYMPÉ", + "OLYMPUSLABS": "Olympus Labs", + "OLYN": "Olyn by Virtuals", + "OM": "MANTRA", + "OMA": "OmegaCoin", + "OMALLEY": "O'Malley", + "OMAX": "Omax", + "OMAXV1": "Omax v1", + "OMC": "Omchain", + "OMD": "OneMillionDollars", + "OME": "o-mee", + "OMEGA": "OMEGA", + "OMEGAX": "Ome‎gaX He‎alth", + "OMFG": "Omnipair", + "OMG": "OMG Network", + "OMGC": "OmiseGO Classic", + "OMI": "ECOMI", + "OMIC": "Omicron", + "OMIKAMI": "Amaterasu Omikami", + "OMIX": "Omix", + "OMMI": "Ommniverse", + "OMNI": "Omni Network", + "OMNIA": "OMNIA Protocol", + "OMNIAV1": "OmniaVerse v1", + "OMNIAV2": "OmniaVerse", + "OMNIC": "OmniCat", + "OMNICRON": "OmniCron", + "OMNILAYER": "Omni", + "OMNIR": "Omni Real Estate Token", + "OMNIX": "OmniBotX", + "OMNIXIO": "OMNIX", + "OMNOM": "Doge Eat Doge", + "OMNOMN": "Omega Network", + "OMT": "Oracle Meta Technologies", + "OMV1": "OM Token (v1)", + "OMX": "Project Shivom", + "OMZ": "Open Meta City", + "ON": "Orochi Network Token", + "ONAM": "ONAM", + "ONC": "One Cash", + "ONCH": "OnchainPoints.xyz", + "ONDO": "Ondo", + "ONDOAI": "Ondo DeFAI", + "ONDSON": "Ondas Holdings (Ondo Tokenized)", + "ONE": "Harmony", + "ONEROOT": "OneRoot Network", + "ONES": "OneSwap DAO", + "ONET": "ONE Token", + "ONEX": "ONE TECH", + "ONF": "ONF Token", + "ONFA": "ONFA Hope", + "ONGAS": "Ontology Gas", + "ONI": "ONINO", + "ONIG": "Onigiri", + "ONIGIRI": "Onigiri The Cat", + "ONION": "DeepOnion", + "ONIT": "ONBUFF", + "ONIX": "Onix", + "ONL": "On.Live", + "ONLINE": "Onlinebase", + "ONLY": "OnlyCam", + "ONLYCUMIES": "OnlyCumies", + "ONNO": "Onno Vault", + "ONOMY": "Onomy Protocol", + "ONOT": "ONO", + "ONS": "One Share", + "ONSTON": "Onston", + "ONT": "Ontology", + "ONTACT": "OnTact", + "ONUS": "ONUS", + "ONX": "OnX.finance", + "OOB": "Oobit", + "OOBV1": "Oobit", + "OOE": "OpenOcean", + "OOFP": "OOFP", + "OOGI": "OOGI", + "OOKI": "Ooki", + "OOKS": "Onooks", + "OOM": "OomerBot", + "OOOO": "oooo", + "OOPS": "OOPS", + "OORC": "Orbit Bridge Klaytn Orbit Chain", + "OORT": "OORT", + "OOT": "Utrum", + "OOW": "OPP Open WiFi", + "OP": "Optimism", + "OPA": "Option Panda Platform", + "OPAD": "OpenPad AI", + "OPAI": "Optopia AI", + "OPAIG": "OvalPixel", + "OPC": "OP Coin", + "OPCA": "OP_CAT(BIP-420)", + "OPCAT": "OPCAT", + "OPCATVIP": "OP_CAT", + "OPCT": "Opacity", + "OPEN": "OpenLedger", + "OPENAI": "OpenAI ERC", + "OPENCHAT": "OpenChat", + "OPENCUSTODY": "Open Custody Protocol", + "OPENDAO": "OpenDAO", + "OPENGO": "OPEN Governance Token", + "OPENON": "Opendoor Technologies (Ondo Tokenized)", + "OPENP": "Open Platform", + "OPENRI": "Open Rights Exchange", + "OPENSOURCE": "Open Source Network", + "OPENSWAP": "OpenSwap Optimism Token", + "OPENVC": "OpenVoiceCoin", + "OPENW": "OpenWorld", + "OPENX": "OpenxAI", + "OPENXSTOCK": "OPEN xStock", + "OPEPE": "Optimism PEPE", + "OPERATOR": "OpenAI Agent", + "OPES": "Opes", + "OPET": "ÕpetFoundation", + "OPEX": "Optherium Token", + "OPHX": "Operation Phoenix", + "OPINU": "Optimus Inu", + "OPIUM": "Opium", + "OPMND": "Open Mind Network", + "OPN": "OPEN Ticketing Ecosystem", + "OPNN": "Opennity", + "OPNV1": "GET Protocol", + "OPP": "Opporty", + "OPS": "Octopus Protocol", + "OPSC": "OpenSourceCoin", + "OPSEC": "OpSec", + "OPSECV1": "OpSec v1", + "OPSV1": "Octopus Protocol v1", + "OPSV2": "Octopus Protocol v2", + "OPT": "Opus", + "OPTA": "Opta Global", + "OPTC": "Open Predict Token", + "OPTCM": "Optimus", + "OPTI": "Optimus AI", + "OPTIG": "Catgirl Optimus", + "OPTIM": "Optimus X", + "OPTIMOUSE": "Optimouse", + "OPTIMUS": "Optimus", + "OPTIO": "Optio", + "OPTION": "OptionCoin", + "OPU": "Opu Coin", + "OPUL": "Opulous", + "OPUS": "Opus", + "OPV": "OpenLive NFT", + "OPXVEVELO": "OpenX Locked Velo", + "ORA": "ORA Coin", + "ORACLE": "oracle", + "ORACLEAI": "Oracle AI", + "ORACLECHAIN": "OracleChain", + "ORACLER": "Oracler", + "ORACOLXOR": "Oracolxor", + "ORACUL": "Oracul Ai", + "ORAI": "Oraichain Token", + "ORAIX": "OraiDEX", + "ORANGE": "Annoying Orange", + "ORAO": "ORAO Network", + "ORARE": "OneRare", + "ORB": "KlayCity ORB", + "ORBD": "OrbitEdge", + "ORBI": "Orbs", + "ORBIS": "Orbis", + "ORBIT": "Orbit Protocol", + "ORBITCOIN": "Orbitcoin", + "ORBK": "Ordibank", + "ORBR": "Orbler", + "ORBS": "Orbs", + "ORBT": "Orbitt Token", + "ORBTV1": "Orbitt Pro", + "ORC": "Orbit Chain", + "ORCA": "Orca", + "ORCAI": "ORCA", + "ORCLX": "Oracle xStock", + "ORD": "ordinex", + "ORDER": "Orderly Network", + "ORDI": "Ordinals ", + "ORDI2": "ORDI 2.0", + "ORDIFI": "OrdinalsFi", + "ORDIN": "ORDINAL HODL MEME", + "ORDS": "Ordiswap", + "ORE": "Galactrum", + "OREO": "OreoFi", + "ORET": "ORET Token", + "ORFY": "Ordify", + "ORGA": "Organicco", + "ORGO": "Orgo", + "ORGT": "Organic Token", + "ORI": "Orizon", + "ORIGAMI": "Origami", + "ORIGIN": "Origin Foundation", + "ORIGINA": "Original Gangsters", + "ORION": "Orion Money", + "ORKL": "Orakler", + "ORLY": "OrlyCoin", + "ORM": "ORIUM", + "ORME": "Ormeus Coin", + "ORMO": "Ormolus", + "ORN": "Orion Protocol", + "ORNG": "Juice Town", + "ORNJ": "Orange", + "ORO": "Operon Origins", + "OROC": "Orocrypt", + "OROCOIN": "OroCoin", + "OROP": "ORO", + "OROX": "Cointorox", + "ORS": "ORS Group", + "ORT": "Okratech Token", + "ORV": "Orvium", + "ORYX": "OryxCoin", + "OS": "Ethereans", + "OS76": "OsmiumCoin", + "OSA": "OSA Token", + "OSAK": "Osaka Protocol", + "OSC": "iOscar", + "OSCAR": "OSCAR", + "OSEA": "Omnisea", + "OSEAN": "OSEAN", + "OSETH": "StakeWise Staked ETH", + "OSF": "One Solution", + "OSH": "OSHI", + "OSHI": "Oshi Token", + "OSIS": "OSIS", + "OSK": "OSK", + "OSKDAO": "OSK DAO", + "OSKY": "OpenSky Token", + "OSL": "OSL AI", + "OSMI": "OSMI", + "OSMO": "Osmosis", + "OSOL": "OSOL", + "OSQTH": "Opyn Squeeth", + "OSS": "OSSChain", + "OST": "OST", + "OSUSHI": "Sushi (Optimism Bridge)", + "OSWAP": "OpenSwap", + "OT": "Onchain Trade", + "OTB": "OTCBTC Token", + "OTHR": "OtherDAO", + "OTK": "Octokn", + "OTN": "Open Trading Network", + "OTO": "OTOCASH", + "OTSEA": "OTSea", + "OTT": "Coost", + "OTTERHOME": "OtterHome", + "OTTERSPACE": "Otter Space", + "OTX": "Octanox", + "OUCHI": "OUCHI", + "OUD": "OUD", + "OUR": "Our Pay", + "OUSD": "Origin Dollar", + "OUSDC": "Orbit Bridge Klaytn USDC", + "OUSE": "OUSE Token", + "OUSG": "OUSG", + "OUT": "Netscouters", + "OUTL": "Outlanders Token", + "OUTLAW": "OUTLAW Crypto Games", + "OVATO": "Ovato", + "OVC": "OVCODE", + "OVER": "OverProtocol", + "OVERLORD": "Overlord", + "OVL": "Overlay", + "OVN": "Overnight", + "OVO": "OVO", + "OVPP": "OpenVPP", + "OVR": "Ovr", + "OWB": "OWB", + "OWC": "Oduwa", + "OWD": "Owlstand", + "OWL": "Owlto", + "OWLTOKEN": "OWL Token", + "OWN": "OTHERWORLD", + "OWNDATA": "OWNDATA", + "OWNLY": "Ownly", + "OWO": "SoMon", + "OWOCOIN": "Owo", + "OX": "Open Exchange Token", + "OXAI": "OxAI.com", + "OXB": "Oxbull Tech", + "OXBT": "OXBT (Ordinals)", + "OXD": "0xDAO", + "OXEN": "Oxen", + "OXM": "OXM Protocol", + "OXN": "0xNumber", + "OXO": "OXO Network", + "OXS": "0xS", + "OXT": "Orchid Protocol", + "OXY": "Oxygen", + "OXY2": "Cryptoxygen", + "OXYC": "Oxycoin", + "OYS": "Oyster Platform", + "OZG": "Ozagold", + "OZK": "OrdiZK", + "OZMPC": "Ozempic", + "OZNI": "Ni Token", + "OZO": "Ozone Chain", + "OZONE": "Ozone metaverse", + "OZONEC": "Ozonechain", + "OZP": "OZAPHYRE", + "P": "PoP Planet", + "P1": "PEPE ONE", + "P202": "Project 202", + "P2P": "Sentinel", + "P2PS": "P2P Solutions Foundation", + "P2PV1": "Sentinel", + "P33L": "THE P33L", + "P3D": "3DPass", + "P404": "Potion 404", + "PAAL": "PAAL AI", + "PAALV1": "PAAL AI v1", + "PABLO": "PABLO DEFI", + "PAC": "PacMoon", + "PACE": "3space Art", + "PACK": "HashPack", + "PACM": "Pacman Blastoff", + "PACMAN": "Pac Man", + "PACO": "Paco", + "PACOCA": "Pacoca", + "PACP": "PAC Protocol", + "PACT": "impactMarket", + "PACTTOKEN": "PACT community token", + "PACTV1": "impactMarket v1", + "PAD": "NearPad", + "PAF": "Pacific", + "PAGE": "Page", + "PAI": "ParallelAI", + "PAID": "PAID Network", + "PAIDV1": "PAID Network v1", + "PAIN": "PAIN", + "PAINT": "MurAll", + "PAIRED": "PairedWorld", + "PAJAMAS": "The First Youtube Cat", + "PAK": "Pakcoin", + "PAL": "PolicyPal Network", + "PALAI": "PaladinAI", + "PALCOIN": "Palcoin Ventures", + "PALCOINV1": "PALCOIN Venture Capital v1", + "PALE": "Palette", + "PALET": "Palette", + "PALG": "PalGold", + "PALLA": "Pallapay", + "PALM": "PaLM AI", + "PALMECO": "Palm Economy", + "PALMO": "ORCIB", + "PALMP": "PalmPay", + "PALMV1": "PaLM AI v1", + "PALMY": "Palmy", + "PALU": "Palu", + "PAM": "PAM", + "PAMBI": "Pambicoin", + "PAMP": "PAMP Network", + "PAN": "Pankito", + "PAND": "Panda Finance", + "PANDA": "PandaDAO", + "PANDAI": "PandAI", + "PANDAS": "Panda Swap", + "PANDE": "Pande", + "PANDO": "Pando", + "PANDOP": "PandoProject", + "PANDORA": "Pandora", + "PANDU": "Pandu Pandas", + "PANGEA": "PANGEA", + "PANIC": "PanicSwap", + "PANO": "PanoVerse", + "PANTHER": "Panther Protocol", + "PANTOS": "Pantos", + "PAO": "South Pao", + "PAPA": "Papa Bear", + "PAPADOGE": "Papa Doge", + "PAPARAZZI": "Paparazzi Token", + "PAPAT": "PAPA Trump", + "PAPER": "Dope Wars Paper", + "PAPERBASE": "Paper", + "PAPI": "Papi", + "PAPO": "PAPO NINJA", + "PAPPAY": "PAPPAY", + "PAPPLE": "Pineapple", + "PAPU": "Papu Token", + "PAPUSHA": "Papusha", + "PAR": "Parachute", + "PARA": "Paralink Network", + "PARAB": "Parabolic", + "PARABO": "PARABOLIC AI", + "PARAD": "Paradox", + "PARADOX": "The Paradox Metaverse", + "PARAG": "Paragon Network", + "PARAL": "Parallel", + "PARALL": "Parallel Finance", + "PARAM": "Param", + "PARANOIA": "ParanoiaCoin", + "PARAS": "Paras", + "PARAW": "Para", + "PARETO": "Pareto Network Token", + "PARI": "Paribus", + "PARKGENE": "PARKGENE", + "PARKINGO": "ParkinGo", + "PARLAY": "Parlay", + "PARMA": "PARMA Fan Token", + "PARQ": "PARQ", + "PARROT": "Parrot USD", + "PARRY": "Parry Parrot", + "PART": "Particl", + "PARTI": "PARTI Token", + "PARTY": "Party", + "PAS": "Passive Coin", + "PASC": "Pascal Coin", + "PASG": "Passage", + "PASL": "Pascal Lite", + "PASS": "Blockpass", + "PAT": "PATRON", + "PATEK": "Silly Patek", + "PATEX": "Patex", + "PATH": "PathDAO", + "PATLU": "Patlu", + "PATRIOT": "Patriot", + "PATTON": "Patton", + "PAUL": "Elephant Penguin", + "PAVIA": "Pavia", + "PAVO": "Pavocoin", + "PAW": "PAWSWAP", + "PAWPAW": "PawPaw", + "PAWS": "PAWS", + "PAWSE": "PAWSE", + "PAWSTA": "dogeatingpasta", + "PAWSTARS": "PawStars", + "PAWTH": "Pawthereum", + "PAXE": "Paxe", + "PAXEX": "PAXEX", + "PAXG": "PAX Gold", + "PAXU": "Pax Unitas", + "PAXW": "pax.world", + "PAY": "TenX", + "PAYAI": "PayAI Network", + "PAYB": "Paybswap", + "PAYCENT": "Paycent", + "PAYCHECK": "Paycheck", + "PAYCON": "Paycon", + "PAYD": "PAYD", + "PAYN": "PayNet Coin", + "PAYP": "PayPeer", + "PAYS": "Payslink", + "PAYT": "PayAccept", + "PAYU": "Platform of meme coins", + "PAYX": "Paypex", + "PAZZI": "Paparazzi", + "PBAR": "Pangolin Hedera", + "PBASE": "Polkabase", + "PBB": "PEPE BUT BLUE", + "PBC": "PabyosiCoin", + "PBET": "PBET", + "PBIRB": "Parrotly", + "PBL": "Pebbles", + "PBLK": "PayBlock", + "PBQ": "PUBLIQ", + "PBR": "PolkaBridge", + "PBRV1": "PolkaBridge v1", + "PBT": "Primalbase", + "PBTC": "pTokens BTC", + "PBTC35A": "pBTC35A", + "PBTCV1": "pTokens BTC v1", + "PBUX": "Playbux", + "PBX": "Probinex", + "PBXV1": "Probinex v1", + "PC": "Promotion Coin", + "PCC": "PCORE", + "PCCM": "Poseidon Chain", + "PCD": " Phecda", + "PCE": "PEACE COIN", + "PCH": "Pichi", + "PCHS": "Peaches.Finance", + "PCI": "PayProtocol Paycoin", + "PCKB": "pCKB (via Godwoken Bridge from CKB)", + "PCL": "Peculium", + "PCM": "Procom", + "PCN": "PeepCoin", + "PCNT": "Playcent", + "PCO": "Pecunio", + "PCOCK": "PulseChain Peacock", + "PCOIN": "Pioneer Coin", + "PCR": "Paycer Protocol", + "PCS": "Pabyosi Coin", + "PCSP": "GenomicDao G-Stroke", + "PCT": "PET CASH TOKEN", + "PCW": "Power Crypto World", + "PCX": "ChainX", + "PD": "PUDEL", + "PDA": "PlayDapp", + "PDAI": "Dai (Polygon Portal)", + "PDATA": "PDATA", + "PDC": "Project Decorum", + "PDD": "PDDOLLAR", + "PDEX": "Polkadex", + "PDF": "Port of DeFi Network", + "PDI": "Phuture DeFi Index", + "PDJT": "President Donald J. Trump", + "PDOG": "Polkadog", + "PDOGE": "PolkaDoge", + "PDRAGON": "Phoenix Dragon", + "PDRIP": "Pulse Drip", + "PDT": "ParagonsDAO", + "PDX": "PDX Coin", + "PE": "Pe", + "PEA": "Pea Farm", + "PEACH": "Based Peaches", + "PEACHY": "Peachy", + "PEAGUY": "The Pea Guy by Virtuals", + "PEAK": "PEAKDEFI", + "PEAN": "Peanut the Squirrel (peanut-token.xyz)", + "PEANIE": "Peanie", + "PEANU": "PEANUT INU", + "PEANUT": "#1 Tiktok Squirrel", + "PEAQ": "peaq", + "PEAR": "Pear Swap", + "PEARL": "Pearl Finance", + "PEAS": "Peapods Finance", + "PEBBLE": "Etherrock #72", + "PEBIRD": "PEPE BIRD", + "PEC": "PeaceCoin", + "PECH": "PEPE CASH", + "PECL": "PECland", + "PED": "PEDRO", + "PEDRO": "Pedro The Raccoon", + "PEE": "peecoin", + "PEEL": "Meta Apes", + "PEENO": "Peeno", + "PEEP": "Peepo", + "PEEPA": "Peepa", + "PEEPEE": "Peepee", + "PEEPO": "PEEPO", + "PEEPOBASE": "Peepo (peepobase.org)", + "PEEPS": "The People’s Coin", + "PEEZY": "Young Peezy AKA Pepe", + "PEFI": "Penguin Finance", + "PEG": "PegNet", + "PEGA": "PEGA", + "PEGAMAGA": "Pepe Maga", + "PEGASCOIN": "Pegascoin", + "PEGAXY": "Pegaxy Stone", + "PEGG": "PokPok Golden Egg", + "PEGS": "PegShares", + "PEIPEI": "PeiPei", + "PEIPEICN": "PEIPEI", + "PEKA": "PEKA", + "PEKC": "Peacock Coin", + "PEKINU": "PEKI INU", + "PEKO": "Pepe Neko", + "PEL": "Propel Token", + "PELF": "PELFORT", + "PELL": "PELL Network Token", + "PEM": "Pembrock", + "PEME": "PEME", + "PENC": "PenCoin", + "PENDLE": "Pendle", + "PENDY": "Pendy", + "PENG": "Peng", + "PENGCOIN": "PENG", + "PENGO": "Petro Penguins", + "PENGU": "Pudgy Penguins", + "PENGUAI": "PENGU AI", + "PENGUI": "Penguiana", + "PENGUIN": "Penguin", + "PENGYX": "PengyX", + "PENIS": "PenisGrow", + "PENJ": "Penjamin Blinkerton", + "PENP": "Penpie", + "PENR": "Penrose Finance", + "PENTA": "Penta", + "PEON": "Peon", + "PEOPLE": "ConstitutionDAO", + "PEOPLEFB": "PEOPLE", + "PEOS": "EOS (pTokens)", + "PEOSONE": "pEOS", + "PEP": "Pepechain", + "PEPA": "Pepa Inu", + "PEPAY": "PEPAY", + "PEPC": "Pepe Classic", + "PEPE": "Pepe", + "PEPE2": "Pepe 2.0", + "PEPE2024": "Olympic Pepe 2024", + "PEPE20V1": "Pepe 2.0 v1", + "PEPEA": "Pepeandybrettlandwolf", + "PEPEAI": "Pepe Analytics", + "PEPEARMY": "PEPEARMY", + "PEPEB": "PEPEBOMB", + "PEPEBNB": "Pepe The Frog", + "PEPEBRC": "PEPE (Ordinals)", + "PEPEBSC": "Pepe Coin", + "PEPEBURN": "Pepeburn", + "PEPEC": "Pepe Chain", + "PEPECASH": "Pepe Cash", + "PEPECAT": "PEPECAT", + "PEPECATCLUB": "PEPE CAT", + "PEPECEO": "REAL PEPE CEO", + "PEPECHAIN": "PEPE Chain", + "PEPECO": "PEPE COIN BSC", + "PEPECOIN": "PepeCoin", + "PEPED": "PepeDAO Coin", + "PEPEDAO": "PEPE DAO", + "PEPEDERP": "PepeDerp", + "PEPEDNA": "PEPE DNA", + "PEPEE": "Pepe the pepe", + "PEPEF": "PEPEFLOKI", + "PEPEFC": "Pepe FC", + "PEPEFLOKI": "PEPE FLOKI", + "PEPEG": "Pepe Girl", + "PEPEGA": "Pepe GEM AI", + "PEPEGAINS": "PepeGains", + "PEPEGOAT": "pepeGOAT", + "PEPEGRINCH": "Pepe Grinch", + "PEPEINU": "PEPE inu", + "PEPEKING": "PEPEKING", + "PEPEKRC20": "PEPE KRC20", + "PEPELON": "Pepelon", + "PEPEMAGA": "Trump Pepe", + "PEPEMO": "PepeMo", + "PEPEMOON": "PEPEMOON", + "PEPEMUSK": "pepemusk", + "PEPENODE": "PEPENODE", + "PEPEOFSOL": "Pepe of Solana", + "PEPEPI": "PEPEPi", + "PEPER": "Baby Pepe", + "PEPERA": "PEPERA", + "PEPESOL": "PEPE SOL", + "PEPESOLCTO": "Pepe (pepesolcto.vip)", + "PEPESORA": "Pepe Sora AI", + "PEPESWAP": "PEPE Swap", + "PEPET": "PepeTrump", + "PEPETR": "PEPE TREMP", + "PEPEW": "PEPEPOW", + "PEPEWIFHAT": "Pepewifhat", + "PEPEWO": "PEPE World", + "PEPEX": "pepeX", + "PEPEYE2": "PEPEYE 2.0", + "PEPEZILLA": "PEPEZilla", + "PEPI": "PEPI", + "PEPINU": "Pepinu", + "PEPIT": "Pepito", + "PEPLO": "Peplo Escobar", + "PEPO": "Peepo", + "PEPOC": "Pepoclown", + "PEPPA": "PEPPA", + "PEPPER": "Pepper Token", + "PEPS": "PEPS Coin", + "PEPU": "Pepe Unchained", + "PEPURAI": "PEPURAI", + "PEPVERS": "PepVerse", + "PEPX": "Pepsico xStock", + "PEPY": "Pepy", + "PER": "Perproject", + "PERA": "Pera Finance", + "PERC": "Perion", + "PERCY": "Percy Verence", + "PERI": "PERI Finance", + "PERKSCOIN": "PerksCoin ", + "PERL": "PERL.eco", + "PERMIAN": "Permian", + "PERP": "Perpetual Protocol", + "PERRY": "Perry The BNB", + "PERU": "PeruCoin", + "PERX": "PeerEx Network", + "PESA": "Credible", + "PESETACOIN": "PesetaCoin", + "PESHI": "PESHI", + "PESOBIT": "PesoBit", + "PESTO": "Pesto the Baby King Penguin", + "PET": "Hello Pets", + "PETE": "PETE", + "PETERTODD": "Peter Todd", + "PETF": "PEPE ETF", + "PETG": "Pet Games", + "PETH": "pETH", + "PETL": "Petlife", + "PETN": "Pylon Eco Token", + "PETO": "Petoverse", + "PETOSHI": "Petoshi", + "PETS": "PolkaPets", + "PETT": "Pett Network", + "PETUNIA": "Petunia", + "PEUSD": "peg-eUSD", + "PEW": "pepe in a memes world", + "PEX": "Pexcoin", + "PF": "Purple Frog", + "PFEON": "Pfizer (Ondo Tokenized)", + "PFEX": "Pfizer xStock", + "PFF": "PumpFunFloki", + "PFI": "PrimeFinance", + "PFID": "Pofid Dao", + "PFL": "Professional Fighters League Fan Token", + "PFR": "PayFair", + "PFT": "PolarFighters", + "PFVS": "Puffverse Token", + "PFY": "Portify", + "PG": "Pepe Grow", + "PGALA": "pGALA", + "PGC": "PiggyPiggyCoin", + "PGEN": "Polygen", + "PGF7T": "PGF500", + "PGL": "Prospectors", + "PGN": "Paragon", + "PGOLD": " Polkagold", + "PGPT": "PrivateAI", + "PGROK": "Papa Grok", + "PGT": "Polyient Games Governance Token", + "PGTS": "Puregold token", + "PGU": "Polyient Games Unity", + "PGX": "Procter & Gamble xStock", + "PHA": "Phala Network", + "PHAE": "Phaeton", + "PHALA": "Phalanx", + "PHAME": "PHAME", + "PHAR": "Pharaoh", + "PHAUNTEM": "Phauntem", + "PHB": "Phoenix Global [v2]", + "PHBD": "Polygon HBD", + "PHCR": "PhotoChromic", + "PHEN": "Phenx", + "PHEX": "HEX (Polygon Portal)", + "PHI": "PHI Token", + "PHIBA": "Papa Shiba", + "PHIGOLD": "PhiGold Coin", + "PHIL": "Philtoken", + "PHL": "Philcoin", + "PHM": "Phomeum", + "PHMN": "POSTHUMAN", + "PHN": "Phayny", + "PHNIX": "Phoenix", + "PHNX": "PhoenixDAO", + "PHO": "Photon", + "PHOENIX": "Phoenix Finance", + "PHONON": "Phonon DAO ", + "PHOON": "Typhoon Cash", + "PHORE": "Phore", + "PHR": "Phreak", + "PHRYG": "PHRYGES", + "PHRYGE": "PHRYGES", + "PHRYGES": "The Phryges", + "PHRZ": "Pharaohs", + "PHS": "PhilosophersStone", + "PHT": "Photon Token", + "PHTC": "Photochain", + "PHTR": "Phuture", + "PHUN": "PHUNWARE", + "PHV": "PATHHIVE", + "PHY": "DePHY", + "PI": "Pi Network", + "PIA": "Olympia AI", + "PIAI": "Pi Network AI", + "PIAS": "PIAS", + "PIB": "Pibble", + "PICA": "Picasso", + "PICAARTMONEY": "PicaArtMoney", + "PICKL": "PICKLE", + "PICKLE": "Pickle Finance", + "PICO": "PicoGo", + "PICOLO": "PICOLO", + "PIDOG": "Pi Network Dog", + "PIDOGE": "Pi Network Doge", + "PIE": "Persistent Information Exchange", + "PIERRE": "sacré bleu", + "PIEVERSE": "Pieverse Token", + "PIF": "Pepe Wif Hat", + "PIG": "Pig Finance", + "PIGC": "Pigcoin", + "PIGE": "Pige", + "PIGEON": "Pigeon In Yellow Boots", + "PIGEONC": "Pigeoncoin", + "PIGGY": "Piggycell", + "PIGGYCOIN": "Piggy Coin", + "PIGGYFI": "Piggy", + "PIGLET": "PIGLET", + "PIGONK": "PIGONK", + "PIGS": "Elon Vitalik Pigs", + "PIIN": "piin (Ordinals)", + "PIK": "Pika Protocol", + "PIKA": "Pikaboss", + "PIKACHU": "Pikachu Inu", + "PIKACRYPTO": "Pika", + "PIKAM": "Pikamoon", + "PIKE": "Pike Token", + "PIKO": "Pinnako", + "PIKZ": "PIKZ", + "PILLAR": "PillarFi", + "PILOT": "Unipilot", + "PIM": "PIM", + "PIN": "PinLink", + "PINCHAIN": "Pin", + "PINCHI": "Da Pinchi", + "PINE": "Pine", + "PINETWORKDEFI": "Pi Network DeFi", + "PINEYE": "PinEye", + "PING": "Ping", + "PINGO": "PinGo", + "PINGPONG": "PINGPONG Token", + "PINK": "PINK - The Panther", + "PINKCOIN": "PinkCoin", + "PINKSALE": "PinkSale", + "PINKX": "PantherCoin", + "PINMO": "Pinmo", + "PINO": "Pinocchu", + "PINS": "PINs Network Token", + "PINU": "Piccolo Inu", + "PINU100X": "Pi INU 100x", + "PIO": "Pioneershares", + "PIP": "Pip", + "PIPA": "Pipa Coin", + "PIPE": "Pipe", + "PIPI": "Pippi Finance", + "PIPL": "PiplCoin", + "PIPO": "Pipo", + "PIPONHL": "PiP", + "PIPPIN": "pippin", + "PIPT": "Power Index Pool Token", + "PIRATE": "Pirate Nation", + "PIRATECASH": "PirateCash", + "PIRATECASHV1": "PirateCash v1", + "PIRATECASHV2": "PirateCash v2 (PirateCash Telegram bot)", + "PIRATECOIN": "Pirate Coin Games", + "PIRB": "PIRB", + "PIRI": "Pirichain", + "PIRL": "Pirl", + "PIS": "Polkainsure Finance", + "PIST": "Pist Trust", + "PIT": "Pitbull", + "PITCH": "PITCH", + "PITCHFINANCE": "Pitch Finance Token", + "PITISCOIN": "Pitis Coin", + "PIUU": "PIXIU", + "PIVN": "PIVN", + "PIVOTTOKEN": "Pivot Token", + "PIVX": "Private Instant Verified Transaction", + "PIX": "PixelSwap", + "PIXEL": "Pixels", + "PIXELV": "PixelVerse", + "PIXFI": "Pixelverse", + "PIXL": "PIXL", + "PIZA": "PIZA", + "PIZPEPE": "Pepe Pizzeria", + "PIZZA": "Pizza", + "PIZZACOIN": "PizzaCoin", + "PIZZASWAP": "PizzaSwap", + "PJM": "Pajama.Finance", + "PJN": "PJN", + "PKB": "ParkByte", + "PKC": "Pikciochain", + "PKD": "PetKingdom", + "PKF": "PolkaFoundry", + "PKG": "PKG Token", + "PKIN": "PUMPKIN", + "PKM": "Pockemy", + "PKN": "Poken", + "PKOIN": "Pocketcoin", + "PKT": "PKT", + "PLA": "PlayDapp", + "PLAAS": "PLAAS FARMERS TOKEN", + "PLAC": "PLANET", + "PLACE": "PlaceWar Governance", + "PLAI": "PLAY AI", + "PLAIR": "Plair", + "PLAN": "Plancoin", + "PLANCK": "Planck", + "PLANE": "Paper Plane", + "PLANET": "PLANET", + "PLANETCOIN": "PlanetCoin", + "PLANETS": "PlanetWatch", + "PLANT": "Plant", + "PLASTIK": "Plastiks", + "PLAT": "BitGuild PLAT", + "PLATC": "PlatinCoin", + "PLATINUM": "Platinum", + "PLATO": "Plato Game", + "PLAY": "Play", + "PLAYC": "PlayChip", + "PLAYCOIN": "PlayCoin", + "PLAYFUN": "PLAYFUN", + "PLAYKEY": "Playkey", + "PLAYSOLANA": "Play Solana", + "PLAYTOKEN": "Play Token", + "PLB": "Paladeum", + "PLBT": "Polybius", + "PLC": "PlusCoin", + "PLCU": "PLC Ultima", + "PLCUC": "PLC Ultima Classic", + "PLD": "Plutonian DAO", + "PLE": "Plethori", + "PLEA": "Plearn", + "PLEB": "PLEBToken", + "PLEBONBASE": "PLEB", + "PLENA": "PLENA", + "PLENTY": "Plenty DeFi", + "PLEO": "Empleos", + "PLERF": "Plerf", + "PLEX": "PLEX", + "PLEXCOIN": "PlexCoin", + "PLF": "PlayFuel", + "PLG": "Pledgecamp", + "PLGR": "Pledge Finance", + "PLI": "Plugin", + "PLIAN": "Plian", + "PLINK": "Chainlink (Polygon Portal)", + "PLM": "Plasmonics", + "PLMC": "Polimec", + "PLMS": "Polemos", + "PLMT": "Pallium", + "PLNC": "PLNCoin", + "PLNX": "Planumex", + "PLOT": "PlotX", + "PLPA": "Palapa", + "PLQ": "Planq", + "PLR": "Pillar", + "PLS": "Pulsechain", + "PLSARB": "Plutus ARB", + "PLSB": "PulseBitcoin", + "PLSD": "PulseDogecoin", + "PLSPAD": "PulsePad", + "PLSRDNT": "Plutus RDNT", + "PLSX": "PulseX", + "PLT": "Poollotto.finance", + "PLTC": "PlatonCoin", + "PLTRON": "Palantir Technologies (Ondo Tokenized)", + "PLTRX": "Palantir xStock", + "PLTX": "PlutusX", + "PLTXYZ": "Add.xyz", + "PLU": "Pluton", + "PLUG": "PL^Gnet", + "PLUGCN": "Plug Chain", + "PLUGON": "Plug Power (Ondo Tokenized)", + "PLUME": "Plume", + "PLUP": "PoolUp", + "PLURA": "PluraCoin", + "PLUS1": "PlusOneCoin", + "PLUTUS": "PlutusDAO", + "PLX": "Planet Labs xStock", + "PLXY": "Plxyer", + "PLY": "Aurigami", + "PLYR": "PLYR L1", + "PLZ": "PLUNZ", + "PM": "PumpMeme", + "PMA": "PumaPay", + "PMD": "Pandemic Multiverse", + "PME": "DogePome", + "PMEER": "Qitmeer", + "PMG": "Pomerium Ecosystem Token", + "PMGT": "Perth Mint Gold Token", + "PMKR": "Maker (Polygon Portal)", + "PMM": "Perpetual Motion Machine", + "PMNT": "Paymon", + "PMON": "Polkamon", + "PMOON": "Pookimoon", + "PMPY": "Prometheum Prodigy", + "PMR": "Pomerium Utility Token", + "PMT": "Public Masterpiece Token", + "PMTN": "Peer Mountain", + "PMX": "Phillip Morris xStock", + "PNB": "Pink BNB", + "PNC": "PlatiniumCoin", + "PND": "PandaCoin", + "PNDC": "Pond Coin", + "PNDN": "Pandana", + "PNDO": "Pondo", + "PNDR": "Pandora Finance", + "PNFT": "Pawn My NFT", + "PNG": "Pangolin", + "PNGDA": "Pengda Yellow Panda", + "PNGN": "SpacePenguin", + "PNIC": "Phoenic", + "PNK": "Kleros", + "PNL": "True PNL", + "PNODE": "Pinknode", + "PNT": "pNetwork Token", + "PNUT": "Peanut the Squirrel", + "PNUTDOGE": "PNUT DOGE", + "PNUTRUMP": "Peanut Trump", + "PNUTS": "Pnuts for squirrel", + "PNX": "PhantomX", + "PNY": "Peony Coin", + "POA": "Poa Network", + "POAI": "Port AI", + "POC": "POC Blockchain", + "POCAT": "Polite Cat", + "POCC": "POC Chain", + "POCHITA": "pochita", + "POCHITAV1": "Pochita", + "POCKET": "XPocket", + "POCO": "Pocoland", + "POD": "Podo Point", + "PODFAST": "PodFast", + "PODIUM": "Smart League", + "PODO": "Power Of Deep Ocean", + "POE": "Portal Network", + "POET": "Po.et", + "POFU": "POFU", + "POG": "PolygonumOnline", + "POGAI": "POGAI", + "POGS": "POG", + "POINT": "SportPoint", + "POINTS": "POINTS", + "POK": "Pokmonsters", + "POKEGROK": "PokeGROK", + "POKEM": "Pokemonio", + "POKEMO": "Pokemon", + "POKEMON": "Pokemon", + "POKER": "PokerCoin", + "POKERFI": "PokerFi", + "POKKY": "Pokky Cat", + "POKO": "POKOMON", + "POKT": "Pocket Network", + "POL": "Polygon Ecosystem Token", + "POLA": "Polaris Share", + "POLAO": "Pola On Base", + "POLAR": "Polaris", + "POLC": "Polka City", + "POLI": "Polinate", + "POLIS": "Star Atlas DAO", + "POLISPLAY": "PolisPay", + "POLK": "Polkamarkets", + "POLKER": "Polker", + "POLL": "Pollchain", + "POLLEN": "Beraborrow", + "POLLUK": "Jasse Polluk", + "POLLUX": "Pollux Coin", + "POLLY": "Polly Penguin", + "POLNX": "eToro Polish Zloty", + "POLO": "NftyPlay", + "POLS": "Polkastarter", + "POLVEN": "Polka Ventures", + "POLX": "Polylastic", + "POLY": "Polymath Network", + "POLYCUB": "PolyCub", + "POLYDOGE": "PolyDoge", + "POLYN": "Polynetica", + "POLYPAD": "PolyPad", + "POLYX": "Polymesh", + "POM": "Proof Of Memes", + "PON": "Ponder", + "PONCH": "Ponchiqs", + "PONCHO": "Poncho", + "POND": "Marlin", + "PONGO": "Pongo", + "PONK": "PONK", + "PONKE": "Ponke", + "PONKEBNB": "Ponke BNB", + "PONKEI": "Chinese Ponkei the Original", + "PONTEM": "Pontem Liquidswap", + "PONYO": "Ponyo Impact", + "PONZI": "Ponzi", + "PONZIO": "Ponzio The Cat", + "PONZU": "Ponzu Inu", + "POO": "POOMOON", + "POOC": "Poo Chi", + "POOCOIN": "PooCoin", + "POODL": "Poodl", + "POODOGE": "Poo Doge", + "POOH": "POOH", + "POOKU": "Pooku", + "POOL": "PoolTogether", + "POOLX": "Poolz Finance", + "POOLXT": "Pool-X", + "POOLZ": "Poolz Finance", + "POOP": "Poopsicle", + "POOPC": "Poopcoin", + "POOWEL": "Joram Poowel", + "POP": "Zypher Network", + "POPC": "PopChest", + "POPCAT": "Popcat", + "POPCHAIN": "POPCHAIN", + "POPCO": "Popcorn", + "POPCOIN": "Popcoin", + "POPDOG": "PopDog", + "POPE": "PopPepe", + "POPECOIN": "Popecoin", + "POPEPE": "POPEPE", + "POPG": "POPG", + "POPGOAT": "Goatseus Poppimus", + "POPK": "POPKON", + "POPMART": "POP MART", + "POPO": "popo", + "POPOETH": "POPO", + "POPSICLE": "Popsicle Finance", + "POPU": "Populous", + "POPULARCOIN": "PopularCoin", + "POR": "Portugal National Team Fan Token", + "PORA": "PORA AI", + "PORK": "PepeFork", + "PORKE": "PONKE FORK", + "PORKINU": "PepeFork INU", + "PORNROCKET": "PornRocket", + "PORT": "Port Finance", + "PORT3": "Port3 Network", + "PORT3V2": "Port3 Network v2", + "PORTAL": "Portal", + "PORTALS": "Portals", + "PORTALTOKEN": "Portal", + "PORTO": "FC Porto", + "PORTU": "Portuma", + "PORTX": "ChainPort", + "POS": "PoSToken", + "POSEX": "PosEx", + "POSI": "Position Token", + "POSQ": "Poseidon Quark", + "POSS": "Posschain", + "POST": "InterPlanetary Search Engine", + "POSTC": "PostCoin", + "POSW": "PoSW Coin", + "POT": "PotCoin", + "POTATO": "Potato", + "POTS": "Moonpot", + "POTTER": "POTTER", + "POTUS": "President Trump", + "POTUS47": "Trump Coin", + "POU": "Pou", + "POUCH": "CoinPouch", + "POUPE": "Poupe", + "POUW": "Pouwifhat", + "POW": "PowBlocks", + "POWELL": "Jerome Powell", + "POWER": "Power", + "POWERLOOM": "Powerloom Token", + "POWERMARKET": "POWER MARKET", + "POWR": "Power Ledger", + "POWSCHE": "Powsche", + "POX": "Monkey Pox", + "POZO": "Pozo Coin", + "PP": "ProducePay Chain", + "PPAD": "PlayPad", + "PPALPHA": "Phoenix Protocol", + "PPAY": "Plasma Finance", + "PPBLZ": "Pepemon Pepeballs", + "PPC": "PeerCoin", + "PPCOIN": "Project Plutus", + "PPFT": "Papparico Finance", + "PPI": "Primpy", + "PPIZZA": "P Pizza", + "PPL": "Pink Panther Lovers", + "PPM": "Punk Panda Messenger", + "PPN": "Puppies Network", + "PPOVR": "POVR", + "PPP": "PayPie", + "PPR": "Papyrus", + "PPS": "PopulStay", + "PPT": "Pop Token", + "PPX": "Prophex", + "PPY": "Peerplays", + "PQT": "Prediqt", + "PRA": "ProChain", + "PRAI": "Privasea AI", + "PRARE": "Polkarare", + "PRB": "Paribu Net", + "PRC": "ProsperCoin", + "PRCH": "Power Cash", + "PRCL": "Parcl", + "PRCM": "Precium", + "PRCY": "PRivaCY Coin", + "PRDS": "Brise Paradise", + "PRDX": "ParamountDax Token", + "PRE": "Presearch", + "PREAI": "Predict Crypto", + "PREC": "Precipitate.AI", + "PRED": "Predictcoin", + "PREDIC": "PredicTools", + "PREM": "Premium", + "PREME": "PREME Token", + "PREMIA": "Premia", + "PRES": "President Trump", + "PRESALE": "Presale.World", + "PRESI": "Turbo Trump", + "PRESID": "President Ron DeSantis", + "PRESIDEN": "President Elon", + "PRESSX": "PressX", + "PRFT": "Proof Suite Token", + "PRG": "Paragon", + "PRI": "PRIVATEUM INITIATIVE", + "PRIA": "PRIA", + "PRICELESS": "Priceless", + "PRICK": "Pickle Rick", + "PRIDE": "Nomad Exiles", + "PRIMAL": "PRIMAL", + "PRIMATE": "Primate", + "PRIME": "Echelon Prime", + "PRIMECHAIN": "PrimeChain", + "PRIMEETH": "Prime Staked ETH", + "PRIMEX": "Primex Finance", + "PRIN": "Print The Pepe", + "PRINT": "Printer.Finance", + "PRINTERIUM": "Printerium", + "PRINTS": "FingerprintsDAO", + "PRISM": "Prism", + "PRISMA": "Prisma Finance", + "PRIVIX": "Privix", + "PRIX": "Privatix", + "PRL": "Oyster Pearl", + "PRM": "PrismChain", + "PRMX": "PREMA", + "PRNT": "Prime Numbers", + "PRO": "Propy", + "PROB": "ProBit Token", + "PROC": "ProCurrency", + "PROD": "Productivist", + "PROFITHUNTERS": "Profit Hunters Coin", + "PROGE": "Protector Roge", + "PROJECT89": "Project 89", + "PROJECT89V1": "Project89", + "PROJECTARENA": "Arena", + "PROJECTPAI": "Project Pai", + "PROLIFIC": "Prolific Game Studio", + "PROM": "Prometeus", + "PROMPT": "Wayfinder", + "PROOF": "PROVER", + "PROP": "Propeller", + "PROPC": "Propchain", + "PROPEL": "PayRue (Propel)", + "PROPHET": "PROPHET", + "PROPS": "Propbase", + "PROPSPROJECT": "Props", + "PROS": "Prosper", + "PROSP": "Prospective", + "PROT": "PROT", + "PROTEO": "Proteo DeFi", + "PROTO": "Protocon", + "PROTOCOLZ": "Protocol Zero", + "PROTON": "Proton", + "PROUD": "PROUD Money", + "PROVE": "Succinct", + "PROXI": "PROXI", + "PRP": "Pepe Prime", + "PRPS": "Purpose", + "PRPT": "Purple Token", + "PRQ": "PARSIQ", + "PRRR": "Cats Are Liquidity", + "PRS": "PressOne", + "PRT": "Parrot Protocol", + "PRTC": "Protectorate Protocol", + "PRTCLE": "Particle", + "PRTG": "Pre-Retogeum", + "PRV": "PrivacySwap", + "PRVC": "PrivaCoin", + "PRVS": "Previse", + "PRX": "Parex", + "PRXY": "Proxy", + "PRXYV1": "Proxy v1", + "PRY": "PRIMARY", + "PRZS": "Perezoso", + "PS1": "POLYSPORTS", + "PSB": "Planet Sandbox", + "PSC": "PSC Token", + "PSD": "Poseidon", + "PSEUD": "PseudoCash", + "PSF": "Prime Shipping Foundation", + "PSG": "Paris Saint-Germain Fan Token", + "PSI": "Trident", + "PSICOIN": "PSIcoin", + "PSILOC": "Psilocybin", + "PSK": "Pool of Stake", + "PSL": "Pastel", + "PSLIP": "Pinkslip Finance", + "PSM": "Prasm", + "PSOL": "Parasol Finance", + "PSP": "ParaSwap", + "PSPS": "BobaCat", + "PSSYMONSTR": "PSSYMONSTR", + "PST": "Primas", + "PSTAKE": "pSTAKE Finance", + "PSTN": "Piston", + "PSUB": "Payment Swap Utility Board", + "PSUSHI": "Sushi (Polygon Portal)", + "PSWAP": "Polkaswap", + "PSY": "PsyOptions", + "PSYOP": "PSYOP", + "PSYOPANIME": "PsyopAnime", + "PT": "Phemex", + "PTA": "PentaCoin", + "PTAS": "La Peseta", + "PTB": "Portal to Bitcoin", + "PTC": "Particle Trade", + "PTD": "Pilot", + "PTERIA": "Pteria", + "PTF": "PowerTrade Fuel", + "PTGC": "The Grays Currency", + "PTH": "PlasticHero", + "PTI": "Paytomat", + "PTM": "Potentiam", + "PTN": "PalletOneToken", + "PTO": "Patentico", + "PTON": "Foresting", + "PTOY": "Patientory", + "PTP": "Platypus Finance", + "PTR": "Petro", + "PTRUMP": "Pepe Trump", + "PTS": "Petals", + "PTT": "Pink Taxi Token", + "PTU": "Pintu Token", + "PTX": "PlatinX", + "PUBLIC": "PublicAI", + "PUCA": "Puss Cat", + "PUCCA": "PUCCA", + "PUFETH": "pufETH", + "PUFF": "Puff The Dragon", + "PUFFCOIN": "Puff", + "PUFFER": "Puffer", + "PUFFIN": "Puffin Global", + "PUFFV1": "Puff The Dragon v1", + "PUFFY": "Puffy", + "PUGAI": "PUG AI", + "PUGDOG": "PUGDOG", + "PUGGY": "PUGGY Coin", + "PUGL": "PugLife", + "PUGWIF": "PUGWIFHAT", + "PUL": "PulseTrailerPark", + "PULI": "Puli", + "PULSE": "Pulse", + "PUMA": "Puma", + "PUMBAA": "Pumbaa", + "PUMLX": "PUMLx", + "PUMP": "Pump.fun", + "PUMPAI": "PumpAI", + "PUMPB": "Pump", + "PUMPBTC": "pumpBTC", + "PUMPBTCXYZ": "PumpBTC", + "PUMPFUNBAN": "Pump Fun Ban", + "PUMPIT": "BOGDANOFF", + "PUMPTRUMP": "PUMP TRUMP", + "PUMPY": "WOW MOON LAMBO PUMPPPPPPY", + "PUN": "Punkko", + "PUNCH": "PUNCHWORD", + "PUNCHI": "Punchimals", + "PUNDIAI": "Pundi AI", + "PUNDIX": "Pundi X", + "PUNDU": "Pundu", + "PUNGU": "PUNGU", + "PUNI": "Uniswap Protocol Token (Polygon Portal)", + "PUNK": "PunkCity", + "PUNKAI": "PunkAI", + "PUNKV": "Punk Vault (NFTX)", + "PUP": "Puppy Coin", + "PUPA": "PupaCoin", + "PUPPER": "Pupper", + "PUPPET": "Puppet", + "PUPPETH": "Puppeth", + "PUPPETS": "Puppets Coin", + "PUPPIES": "I love puppies", + "PUPS": "PUPS (Ordinals)", + "PUPSWORLD": "PUPS•WORLD•PEACE", + "PUPU": "Pepe's Dog", + "PURA": "Pura", + "PURE": "Puriever", + "PUREALT": "Pure", + "PURGE": "Forgive Me Father", + "PURP": "Purple Platform io", + "PURPE": "Purple Pepe", + "PURPLEBTC": "Purple Bitcoin", + "PURR": "Purr", + "PURRC": "Purrcoin", + "PURSE": "Pundi X PURSE", + "PUS": "Pussy Cat", + "PUSD": "PegsUSD", + "PUSDC": "USD Coin (Polygon Portal)", + "PUSH": "Ethereum Push Notification Service", + "PUSHI": "Pushi", + "PUSS": "PussFi", + "PUSSY": "Pussy Financial", + "PUSSYINBIO": "Pussy In Bio", + "PUT": "PutinCoin", + "PUTIN": "Putin Meme", + "PUUSH": "puush da button", + "PUX": "pukkamex", + "PVC": "PVC Meta", + "PVFYBO": "JRVGCUPVSC", + "PVP": "Pvpfun", + "PVPCHAIN": "PVPChain", + "PVPGAME": "PvP", + "PVT": "Punkvism Token", + "PVU": "Plant vs Undead Token", + "PWAR": "PolkaWar", + "PWC": "PixelWorldCoin", + "PWEASE": "Pwease", + "PWH": "pepewifhat", + "PWINGS": "JetSwap pWings", + "PWOG": "Purple Fwog", + "PWON": "Personal Wager", + "PWR": "MaxxChain", + "PWRC": "PWR Coin", + "PWT": "PANDAINU", + "PX": "Not Pixel", + "PXB": "PixelBit", + "PXC": "PhoenixCoin", + "PXCOIN": "PXcoin", + "PXG": "PlayGame", + "PXI": "Prime-X1", + "PXL": "PIXEL", + "PXP": "PointPay", + "PXT": "Pixer Eternity", + "PYBOBO": "Capybobo", + "PYC": "PayCoin", + "PYE": "CreamPYE", + "PYI": "PYRIN", + "PYLNT": "Pylon Network", + "PYLON": "Pylon Finance", + "PYM": "Playermon", + "PYME": "PymeDAO", + "PYN": "Paynetic", + "PYP": "PayPro", + "PYPLON": "PayPal (Ondo Tokenized)", + "PYQ": "PolyQuity", + "PYR": "Vulcan Forged", + "PYRAM": "Pyram Token", + "PYRAMID": "Pyramid", + "PYRK": "Pyrk", + "PYRO": "PYRO Network", + "PYRV1": "Vulcan Forged v1", + "PYT": "Payther", + "PYTH": "Pyth Network", + "PYTHIA": "Pythia", + "PYUSD": "PayPal USD", + "PZETH": "pzETH", + "PZM": "Prizm", + "PZP": "PlayZap", + "PZT": "Pizon", + "Q": "Quack AI", + "Q1S": "Quantum1Net", + "Q2C": "QubitCoin", + "QA": "Quantum Assets", + "QAC": "Quasarcoin", + "QACE": "Qace Dynamics", + "QAI": "QuantixAI", + "QANX": "QANplatform", + "QANXV2": "QANplatform v2", + "QARK": "QANplatform", + "QASH": "Quoine Liquid", + "QAU": "Quantum", + "QBAO": "Qbao", + "QBC": "Quebecoin", + "QBIT": "Project Quantum", + "QBK": "QuBuck Coin", + "QBT": "Cubits", + "QBU": "Quannabu", + "QBX": "qiibee foundation", + "QBZ": "QUEENBEE", + "QC": "Qcash", + "QCAD": "QCAD", + "QCH": "QChi", + "QCN": "Quazar Coin", + "QCO": "Qravity", + "QCX": "QuickX Protocol", + "QDC": "Quadrillion Coin", + "QDFI": "Qudefi", + "QDROP": "QuizDrop", + "QDT": "QCHAIN", + "QDX": "Quidax", + "QFI": "QFinance", + "QGOLD": "Quorium", + "QGOV": "Q Protocol", + "QI": "BENQI", + "QIE": "QI Blockchain", + "QINGWA": "ShangXin QingWa", + "QISWAP": "QiSwap", + "QKA": "Qkacoin", + "QKC": "QuarkChain", + "QKITTY": "QueenKitty", + "QKNTL": "Quick Intel", + "QLC": "Kepple [OLD]", + "QLINDO": "QLINDO", + "QLIX": "QLix", + "QLK": "Quantlink", + "QMALL": "QMALL TOKEN", + "QMV": "Qumva Network", + "QNT": "Quant", + "QNTR": "Quantor", + "QNTU": "Quanta", + "QNX": "QueenDex Coin", + "QOBI": "Qobit", + "QOM": "Shiba Predator", + "QONE": "QONE", + "QOOB": "QOOBER", + "QORA": "QoraCoin", + "QORPO": "QORPO WORLD", + "QPAY": "QPAY SOL", + "QQBC": "QQBC IPFS BLOCKCHAIN", + "QQQ": "Poseidon Network", + "QQQF": "Standard Crypto Fund", + "QQQX": "Nasdaq xStock", + "QR": "Qrolli", + "QRK": "QuarkCoin", + "QRL": "Quantum Resistant Ledger", + "QRO": "Querio", + "QRP": "Cryptics", + "QRT": "Qrkita Token", + "QRX": "QuiverX", + "QSHX": "Queen Sherex", + "QSLV": "Quicksilver coin", + "QSP": "Quantstamp", + "QSR": "Quasar", + "QST": "QuStream", + "QSTAY": "Qstay", + "QSWAP": "Quantum Network", + "QTC": "Qitcoin", + "QTCC": "Quick Transfer coin", + "QTCON": "Quiztok", + "QTDAO": "Quantum DAO", + "QTF": "Quantfury", + "QTK": "QuantCheck", + "QTL": "Quatloo", + "QTLX": "Quantlytica", + "QTO": "Quanto", + "QTOK": "QToken", + "QTUM": "QTUM", + "QTZ": "Quartz", + "QU3": "QU3ai", + "QU3V1": "QU3ai v1", + "QUA": "Quantum Tech", + "QUAC": "QUACK", + "QUACK": "Rich Quack", + "QUADRANS": "QuadransToken", + "QUAI": "Quai Network", + "QUAIN": "QUAIN", + "QUAM": "Quam Network", + "QUAN": "Quant AI", + "QUANT": "Quant Finance", + "QUARASHI": "Quarashi Network", + "QUARTZ": "Sandclock", + "QUASA": "Quasacoin", + "QUASAR": "Quasar", + "QUB": "Qubism", + "QUBE": "Qube", + "QUBIC": "Qubic", + "QUBITICA": "Qubitica", + "QUBY": "Quby", + "QUDEFI": "Qudefi", + "QUE": "Queen Of Memes", + "QUEEN": "Queen of Engrand", + "QUICK": "Quickswap", + "QUICKOLD": "Quickswap", + "QUIDD": "Quidd", + "QUIL": "Wrapped QUIL", + "QUILL": "InkFinance", + "QUIN": "QUINADS", + "QUINT": "Quint", + "QUIPU": "QuipuSwap Governance Token", + "QUIZ": "Quizando", + "QUNT": "Quants", + "QUO": "Quoll Finance", + "QUQ": "quq", + "QUROZ": "Qfora", + "QUSD": "QUSD", + "QUSDT": "Tether (Qom Bridge)", + "QVT": "Qvolta", + "QWAN": "The QWAN", + "QWARK": "Qwark", + "QWC": "Qwertycoin", + "QWEN": "Qwen AI", + "QWLA": "Qawalla", + "QWT": "QoWatt", + "QXC": "QuantumXC", + "R1": "Recast1", + "R2R": "CitiOs", + "R34P": "R34P", + "R3FI": "r3fi.finance", + "R3T": "Real Estate Token", + "R4RE": "R4RE Token", + "RAB": "Rabbit", + "RABB": "Rabbit INU", + "RABBI": "Len \"rabbi\" Sassaman", + "RABBIT": "Rabbit Finance", + "RABBITC": "RabBitcoin", + "RABET": "Rabet", + "RABI": "Rabi", + "RAC": "RAcoin", + "RACA": "Radio Caca", + "RACEFI": "RaceFi", + "RACING": "Racing Club Fan Token", + "RAD": "Radworks", + "RADAR": "DappRadar", + "RADI": "RadicalCoin", + "RADIO": "RadioShack", + "RADR": "RADR", + "RADX": "Radx AI", + "RAFF": "Ton Raffles", + "RAFFLES": "Degen Raffles", + "RAFL": "RAFL", + "RAFT": "Raft", + "RAGDOLL": "Ragdoll", + "RAGE": "Rage Fan", + "RAGET": "RAGE", + "RAI": "Reploy", + "RAID": "Raid Token", + "RAIDER": "Crypto Raiders", + "RAIF": "RAI Finance", + "RAIIN": "Raiin", + "RAIL": "Railgun", + "RAILS": "Rails Token", + "RAIN": "Rain", + "RAINBOW": "Rainbow Token", + "RAINC": "RainCheck", + "RAINCO": "Rain Coin", + "RAINI": "Rainicorn", + "RAINMAKER": "Rainmaker Games", + "RAIREFLEX": "Rai Reflex Index", + "RAISE": "Raise Token", + "RAIT": "Rabbitgame", + "RAITOKEN": "RAI", + "RAIZER": "RAIZER", + "RAK": "Rake Finance", + "RAKE": "Rake Coin", + "RAKU": "RAKUN", + "RALLY": "Trump Rally", + "RAM": "Ramifi Protocol", + "RAMA": "Ramestta", + "RAME": "Ramen", + "RAMEN": "RamenSwap", + "RAMON": "Ramon", + "RAMP": "RAMP", + "RANKER": "RankerDao", + "RAP": "Philosoraptor", + "RAPDOGE": "RapDoge", + "RAPTOR": "Jesus-Raptor", + "RAR": "Rare Pepe", + "RARE": "SuperRare", + "RARI": "Rarible", + "RASTA": "ZionLabs Token", + "RAT": "RatCoin", + "RATECOIN": "Ratecoin", + "RATING": "DPRating", + "RATIO": "Ratio Governance Token", + "RATO": "Rato The Rat", + "RATOTHERAT": "Rato The Rat", + "RATS": "Rats", + "RATWIF": "RatWifHat", + "RAVE": "RaveDAO", + "RAVELOUS": "Ravelous", + "RAVEN": "Raven Protocol", + "RAVENCOINC": "Ravencoin Classic", + "RAVENDEX": "Ravendex", + "RAWDOG": "RawDog", + "RAWG": "RAWG", + "RAY": "Raydium", + "RAYS": "Rays Network", + "RAZE": "Raze Network", + "RAZOR": "Razor Network", + "RAZORCOIN": "RazorCoin", + "RB": "REBorn", + "RBBT": "RabbitCoin", + "RBC": "Rubic", + "RBD": "Rubidium", + "RBDT": "RoBust Defense Token", + "RBIES": "Rubies", + "RBIF": "Robo Inu Finance", + "RBIS": "ArbiSmart", + "RBIT": "ReturnBit", + "RBLS": "Rebel Bots", + "RBLZ": "RebelSatoshi", + "RBN": "Ribbon Finance", + "RBNB": "StaFi Staked BNB", + "RBNT": "Redbelly Network", + "RBP": "Rare Ball Potion", + "RBR": "Ribbit Rewards", + "RBRETT": "ROARING BRETT", + "RBT": "RebootWorld", + "RBTC": "Smart Bitcoin", + "RBUNNY": "Rocket Bunny", + "RBW": "Crypto Unicorns Rainbow", + "RBX": "RabbitX", + "RBXDEFI": "RBX", + "RBXS": "RBXSamurai", + "RBY": "RubyCoin", + "RC": "Russiacoin", + "RC20": "RoboCalls", + "RCADE": "RCADE", + "RCC": "Reality Clash", + "RCCC": "RCCC", + "RCG": "Recharge", + "RCGE": "RCGE", + "RCH": "Rich", + "RCHV": "Archivas", + "RCKT": "RocketSwap", + "RCM": "READ2N", + "RCN": "Ripio", + "RCOIN": "ArCoin", + "RCOINEU": "RCoin", + "RCT": "RealChain", + "RCX": "RedCrowCoin", + "RD": "Round Dollar", + "RDAC": "Redacted Coin", + "RDC": "Ordocoin", + "RDD": "Reddcoin", + "RDDT": "Reddit", + "RDEX": "Orders.Exchange", + "RDF": "ReadFi", + "RDGX": "R-DEE Protocol", + "RDMP": "Roadmap Coin", + "RDN": "Raiden Network Token", + "RDNT": "Radiant Capital", + "RDNTV1": "Radiant Capital v1", + "RDO": "RDO Token", + "RDOG": "Repost Dog", + "RDPX": "Dopex Rebate Token", + "RDR": "Rise of Defenders", + "RDS": "Reger Diamond", + "RDT": "Ridotto", + "RDX": "Redux Protocol", + "REA": "Realisto", + "REACH": "/Reach", + "REACT": "Reactive Network", + "READY": "READY!", + "REAL": "RealLink", + "REALESTATE": "RealEstate", + "REALIS": "Realis Worlds", + "REALM": "Realm", + "REALMS": "Realms of Ethernity", + "REALP": "Real Pepe", + "REALPLATFORM": "REAL", + "REALPUMPITCOIN": "PUMP", + "REALR": "Real Realm", + "REALTRACT": "RealTract", + "REALUSD": "Real USD", + "REALUSDV1": "Real USD v1", + "REALUSDV2": "Real USD v2", + "REALY": "Realy Metaverse", + "REALYN": "Real", + "REAP": "ReapChain", + "REAPER": "Grim Finance", + "REAT": "REAT", + "REAU": "Vira-lata Finance", + "REBD": "REBORN", + "REBL": "REBL", + "REBUS": "Rebuschain", + "REC": "Rec Token (REC)", + "RECA": "The Resistance Cat", + "RECALL": "Recall", + "RECKOON": "Reckoon", + "RECOM": "Recom", + "RECON": "RECON", + "RECORD": "Music Protocol", + "RECT": "ReflectionAI", + "RED": "RedStone", + "REDC": "RedCab", + "REDCO": "Redcoin", + "REDDIT": "Reddit", + "REDFEG": "RedFEG", + "REDFLOKI": "Red Floki", + "REDI": "REDi", + "REDLANG": "RED", + "REDLC": "Redlight Chain", + "REDLUNA": "Redluna", + "REDN": "Reden", + "REDNOTE": "RedNote Xiaohongshu", + "REDO": "Resistance Dog", + "REDP": "Red Ponzi Gud", + "REDPEPE": "Red Pepe", + "REDTH": "Red The Mal", + "REDTOKEN": "RED TOKEN", + "REDX": "REDX", + "REDZILLA": "REDZILLA COIN", + "REE": "ReeCoin", + "REEE": "REEE", + "REEF": "Reef", + "REELT": "Reel Token", + "REF": "Ref Finance", + "REFI": "Realfinance Network", + "REFLECT": "REFLECT", + "REFLECTO": "Reflecto", + "REFTOKEN": "RefToken", + "REFUND": "Refund", + "REG": "RealToken Ecosystem Governance", + "REGALCOIN": "Regalcoin", + "REGE": "Regent of the North Winds", + "REGEN": "Regen Network", + "REGENT": "REGENT COIN", + "REGI": "Resistance Girl", + "REGRET": "Regret", + "REHA": "Resistance Hamster", + "REHAB": "NFT Rehab", + "REI": "REI Network", + "REIGN": "Reign of Terror", + "REINDEER": "Reindeer", + "REK": "Rekt", + "REKT": "Rekt", + "REKTV1": "REKT", + "REKTV2": "REKT 2.0", + "REKTV3": "REKT v3 (rekt.game)", + "REL": "Reliance", + "RELAY": "Relay Token", + "RELI": "Relite Finance", + "RELIGN": "RELIGN", + "RELOADED": "Doge Reloaded", + "RELVT": "Relevant", + "REM": "REMME", + "REMCO": "Remco", + "REME": "REME-Coin", + "REMILIA": " Remilia", + "REMIT": "BlockRemit", + "REMMETA": "Real Estate Metaverse", + "REMUS": "REMUS", + "REN": "REN", + "RENA": "Warena", + "RENBTC": "renBTC", + "RENC": "RENC", + "RENDER": "Render Network", + "RENDOGE": "renDOGE", + "RENEC": "RENEC", + "RENQ": "Renq Finance", + "RENS": "Rens", + "RENT": "Rent AI", + "RENTA": "Renta Network", + "RENTBE": "Rentberry", + "REP": "Augur", + "REPE": "Resistance Pepe", + "REPO": "Repo Coin", + "REPPO": "REPPO", + "REPUB": "Republican", + "REPUBLICAN": "Republican", + "REPUX": "Repux", + "REPV1": "Reputation", + "REQ": "Request Network", + "RES": "Resistance", + "RESCUE": "Rescue", + "RESOLV": "Resolv", + "REST": "Restore", + "RET": "Renewable Energy", + "RETA": "Realital Metaverse", + "RETAIL": "Retail.Global", + "RETAR": "Retard Finder Coin", + "RETARD": "retardcoin", + "RETARDIA": "RETARDIA", + "RETARDIO": "RETARDIO", + "RETH": "Rocket Pool ETH", + "RETH2": "rETH2", + "RETIK": "Retik Finance", + "RETIRE": "Retire Token", + "RETSA": "Retsa Coin", + "REU": "REUCOIN", + "REUNI": "Reunit Wallet", + "REUSDC": "Relend USDC", + "REV": "Revain", + "REV3L": "REV3AL", + "REVA": "Revault Network", + "REVAL": "RevaLink Wallet Token", + "REVE": "Revenu", + "REVO": "Revomon", + "REVOAI": "revoAI", + "REVOL": "Revolution", + "REVOLAND": "Revoland Governance Token", + "REVON": "RevoNetwork", + "REVU": "Revuto", + "REVV": "REVV", + "REW": "Review.Network", + "REWARD": "Rewardable", + "REWARDS": "Solana Rewards", + "REX": "REVOX", + "REXBT": "rexbt by VIRTUALS", + "REXHAT": "rexwifhat", + "REZ": "Renzo", + "RF": "Raido Financial", + "RFC": "Royal Finance Coin", + "RFCTR": "Reflector.Finance", + "RFD": "RefundCoin", + "RFDB": "Refund", + "RFG": "Refugees Token", + "RFI": "reflect.finance", + "RFKJ": "Independence Token", + "RFL": "RAFL", + "RFOX": "RedFOX Labs", + "RFR": "Refereum", + "RFRM": "Reform DAO", + "RFT": "Rangers Fan Token", + "RFUEL": "Rio DeFi", + "RFX": "Reflex", + "RGAME": "RGAMES", + "RGC": "RG Coin", + "RGEN": "Paragen", + "RGOAT": "RealGOAT", + "RGOLD": "Royal Gold", + "RGP": "Rigel Protocol", + "RGT": "Rari Governance Token", + "RHEA": "Rhea", + "RHEATOKEN": "Rhea", + "RHINO": "RHINO", + "RHINOMARS": "RhinoMars", + "RHOC": "RChain", + "RHP": "Rhypton Club", + "RHUB": "ROLLHUB", + "RIA": "aRIA Currency", + "RIB": "Ribus", + "RIBB": "Ribbit", + "RIBBIT": "Ribbit", + "RIC": "Riecoin", + "RICE": "RICE AI", + "RICECOIN": "RiceCoin", + "RICEFARM": "RiceFarm", + "RICH": "GET RICH QUICK", + "RICHCOIN": "RICHCOIN", + "RICHIE": "Richie2.0", + "RICHIEV1": "Richie", + "RICHOFME": "Rich Of Memes", + "RICHR": "RichRabbit", + "RICK": "Infinite Ricks", + "RICKMORTY": "Rick And Morty", + "RIDE": "Holoride", + "RIDECHAIN": "Ride Chain Coin", + "RIDEMY": "Ride My Car", + "RIF": "RIF Token", + "RIF3": "MetaTariffv3", + "RIFA": "Rifampicin", + "RIFI": "Rikkei Finance", + "RIFT": "RIFT AI", + "RIFTS": "Rifts Finance", + "RIGEL": "Rigel Finance", + "RIK": "RIKEZA", + "RIL": "Rilcoin", + "RIM": "MetaRim", + "RIMBIT": "Rimbit", + "RIN": "Aldrin", + "RING": "Darwinia Network", + "RINGA": "Ring AI", + "RINGF": "Ring Financial", + "RINGX": "RING X PLATFORM", + "RINIA": "Rinia Inu", + "RINO": "Rino", + "RINT": "Rin Tin Tin", + "RINTARO": "Rintaro", + "RINU": "Raichu Inu", + "RIO": "Realio Network", + "RION": "Hyperion", + "RIOT": "Riot Racers", + "RIOV1": "Realio Network v1", + "RIP": "Fantom Doge", + "RIPAX": "RipaEx", + "RIPO": "RipOffCoin", + "RIPT": "RiptideCoin", + "RIPTO": "RiptoBuX", + "RIS": "Riser", + "RISE": "EverRise", + "RISEP": "Rise Protocol", + "RISEVISION": "Rise", + "RISITA": "Risitas", + "RITA": "Rita Elite Order", + "RITE": "ritestream", + "RITO": "Ritocoin", + "RITZ": "Ritz.Game", + "RIVER": "River", + "RIVERPTS": "River Point Reward Token", + "RIVUS": "RivusDAO", + "RIYA": "Etheriya", + "RIZ": "Rivalz Network", + "RIZE": "RIZE", + "RIZESPOR": "Rizespor Token", + "RIZO": "HahaYes", + "RIZOLOL": "Rizo", + "RIZZ": "Rizz", + "RIZZMAS": "Rizzmas", + "RJV": "Rejuve.AI", + "RKC": "Royal Kingdom Coin", + "RKEY": "RKEY", + "RKI": "RAKHI", + "RKN": "RAKON", + "RKR": "REAKTOR", + "RKT": "Rock Token", + "RLB": "Rollbit Coin", + "RLC": "iExec", + "RLM": "MarbleVerse", + "RLOOP": "rLoop", + "RLP": "Resolv RLP", + "RLS": "Rayls", + "RLT": "Runner Land", + "RLTM": "RealityToken", + "RLUSD": "Ripple USD", + "RLX": "Relex", + "RLY": "Rally", + "RMATIC": "StaFi Staked MATIC", + "RMBCASH": "RMBCASH", + "RMC": "Russian Mining Coin", + "RMESH": "RightMesh", + "RMK": "KIM YONG EN", + "RMOB": "RewardMob", + "RMPL": "RMPL", + "RMRK": "RMRK.app", + "RMS": "Resumeo Shares", + "RMT": "SureRemit", + "RMV": "Reality Metaverse", + "RNAPEPE": "RNA PEPE", + "RNB": "Rentible", + "RNBW": "Rainbow Token", + "RNC": "ReturnCoin", + "RND": "The RandomDAO", + "RNDR": "Render Token", + "RNDX": "Round X", + "RNEAR": "Near (Rainbow Bridge)", + "RNGR": "Ranger", + "RNS": "RenosCoin", + "RNT": "REAL NIGGER TATE", + "RNTB": "BitRent", + "RNX": "ROONEX", + "ROA": "ROA CORE", + "ROAD": "ROAD", + "ROAM": "Roam Token", + "ROAR": "Alpha DEX", + "ROARINGCAT": "Roaring Kitty", + "ROB": "ROB", + "ROBET": "RoBet", + "ROBI": "Robin Rug", + "ROBIN": "Robin of Da Hood", + "ROBINH": "ROBIN HOOD", + "ROBO": "RoboHero", + "ROBOCOIN": "First Bitcoin ATM", + "ROBOTA": "TAXI", + "ROBOTAXI": "ROBOTAXI", + "ROC": "Rasputin Online Coin", + "ROCCO": "Just A Rock", + "ROCK": "Zenrock", + "ROCK2": "Ice Rock Mining", + "ROCKET": "Team Rocket", + "ROCKETCOIN": "RocketCoin", + "ROCKETFI": "RocketFi", + "ROCKI": "Rocki", + "ROCKY": "Rocky", + "ROCKYCOIN": "ROCKY", + "ROCO": "ROCO FINANCE", + "RODAI": "ROD.AI", + "RODEO": "Rodeo Finance", + "ROE": "Rover Coin", + "ROG": "ROGin AI", + "ROGER": "ROGER", + "ROI": "ROIcoin", + "ROK": "Rockchain", + "ROKM": "Rocket Ma", + "ROKO": "Roko", + "ROLL": "Roll", + "ROLLSROYCE": "RollsRoyce", + "ROLS": "RollerSwap", + "ROM": "ROMCOIN", + "ROME": "Rome", + "RONALDINHO": "Ronaldinho Soccer Coin", + "RONCOIN": "RON", + "ROND": "ROND", + "RONIN": "Ronin", + "RONNIE": "Ronnie", + "ROO": "Lucky Roo", + "ROOBEE": "ROOBEE", + "ROOK": "KeeperDAO", + "ROOM": "OptionRoom", + "ROON": "Raccoon", + "ROOST": "Roost Coin", + "ROOSTV1": "Roost Coin v1", + "ROOT": "The Root Network", + "ROOTCOIN": "RootCoin", + "ROOTS": "RootProject", + "ROP": "Redemption Of Pets", + "ROPE": "Rope Token", + "ROPELOL": "Rope", + "ROPIRITO": "Ropirito", + "ROS": "ROS Coin", + "ROSA": "Rosa Inu", + "ROSCOE": "Roscoe", + "ROSE": "Oasis Labs", + "ROSEC": "Rosecoin", + "ROSEW": "RoseWifHat", + "ROSN": "Roseon Finance", + "ROSS": "Ross Ulbricht", + "ROSX": "Roseon", + "ROT": "Rotten", + "ROTTY": "ROTTYCOIN", + "ROUGE": "Rouge Studio", + "ROUND": "RoundCoin", + "ROUP": "Roup (Ordinals)", + "ROUSH": "Roush Fenway Racing Fan Token", + "ROUTE": "Router Protocol", + "ROUTEV1": "Router Protocol v1", + "ROUTINE": "Morning Routine", + "ROVI": "ROVI", + "ROVR": "ROVR Network", + "ROW": "Rage On Wheels", + "ROWAN": "Sifchain", + "ROX": "Robotina", + "ROXY": "ROXY FROG", + "ROY": "Crypto Royale", + "ROYA": "Royale", + "ROYAL": "RoyalCoin", + "RPB": "Republia", + "RPC": "RonPaulCoin", + "RPD": "Rapids", + "RPEPEc": "RoaringPepe", + "RPG": "Rangers Protocol", + "RPGV1": "Rangers Protocol v1", + "RPILL": "Red Pill", + "RPK": "RepubliK", + "RPL": "RocketPool", + "RPLAY": "Replay", + "RPM": "Render Payment", + "RPR": "The Reaper", + "RPS": "Rps League", + "RPT": "Rug Proof", + "RPTR": "Raptor Finance", + "RPUT": "Robin8 Profile Utility Token", + "RPX": "Red Pulse Token", + "RPZX": "Rapidz", + "RRB": "Renrenbit", + "RRC": "Recycling Regeneration Chain", + "RREN": "REN (Rainbow Bridge)", + "RRT": "Recovery Right Tokens", + "RS": "ReadySwap", + "RSC": "ResearchCoin", + "RSETH": "Kelp DAO Restaked ETH", + "RSF": "Royal Sting", + "RSG": "RSG TOKEN", + "RSIC": "RSIC•GENESIS•RUNE", + "RSIN": "Roketsin", + "RSO": "Real Sociedad Fan Token", + "RSPN": "Respan", + "RSR": "Reserve Rights", + "RSRV": "Reserve", + "RSRV1": "Reserve Rights v1", + "RSS3": "RSS3", + "RST": "REGA Risk Sharing Token", + "RSTK": "Restake Finance", + "RSUN": "RisingSun", + "RSUSHI": "Sushi (Rainbow Bridge)", + "RSV": "Reserve", + "RSVV1": "Reserve v1", + "RSWETH": "Restaked Swell Ethereum", + "RT2": "RotoCoin", + "RTB": "AB-CHAIN", + "RTBL": "Rolling T-bill", + "RTC": "Reltime", + "RTD": "Retard", + "RTE": "Rate3", + "RTF": "Ready to Fight", + "RTH": "Rotharium", + "RTK": "RetaFi", + "RTM": "Raptoreum", + "RTR": "Restore The Republic", + "RTT": "Restore Truth Token", + "RTX": "RateX", + "RU": "RIFI United", + "RUBB": "Rubber Ducky Cult", + "RUBCASH": "RUBCASH", + "RUBIT": "Rublebit", + "RUBIUS": "Rubius", + "RUBIX": "Rubix", + "RUBMEME": "Reverse Unit Bias", + "RUBX": "eToro Russian Ruble", + "RUBY": "RubyToken", + "RUBYEX": "Ruby.Exchange", + "RUC": "Rush", + "RUFF": "Ruff", + "RUG": "RUGMAN", + "RUGA": "RUGAME", + "RUGMONEY": "Rug", + "RUGPROOF": "Launchpad", + "RUGPULL": "Captain Rug Pull", + "RUGZ": "pulltherug.finance", + "RULER": "Ruler Protocol", + "RUM": "RUM Pirates of The Arrland Token", + "RUN": "Speedrun", + "RUNE": "Thorchain", + "RUNESX": "RUNES·X·BITCOIN", + "RUNEVM": "RUNEVM", + "RUNI": "Runesterminal", + "RUNNER": "Runner", + "RUNNODE": "Run", + "RUNWAGO": "Runwago", + "RUNY": "Runy", + "RUP": "Rupee", + "RUPX": "Rupaya", + "RURI": "Ruri - Truth Terminal's Crush", + "RUSD": "Reflecto USD", + "RUSH": "RUSH COIN", + "RUSHCMC": "RUSHCMC", + "RUSSELL": "Russell", + "RUST": "RustCoin", + "RUSTBITS": "Rustbits", + "RUTH": "RUTH", + "RUUF": "RuufCoin", + "RUX": "Gacrux NFT", + "RVC": "Revenue Coin", + "RVF": "RocketX exchange", + "RVFV1": "RocketX exchange v1", + "RVFV2": "RocketX exchange v2", + "RVL": "Revolotto", + "RVLNG": "RevolutionGames", + "RVLT": "Revolt 2 Earn", + "RVLTV1": "Revolt 2 Earn v1", + "RVM": "Realvirm", + "RVN": "Ravencoin", + "RVO": "AhrvoDEEX", + "RVP": "Revolution Populi", + "RVR": "Revolution VR", + "RVST": "Revest Finance", + "RVT": "Rivetz", + "RVV": "REVIVE", + "RVX": "Rivex", + "RWA": "Allo", + "RWAECO": "RWA Ecosystem", + "RWAI": "RWA Inc.", + "RWAS": "RWA Finance", + "RWB": "RawBlock", + "RWD": "Reward Vision", + "RWE": "Real-World Evidence", + "RWN": "Rowan Token", + "RWS": "Robonomics Web Services", + "RWT": "RWT TOKEN", + "RX": "RealtyX", + "RXCG": "RXCGames", + "RXD": "Radiant", + "RXO": "RocketXRP Official", + "RXR": "RXR Coin", + "RXT": "RIMAUNANGIS", + "RYAN": "OFFICIAL RYAN", + "RYC": "RoyalCoin", + "RYCN": "RoyalCoin 2.0", + "RYD": "RYderOSHI", + "RYIU": "RYI Unity", + "RYO": "RYO Coin", + "RYOCURRENCY": "Ryo", + "RYOMA": "Ryoma", + "RYOSHI": "Ryoshis Vision", + "RYS": "RefundYourSOL", + "RYT": "Real Yield Token", + "RYU": "The Blue Dragon", + "RYZ": "Anryze", + "RZR": "Rezor", + "RZTO": "RZTO Token", + "RZUSD": "RZUSD", + "RedFlokiCEO": "Red Floki CEO", + "S": "Sonic Labs", + "S2K": "Sports 2K75", + "S315": "SWAP315", + "S4F": "S4FE", + "S8C": "S88 Coin", + "SA": "Superalgos", + "SAAD": "Saad Boi", + "SAAS": "SaaSGo", + "SABAI": "Sabai Protocol", + "SABER": "Saber", + "SABLE": "Sable Finance", + "SABR": "SABR Coin", + "SAC1": "Sable Coin", + "SACKS": "SackFurie", + "SAD": "SadCat", + "SAF": "Safinus", + "SAFE": "Safe", + "SAFEBTC": "SafeBTC", + "SAFEBULL": "SafeBull", + "SAFECOIN": "SafeCoin", + "SAFEGROK": "SafeGrok", + "SAFEHAMSTERS": "SafeHamsters", + "SAFELIGHT": "SafeLight", + "SAFELUNAR": "SafeLunar", + "SAFEM": "SAFEMOON SOLANA", + "SAFEMARS": "Safemars", + "SAFEMOO": "SafeMoo", + "SAFEMOON": "SafeMoon", + "SAFEMOONCASH": "SafeMoonCash", + "SAFEMUUN": "Safemuun", + "SAFEREUM": "Safereum", + "SAFES": "SafeSwap", + "SAFESTAR": "Safe Star", + "SAFESV1": "SafeSwap v1", + "SAFET": "SafemoonTon", + "SAFEX": "SafeExchangeCoin", + "SAFLE": "Safle", + "SAFTP": "Simple Agreement for Future Tokens", + "SAFUU": "SAFUU", + "SAGA": "Saga", + "SAGACOIN": "SagaCoin", + "SAGE": "Ceremonies AI", + "SAHA": "Sahara AI Coin", + "SAHARA": "Sahara AI", + "SAI": "Sharpe AI", + "SAIL": "SAIL", + "SAITA": "SaitaChain", + "SAITABIT": "SaitaBit", + "SAITAMA": "Saitama Inu", + "SAITAMAV1": "Saitama v1", + "SAITANOBI": "Saitanobi", + "SAITO": "Saito", + "SAIV1": "SAI", + "SAIY": "Saiyan PEPE", + "SAK": "SharkCoin", + "SAKAI": "Sakai Vault", + "SAKATA": "Sakata Inu", + "SAKE": "SakeToken", + "SAKURACOIN": "Sakuracoin", + "SAL": "Salvium", + "SALD": "Salad", + "SALE": "DxSale Network", + "SALL": "Sallar", + "SALLY": "SALAMANDER", + "SALMAN": "Mohameme Bit Salman", + "SALMON": "Salmon", + "SALPAY": "SalPay", + "SALT": "Salt Lending", + "SALUTE": "Salute", + "SAM": "Samsunspor Fan Token", + "SAMA": "Moonsama", + "SAMMY": "Samoyed", + "SAMO": "Samoyedcoin", + "SAMS": "Samsara.Build", + "SAN": "San Chan", + "SANA": "Storage Area Network Anywhere", + "SANCHO": "Sancho", + "SAND": "The Sandbox", + "SANDG": "Save and Gain", + "SANDWICH": " Sandwich Network", + "SANDY": "Sandy", + "SANI": "Sanin Inu", + "SANIN": "Sanin", + "SANJI": "Sanji Inu", + "SANSFOREST": "FOREST", + "SANSHU": "Sanshu Inu", + "SANTA": "SANTA CHRISTMAS INU", + "SANTAGROK": "Santa Grok", + "SANTAHAT": "SANTA HAT", + "SANTI": "Santiment", + "SANTOS": "Santos FC Fan Token", + "SAO": "Sator", + "SAP": "SwapAll", + "SAPE": "SolanaApe", + "SAPIEN": "Sapien", + "SAPP": "Sapphire", + "SAPPC": "SappChat", + "SAR": "Saren", + "SARA": "Pulsara", + "SARAH": "Sarah", + "SARCO": "Sarcophagus", + "SARM": "Stella Armada", + "SAROS": "Saros", + "SAS": "Stand Share", + "SASHA": "SASHA CAT", + "SASHIMI": "Sashimi", + "SAT": "Satisfaction Token", + "SAT2": "Saturn2Coin", + "SATA": "Signata", + "SATAN": "MrBeast's Cat", + "SATO": "Atsuko Sato", + "SATOEXCHANGE": "SatoExchange Token", + "SATOPAY": "SatoPay", + "SATORI": "Satori Network", + "SATOSHI": "SATOSHI•NAKAMOTO", + "SATOSHINAKAMOTO": "Satoshi Nakamoto", + "SATOTHEDOG": "Sato The Dog", + "SATOX": "Satoxcoin", + "SATOZ": "Satozhi", + "SATS": "SATS (Ordinals)", + "SATSALL": "ALL BEST ICO SATOSHI", + "SATT": "SaTT", + "SATX": "SATX", + "SAUBER": "Alfa Romeo Racing ORLEN Fan Token", + "SAUCE": "SaucerSwap", + "SAUCEINU": "SAUCEINU", + "SAUDIBONK": "Saudi Bonk", + "SAUDIPEPE": "SAUDI PEPE", + "SAUDISHIB": "Saudi Shiba Inu", + "SAUNA": "SaunaFinance Token", + "SAV": "Save America", + "SAV3": "SAV3", + "SAVAX": "BENQI Liquid Staked AVAX", + "SAVEOCEAN": "Save The Ocean", + "SAVG": "SAVAGE", + "SAVM": "SatoshiVM", + "SAVVA": "SAVVA", + "SAY": "SAY Coin", + "SB": "DragonSB", + "SBA": "simplyBrand", + "SBABE": "SNOOPYBABE", + "SBAE": "Salt Bae For The People", + "SBB": "Slim Beautiful Bill", + "SBC": "StableCoin", + "SBCC": "Smart Block Chain City", + "SBCH": "Smart Bitcoin Cash", + "SBE": "Sombe", + "SBEFE": "BEFE", + "SBET": "SBET", + "SBF": "SBF In Jail", + "SBGO": "Bingo Share", + "SBIO": "Vector Space Biosciences, Inc.", + "SBNB": "Binance Coin (SpookySwap)", + "SBOX": "SUIBOXER", + "SBR": "STRATEGIC BITCOIN RESERVE", + "SBRT": "SaveBritney", + "SBSC": "Subscriptio", + "SBT": "SOLBIT", + "SBTC": "Super Bitcoin", + "SC": "Siacoin", + "SC20": "Shine Chain", + "SCA": "Scallop", + "SCALE": "Scalia Infrastructure", + "SCALR": "Scalr", + "SCAM": "Scam Coin", + "SCAMP": "ScamPump", + "SCANS": "0xScans", + "SCAP": "SafeCapital", + "SCAPE": "Etherscape", + "SCAR": "Velhalla", + "SCARAB": "Scarab Finance", + "SCARCITY": "SCARCITY", + "SCASH": "SpaceCash", + "SCAT": "Sad Cat Token", + "SCC": "StockChain Coin", + "SCCP": "S.C. Corinthians Fan Token", + "SCDS": "Shrine Cloud Storage Network", + "SCF": "Smoking Chicken Fish", + "SCFX": "Shui CFX", + "SCH": "SoccerHub", + "SCHO": "Scholarship Coin", + "SCHR": "Schrodinger", + "SCHRO": "Schrodinger", + "SCHRODI": "Schrödi", + "SCIA": "Stem Cell", + "SCIHUB": "sci-hub", + "SCIVIVE": "sciVive", + "SCIX": "Scientix", + "SCK": "Space Corsair Key", + "SCL": "Sociall", + "SCLASSIC": "Solana Classic", + "SCLP": "Scallop", + "SCM": "ScamFari token", + "SCN": "Swiscoin", + "SCNR": "Swapscanner", + "SCNSOL": "Socean Staked Sol", + "SCO": "SCOPE", + "SCOIN": "ShinCoin", + "SCONE": "Sportcash One", + "SCOOBY": "Scooby coin", + "SCOR": "Scor", + "SCORE": "Scorecoin", + "SCOT": "Scotcoin", + "SCOTT": "Scottish", + "SCOTTY": "Scotty Beam", + "SCP": "ScPrime", + "SCPT": "Script Network", + "SCRAP": "Scrap", + "SCRAPPY": "Scrappy", + "SCRAT": "Scrat", + "SCRATCH": "Scratch", + "SCREAM": "Scream", + "SCRIBE": "Scribe Network", + "SCRIV": "SCRIV", + "SCRL": "Scroll", + "SCRM": "Scorum", + "SCROLL": "Scroll Network", + "SCROLLY": "Scrolly the map", + "SCROOGE": "Scrooge", + "SCRPT": "ScryptCoin", + "SCRT": "Secret", + "SCRVUSD": "Savings crvUSD", + "SCRYPTA": "Scrypta", + "SCRYPTTOKEN": "ScryptToken", + "SCS": "Solcasino Token", + "SCSX": "Secure Cash", + "SCT": "SuperCells", + "SCTK": "SharesChain", + "SCTRL": "SOLCONTROL", + "SCUBA": "Scuba Dog", + "SCY": "Synchrony", + "SD": "Stader", + "SDA": "SDChain", + "SDAI": "Savings Dai", + "SDAO": "SingularityDAO", + "SDC": "ShadowCash", + "SDCRV": "Stake DAO CRV", + "SDEUSD": "Staked deUSD", + "SDEX": "SmarDex", + "SDL": "Saddle Finance", + "SDM": "Shieldeum", + "SDME": "SDME", + "SDN": "Shiden Network", + "SDO": "TheSolanDAO", + "SDOG": "Small Doge", + "SDOGE": "SpaceXDoge", + "SDOPE": "SHIBADOGEPEPE", + "SDP": "SydPakCoin", + "SDR": "SedraCoin", + "SDRN": "Senderon", + "SDS": "Alchemint Standards", + "SDT": "TerraSDT", + "SDUSD": "SDUSD", + "SDX": "SwapDEX", + "SEAGULL": "SEAGULL SAM", + "SEAIO": "Second Exchange Alliance", + "SEAL": "Seal", + "SEALFINANCE": "Seal Finance", + "SEALN": "Seal Network", + "SEAM": "Seamless Protocol", + "SEAMLESS": "SeamlessSwap", + "SEAN": "Starfish Finance", + "SEAS": "Seasons", + "SEAT": "Seamans Token", + "SEATLABNFT": "SeatlabNFT", + "SEBA": "Seba", + "SEC": "SecureCryptoPayments", + "SECO": "Serum Ecosystem Token", + "SECOND": "MetaDOS", + "SECRT": "SecretCoin", + "SECT": "SECTBOT", + "SECTO": "Sector Finance", + "SEDA": "SEDA Protocol", + "SEED": "Superbloom", + "SEEDS": "SeedShares", + "SEEDV": "Seed Venture", + "SEEDX": "SEEDx", + "SEEK": "Talisman", + "SEELE": "Seele", + "SEEN": "SEEN", + "SEER": "SEER", + "SEFA": "Mesefa", + "SEG": "Solar Energy", + "SEI": "Sei", + "SEILOR": "Kryptonite", + "SEIYAN": "Seiyan Token", + "SEKAI": "Sekai DAO", + "SEKOIA": "sekoia by Virtuals", + "SEL": "SelenCoin", + "SELF": "SELFCrypto", + "SELFI": "SelfieSteve", + "SELFIE": "SelfieDogCoin", + "SELFIEC": "Selfie Cat", + "SELFT": "SelfToken", + "SELLC": "Sell Token", + "SELO": "SELO+", + "SEM": "Semux", + "SEN": "Sentaro", + "SENA": "Ethena Staked ENA", + "SENATE": "SENATE", + "SENC": "Sentinel Chain", + "SEND": "Suilend", + "SENDCOIN": "Sendcoin", + "SENDOR": "Sendor", + "SENK": "Senk", + "SENNO": "SENNO", + "SENSE": "Sense Token", + "SENSI": "Sensi", + "SENSO": "SENSO", + "SENSOR": "Sensor Protocol", + "SENSOV1": "SENSO v1", + "SENSUS": "Sensus", + "SENT": "Sentient", + "SENTAI": "SentAI", + "SENTI": "Sentinel Bot Ai", + "SENTIS": "Sentism AI Token", + "SENTR": "Sentre Protocol", + "SEON": "Seedon", + "SEOR": "SEOR Network", + "SEOS": "Smart Eye Operating System", + "SEP": "Smart Energy Pay", + "SEPA": "Secure Pad", + "SEQ": "Sequence", + "SER": "Secretum", + "SERAPH": "Seraph", + "SERG": "Seiren Games Network", + "SERO": "Super Zero", + "SERP": "Shibarium Perpetuals", + "SERSH": "Serenity Shield", + "SERV": "OpenServ", + "SERVE": "Metavice", + "SERVEIO": "Serve", + "SESE": "Simpson Pepe", + "SESH": "Session Token", + "SESSIA": "SESSIA", + "SETH": "sETH", + "SETH2": "sETH2", + "SETHER": "Sether", + "SETHH": "Staked ETH Harbour", + "SETS": "Sensitrust", + "SEUR": "Synth sEUR", + "SEW": "simpson in a memes world", + "SEX": "SEX Odyssey", + "SEXY": "EthXY", + "SEXYP": "SEXY PEPE", + "SFAGRO": "SFAGRO", + "SFARM": "SolFarm", + "SFC": "Solarflarecoin", + "SFCP": "SF Capital", + "SFD": "SafeDeal", + "SFEX": "SafeLaunch", + "SFF": "Sunflower Farm", + "SFG": "S.Finance", + "SFI": "Saffron.finance", + "SFIN": "Songbird Finance", + "SFIT": "Sense4FIT", + "SFL": "Sunflower Land", + "SFLOKI": "SuiFloki-Inu", + "SFLR": "Sceptre Staked FLR", + "SFM": "SafeMoon V2", + "SFMV2": "SafeMoon v2", + "SFP": "SafePal", + "SFR": "SaffronCoin", + "SFRAX": "Staked FRAX", + "SFRC": "Safari Crush", + "SFRXETH": "Frax Staked Ether", + "SFRXUSD": "Staked Frax USD", + "SFT": "Fightly", + "SFTMX": "Stader sFTMX", + "SFTY": "Stella Fantasy", + "SFU": "Saifu", + "SFUEL": "SparkPoint Fuel", + "SFUND": "Seedify.fund", + "SFV2": "ShibaFameV2", + "SFX": "SUBX FINANCE LAB", + "SFY": "Stakefy", + "SG": "SocialGood", + "SGA": "Saga", + "SGB": "Songbird", + "SGDX": "eToro Singapore Dollar", + "SGE": "Society of Galactic Exploration", + "SGI": "SmartGolfToken", + "SGLY": "Singularity", + "SGN": "Signals Network", + "SGO": "SafuuGO", + "SGOLD": "SpaceGold", + "SGP": "SGPay", + "SGPT": "ShitGPT", + "SGR": "Schrodinger", + "SGROK": "Super Grok", + "SGT": "SharedStake Governance Token", + "SHA": "Safe Haven", + "SHACK": "Shackleford", + "SHACOIN": "Shacoin", + "SHAD": "Shadowswap Finance", + "SHADE": "ShadeCoin", + "SHAK": "Shakita Inu", + "SHAKE": "Spaceswap SHAKE", + "SHAMAN": "Shaman King Inu", + "SHAN": "Shanum", + "SHANG": "Shanghai Inu", + "SHAR": "Shark Cat", + "SHARBI": "SHARBI", + "SHARDS": "WorldShards", + "SHARE": "Seigniorage Shares", + "SHARECHAIN": "ShareChain", + "SHARES": "shares.finance", + "SHAREV1": "Seigniorage Shares v1", + "SHARK": "Sharky", + "SHARKI": "Sharki", + "SHARKS": "Sharks", + "SHARKYSH": "Sharky Sharkx", + "SHARP": "Sharp", + "SHARPE": "Sharpe Capital", + "SHARPLINK": "SharpLink Gaming", + "SHAUN": "SHAUN INU", + "SHB4": "Super Heavy Booster 4", + "SHC": "School Hack Coin", + "SHD": "ShardingDAO", + "SHDW": "Shadow Token", + "SHDX": "Shido DEX", + "SHE": "Shine Chain", + "SHEB": "SHEBOSHIS", + "SHEEESH": "Secret Gem", + "SHEESH": "Sheesh it is bussin bussin", + "SHEESHA": "Sheesha Finance", + "SHEGEN": "Aiwithdaddyissues", + "SHEI": "SheikhSolana", + "SHELL": "MyShell", + "SHELLTOKEN": "Shell Token", + "SHEN": "Shen", + "SHEPE": "Shiba V Pepe", + "SHERA": "Shera Tokens", + "SHEZMU": "Shezmu", + "SHFL": "Shuffle", + "SHFT": "Shyft Network", + "SHG": "Shib Generating", + "SHI": "Shirtum", + "SHIA": "Shiba Saga", + "SHIB": "Shiba Inu", + "SHIB05": "Half Shiba Inu", + "SHIB1": "Shib1", + "SHIB2": "SHIB2", + "SHIB20": "Shib2.0", + "SHIBA": "Shibaqua", + "SHIBAAI": "SHIBAAI", + "SHIBAC": "SHIBA CLASSIC", + "SHIBACASH": "ShibaCash", + "SHIBADOG": "Shiba San", + "SHIBAI": "AiShiba", + "SHIBAINU": "SHIBA INU", + "SHIBAKEN": "Shibaken Finance", + "SHIBAMOM": "Shiba Mom", + "SHIBANCE": "Shibance Token", + "SHIBAR": "Shibarium Name Service", + "SHIBARMY": "Shib Army", + "SHIBAW": "Shiba $Wing", + "SHIBAY": "Shiba Inu Pay", + "SHIBAZILLA": "ShibaZilla2.0", + "SHIBCAT": "SHIBCAT", + "SHIBCEO": "ShibCEO", + "SHIBDOGE": "ShibaDoge", + "SHIBEINU": "Shibe Inu", + "SHIBELON": "ShibElon", + "SHIBEMP": "Shiba Inu Empire", + "SHIBGF": "Shiba Girlfriend", + "SHIBIC": "SHIBIC", + "SHIBK": "ShibaKeanu", + "SHIBKILLER": "ShibKiller", + "SHIBKING": "Shibking Inu", + "SHIBL": "ShibLa", + "SHIBLITE": "Shiba Lite", + "SHIBMERICAN": "Shibmerican", + "SHIBO": "ShiBonk", + "SHIBON": "SHIB ON SOLANA", + "SHIBS": "Shibsol", + "SHIBTC": "Shibabitcoin", + "SHIBU": "SHIBU INU", + "SHICO": "ShibaCorgi", + "SHIDO": "Shido", + "SHIELD": "Crypto Shield", + "SHIELDNET": "Shield Network", + "SHIFT": "Shift", + "SHIH": "Shih Tzu", + "SHIK": "Shikoku", + "SHIKOKU": "Mikawa Inu", + "SHIL": "Shila Inu", + "SHILL": "SHILL Token", + "SHILLD": "SHILLD", + "SHILLG": "Shill Guard Token", + "SHIN": "Shin Chan", + "SHINA": "Shina Inu", + "SHINJA": "Shibnobi", + "SHINO": "ShinobiVerse", + "SHINOB": "Shinobi", + "SHINT": "Shiba Interstellar", + "SHIP": "ShipChain", + "SHIR": "SHIRO", + "SHIRO": "Shiro Neko", + "SHIROSOL": "Shiro Neko (shirosol.online)", + "SHIRYOINU": "Shiryo-Inu", + "SHISA": "SHISA", + "SHISHA": "Shisha Coin", + "SHIT": "I will poop it NFT", + "SHITC": "Shitcoin", + "SHITCOIN": "just a shitcoin", + "SHIV": "Shiva Inu", + "SHK": "Shrike", + "SHL": "Oyster Shell", + "SHLD": "ShieldCoin", + "SHM": "Shardeum", + "SHND": "StrongHands", + "SHNT": "Sats Hunters", + "SHO": "Showcase Token", + "SHOE": "ShoeFy", + "SHOG": "SHOG", + "SHOGGOTH": "Shoggoth (shoggoth.monster)", + "SHOGGOTHAI": "Shoggoth", + "SHOKI": "Shoki", + "SHON": "ShonToken", + "SHONG": "Shong Inu", + "SHOOK": "SHOOK", + "SHOOT": "Mars Battle", + "SHOOTER": "Top Down Survival Shooter", + "SHOP": "Shoppi Coin", + "SHOPN": "ShopNEXT", + "SHOPX": "Splyt", + "SHORK": "shork", + "SHORT": "Bermuda Shorts", + "SHORTY": "ShortyCoin", + "SHOW": "ShowCoin", + "SHPING": "Shping Coin", + "SHR": "ShareToken", + "SHRA": "Shrapnel", + "SHRAP": "Shrapnel", + "SHRED": "ShredN", + "SHREK": "Shrek", + "SHRI": "Shrimp Paste", + "SHRIMP": "SHRIMP", + "SHROO": "Shroomates", + "SHROOM": "Shroom.Finance", + "SHROOMFOX": "Magic Shroom", + "SHRUB": "Shrub", + "SHRUBIUS": "Shrubius Maximus", + "SHRX": "Sherex", + "SHS": "SHEESH", + "SHU": "Shutter", + "SHUB": "SimpleHub", + "SHUFFLE": "SHUFFLE!", + "SHVR": "Shivers", + "SHX": "Stronghold Token", + "SHXV1": "Stronghold Token v1", + "SHY": "Shytoshi Kusama", + "SHYTCOIN": "ShytCoin", + "SI": "Siren", + "SI14": "Si14", + "SIACLASSIC": "SiaClassic", + "SIB": "SibCoin", + "SIBA": "SibaInu", + "SIC": "Swisscoin", + "SID": "Sid", + "SIDE": "Side.xyz", + "SIDELINED": "Sidelined?", + "SIDELINER": "Sideliner Coin", + "SIDESHIFT": "SideShift Token", + "SIDUS": "Sidus", + "SIERRA": "Sierracoin", + "SIF": "Solana Index Fund", + "SIFT": "Smart Investment Fund Token", + "SIFU": "SIFU", + "SIG": "Signal", + "SIGHT": "Empire of Sight", + "SIGM": "Sigma", + "SIGMA": "SIGMA", + "SIGN": "Sign", + "SIGNA": "Signa", + "SIGNAT": "SignatureChain", + "SIGNMETA": "Sign Token", + "SIGT": "Signatum", + "SIGU": "Singular", + "SIH": "Salient Investment Holding", + "SIKA": "SikaSwap", + "SIL": "SIL Finance Token V2", + "SILENTIS": "Silentis", + "SILK": "SilkCoin", + "SILKR": "SilkRoadCoin", + "SILKT": "SilkChain", + "SILL": "Silly Duck", + "SILLY": "Silly Dragon", + "SILO": "Silo Finance", + "SILV": "Silver Surfer Solana", + "SILV2": "Escrowed Illuvium 2", + "SILVA": "Silva Token", + "SILVER": "SILVER", + "SILVERKRC": "Silver KRC-20", + "SILVERNOV": "Silvernova Token", + "SILVERSTAND": "Silver Standard", + "SILVERWAY": "Silverway", + "SIM": "Simpson", + "SIMBA": "SIMBA The Sloth", + "SIMMI": "Simmi Token", + "SIMON": "Simon the Gator", + "SIMP": "SO-COL", + "SIMPLE": "SimpleChain", + "SIMPS": "Simpson MAGA", + "SIMPSO": "Simpson Neiro", + "SIMPSON": "Homer", + "SIMPSON6900": "Simpson6900 ", + "SIMPSONAI": "Simpson AI Agent", + "SIMPSONF": "Simpson FUKU", + "SIMPSONP": "Simpson Predictions", + "SIMPSONSINU": "The Simpsons Inu", + "SIMPSONT": "Simpson Trump", + "SIMSOL": "SimSol", + "SIN": "Sinverse", + "SINE": "Sinelock", + "SING": "SingularFarm", + "SINGLE": "Single Finance", + "SINGULARRY": "SINGULARRY", + "SINK": "Let that sink in", + "SINS": "SafeInsure", + "SINSO": "SINSO", + "SINX": "SINX Token", + "SIO": "SAINO", + "SION": "FC Sion", + "SIP": "Space SIP", + "SIPHER": "Sipher", + "SIPHON": "Siphon Life Spell", + "SIR": "Sir", + "SIREN": "siren", + "SIRIUS": "first reply", + "SIS": "Symbiosis Finance", + "SISA": "Strategic Investments in Significant Areas", + "SISC": "Shirushi Coin", + "SISHI": "Sishi Finance", + "SIU": "Siu", + "SIUU": "SIUUU", + "SIUUU": "Crustieno Renaldo", + "SIV": "Sivasspor Token", + "SIX": "SIX Network", + "SIXP": "Sixpack Miner", + "SIXPACK": "SIXPACK", + "SIXSI": "SIX SIGMA", + "SIZ": "Sizlux", + "SIZE": "SIZE", + "SJCX": "StorjCoin", + "SKAI": "Skillful AI", + "SKAIN": "SKAINET", + "SKATE": "Skate", + "SKB": "SkullBuzz", + "SKBDI": "Skibidi Toilet", + "SKC": "Skeincoin", + "SKCS": "Staked KCS", + "SKEB": "Skeb", + "SKET": "Sketch coin", + "SKEY": "SmartKey", + "SKG888": "Safu & Kek Gigafundz 888", + "SKI": "Ski Mask Dog", + "SKIBIDI": "Skibidi Toilet", + "SKICAT": "SKI MASK CAT", + "SKID": "Success Kid", + "SKILL": "CryptoBlades", + "SKILLC": "Skillchain", + "SKIN": "Skincoin", + "SKING": "Solo King", + "SKINS": "Coins & Skins", + "SKINUT": "Skimask Pnut", + "SKIPUP": "SKI MASK PUP", + "SKITTEN": "Ski Mask Kitten", + "SKL": "SKALE Network", + "SKLAY": "sKLAY", + "SKM": "Skrumble Network", + "SKO": "Sugar Kingdom Odyssey", + "SKOP": "Skulls of Pepe Token", + "SKPEPE": "Sheikh Pepe", + "SKR": "Seeker", + "SKRB": "Sakura Bloom", + "SKRIMP": "Skrimples", + "SKRP": "Skraps", + "SKRT": "Skrilla Token", + "SKRY": "Sakaryaspor Token", + "SKT": "Sukhavati Network", + "SKU": "Sakura", + "SKULL": "Pirate Blocks", + "SKUY": "Token Sekuya", + "SKX": "SKPANAX", + "SKY": "Sky", + "SKYA": "Sekuya Multiverse", + "SKYAI": "SKYAI", + "SKYCOIN": "Skycoin", + "SKYFT": "SKYFchain", + "SKYM": "SkyMap", + "SKYOPS": "Skyops", + "SKYRIM": "Skyrim Finance", + "SKYX": "SKUYX", + "SLA": "SUPERLAUNCH", + "SLAM": "Slam Token", + "SLAP": "CatSlap", + "SLAPS": "Slap", + "SLATE": "Slate", + "SLAVI": "Slavi Coin", + "SLAY": "SatLayer", + "SLAYER": "ThreatSlayerAI by Virtuals", + "SLB": "Solberg", + "SLC": "Silencio", + "SLCL": "Solcial", + "SLEEP": "Sleep Ecosystem", + "SLEEPEE": "SleepFuture", + "SLEPE": "Slepe", + "SLERF": "SLERF", + "SLERF2": "SLERF 2.0", + "SLERFFORK": "SlerfFork", + "SLEX": "SLEX Token", + "SLF": "Self Chain", + "SLG": "Land Of Conquest", + "SLICE": "Tranche Finance", + "SLICEC": "SLICE", + "SLIM": "Solanium", + "SLIME": "Snail Trail", + "SLING": "Sling Coin", + "SLINK": "Soft Link", + "SLIPPY": "SLIPPY", + "SLISBNB": "Lista Staked BNB", + "SLISBNBX": "slisBNBx", + "SLK": "SLK", + "SLM": "SlimCoin", + "SLN": "Smart Layer Network", + "SLND": "Solend", + "SLNV2": "SLNV2", + "SLOKI": "Super Floki", + "SLOP": "Slop", + "SLORK": "SLORK", + "SLOT": "Alphaslot", + "SLOTH": "Sloth", + "SLOTHA": "Slothana", + "SLP": "Smooth Love Potion", + "SLPV1": "Smooth Love Potion v1", + "SLR": "SolarCoin", + "SLRR": "Solarr", + "SLRS": "Solrise Finance", + "SLS": "SaluS", + "SLST": "SmartLands", + "SLT": "SLT", + "SLUGDENG": "SLUG DENG", + "SLUMBO": "SLUMBO", + "SLVLUSD": "Staked Level USD", + "SLVON": "iShares Silver Trust (Ondo Tokenized)", + "SLVX": "eToro Silver", + "SLX": "SLIMEX", + "SMA": "Soma Network", + "SMAC": "Social Media Coin", + "SMAK": "Smartlink", + "SMARS": "SafeMars", + "SMART": "Smart game", + "SMARTB": "Smart Coin", + "SMARTCASH": "SmartCash", + "SMARTCREDIT": "SmartCredit Token", + "SMARTH": "SmartHub", + "SMARTLOX": "SmartLOX", + "SMARTM": "SmartMesh", + "SMARTMEME": "SmartMEME", + "SMARTMFG": "Smart MFG", + "SMARTNFT": "SmartNFT", + "SMARTO": "smARTOFGIVING", + "SMARTSHARE": "Smartshare", + "SMARTUP": "Smartup", + "SMAT": "Smathium", + "SMB": "SMB Token", + "SMBR": "Sombra", + "SMBSWAP": "SimbCoin Swap", + "SMC": "SmartCoin", + "SMCION": "Super Micro Computer (Ondo Tokenized)", + "SMCW": "Space Misfits", + "SMD": "SMD Coin", + "SMETA": "StarkMeta", + "SMETX": "SpecialMetalX", + "SMF": "SmurfCoin", + "SMG": "Smaugs NFT", + "SMH": "Spacemesh", + "SMI": "SafeMoon Inu", + "SMIDGE": "Smidge", + "SMIDGEETH": "Smidge", + "SMIL": "Smile Token", + "SMILE": "bitSmiley", + "SMILEAI": "Smile AI", + "SMILEK": "Smilek to the Bank", + "SMILEY": "SMILEY", + "SMILY": "Smily Trump", + "SMKNG": "SmonkeyKong", + "SML": "Saltmarble", + "SMLY": "SmileyCoin", + "SMM": "TrendingTool.io", + "SMOG": "Smog", + "SMOK": "Smoking Chicken Fish", + "SMOKE": "Smoke", + "SMOL": "Smolcoin", + "SMOLE": "smolecoin", + "SMON": "StarMon", + "SMOON": "SaylorMoon", + "SMPF": "SMP Finance", + "SMPL": "SMPL Foundation", + "SMR": "Shimmer", + "SMRAT": "Secured MoonRat", + "SMRT": "SmartMoney", + "SMRTR": "SmarterCoin", + "SMSR": "Samsara Coin", + "SMT": "Swarm Markets", + "SMTF": "SmartFi", + "SMTY": "Smoothy", + "SMU": "SafeMoneyUP", + "SMUDCAT": "Smudge Cat", + "SMUDGE": "Smudge Lord", + "SMURFCATBSC": "Real Smurf Cat", + "SMURFCATETH": "Real Smurf Cat", + "SMURFCATSOL": "Real Smurf Cat", + "SMX": "Snapmuse.io", + "SN": "SpaceN", + "SNA": "SUKUYANA", + "SNAC": "SnackboxAI", + "SNACK": "Crypto Snack", + "SNAI": "SwarmNode.ai", + "SNAIL": "SnailBrook", + "SNAKE": "snake", + "SNAKEAI": "Snake-ai", + "SNAKEMOON": "Snakemoon", + "SNAKES": "Snakes Game", + "SNAKETOKEN": "Snake Token", + "SNAKT": "Sna-King Trump", + "SNAP": "SnapEx", + "SNAPCAT": "Snapcat", + "SNAPKERO": "SNAP", + "SNAPON": "Snap (Ondo Tokenized)", + "SNB": "SynchroBitcoin", + "SNC": "SunContract", + "SNCT": "SnakeCity", + "SND": "Sandcoin", + "SNE": "StrongNode", + "SNEED": "Sneed", + "SNEK": "Snek", + "SNEKE": "Snek on Ethereum", + "SNET": "Snetwork", + "SNFT": "Spanish National Team Fan Token", + "SNFTS": "Seedify NFT Space", + "SNG": "SINERGIA", + "SNGLS": "SingularDTV", + "SNGT": "SNG Token", + "SNIBBU": "Snibbu", + "SNIBBUTHEC": "Snibbu The Crab", + "SNIFT": "StarryNift", + "SNIP": "LyrnAI", + "SNIPPEPE": "SNIPING PEPE", + "SNITCH": "Randall", + "SNK": "Snook", + "SNL": "Sport and Leisure", + "SNM": "SONM", + "SNMT": "Satoshi Nakamoto Token", + "SNN": "SeChain", + "SNO": "Snow Leopard", + "SNOB": "Snowball", + "SNOLEX": "Snolex", + "SNOOP": "SnoopDAO", + "SNOOPY": "Snoopy", + "SNOR": "SNOR", + "SNORK": "Snork", + "SNORT": "SNORT", + "SNOV": "Snovio", + "SNOW": "Snowswap", + "SNOWBALL": "Simpson Cat", + "SNOWMANTASTIC": "Snowmantastic", + "SNPAD": "SNP adverse", + "SNPC": "SnapCoin", + "SNPS": "Snaps", + "SNPT": "SNPIT TOKEN", + "SNRG": "Synergy", + "SNRK": "Snark Launch", + "SNS": "Solana Name Service", + "SNST": "Smooth Network Solutions Token", + "SNSY": "Sensay", + "SNT": "Status Network Token", + "SNTR": "Silent Notary", + "SNTVT": "Sentivate", + "SNX": "Synthetix", + "SNY": "Synthetify ", + "SO": "Shiny Ore", + "SOAI": "SOAI", + "SOAK": "Soak Token", + "SOAR": "Soarcoin", + "SOARX": "Soarx Coin", + "SOBA": "SOBA Token", + "SOBB": "SoBit", + "SOBER": "Solabrador", + "SOBTC": "Wrapped Bitcoin (Sollet)", + "SOBULL": "SoBULL", + "SOC": "All Sports Coin", + "SOCA": "Socaverse", + "SOCC": "SocialCoin", + "SOCCER": "SoccerInu", + "SOCIAL": "Phavercoin", + "SOCIALLT": "Social Lending Network", + "SOCIALSEND": "Social Send", + "SOCKS": "Alpaca Socks", + "SOCOLA": "SOCOLA INU", + "SODA": "SODA Coin", + "SODAL": "Sodality Coin", + "SODO": "Scooby Doo", + "SOETH": "Wrapped Ethereum (Sollet)", + "SOFAC": "SofaCat", + "SOFI": "RAI Finance", + "SOFION": "SoFi Technologies (Ondo Tokenized)", + "SOFTCO": "SOFT COQ INU", + "SOFTT": "Wrapped FTT (Sollet)", + "SOGNI": "Sogni AI", + "SOGUR": "Sogur Currency", + "SOH": "Stohn Coin", + "SOHMV1": "Staked Olympus v1", + "SOHOT": "SOHOTRN", + "SOIL": "Soil", + "SOILCOIN": "SoilCoin", + "SOJ": "Sojourn Coin", + "SOK": "shoki", + "SOKU": "Soku Swap", + "SOL": "Solana", + "SOL10": "SOLANA MEME TOKEN", + "SOLA": "Sola", + "SOLAI": "Solana AI BNB", + "SOLALA": "Solala", + "SOLAMA": "Solama", + "SOLAMB": "SOLAMB", + "SOLAN": "Solana Beach", + "SOLANACORN": "Solanacorn", + "SOLANAP": "Solana Poker", + "SOLANAS": "Solana Swap", + "SOLANATREASURY": "Solana Treasury Machine", + "SOLAPE": "SolAPE Token", + "SOLAR": "Solar", + "SOLARA": "Solara", + "SOLARDAO": "Solar DAO", + "SOLARE": "Solareum", + "SOLAREU": "Solareum", + "SOLARFARM": "SolarFarm", + "SOLARIX": "SOLARIX", + "SOLAV": "SOLAV TOKEN", + "SOLBANK": "Solbank", + "SOLBET": "SOL STREET BETS", + "SOLBO": "SolBoss", + "SOLBOX": "SolBox", + "SOLBULL": "SOLBULL", + "SOLC": "SolCard", + "SOLCASH": "SOLCash", + "SOLCAT": "SOLCAT", + "SOLCEX": "SolCex", + "SOLCHICKSSHARDS": "SolChicks Shards", + "SOLE": "SoleCoin", + "SOLER": "Solerium", + "SOLETF": "SOL ETF", + "SOLEX": "Solex Launchpad", + "SOLEY": "Soley", + "SOLFI": "SoliDefi", + "SOLFUN": "SolFun", + "SOLGOAT": "SOLGOAT", + "SOLGUN": "Solgun", + "SOLIC": "Solice", + "SOLID": "Solidified", + "SOLIDSEX": "SOLIDsex: Tokenized veSOLID", + "SOLINK": "Wrapped Chainlink (Sollet)", + "SOLITO": "SOLITO", + "SOLKABOSU": "Kabosu", + "SOLKIT": "Solana Kit", + "SOLLY": "Solly", + "SOLM": "SolMix", + "SOLMATES": "SOLMATES", + "SOLME": "Solmedia", + "SOLMEME": "TrumpFFIEGMEBidenCAT2024AMC", + "SOLNAV": "SOLNAV AI", + "SOLNIC": "Solnic", + "SOLO": "Sologenic", + "SOLOM": "Solomon", + "SOLOR": "Solordi", + "SOLP": "SolPets", + "SOLPAD": "Solpad Finance", + "SOLPAKA": "Solpaka", + "SOLPENG": "SOLPENG", + "SOLR": "SolRazr", + "SOLS": "sols", + "SOLSCC": "sols", + "SOLSPONGE": "Solsponge", + "SOLT": "Soltalk AI", + "SOLTAN": "SOLTAN", + "SOLTR": "SolTrump", + "SOLV": "Solv Protocol", + "SOLVBTC": "Solv Protocol SolvBTC", + "SOLVBTCBBN": "Solv Protocol SolvBTC.BBN", + "SOLVBTCCORE": "Solv Protocol SolvBTC.CORE", + "SOLVBTCENA": "SolvBTC Ethena", + "SOLVBTCJUP": "SolvBTC Jupiter", + "SOLVE": "SOLVE", + "SOLVEX": "SOLVEX", + "SOLWIF": "Solwif", + "SOLX": "SolarX", + "SOLXD": "Solxdex", + "SOLY": "Solamander", + "SOLYMPICS": "Solympics", + "SOLZILLA": "Solzilla", + "SOM": "Souls of Meta", + "SOMA": "Soma", + "SOMEE": "SoMee.Social", + "SOMEEV1": "SoMee.Social", + "SOMI": "Somnia", + "SOMM": "Sommelier", + "SOMPS": "SompsOnKas", + "SON": "Simone", + "SONAR": "SonarWatch", + "SONG": "Song Coin", + "SONGOKU": "SONGOKU", + "SONIC": "Sonic SVM", + "SONICO": "Sonic", + "SONICSONIC": "Sonic", + "SONICWIF": "SonicWifHat", + "SONNE": "Sonne Finance", + "SONOF": "Son of Solana", + "SONOR": "SonorusToken", + "SOON": "SOON Token", + "SOONAVERSE": "Soonaverse", + "SOONCOIN": "SoonCoin", + "SOONTOKEN": "SOON", + "SOOTCASE": "I like my sootcase", + "SOP": "SoPay", + "SOPH": "Sophon", + "SOPHIA": "SophiaVerse", + "SOPHON": "Sophon (Atomicals)", + "SOR": "Sorcery", + "SORA": "Sora Validator Token", + "SORACEO": "SORA CEO", + "SORADOGE": "Sora Doge", + "SORAETH": "SORA", + "SORAI": "Sora AI", + "SORAPORN": "Sora Porn", + "SOSO": "SoSoValue", + "SOT": "Soccer Crypto", + "SOTA": "SOTA Finance", + "SOUL": "Phantasma", + "SOULO": "SouloCoin", + "SOULS": "Unfettered Ecosystem", + "SOULSA": "Soulsaver", + "SOUND": "Sound Coin", + "SOURCE": "ReSource Protocol", + "SOUTH": "DeepSouth AI", + "SOV": "Sovryn", + "SOVE": "Soverain", + "SOVI": "Sovi Finance", + "SOVRN": "Sovrun", + "SOWA": "Sowa AI", + "SOX": "Nobby Game", + "SOXRP": "Wrapped XRP (Sollet)", + "SOY": "Soy Finance", + "SP": "Sex Pistols", + "SP8DE": "Sp8de", + "SPA": "Sperax", + "SPAC": "SPACE DOGE", + "SPACE": "Spacecoin", + "SPACECOIN": "SpaceCoin", + "SPACED": "SPACE DRAGON", + "SPACEHAMSTER": "Space Hamster", + "SPACELENS": "Spacelens", + "SPACEM": "Spacem Token", + "SPACEPI": "SpacePi", + "SPAD": "SolPad", + "SPAI": "Starship AI", + "SPAIN": "SpainCoin", + "SPANK": "SpankChain", + "SPARK": "Sparklife", + "SPARKLET": "Upland", + "SPARKO": "Sparko", + "SPARKSPAY": "SparksPay", + "SPARTA": "Spartan Protocol Token", + "SPARTACATS": "SpartaCats", + "SPARTAD": "SpartaDex", + "SPAT": "Meta Spatial", + "SPAVAX": "Avalanche (Synapse Protocol)", + "SPAY": "SpaceY 2025", + "SPC": "SpaceChain ERC20", + "SPC.QRC": "SpaceChain (QRC-20)", + "SPCIE": "Specie", + "SPCT": "Spectra Chain", + "SPCX": "Paimon SpaceX SPV Token", + "SPD": "Stipend", + "SPDR": "SpiderDAO", + "SPDX": "Speedex", + "SPE": "SavePlanetEarth", + "SPEC": "SpecCoin", + "SPECT": "Spectral", + "SPECTRE": "SPECTRE AI", + "SPEE": "SpeedCash", + "SPEED": "IShowSpeed", + "SPEEDCOIN": "Speed Coin", + "SPEEDY": "Speedy", + "SPELL": "Spell Token", + "SPELLFIRE": "Spellfire", + "SPEND": "Spend", + "SPENDC": "SpendCoin", + "SPENT": "Espento", + "SPEPE": "SolanaPepe", + "SPERG": "Bloomsperg Terminal", + "SPEX": "StepEx", + "SPF": "SportyCo", + "SPFC": "São Paulo FC Fan Token", + "SPG": "Space Crypto", + "SPGBB": "SPGBB", + "SPH": "Spheroid Universe", + "SPHERE": "Sphere Finance", + "SPHR": "Sphere Coin", + "SPHRI": "Spherium", + "SPHTX": "SophiaTX", + "SPHYNX": "Sphynx Token", + "SPHYNXV1": "Sphynx Token v1", + "SPHYNXV2": "Sphynx Token v2", + "SPHYNXV3": "Sphynx Token v3", + "SPHYNXV4": "Sphynx Token v4", + "SPI": "Shopping.io", + "SPICE": "SPICE", + "SPICETOKEN": "Spice", + "SPIDER": "Spider Man", + "SPIDERMAN": "SPIDERMAN BITCOIN", + "SPIDEY": "Spidey", + "SPIKE": "Spiking", + "SPILLWAYS": "SpillWays", + "SPIN": "SPIN Protocol", + "SPINT": "Spintria", + "SPIRIT": "SpiritSwap", + "SPITT": "Hawk Ttuuaahh", + "SPIZ": "SPACE-iZ", + "SPK": "Spark", + "SPKL": "SpokLottery", + "SPKTR": "Ghost Coin", + "SPKY": "GhostyCash", + "SPL": "SocialPal", + "SPLA": "SmartPlay", + "SPLD": "Splendor", + "SPM": "Supreme", + "SPN": "Sapien Network", + "SPND": "Spindle", + "SPO": "Spores Network", + "SPOK": "Spock", + "SPOL": "Starterpool", + "SPON": "Spheron Network", + "SPONG": "Spongebob", + "SPONGE": "Sponge", + "SPONGEBOB": "Spongebob Squarepants", + "SPOODY": "Spoody Man", + "SPOOF": "Spoofify", + "SPOOL": "Spool DAO Token", + "SPORE": "Spore", + "SPORT": "SportsCoin", + "SPORTFUN": "Sport.fun", + "SPORTS": "ZenSports", + "SPORTSFIX": "SportsFix", + "SPORTSP": "SportsPie", + "SPOT": "Defispot", + "SPOTCOIN": "Spotcoin", + "SPOTS": "Spots", + "SPOX": "Sports Future Exchange Token", + "SPRING": "Spring", + "SPRITZMOON": "SpritzMoon Crypto Token", + "SPRKL": "Sparkle Loyalty", + "SPROUT": "Sprout", + "SPRSTR": "SprotoStrategy", + "SPRT": "Sportium", + "SPRTS": "Sprouts", + "SPRTZ": "SpritzCoin", + "SPRX": "Sprint Coin", + "SPS": "Splinterlands", + "SPT": "SPECTRUM", + "SPUME": "Spume", + "SPUNK": "PUNK", + "SPURD": "Spurdo Spärde", + "SPURDO": "spurdo", + "SPURS": "Tottenham Hotspur Fan Token", + "SPWN": "Bitspawn", + "SPX": "SPX6900", + "SPX6969": "SPX 6969", + "SPXC": "SpaceXCoin", + "SPY": "Smarty Pay", + "SPYON": "SPDR S&P 500 ETF (Ondo Tokenized)", + "SPYRO": "SPYRO", + "SPYX": "SP500 xStock", + "SQ3": "Squad3", + "SQAT": "Syndiqate", + "SQD": "SQD", + "SQG": "Squid Token", + "SQGROW": "SquidGrow", + "SQL": "Squall Coin", + "SQQQON": "ProShares UltraPro Short QQQ (Ondo Tokenized)", + "SQR": "Magic Square", + "SQRL": "Squirrel Swap", + "SQT": "SubQuery Network", + "SQTS": "Sqts (Ordinals)", + "SQU": "SquadSwap", + "SQUA": "Square Token", + "SQUAD": "Superpower Squad", + "SQUATCH": "SASQUATCH", + "SQUAWK": "Squawk", + "SQUEEZER": "Squeezer", + "SQUIBONK": "SQUIBONK", + "SQUID": "Squid Game", + "SQUID2": "Squid Game 2.0", + "SQUIDGROW": "SquidGrow", + "SQUIDGROWV1": "SquidGrow v1", + "SQUIDV1": "Squid Game v1", + "SQUIDW": "Squidward Coin", + "SQUIG": "Squiggle DAO Token", + "SQUIGDAO": "SquiggleDAO ERC20", + "SQUIRT": "SQUIRTLE", + "SQUOGE": "DogeSquatch", + "SR30": "SatsRush", + "SRBP": "Super Rare Ball Potion", + "SRC": "SecureCoin", + "SRCH": "SolSrch", + "SRCOIN": "SRCoin", + "SRCX": "Source Token", + "SREUR": "SocialRemit", + "SRG": "Street Runner NFT", + "SRGD": "Studio Releasing Gold", + "SRK": "SparkPoint", + "SRLTY": "SaitaRealty", + "SRLY": "Rally (Solana)", + "SRM": "Serum", + "SRN": "SirinLabs", + "SRNT": "Serenity", + "SRP": "Starpunk", + "SRT": "Smart Reward Token", + "SRWD": "ShibRWD", + "SRX": "StorX", + "SS": "Sharder", + "SS20": "Shell Trade", + "SSB": "SatoshiStreetBets", + "SSC": "SelfSell", + "SSD": "Sonic Screw Driver Coin", + "SSDX": "SpunkySDX", + "SSE": "Solana Social Explorer", + "SSEV1": "Soroosh Smart Ecosystem v1", + "SSEV2": "Soroosh Smart Ecosystem", + "SSG": "SOMESING", + "SSGT": "Safeswap", + "SSGV1": "SOMESING", + "SSH": "StreamSpace", + "SSHIB": "Solana Shib", + "SSHIP": "SSHIP", + "SSLX": "StarSlax", + "SSNC": "SatoshiSync", + "SSOL": "Solayer SOL", + "SSR": "SOL Strategic Reserve", + "SSS": "Sparkle Token", + "SSSSS": "Snake wif Hat", + "SST": "SIMBA Storage Token", + "SSTC": "SunShotCoin", + "SSTZ": "SSTZ", + "SSU": "Sunny Side up", + "SSUI": "Spring Staked SUI", + "SSV": "ssv.network", + "SSVCOIN": "SSVCoin", + "SSVV1": "Blox", + "SSWP": "Suiswap", + "SSX": "Solana Stock Index", + "ST": "Skippy Token", + "STA": "STOA Network", + "STAB": "STABLE ASSET", + "STABLE": "Stable", + "STABLZ": "Stablz", + "STABUL": "Stabull Finance", + "STAC": "STAC", + "STACK": "StackOS", + "STACKS": " STACKS PAY", + "STACS": "STACS Token", + "STAFIRETH": "StaFi Staked ETH", + "STAGE": "Stage", + "STAI": "StereoAI", + "STAK": "Jigstack", + "STAKE": "xDai Chain", + "STAKEDETH": "StakeHound Staked Ether", + "STAKERDAOWXTZ": "Wrapped Tezos", + "STALIN": "StalinCoin", + "STAMP": "SafePost", + "STAN": "Stank Memes", + "STANDARD": "Stakeborg DAO", + "STAPT": "Ditto Staked Aptos", + "STAR": "Starpower Network Token", + "STAR10": "Ronaldinho Coin", + "STARAMBA": "Staramba", + "STARBASE": "Starbase", + "STARC": "StarChain", + "STARDOGE": "StarDOGE", + "STARGATEAI": "Stargate AI Agent", + "STARHEROES": "StarHeroes", + "STARL": "StarLink", + "STARLAUNCH": "StarLaunch", + "STARLY": "Starly", + "STARP": "Star Pacific Coin", + "STARRI": "starri", + "STARS": "Stargaze", + "STARSH": "StarShip Token", + "STARSHARKS": "StarSharks", + "STARSHI": "Starship", + "STARSHIP": "STARSHIP", + "STARSHIPDOGE": "Starship Doge", + "STARSHIPONSOL": "Starship", + "START": "StartCoin", + "STARTA": "Starta", + "STARTER": "Starter.xyz", + "STARTUP": "Startup", + "STARWARS": "Star Wars", + "STARX": "STARX TOKEN", + "STASH": "STASH INU", + "STASHV1": "BitStash", + "STAT": "STAT", + "STATE": "New World Order", + "STATER": "Stater", + "STATERA": "Statera", + "STATOK": "STA", + "STATOKEN": "STA", + "STATOM": "Stride Staked ATOM", + "STATS": "Stats", + "STAU": "STAU", + "STAX": "Staxcoin", + "STAY": "NFsTay", + "STB": "stabble", + "STBL": "STBL Governance Token", + "STBOT": "SolTradingBot", + "STBTC": "Lorenzo stBTC", + "STBU": "Stobox Token", + "STC": "Satoshi Island", + "STCN": "Stakecoin", + "STD": "STEED", + "STDYDX": "Stride Staked DYDX", + "STEAK": "SteakHut Finance", + "STEAKUSDC": "Steakhouse USDC Morpho Vault", + "STEALTH": "StealthPad", + "STEAMPUNK": "SteamPunk", + "STEAMX": "Steam Exchange", + "STEEL": "SteelCoin", + "STEEM": "Steem", + "STEEMD": "Steem Dollars", + "STEEP": "SteepCoin", + "STELLA": "StellaSwap", + "STEMX": "STEMX", + "STEN": "Steneum Coin", + "STEP": "Step Finance", + "STEPG": "StepG", + "STEPH": "Step Hero", + "STEPR": "Step", + "STEPS": "Steps", + "STERLINGCOIN": "SterlingCoin", + "STETH": "Staked Ether", + "STEVMOS": "Stride Staked EVMOS", + "STEWIE": "Stewie Coin", + "STEX": "STEX", + "STF": "Structure Finance", + "STFLOW": "Increment Staked FLOW", + "STFX": "STFX", + "STG": "Stargate Finance", + "STHR": "Stakerush", + "STHYPE": "Staked HYPE", + "STI": "Seek Tiger", + "STIC": "StickMan", + "STICKMAN": "stickman", + "STIK": "Staika", + "STIMA": "STIMA", + "STING": "Sting", + "STINJ": "Stride Staked INJ", + "STIPS": "Stips", + "STITCH": "Stitch", + "STIX": "STIX", + "STJUNO": "Stride Staked JUNO", + "STK": "STK Token", + "STKAAVE": "Staked Aave", + "STKATOM": "pSTAKE Staked ATOM", + "STKBNB": "pSTAKE Staked BNB", + "STKC": "Streakk Chain", + "STKD": "Stkd SCRT", + "STKHUAHUA": "pSTAKE Staked HUAHUA", + "STKK": "Streakk", + "STKSTARS": "pSTAKE Staked STARS", + "STKXPRT": "pSTAKE Staked XPRT", + "STLE": "Saint Ligne", + "STMAN": "Stickman Battleground", + "STMATIC": "Lido Staked Matic", + "STMX": "StormX", + "STMXV1": "Storm", + "STMXV2": "StormX v1", + "STND": "Standard Protocol", + "STNEAR": "Staked NEAR", + "STNK": "Stonks", + "STO": "StakeStone", + "STOC": "STO Cash", + "STOCK": "Digital Asset Stockpile", + "STOG": "Stooges", + "STOGE": "Stoner Doge Finance", + "STOIC": "stoicDAO", + "STON": "STON", + "STONE": "Stone Token", + "STONEDE": "Stone DeFi", + "STONK": "STONK", + "STONKS": "STONKS", + "STOP": "LETSTOP", + "STOR": "Self Storage Coin", + "STORE": "Bit Store", + "STOREFUN": "FUN", + "STOREP": "Storepay", + "STORJ": "Storj", + "STORM": "STORM", + "STORY": "Story", + "STOS": "Stratos", + "STOSMO": "Stride Staked OSMO", + "STOX": "Stox", + "STP": "StashPay", + "STPL": "Stream Protocol", + "STPT": "STP Network", + "STQ": "Storiqa Token", + "STR": "Sourceless", + "STRA": "STRAY", + "STRAKS": "Straks", + "STRAT": "Strategic Hub for Innovation in Blockchain", + "STRAX": "Stratis", + "STRAY": "Stray Dog", + "STRAYDOG": "Stray Dog", + "STRD": "Stride", + "STRDY": "Sturdy", + "STREAM": "Streamflow", + "STREAMER": "StreamerCoin", + "STREAMIT": "STREAMIT COIN", + "STREETH": "STREETH", + "STRI": "Strite", + "STRIKE": "StrikeBit", + "STRIKETOKEN": "Strike", + "STRIP": "Stripto", + "STRK": "Starknet", + "STRM": "StreamCoin", + "STRNGR": "Stronger", + "STRONG": "Strong", + "STRONGSOL": "Stronghold Staked SOL", + "STRONGX": "StrongX", + "STRP": "Strips Finance", + "STRS": "STARS", + "STRSZN": "Stream SZN", + "STRUMP": "Super Trump", + "STRX": "StrikeX", + "STS": "SBank", + "STSHIP": "StarShip", + "STSOL": "Lido Staked SOL", + "STSOMM": "Stride Staked SOMM", + "STSR": "SatelStar", + "STSTARS": "Stride Staked Stars", + "STSW": "Stackswap", + "STT": "Statter Network ", + "STTAO": "Tensorplex Staked TAO", + "STTIA": "Stride Staked TIA", + "STTON": "bemo staked TON", + "STU": "BitJob", + "STUART": "Stuart Inu", + "STUCK": "mouse in pasta", + "STUD": "Studyum", + "STUDENTC": "Student Coin", + "STUFF": "STUFF.io", + "STUMEE": "Stride Staked UMEE", + "STUPID": "StupidCoin", + "STUSDT": "Staked USDT", + "STV": "Sativa Coin", + "STWEMIX": "Staked WEMIX", + "STX": "Stacks", + "STYL": "Stylike Governance", + "STYLE": "Style", + "STZ": "99Starz", + "STZEN": "StakedZEN", + "STZETA": "ZetaEarn", + "STZU": "Shihtzu Exchange Token", + "SU": "Smol Su", + "SUAI": "SuiAI", + "SUB": "Subsocial", + "SUBA": "Yotsuba", + "SUBAWU": "Subawu Token", + "SUBF": "Super Best Friends", + "SUBHUB": "SUBHUB", + "SUBS": "Substratum Network", + "SUCR": "Sucre", + "SUD": "Sudo Labs", + "SUDO": "sudoswap", + "SUEDE": "Johnny Suede", + "SUGAR": "Sugar Exchange", + "SUGARB": "SugarBlock", + "SUI": "Sui", + "SUIA": "SUIA", + "SUIAGENT": "aiSUI", + "SUIAI": "SUI Agents", + "SUIB": "Suiba Inu", + "SUIDEPIN": "Sui DePIN", + "SUIJAK": "Suijak", + "SUILAMA": "Suilama", + "SUIMAN": "Suiman", + "SUIMON": "Sui Monster", + "SUIP": "SuiPad", + "SUIRWA": "Sui RWA", + "SUIRWAPIN": "SUIRWAPIN", + "SUISHIB": "SuiShiba", + "SUITE": "Suite", + "SUKI": "SUKI", + "SUKU": "SUKU", + "SULFERC": "SULFERC", + "SUM": "SumSwap", + "SUMI": "SUMI", + "SUMMER": "Summer", + "SUMMIT": "Summit", + "SUMMITTHE": "SUMMIT", + "SUMO": "Sumokoin", + "SUN": "Sun Token", + "SUNC": "Sunrise", + "SUNCAT": "Suncat", + "SUNDAE": "Sundae the Dog", + "SUNDOG": "SUNDOG", + "SUNEX": "The Sun Exchange", + "SUNGOAT": "SUNGOAT", + "SUNGOU": "Sungou", + "SUNI": "SUNI", + "SUNJAK": "Sunjak", + "SUNLION": "SUNLION", + "SUNMAGA": "SunMaga", + "SUNN": "Sunny on Tron", + "SUNNED": "SUNNED", + "SUNNEIRO": "SunNeiro", + "SUNNY": "Sunny Aggregator", + "SUNOLD": "Sun Token", + "SUNPEPE": "sunpepe", + "SUNPUMP": "To The Sun", + "SUNTRON": "TRON MASCOT", + "SUNV1": "Sun Token v1", + "SUNWUKONG": "SunWukong", + "SUP": "Superp", + "SUP8EME": "SUP8EME Token", + "SUPCOIN": "Supcoin", + "SUPE": "Supe Infinity", + "SUPER": "SuperVerse", + "SUPERBID": "SuperBid", + "SUPERBONDS": "SuperBonds Token", + "SUPERBONK": "SUPER BONK", + "SUPERC": "SuperCoin", + "SUPERCAT": "SUPERCAT", + "SUPERCYCLE": "Crypto SuperCycle", + "SUPERDAPP": "SuperDapp", + "SUPERF": "SUPER FLOKI", + "SUPERFL": "Superfluid", + "SUPERGROK": "SuperGrok", + "SUPEROETHB": "Super OETH", + "SUPERT": "Super Trump", + "SUPERTX": "SuperTX", + "SUPFRIEND": "SuperFriend", + "SUPR": "Superseed", + "SUPRA": "Supra", + "SUPREMEFINANCE": "Hype", + "SUR": "Suretly", + "SURE": "inSure", + "SURF": "Surf.Finance", + "SURGE": "Surge", + "SURV": "Survival Game Online", + "SURVIVING": "Surviving Soldiers", + "SUSD": "sUSD", + "SUSDA": "sUSDa", + "SUSDE": "Ethena Staked USDe", + "SUSDS": "Savings USDS", + "SUSDX": "Staked USDX", + "SUSHI": "Sushi", + "SUSX": "Savings USX", + "SUT": "SuperTrust", + "SUTEKU": "Suteku", + "SUTER": "Suterusu", + "SUWI": "suwi", + "SUZUME": "Shita-kiri Suzume", + "SVD": "savedroid", + "SVETH": "Savvy ETH", + "SVL": "Slash Vision Labs", + "SVN": "Savanna", + "SVNN": "Savanna Haus", + "SVPN": "Shadow Node", + "SVS": "GivingToServices SVS", + "SVSA": "SavannaSurvival", + "SVT": "Solvent", + "SVTS": "Syncvault", + "SVX": "Savix", + "SVY": "Savvy", + "SWA": "Swace", + "SWACH": "Swachhcoin", + "SWAG": "SWAG Finance", + "SWAGGY": "swaggy", + "SWAGT": "Swag Token", + "SWAI": "Safe Water AI", + "SWAMP": "Swampy", + "SWAN": "Swan Chain", + "SWANSOL": "Black Swan", + "SWAP": "Trustswap", + "SWAPP": "SWAPP Protocol", + "SWAPZ": "SWAPZ.app", + "SWARM": "SwarmCoin", + "SWARMS": "Swarms", + "SWASH": "Swash", + "SWAST": "Swasticoin", + "SWAY": "Sway Social", + "SWBTC": "Swell Restaked BTC", + "SWC": "Scanetchain Token", + "SWCH": "SwissCheese", + "SWD": "SW DAO", + "SWDAO": "Super Whale DAO", + "SWEAT": "Sweat Economy", + "SWEEP": "Sweeptoken", + "SWEET": "SweetStake", + "SWELL": "Swell Network", + "SWETH": "swETH", + "SWFL": "Swapfolio", + "SWFTC": "SWFTCoin", + "SWG": "Swirge", + "SWGT": "SmartWorld Global", + "SWH": "simbawifhat", + "SWIF": "SUNwifHat", + "SWIFT": "BitSwift", + "SWIFTIES": "Taylor Swift", + "SWIM": "SWIM - Spread Wisdom", + "SWIN": "SwinCoin", + "SWING": "SwingCoin", + "SWINGBY": "Swingby", + "SWIPES": "BNDR", + "SWIRL": "Swirl Social", + "SWIRLX": "SwirlToken", + "SWIS": "Swiss Cash Coin", + "SWISE": "StakeWise", + "SWITCH": "Switch", + "SWM": "Swarm Fund", + "SWO": "SwordMagicToken", + "SWOL": "Snowy Owl", + "SWOLE": "Swole Doge", + "SWOP": "Swop", + "SWORD": "eZKalibur", + "SWORLD": "Seedworld", + "SWOT": "Swot AI", + "SWP": "Kava Swap", + "SWPR": "Swapr", + "SWPRS": "Maid Sweepers", + "SWPX": "SwapX", + "SWRV": "Swerve", + "SWRX": "SwissRx Coin", + "SWT": "Swarm City Token", + "SWTCH": "Switchboard", + "SWTH": "Carbon", + "SWTS": "SWEETS", + "SWU": "Smart World Union", + "SWY": "Swype", + "SWYFTT": "SWYFT", + "SX": "SX Network", + "SXC": "SexCoin", + "SXCH": "SolarX", + "SXDT": "SPECTRE Dividend Token", + "SXM": "saxumdao", + "SXP": "SXP", + "SXS": "Sphere", + "SXT": "Space and Time", + "SXUT": "SPECTRE Utility Token", + "SYA": "SaveYourAssets", + "SYBC": "SYB Coin", + "SYBL": "Sybulls", + "SYBTC": "sBTC", + "SYC": "SynchroCoin", + "SYK": "Stryke", + "SYL": "XSL Labs", + "SYLO": "Sylo", + "SYLV": "Sylvester", + "SYM": "SymVerse", + "SYMM": "Symmio", + "SYMP": "Sympson AI", + "SYN": "Synapse", + "SYNC": "Syncus", + "SYNCC": "SyncCoin", + "SYNCG": "SyncGPT", + "SYNCN": "Sync Network", + "SYNCO": "Synco", + "SYND": "Syndicate", + "SYNDOG": "Synthesizer Dog", + "SYNESIS": "Synesis One", + "SYNK": "Synk", + "SYNLEV": "SynLev", + "SYNO": "Synonym Finance", + "SYNR": "MOBLAND", + "SYNT": "Synthetix Network", + "SYNTE": "Synternet", + "SYNTH": "SYNTHR", + "SYNTHSWAP": "Synthswap", + "SYNX": "Syndicate", + "SYPOOL": "Sypool", + "SYRAX": "Syrax AI", + "SYRUP": "Syrup", + "SYRUPUSDC": "SyrupUSDC", + "SYRUPUSDT": "Syrup USDT", + "SYS": "Syscoin", + "SZCB": "Zugacoin", + "SZN": "BNB SZN", + "T": "Threshold Network Token", + "T1": "Trump Mobile", + "T23": "T23", + "T99": "Tethereum", + "TA": "Trusta.AI", + "TAAS": "Token as a Service", + "TAB": "MollyCoin", + "TABOO": "Taboo Token", + "TAC": "TAC", + "TACC": "TACC", + "TACHYON": "Tachyon Protocol", + "TAD": "Tadpole", + "TADA": "Ta-da", + "TADDY": "DADDY TRUMP", + "TADPOLEF": "Tadpole Finance", + "TAF": "TAF", + "TAG": "Tagger", + "TAGR": "Think And Get Rich Coin", + "TAI": "TARS Protocol", + "TAIKO": "Taiko", + "TAIKULA": "TAIKULA COIN", + "TAIL": "Tail", + "TAIYO": "Taiyo", + "TAJ": "TajCoin", + "TAK": "TakCoin", + "TAKE": "TAKE", + "TAKEAMERICAB": "Take America Back", + "TAKER": "Taker", + "TAKI": "Taki", + "TAKO": "Tako", + "TALA": "Baby Tala", + "TALAHON": "Talahon", + "TALAO": "Talao", + "TALE": "PrompTale AI", + "TALENT": "Talent Protocol", + "TALES": "Tales of Pepe", + "TALEX": "TaleX", + "TALIS": "Talis Protocol", + "TALK": "Talken", + "TAMA": "Tamadoge", + "TAN": "Taklimakan", + "TANG": "Tangent", + "TANGO": "keyTango", + "TANGYUAN": "TangYuan", + "TANK": "AgentTank", + "TANPIN": "Tanpin", + "TANSSI": "TANSSI", + "TANUKI": "Tanuki", + "TANUPAD": "Tanuki Launchpad", + "TAO": "Bittensor", + "TAOBOT": "tao.bot", + "TAOCAT": "TAOCat by Virtuals", + "TAONU": "TAO INU", + "TAOP": "TaoPad", + "TAOTOOLS": "TAOTools", + "TAP": "TAP FANTASY", + "TAPC": "Tap Coin", + "TAPCOIN": "TAP FANTASY", + "TAPPINGCOIN": "TappingCoin", + "TAPROOT": "Taproot Exchange", + "TAPS": "TapSwap", + "TAPT": "Tortuga Staked Aptos", + "TARA": "Taraxa", + "TARAL": "TARALITY", + "TARD": "Tard", + "TARDI": "Tardi", + "TARGETCOIN": "TargetCoin", + "TARI": "Tari World", + "TAROT": "Tarot", + "TAROTV1": "Tarot v1", + "TARP": "Totally A Rug Pull", + "TAS": "TARUSH", + "TASH": "Smart Trip Platform", + "TASSHUB": "TASS HUB", + "TASTE": "TasteNFT", + "TAT": "Tatcoin", + "TATA": "TATA Coin", + "TATE": "Tate Terminal", + "TATES": "Tate Stop", + "TATETOKENETH": "Tate", + "TATSU": "Taτsu", + "TAU": "Lamden Tau", + "TAUC": "Taurus Coin", + "TAUD": "TrueAUD", + "TAUM": "Orbitau Taureum", + "TAUR": "Marnotaur", + "TAVA": "ALTAVA", + "TAX": "MetaToll", + "TAXAD": "TAXAD", + "TAXI": "Robotaxi", + "TAXLESSTRUMP": "MAGA TAXLESS", + "TAXP": "Taxpad", + "TBAC": "BlockAura", + "TBANK": "TaoBank", + "TBAR": "Titanium BAR", + "TBB": "Trade Butler Bot", + "TBC": "Ten Best Coins", + "TBCC": "TBCC", + "TBCI": "tbci", + "TBCX": "TrashBurn", + "TBD": "THE BIG DEBATE", + "TBE": "TrustBase", + "TBEER": "TRON BEER", + "TBFT": "Türkiye Basketbol Federasyon Token", + "TBILL": "OpenEden T-Bills", + "TBILLV1": "OpenEden T-Bills v1", + "TBIS": "TBIS token", + "TBK": "TBK Token", + "TBL": "Tombola", + "TBLLX": "TBLL xStock", + "TBR": "Tuebor", + "TBRIDGE": "tBridge Token", + "TBT": "T-BOT", + "TBTC": "tBTC", + "TBTCV1": "tBTC v1", + "TBULL": "Tron Bull", + "TBX": "Tokenbox", + "TBY": "TOBY", + "TCANDY": "TripCandy", + "TCAP": "Total Crypto Market Cap", + "TCAPY": "TonCapy", + "TCASH": "Trump Cash", + "TCAT": "The Currency Analytics", + "TCC": "The ChampCoin", + "TCG": "Today's Crypto", + "TCG2": "TCG Coin 2.0", + "TCGC": "TCG Verse", + "TCH": "Thorecash", + "TCHAIN": "Tchain", + "TCHB": "Teachers Blockchain", + "TCHTRX": "ThoreCashTRX", + "TCNH": "TrueCNH", + "TCNX": "Tercet Network", + "TCO": "ThinkCoin", + "TCOM": "TCOM", + "TCP": "The Crypto Prophecies", + "TCR": "Tracer DAO", + "TCS": "Timechain Swap Token", + "TCT": "TokenClub", + "TCU29": "Tempestas Copper", + "TCX": "T-Coin", + "TCY": "The Crypto You", + "TD": "The Big Red", + "TDAN": "TDAN", + "TDC": "Tidecoin", + "TDCCP": "TDCCP", + "TDE": "Trade Ecology Token", + "TDEFI": "Token Teknoloji A.S. Token DeFi", + "TDFB": "TDFB", + "TDFY": "Tidefi", + "TDM": "TDM", + "TDP": "TrueDeck", + "TDROP": "ThetaDrop", + "TDS": "TokenDesk", + "TDX": "Tidex Token", + "TEA": "TeaFi Token", + "TEADAO": "TeaDAO", + "TEAM": "TeamUP", + "TEARS": "Liberals Tears", + "TEC": "TeCoin", + "TECAR": "Tesla Cars", + "TECH": "TechCoin", + "TECHX": "WisdomTree Technology & Innovation 100 Digital Fund", + "TECK": "Technet", + "TECRA": "TecraCoin", + "TED": "Tezos Domains", + "TEDBNB": "TED", + "TEDDY": "Teddy Doge v2", + "TEDDYV1": "Teddy Doge", + "TEE": "Guarantee", + "TEER": "Integritee", + "TEITEI": "TeiTei", + "TEK": "TekCoin", + "TEL": "Telcoin", + "TELE": "Tele", + "TELEBTC": "teleBTC", + "TELEPORT": "Teleport System Token", + "TELL": "Tellurion", + "TELLER": "Teller", + "TELO": "Telo Meme Coin", + "TELOS": "Teloscoin", + "TEM": "Temtum", + "TEMA": "Tema", + "TEMCO": "TEMCO", + "TEMM": "TEM MARKET", + "TEMP": "Tempus", + "TEMPLE": "TempleDAO", + "TEN": "TEN", + "TEND": "Tendies", + "TENDIE": "TendieSwap", + "TENET": "TENET", + "TENFI": "TEN", + "TENGE": "TENGE TENGE", + "TENNET": "Tennet", + "TENS": "TensorScan", + "TENSHI": "Tenshi", + "TENT": "TENT", + "TEP": "Tepleton", + "TEQ": "Teq Network", + "TER": "TerraNovaCoin", + "TERA": "TERA", + "TERA2": "Terareum", + "TERADYNE": "Teradyne", + "TERAV1": "Terareum v1", + "TERAWATT": "Terawatt", + "TERM": "Terminal of Simpson", + "TERMINAL": "Book Terminal of Truths", + "TERMINUS": "Terminus", + "TERN": "Ternio", + "TERN.ETH": "Ternio ERC20", + "TERR": "Terrier", + "TERRA": "Terraport", + "TERRAB": "TERRABYTE AI", + "TERRACOIN": "TerraCoin", + "TERRY": "Terry The Disgruntled Turtle", + "TERZ": "SHELTERZ", + "TES": "TeslaCoin", + "TESLA": "TeslaCoilCoin", + "TESLAI": "Tesla AI", + "TESOURO": "Etherfuse TESOURO", + "TEST": "Test", + "TESTA": "Testa", + "TET": "Tectum", + "TETH": "Treehouse ETH", + "TETHYS": "Tethys", + "TETRA": "Tetra", + "TETSUO": "Tetsuo Coin", + "TETU": "TETU", + "TEVA": "Tevaera", + "TEVI": "TEVI Coin", + "TEW": "Trump in a memes world", + "TEX": "Terrax", + "TF47": "Trump Force 47", + "TFBX": "Truefeedback Token", + "TFC": "The Freedom Coin", + "TFI": "TrustFi Network Token", + "TFL": "True Flip Lottery", + "TFLOW": "TradeFlow", + "TFNY": "TFNY", + "TFS": "TFS Token", + "TFT": "The Famous Token", + "TFUEL": "Theta Fuel", + "TGAME": "TrueGame", + "TGC": "TG.Casino", + "TGCC": "TheGCCcoin", + "TGPT": "Trading GPT", + "TGRAM": "TG20 TGram", + "TGRASS": "Top Grass Club", + "TGT": "Tokyo Games Token", + "TGW": "The Green World", + "TH": "Team Heretics Fan Token", + "THALES": "Thales", + "THAPT": "Thala APT", + "THAVAGE": "Mike Tython", + "THC": "The Hempcoin", + "THD": "Trump Harris Debate", + "THE369": "The 369 code", + "THE9": "THE9", + "THEAICOIN": "AI", + "THEB": "The Boys Club", + "THEBLOX": "The Blox Project", + "THEC": "The CocktailBar", + "THECA": "Theca", + "THECAT": "THECAT", + "THECITADEL": "The Citadel", + "THEDAO": "The DAO", + "THEDEBTBOX": "The Debt Box", + "THEDOGE": "The Dogefather", + "THEF": "The Flash Currency", + "THEFACE": "FACE", + "THEFARM": "FARM", + "THEG": "The GameHub", + "THEHARAMBE": "Harambe", + "THEINTERNS": "Interns", + "THEM": "The Meta DAO", + "THEMIS": "Themis", + "THEN": "THENA", + "THEO": "Theopetra", + "THEOS": "Theos", + "THEP": "The Protocol", + "THEPLAY": "PLAY", + "THEREALCHAIN": "REAL", + "THERESAMAY": "Theresa May Coin", + "THEROS": "THEROS", + "THES": "The Standard Protocol (USDS)", + "THESTANDARD": "Standard Token", + "THETA": "Theta Network", + "THETAN": "Thetan Coin", + "THETRANSFERTOKEN": "The Transfer Token", + "THETRIBE": "The Tribe", + "THEX": "Thore Exchange", + "THG": "Thetan Arena", + "THIK": "ThikDik", + "THING": "Nothing", + "THINGSOP": "ThingsOperatingSystem", + "THINK": "THINK Token", + "THINKWAREAI": "ThinkwareAI", + "THISISF": "This is Fine", + "THISISMYIGUANA": "This Is My Iguana", + "THL": "Thala", + "THN": "Throne", + "THNX": "ThankYou", + "THO": "Athero", + "THOL": "AngelBlock", + "THOLA": "Tholana", + "THOR": "THORSwap", + "THOREUM": "Thoreum V3", + "THP": "TurboHigh Performance", + "THQ": "Theoriq Token", + "THR": "Thorecoin", + "THREE": "Three Protocol Token ", + "THRT": "ThriveToken", + "THRUST": "Thruster", + "THRY": "THEORY", + "THS": "TechShares", + "THT": "Thought", + "THUG": "Thug Life", + "THUN": "Thunder Brawl", + "THUNDER": "ThunderStake", + "THX": "Thorenext", + "TI": "Titanium22", + "TIA": "Celestia", + "TIANHE": "Tianhe", + "TIBBIR": "Ribbita", + "TIC": "TrueInvestmentCoin", + "TICO": "Tico", + "TIDAL": "Tidal Finance", + "TIDDIES": "TIDDIES", + "TIDE": "Tidalflats", + "TIE": "Ties Network", + "TIEDAN": "TieDan", + "TIF": "This Is Fine", + "TIFI": "TiFi Token", + "TIG": "Tigereum", + "TIGER": "TIGER", + "TIGERC": "TigerCash", + "TIGERCOIN": "TigerCoin", + "TIGERCV1": "TigerCash v1", + "TIGERMOON": "TigerMoon", + "TIGERSHARK": "Tiger Shark", + "TIGRA": "Tigra", + "TIGRES": "Tigres Fan Token", + "TIIM": "TriipMiles", + "TIK": "ChronoBase", + "TIKI": "Tiki Token", + "TIKTOK": "Tiktok", + "TIKTOKEN": "TikToken", + "TIM": "TIMTIM GAMES", + "TIME": "Chrono.tech", + "TIMEFUN": "timefun", + "TIMELESS": "Timeless", + "TIMES": "DARKTIMES", + "TIMESW": "Timeswap", + "TIMI": "TIMI", + "TIMICOIN": "Timicoin", + "TIN": "Token IN", + "TINC": "Tiny Coin", + "TIND": "Tinder Swindler", + "TINKU": "TinkuCoin", + "TINU": "Telegram Inu", + "TINY": "TinyBits", + "TIOX": "TIOx", + "TIP": "Tip", + "TIPC": "Tipcoin", + "TIPINU": "Tip Inu", + "TIPS": "FedoraCoin", + "TIPSX": "WisdomTree TIPS Digital Fund", + "TIPSY": "TipsyCoin", + "TIT": "TITANIUM", + "TITA": "Titan Hunters", + "TITAN": "SATOSHI•RUNE•TITAN (Runes)", + "TITANCOIN": "Titan Coin", + "TITANO": "Titano", + "TITANSWAP": "TitanSwap", + "TITANX": "TitanX", + "TITC": "TitCoin", + "TITCOIN": "titcoin", + "TITI": "TiTi Protocol", + "TITN": "Titan", + "TITS": "We Love Tits", + "TITTIECOIN": "TittieCoin", + "TITTY": "TamaKitty", + "TIUSD": "TiUSD", + "TIX": "Blocktix", + "TJRM": "Tajir Tech Hub", + "TKA": "Tokia", + "TKAI": "TAIKAI", + "TKB": "TokenBot", + "TKC": "TurkeyChain", + "TKG": "Takamaka Green Coin", + "TKING": "Tiger King", + "TKINU": "Tsuki Inu", + "TKMK": "TOKAMAK", + "TKMN": "Tokemon", + "TKN": "Token Name Service", + "TKNT": "TKN Token", + "TKO": "Tokocrypto", + "TKP": "TOKPIE", + "TKR": "CryptoInsight", + "TKS": "Tokes", + "TKST": "TokenSight", + "TKT": "Crypto Tickets", + "TKX": "Tokenize Xchange", + "TKY": "THEKEY Token", + "TLC": "Trillioner", + "TLM": "Alien Worlds", + "TLN": "Trustlines Network", + "TLOS": "Telos", + "TLP": "TulipCoin", + "TLTON": "iShares 20+ Year Treasury Bond ETF (Ondo Tokenized)", + "TLW": "TILWIKI", + "TMAGA": "THE MAGA MOVEMENT", + "TMAI": "Token Metrics AI", + "TMANIA": "Trump Mania", + "TME": "Timereum", + "TMED": "MDsquare", + "TMFT": "Turkish Motorcycle Federation", + "TMG": "T-mac DAO", + "TMN": "TranslateMe", + "TMNG": "TMN Global", + "TMNT": "TMNT", + "TMON": "Two Monkey Juice Bar", + "TMOX": "Thermo Fisher xStock", + "TMPL": "TMPL", + "TMRW": "TMRW Coin", + "TMSH": "Bursaspor Fan Token", + "TMT": "Tamy Token", + "TMTG": "The Midas Touch Gold", + "TMWH": "Tom Wif Hat", + "TMX": "TMX", + "TN": "TurtleNetwork", + "TNB": "Time New Bank", + "TNC": "TNC Coin", + "TNDC": "TendaCoin", + "TNGBL": "Tangible", + "TNS": "Transcodium", + "TNSR": "Tensor", + "TNT": "Tierion", + "TOA": "TOA Coin", + "TOAD": "TOAD", + "TOADCOIN": "TOAD", + "TOB": "Tom On Base", + "TOBI": "MOTO DOG", + "TOBY": "toby", + "TOC": "TouchCon", + "TODAY": "TodayCoin", + "TODD": "TURBO TODD", + "TOG": "Token of Games", + "TOILET": "Toilet Dust", + "TOK": "Tokai", + "TOKA": "Tonka Finance", + "TOKABU": "Tokabu", + "TOKAMAK": "Tokamak Network", + "TOKAU": "Tokyo AU", + "TOKC": "Tokyo Coin", + "TOKE": "Tokemak", + "TOKEN": "TokenFi", + "TOKENOMY": "Tokenomy", + "TOKENPLACE": "Tokenplace", + "TOKENSTARS": "TokenStars", + "TOKERO": "TOKERO LevelUP Token", + "TOKKI": "CRYPTOKKI", + "TOKO": "ToKoin", + "TOKU": "TokugawaCoin", + "TOKUD": "Tokuda", + "TOL": "Tolar", + "TOLO": "Tolo Yacoloco", + "TOLYCAT": "Toly's Cat", + "TOM": "TOM Finance", + "TOMA": "Tomarket", + "TOMAHAWKCOIN": "Tomahawkcoin", + "TOMAINFO": "TomaInfo", + "TOMAN": "IRR", + "TOMB": "Tomb", + "TOMC": "TOM CAT", + "TOMI": "tomiNet", + "TOMO": "Tomo Cat", + "TOMOE": "TomoChain ERC20", + "TOMS": "TomTomCoin", + "TON": "Toncoin", + "TONALD": "Tonald Trump", + "TONE": "TE-FOOD", + "TONI": "Daytona Finance", + "TONIC": "Tectonic", + "TONK": "Tonk Inu", + "TONNEL": "TONNEL Network", + "TONO": "Tonomy Token", + "TONS": "TONSniper", + "TONST": "Ton Stars", + "TONT": "TONKIT", + "TONTOKEN": "TONToken", + "TONUP": "TonUP", + "TONXX": "TON xStock", + "TONY": "TONY THE DUCK", + "TOOB": "Toobcoin", + "TOOBIGTORIG": "Too Big To Rig", + "TOOKER": "tooker kurlson", + "TOOLS": "TOOLS", + "TOON": "Pontoon", + "TOONF": "Toon Finance", + "TOOTHLESS": "Toothless", + "TOPBIDDER": "TopBidder", + "TOPC": "Topchain", + "TOPCA": "TOP CAT", + "TOPCAT": "Topcat", + "TOPG": "Tate Token", + "TOPGP": "TOP G PEPE", + "TOPI": "Topi Meme", + "TOPIA": "Hytopia", + "TOPN": "TOP Network", + "TOR": "TOR", + "TORA": "Tensora", + "TORAN": "TORA NEKO", + "TORCH": "Hercules Token", + "TORE": "Toreus Finance", + "TORG": "TORG", + "TORI": "Teritori", + "TORII": "Torii Finance", + "TORN": "Tornado Cash", + "TORO": "Toro Inoue", + "TOROSOL": "Toro", + "TORSY": "TORSY", + "TOS": "Cryptos", + "TOSA": "TosaInu BSC", + "TOSC": "T.OS", + "TOSDIS": "TosDis", + "TOSHE": "Toshe", + "TOSHI": "Toshi", + "TOSHKIN": "Toshkin Coin", + "TOT": "TotCoin", + "TOTAKE": "Totakeke", + "TOTAKEKE": "Dark Cheems", + "TOTEM": "DragonMaster", + "TOTHEMOON": "To The Moon", + "TOTM": "Totem", + "TOTO": "TOTO", + "TOTT": "TOTT", + "TOUCANPROTOCOL": "Toucan Protocol: Base Carbon Tonne", + "TOUCHFAN": "TouchFan", + "TOUCHG": "Touch Grass", + "TOUR": "Tour Billion", + "TOURI": "Tourist Token", + "TOURISTS": "TOURIST SHIBA INU", + "TOWELI": "Towelie", + "TOWER": "Tower", + "TOWN": "Town Star", + "TOWNS": "Towns", + "TOX": "INTOverse", + "TOXI": "ToxicGarden.finance SEED", + "TOYBOX": "Memefi Toybox 404", + "TOZ": "Tozex", + "TP": "Token Swap", + "TPAD": "TrustPad", + "TPAY": "TokenPay", + "TPC": "Techpay", + "TPCASH": "TPCash", + "TPG": "Troll Payment", + "TPRO": "TPRO Network", + "TPT": "Token Pocket", + "TPTU": "Trading and Payment Token", + "TPU": "TensorSpace", + "TPV": "TravGoPV", + "TPY": "Thrupenny", + "TQ": "TonQuestion", + "TQQQON": "ProShares UltraPro QQQ (Ondo Tokenized)", + "TQQQX": "TQQQ xStock", + "TQRT": "TokoQrt", + "TR3": "Tr3zor", + "TRA": "Trabzonspor Fan Token", + "TRAC": "OriginTrail", + "TRACE": "Trace Network Labs", + "TRACEA": "Trace AI", + "TRACEABILITY": "Traceability Chain", + "TRACKEDBIO": "TrackedBio", + "TRACN": "trac (Ordinals)", + "TRADE": "Polytrade", + "TRADEBOT": "TradeBot", + "TRADECHAIN": "Trade Chain", + "TRADETIDE": "Trade Tide Token", + "TRADEX": "TradeX AI", + "TRADOOR": "Tradoor", + "TRAI": "Trackgood AI", + "TRAID": "Traid", + "TRAIMP": "TRUMP AI", + "TRAIN": "Trump Train", + "TRAK": "TrakInvest", + "TRALA": "TRALA", + "TRANQ": "Tranquil Finance", + "TRANS": "Trans Pepe", + "TRANSFER": "TransferCoin", + "TRASH": "TrashCoin", + "TRAT": "Tratok", + "TRAVA": "Trava Finance", + "TRAXIA": "Traxia Membership Token", + "TRAXX": "Traxx", + "TRB": "Tellor", + "TRBT": "Tribute", + "TRBV1": "Tellor Tributes v1", + "TRC": "Terrace", + "TRCB": "TRCB Chain", + "TRCL": "Treecle", + "TRCR": "Tracer", + "TRCT": "Tracto", + "TRDC": "Traders Coin", + "TRDL": "Strudel Finance", + "TRDS": "Traders Token", + "TRDT": "Trident", + "TRDX": "Trendix", + "TREA": "Treat", + "TREAT": "Shiba Inu Treat", + "TREB": "Treble", + "TRECENTO": "Trecento Blockchain Capital", + "TREE": "Treehouse", + "TREEB": "Retreeb", + "TREEINCAT": "Tree stuck in cat", + "TREEOFALPHA": "Tree", + "TREMP": "Doland Tremp", + "TRENCHER": "Trencher", + "TRESTLE": "TRESTLE", + "TRET": "Tourist Review", + "TRG": "The Rug Game", + "TRGI": "The Real Golden Inu", + "TRHUB": "Tradehub", + "TRI": "Triangles Coin", + "TRIA": "TRIA", + "TRIAS": "Trias", + "TRIBE": "Tribe", + "TRIBETOKEN": "TribeToken", + "TRIBEX": "Tribe Token", + "TRIBL": "Tribal Token", + "TRICK": "TrickyCoin", + "TRICKLE": "Trickle", + "TRIG": "Trigger", + "TRINI": "Trinity Network Credit", + "TRIO": "TRIO", + "TRIPAD": "TripAdvisor, Inc.", + "TRIPIO": "Tripio", + "TRIPPKI": "Trippki", + "TRISIG": "TRI SIGMA", + "TRITON": "Triton", + "TRIVI": "TriviAgent by Virtuals", + "TRIVIA": "Trivians", + "TRIX": "TriumphX", + "TRK": "TruckCoin", + "TRKX": "Trakx", + "TRL": "Triall", + "TRMX": "TourismX Token", + "TRNDZ": "Trendsy", + "TRNGUY": "Tron Guy Project", + "TROG": "Trog", + "TROGE": "Troge", + "TROLL": "TROLL", + "TROLLC": "Trollcoin", + "TROLLGE": "TROLLGE", + "TROLLHEIM": "Trollheim", + "TROLLICTO": "TROLLI CTO", + "TROLLMODE": "TROLL MODE", + "TROLLRUN": "TROLL", + "TROLLS": "trolls in a memes world", + "TRONBETLIVE": "TRONbetLive", + "TRONDOG": "TronDog", + "TRONI": "Tron Inu", + "TRONP": "Donald Tronp", + "TRONPAD": "TRONPAD", + "TROP": "Interop", + "TROPPY": "TROPPY", + "TROSS": "Trossard", + "TROVE": "TROVE", + "TROY": "Troy", + "TRP": "Tronipay", + "TRR": "Terran Coin", + "TRSCT": "Transactra Finance", + "TRST": "TrustCoin", + "TRTL": "TurtleCoin", + "TRTT": "Trittium", + "TRU": "TrueFi", + "TRUAPT": "TruFin Staked APT", + "TRUCE": "WORLD PEACE PROJECT", + "TRUE": "True Chain", + "TRUEBIT": "Truebit Protocol", + "TRUEZEUSCOIN": "Zeus", + "TRUF": "Truflation", + "TRUFV1ERC20": "Truflation v1 ERC-20", + "TRUM": "TrumpBucks", + "TRUMAGA": "TrumpMAGA", + "TRUMATIC": "TruFin Staked MATIC", + "TRUMP": "OFFICIAL TRUMP", + "TRUMP2": "Trump2024", + "TRUMP2024": "Donald Trump", + "TRUMP3": "Trump MP3", + "TRUMP47": "47th President of the United States", + "TRUMPA": "TRUMP AI", + "TRUMPAI": "Trump Maga AI", + "TRUMPAMANIA": "TRUMPAMANIA", + "TRUMPARMY": "Trump Army", + "TRUMPBASE": "MAGA (magatrumponbase.tech)", + "TRUMPBIDEN": "Trump vs Biden", + "TRUMPC": "TrumpCat", + "TRUMPCA": "Trump Card", + "TRUMPCAT": "TRUMPCAT", + "TRUMPCATF": "Trump Cat Family", + "TRUMPCATS": "Trump Golden Cat", + "TRUMPCOIN": "MAGA: Fight for Trump", + "TRUMPCOINROCKS": "TrumpCoin", + "TRUMPDAO": "TRUMP DAO", + "TRUMPDO": "TRUMP", + "TRUMPDOGE": "Trump Doge", + "TRUMPDOGECOIN": "DOGE", + "TRUMPE": "Trump Pepe", + "TRUMPEPE": "Trump Pepe", + "TRUMPER": "Trump Era", + "TRUMPF": "Trump Fight", + "TRUMPG": "TRUMP GROK", + "TRUMPHAT": "Trump Hat", + "TRUMPI": "TRUMP IP", + "TRUMPINU": "Trump Inu", + "TRUMPIUS": "Trumpius Maximus", + "TRUMPJ": "TRUMPJR", + "TRUMPJR": "OFFICIAL TRUMP JR", + "TRUMPJRVIP": "TrumpJr", + "TRUMPM": "TRUMP MAGA PRESIDENT", + "TRUMPMA": "TRUMP MAGA SUPER", + "TRUMPMAGA": "President Trump MAGA", + "TRUMPONBASE": "TRUMP ON BASE", + "TRUMPPROJECT": "Trump Project 2025", + "TRUMPS": "Trump SOL", + "TRUMPSB": "TrumpsBags", + "TRUMPSFIGHT": "TrumpsFight", + "TRUMPSHIBA": "Trump Shiba", + "TRUMPTECH": "Trump Tech", + "TRUMPTESLA": "Trump Tesla", + "TRUMPTITANS": "TrumpTitans", + "TRUMPVANCE": "Trump Vance 2024", + "TRUMPX": "Trump X-Maga", + "TRUMPZ": "Trump Zhong", + "TRUNK": "Elephant Money", + "TRUST": "Intuition", + "TRUSTNFT": "TrustNFT", + "TRUSTPLUS": "TrustDAO", + "TRUT": "Truth", + "TRUTH": "Swarm Network", + "TRUTHFI": "Truthfi", + "TRUTHGPT": "TruthGPT", + "TRV": "TrustVerse", + "TRVC": "Trivechain", + "TRVL": "TRVL", + "TRWA": "TRWA", + "TRWP": "Danol Tremp", + "TRX": "TRON", + "TRXC": "TRONCLASSIC", + "TRXDICE": "TRONdice", + "TRXS": "Staked TRX", + "TRXV1": "TRON V1", + "TRXWIN": "TronWin", + "TRYB": "BiLira", + "TRYC": "TRYC", + "TRYHARDS": "TryHards", + "TRYX": "eToro Turkish Lira", + "TSA": "Teaswap Art", + "TSC": "TrusterCoin", + "TSCT": "Transient", + "TSD": "True Seigniorage Dollar", + "TSE": "TattooCoin", + "TSF": "Transaction Service Fee", + "TSG": "The Soldiers Gold", + "TSHARE": "Tomb Shares", + "TSHP": "12Ships", + "TSL": "Energo", + "TSLAON": "Tesla (Ondo Tokenized)", + "TSLAX": "Tesla xStock", + "TSLT": "Tamkin", + "TSMON": "Taiwan Semiconductor Manufacturing (Ondo Tokenized)", + "TSN": "Tsunami Exchange Token", + "TSO": "Thesirion", + "TSOTCHKE": "tsotchke", + "TSR": "Tesra", + "TST": "Test", + "TSTAI": "Test AI", + "TSTON": "Tonstakers TON", + "TSTS": "Test", + "TSUBASAUT": "TSUBASA Utility Token", + "TSUGT": "Captain Tsubasa", + "TSUJI": "Tsutsuji", + "TSUKA": "Dejitaru Tsuka", + "TSX": "TradeStars", + "TT": "ThunderCore", + "TTAJ": "TTAJ", + "TTC": "TonTycoon", + "TTF": "TurboTrix Finance", + "TTK": "The Three Kingdoms", + "TTM": "Tradetomato", + "TTN": "TTN", + "TTNT": "TITA Project", + "TTT": "TRUMPETTOKEN", + "TTTU": "T-Project", + "TTU": "TaTaTu", + "TTV": "TV-TWO", + "TUA": "Atua AI", + "TUBE": "BitTube", + "TUBES": "TUBES", + "TUCKER": "TUCKER CARLSON", + "TUDA": "Tutor's Diary", + "TUF": "TUF Token", + "TUGGIN": "Tuggin", + "TUGOU": "TuGou", + "TUKI": "Tuki", + "TUKIV1": "Tuki v1", + "TULIP": "Tulip Protocol", + "TUNA": "DefiTuna", + "TUNACOIN": "TUNACOIN", + "TUNE": "Bitune", + "TUNETRADEX": "TuneTrade", + "TUP": "Tenup", + "TUPE": "Turtle Pepe", + "TUR": "Turron", + "TURB": "TurboX", + "TURBO": "Turbo", + "TURBOB": "Turbo Browser", + "TURBOS": "Turbos Finance", + "TURBOW": "Turbo Wallet", + "TURT": "TurtSat", + "TURTLE": "Turtle", + "TUS": "Treasure Under Sea", + "TUSD": "True USD", + "TUSDV1": "True USD v1", + "TUT": "Tutorial", + "TUTC": "TUTUT COIN", + "TUTELLUS": "Tutellus", + "TUTTER": "Tutter", + "TUX": "Tux The Penguin", + "TUZKI": "Tuzki", + "TUZLA": "Tuzlaspor Token", + "TVK": "Terra Virtua Kolect", + "TVNT": "TravelNote", + "TVRS": "TiraVerse", + "TVS": "TVS", + "TW": "Winners Coin", + "TWC": "Twilight", + "TWD": "Terra World Token", + "TWEE": "TWEEBAA", + "TWEETY": "Tweety", + "TWELVE": "TWELVE ZODIAC", + "TWEP": "The Web3 Project", + "TWIF": "Tomwifhat", + "TWIFB": "TrumpWifBiden", + "TWIGGY": "Twiggy", + "TWIN": "Twinci", + "TWIST": "TwisterCoin", + "TWLV": "Twelve Coin", + "TWOCAT": "TwoTalkingCats", + "TWOGE": "Twoge Inu", + "TWP": "TrumpWifPanda", + "TWT": "Trust Wallet Token", + "TWURTLE": "twurtle the turtle", + "TX": "Tradix", + "TX20": "Trex20", + "TXA": "TXA", + "TXAG": "tSILVER", + "TXAGV1": "AurusSILVER", + "TXAI": "TrumpX Ai", + "TXAU": "tGOLD", + "TXBIT": "Txbit Token", + "TXC": "TEXITcoin", + "TXG": "TRUSTxGAMING", + "TXL": "Autobahn Network", + "TXT": "Taxa Token", + "TYBENG": "TYBENG", + "TYBG": "Base God", + "TYBGSc": "Base Goddess", + "TYC": "Tycoon", + "TYCOON": "Tycoon Token", + "TYKE": "Tyke The Elephant", + "TYLER": "Tyler", + "TYOGHOUL": "TYO GHOUL", + "TYPE": "TypeAI", + "TYPEL": "TypeIt", + "TYPERIUM": "Typerium", + "TYPUS": "Typus", + "TYRANT": "Fable Of The Dragon", + "TYRION": "Tyrion", + "TYSON": "Mike Tyson", + "TYT": "Tianya Token", + "TZC": "TrezarCoin", + "TZKI": "Tsuzuki Inu", + "TZPEPE": "Tezos Pepe", + "TZU": "Sun Tzu", + "U": "United Stables", + "U2U": "U2U Network", + "U8D": "Universal Dollar", + "UA1": "UA1", + "UAEC": "United Arab Emirates Coin", + "UAHG": "UAHg", + "UAI": "UnifAI", + "UAT": "UltrAlpha", + "UB": "Unibase", + "UBA": "Unbox.Art", + "UBC": "Universal Basic Compute", + "UBCOIN": "Ubcoin", + "UBDN": "UBD Network", + "UBEX": "Ubex", + "UBI": "Universal Basic Income", + "UBIQ": "Ubiqoin", + "UBIT": "UBIT", + "UBITTOKEN": "UBit Token", + "UBQ": "Ubiq", + "UBT": "UniBright", + "UBTC": "UnitedBitcoin", + "UBX": "UBIX Network", + "UBXN": "UpBots Token", + "UBXS": "UBXS", + "UBXT": "UpBots", + "UC": "YouLive Coin", + "UCA": "UCA Coin", + "UCANFIX": "Ucan fix life in1day", + "UCAP": "Unicap.finance", + "UCASH": "U.CASH", + "UCCOIN": "UC Coin", + "UCG": "Universe Crystal Gene", + "UCH": "UChain", + "UCJL": "Utility Cjournal", + "UCM": "UCROWDME", + "UCN": "UCHAIN", + "UCO": "Uniris", + "UCOIN": "UCOIN", + "UCON": "YouCoin Metaverse", + "UCORE": "UnityCore Protocol", + "UCR": "Ultra Clear", + "UCT": "UnitedCrowd", + "UCX": "UCX", + "UDAO": "UDAO", + "UDO": "Unido", + "UDOO": "Hyprr", + "UDS": "Undeads Games", + "UDT": "Unlock Protocol", + "UE": "UE Coin", + "UEC": "United Emirates Coin", + "UEDC": "United Emirate Decentralized Coin", + "UENC": "UniversalEnergyChain", + "UET": "Useless Ethereum Token", + "UETL": "Useless Eth Token Lite", + "UFARM": "UniFarm", + "UFC": "Union Fair Coin", + "UFD": "Unicorn Fart Dust", + "UFFYI": "Unlimited FiscusFYI", + "UFI": "PureFi", + "UFO": "UFO Gaming", + "UFOC": "Unknown Fair Object", + "UFOCOIN": "Uniform Fiscal Object", + "UFOP": "UFOPepe", + "UFR": "Upfiring", + "UFT": "UniLend Finance", + "UGAS": "Ultrain", + "UGC": "ugChain", + "UGO": "UGO", + "UGOLD": "UGOLD Inc.", + "UGT": "Universal Games Token", + "UHP": "Ulgen Hash Power", + "UI": "uiui", + "UIBT": "Unibit", + "UIM": "UNIVERSE ISLAND", + "UIN": "Alliance Chain", + "UIP": "UnlimitedIP", + "UIS": "Unitus", + "UJENNY": "Jenny Metaverse DAO Token", + "UKG": "UnikoinGold", + "UKRAINEDAO": "UkraineDAO Flag NFT", + "ULD": "Unlighted", + "ULT": "Ultiledger", + "ULTC": "Umbrella", + "ULTGG": "UltimoGG", + "ULTI": "Ultiverse", + "ULTIMA": "Ultima", + "ULTIMATEBOT": "Ultimate Tipbot", + "ULTR": "ULTRA MAGA", + "ULTRA": "Ultra", + "ULTRAP": "ULTRA Prisma Finance", + "ULX": "ULTRON", + "UM": "UncleMine", + "UMA": "UMA", + "UMAD": "MADworld", + "UMAMI": "Umami", + "UMB": "Umbrella Network", + "UMBR": "Umbria Network", + "UMBRA": "Umbra", + "UMC": "Umbrella Coin", + "UMI": "Universal Money Instrument", + "UMID": "Umi Digital", + "UMJA": "Umoja", + "UMK": "UMKA", + "UMM": "UMM", + "UMMA": "UMMA Token", + "UMO": "Universal Molecule", + "UMT": "UnityMeta", + "UMX": "UniMex Network", + "UMY": "KaraStar UMY", + "UNA": "Unagi Token", + "UNAT": "Unattanium", + "UNB": "Unbound Finance", + "UNBNK": "Unbanked", + "UNBREAKABLE": "UnbreakableCoin", + "UNC": "UnCoin", + "UNCL": "UNCL", + "UNCN": "Unseen", + "UNCOMMONGOODS": "UNCOMMON•GOODS", + "UNCX": "UniCrypt", + "UND": "United Network Distribution", + "UNDB": "unibot.cash", + "UNDE": "Undead Finance", + "UNDEAD": "Undead Blocks", + "UNDG": "UniDexGas", + "UNDX": "UNODEX", + "UNF": "Unfed Coin", + "UNFI": "Unifi Protocol DAO", + "UNFK": "UNFK", + "UNHX": "UnitedHealth xStock", + "UNI": "Uniswap Protocol Token", + "UNIART": "UNIART", + "UNIBOT": "Unibot", + "UNIBOTV1": "Unibot v1", + "UNIBTC": "uniBTC", + "UNIC": "Unicly", + "UNICE": "UNICE", + "UNICEF": "united normies in crypto extending funds", + "UNICORN": "UNICORN Token", + "UNIDEF": "Unidef", + "UNIDEXAI": "UniDexAI", + "UNIDX": "UniDex", + "UNIDXV1": "UniDex v1", + "UNIE": "Uniswap Protocol Token (Avalanche Bridge)", + "UNIETH": "Universal ETH", + "UNIFI": "Unifi", + "UNIFY": "Unify", + "UNIL": "UniLayer", + "UNIM": "Unicorn Milk", + "UNIO": "Unio Coin", + "UNION": "Union", + "UNIPOWER": "UniPower", + "UNIPT": "Universal Protocol Token", + "UNIQ": "Uniqredit", + "UNIQUE": "Unique One", + "UNIR": "UniRouter", + "UNISD": "unified Stable Dollar", + "UNISDV1": "uniswap State Dollar", + "UNISOCKS": "Unisocks", + "UNISTAKE": "Unistake", + "UNIT": "Universal Currency", + "UNIT0": "UNIT0", + "UNITARYSTATUS": "UnitaryStatus Dollar", + "UNITE": "Unite", + "UNITED": "UnitedCoins", + "UNITEDTRADERS": "United Traders Token", + "UNITPROV2": "Unit Protocol New", + "UNITRADE": "UniTrade", + "UNITREEAI": "Unitree G1 AI", + "UNITREEDOG": "Unitree AI Robot Dog", + "UNITS": "GameUnits", + "UNITY": "SuperNET", + "UNIVRS": "Universe", + "UNIX": "UniX", + "UNIXCOIN": "UNIX", + "UNLEASH": "UnleashClub", + "UNM": "UNIUM", + "UNMD": "Utility Nexusmind", + "UNN": "UNION Protocol Governance Token", + "UNO": "UnoRe", + "UNOB": "Unobtanium", + "UNP": "UNIPOLY", + "UNQ": "UNQ", + "UNQT": "Unique Utility Token", + "UNR": "Unirealchain", + "UNRC": "UniversalRoyalCoin", + "UNS": "UNS TOKEN", + "UNSHETH": "unshETH Ether", + "UNW": "UniWorld", + "UOP": "Utopia Genesis Foundation", + "UOS": "UOS", + "UP": "UpToken", + "UPC": "UPCX", + "UPCG": "Upcomings", + "UPCO2": "Universal Carbon", + "UPCOIN": "UPcoin", + "UPDOG": "What's Updog", + "UPEUR": "Universal Euro", + "UPI": "Pawtocol", + "UPLOAD": "Upload Token", + "UPO": "UpOnly", + "UPP": "Sentinel Protocol", + "UPR": "Upfire", + "UPRO": "ULTRAPRO", + "UPS": "UPFI Network", + "UPT": "UPROCK", + "UPTOP": "UPTOP", + "UPTOS": "UPTOS", + "UPUNK": "Unicly CryptoPunks Collection", + "UPUSD": "Universal US Dollar", + "UPX": "uPlexa", + "UQC": "Uquid Coin", + "UR": "UR", + "URAC": "Uranus", + "URALS": "Urals Coin", + "URANUS": "Uranus", + "URFA": "Urfaspor Token", + "URMOM": "urmom", + "URO": "Urolithin A", + "UROCOIN": "UroCoin", + "URQA": "UREEQA", + "URS": "URUS", + "URUS": "Urus Token", + "URX": "URANIUMX", + "US": "Talus Token", + "USA": "Based USA", + "USACOIN": "American Coin", + "USAGIBNB": "U", + "USAT": "Tether America USD", + "USATINC": "USAT", + "USBT": "Universal Blockchain", + "USC": "Ultimate Secure Cash", + "USCC": "USC", + "USCOIN": "USCoin", + "USCR": "United States Crypto Reserve", + "USD0": "Usual", + "USD1": "World Liberty Financial USD", + "USD3": "Web 3 Dollar", + "USDA": "USDA", + "USDACC": "USDA", + "USDAI": "USDai", + "USDAP": "Bond Appetite USD", + "USDAVALON": "USDa", + "USDB": "USD Bancor", + "USDBC": "Bridged USDC", + "USDBLAST": "USDB Blast", + "USDC": "USD Coin", + "USDCASH": "USDCASH", + "USDCAT": "UpSideDownCat", + "USDCAV": "USD Coin (Portal from Avalanche)", + "USDCBS": "USD Coin (Portal from BSC)", + "USDCE": "USD Coin (Avalanche Bride)", + "USDCEAV": "USD.e Coin (Portal from Avalanche)", + "USDCET": "USD Coin (Portal from Ethereum)", + "USDCPO": "USD Coin (PoS) (Portal from Polygon)", + "USDCSO": "USD Coin (Portal from Solana)", + "USDCV": "USD CoinVertible", + "USDD": "USDD", + "USDDV1": "USDD v1", + "USDE": "Ethena USDe", + "USDEBT": "USDEBT", + "USDEX": "eToro US Dollar", + "USDF": "Falcon USD", + "USDFL": "USDFreeLiquidity", + "USDG": "Global Dollar", + "USDGLOBI": "Globiance USD Stablecoin", + "USDGV1": "USDG v1", + "USDGV2": "USDG", + "USDH": "USDH", + "USDHHUBBLE": "USDH Hubble Stablecoin", + "USDHL": "Hyper USD", + "USDI": "Interest Protocol USDi", + "USDJ": "USDJ", + "USDK": "USDK", + "USDKG": "USDKG", + "USDL": "Lift Dollar", + "USDM": "USDM", + "USDMA": "USD mars", + "USDN": "Ultimate Synthetic Delta Neutral", + "USDNEUTRAL": "Neutral AI", + "USDO": "USD Open Dollar", + "USDON": "U.S. Dollar Tokenized Currency (Ondo)", + "USDP": "Pax Dollar", + "USDPLUS": "Overnight.fi USD+", + "USDQ": "Quantoz USDQ", + "USDQSTABLE": "USDQ", + "USDR": "StablR USD", + "USDS": "Sky Dollar", + "USDSB": "USDSB", + "USDSTABLY": "StableUSD", + "USDT": "Tether", + "USDT0": "USDT0", + "USDT1": "USDT1", + "USDTB": "USDtb", + "USDTBASE": "USDT (Base)", + "USDTV": "TetherTV", + "USDTZ": "USDtez", + "USDU": "Upper Dollar", + "USDUC": "Unstable Coin", + "USDV": "Verified USD", + "USDW": "USD DWIN", + "USDWON": "Won Chang", + "USDX": "USDX Stablecoin", + "USDXL": "Last USD", + "USDY": "Ondo US Dollar Yield", + "USDZ": "Zedxion USDZ", + "USE": "Usechain Token", + "USEDCAR": "A Gently Used 2001 Honda", + "USELESS": "USELESS COIN", + "USETH": "USETH", + "USG": "USGold", + "USH": "unshETHing_Token", + "USHARK": "uShark", + "USHI": "Ushi", + "USHIBA": "American Shiba", + "USK": "USK", + "USN": "USN", + "USNBT": "NuBits", + "USNOTA": "NOTA", + "USOR": "U.S Oil", + "USP": "USP Token", + "USPEPE": "American pepe", + "USPLUS": "Fluent Finance", + "USR": "Resolv USR", + "USSD": "Autonomous Secure Dollar", + "UST": "Wrapped UST Token", + "USTB": "Superstate Short Duration U.S. Government Securities Fund", + "USTBL": "Spiko US T-Bills Money Market Fund", + "USTC": "TerraClassicUSD", + "USTCW": "TerraClassicUSD Wormhole", + "USTREAM": "Ustream Coin", + "USTRY": "Etherfuse USTRY", + "USTX": "UpStableToken", + "USUAL": "Usual", + "USUALX": "USUALx", + "USUD": "USUD", + "USV": "Universal Store of Value", + "USX": "USX", + "USXQ": "USX Quantum", + "USYC": "Hashnote USYC", + "UT": "Ulord", + "UTBAI": "UTB.ai", + "UTC": "UltraCoin", + "UTED": "United", + "UTG": "UltronGlow", + "UTH": "Uther", + "UTHR": "Utherverse Xaeon", + "UTHX": "Utherverse", + "UTI": "Unicorn Technology International", + "UTIL": "Utility Coin", + "UTK": "Utrust", + "UTKV1": "Utrust", + "UTMDOGE": "UltramanDoge", + "UTNP": "Universa", + "UTON": "uTON", + "UTOPIA": "Utopia", + "UTT": "uTrade", + "UTU": "UTU Protocol", + "UTX": "UTIX", + "UTYA": "Utya", + "UTYAB": "Utya Black", + "UUC": "USA Unity Coin", + "UUSD": "Utopia USD", + "UUU": "U Network", + "UVT": "UvToken", + "UW3S": "Utility Web3Shot", + "UWU": "Unlimited Wealth Utility", + "UWUCOIN": "uwu", + "UWULEND": "UwU Lend", + "UX": "Umee", + "UXLINK": "UXLINK", + "UXLINKV1": "UXLINK v1", + "UXOS": "UXOS", + "UXP": "UXD Protocol", + "UZUMAKI": "Uzumaki Inu", + "UZX": "UZX", + "VAAVE": "Venus AAVE", + "VAB": "Vabble", + "VADA": "Venus Cardano", + "VADER": "VaderAI", + "VADERPROTOCOL": "Vader Protocol", + "VAI": "Vai", + "VAIN": "Vainguard by Virtuals", + "VAIOT": "VAIOT", + "VAIOTV1": "VAIOT v1", + "VAIX": "Vectorspace AI X", + "VAL": "Validity", + "VALAN": "Valannium", + "VALAS": "Valas Finance", + "VALENTINE": "Valentine", + "VALI": "VALIMARKET", + "VALID": "Validator Token", + "VALOR": "Valor Token", + "VALORBIT": "Valorbit", + "VALU": "Value", + "VALUE": "Value Liquidity", + "VALYR": "Valyr", + "VAM": "Vitalum", + "VAMPIRE": "Vampire Inu", + "VAN": "Vanspor Token", + "VANA": "Vana", + "VANCAT": "Vancat", + "VANCE": "JD Vance", + "VANCEMEME": "Vance Meme", + "VANF": "Van Fwogh", + "VANKEDISI": "vankedisi", + "VANRY": "Vanar Chain", + "VANT": "Vanta Network", + "VANY": "Vanywhere", + "VAPE": "VAPE", + "VAPOR": "Hypervapor", + "VARA": "Vara Network", + "VARIUS": "Varius", + "VARK": "Aardvark", + "VATAN": "Vatan Token", + "VATO": "vanitis", + "VATR": "Vatra INU", + "VATRENI": "Croatian FF Fan Token", + "VAULT": "Vault Tech", + "VAULTCOIN": "VaultCoin", + "VBCH": "Venus BCH", + "VBETH": "Venus BETH", + "VBG": "Vibing", + "VBILL": "VanEck Treasury Fund", + "VBIT": "Valobit", + "VBK": "VeriBlock", + "VBNB": "Venus BNB", + "VBNT": "Bancor Governance Token", + "VBSC": "Votechain", + "VBSWAP": "vBSWAP", + "VBT": "VB Token", + "VBTC": "Venus BTC", + "VC": "VinuChain", + "VCAKE": "Venus CAKE", + "VCAT": "Vibing Cat", + "VCF": "Valencia CF Fan Token", + "VCG": "VCGamers", + "VCHF": "VNX Swiss Franc", + "VCI": "VinciToken", + "VCK": "28VCK", + "VCNT": "ViciCoin", + "VCORE": "VCORE", + "VCT": "VCHAT Token", + "VCX": "VaultCraft", + "VDA": "Verida", + "VDG": "VeriDocGlobal", + "VDL": "Vidulum", + "VDO": "VidioCoin", + "VDOGE": "Venus Dogecoin", + "VDOT": "Venus DOT", + "VDR": "Vodra", + "VDT": "Vendetta", + "VDV": "VDV Token", + "VDX": "Vodi X", + "VDZ": "Voidz", + "VEC": "VECTOR", + "VEC2": "VectorCoin 2.0", + "VECT": "Vectorium", + "VECTOR": "VectorChat.ai", + "VEE": "BLOCKv", + "VEED": "VEED", + "VEEN": "LIVEEN", + "VEG": "BitVegan", + "VEGA": "Vega Protocol", + "VEGAS": "Vegas", + "VEGASI": "Vegas Inu Token", + "VEGASINO": "Vegasino", + "VEGE": "Vege Token", + "VEIL": "DarkVeil", + "VEILPROJECT": "VEIL", + "VEKTOR": "VEKTOR", + "VELA": "Vela Token", + "VELAAI": "velaai", + "VELAR": "Velar", + "VELO": "Velo", + "VELOD": "Velodrome Finance", + "VELODV1": "Velodrome v1", + "VELOX": "Velox", + "VELOXPROJECT": "Velox", + "VELVET": "Velvet", + "VEMP": "vEmpire DDAO", + "VEN": "VeChain Old", + "VENA": "Vena Network", + "VENKO": "VENKO", + "VENOM": "Venom", + "VENOMAI": "VENOM", + "VENT": "Vent Finance", + "VENTI": "VentiSwap", + "VENTION": "Vention", + "VENTU": "Venture Coin", + "VENUS": "VenusEnergy", + "VEO": "Amoveo", + "VER": "VersalNFT", + "VERA": "Vera", + "VEREM": "Verified Emeralds", + "VERI": "Veritaseum", + "VERIC": "VeriCoin", + "VERIFY": "Verify", + "VERO": "VEROPAD", + "VERSA": "Versa Token", + "VERSACE": "VERSACE", + "VERSE": "Verse World", + "VERSEBIT": "Verse", + "VERT": "VERT", + "VERTAI": "Vertical AI", + "VERTEX": "Vertex", + "VERUM": "Verum Coin", + "VERVE": "Verve", + "VEST": "VestChain", + "VESTARIN": "Vestarin", + "VESTATE": "Vestate", + "VET": "VeChain", + "VETH": "Venus ETH", + "VETME": "VetMe", + "VETTER": "Vetter Token", + "VEUR": "VNX Euro", + "VEX": "Vexanium", + "VEXT": "Veloce", + "VFIL": "Venus Filecoin", + "VFOX": "VFOX", + "VFT": "Value Finance", + "VFX": "ViFoxCoin", + "VFY": "zkVerify", + "VFYV1": "Verify Token", + "VG": "Viu Ganhou", + "VGBP": "VNX British Pound", + "VGO": "Vagabond", + "VGX": "Voyager Token", + "VGXV1": "Voyager v1", + "VHC": "Vault Hill City", + "VI": "Vid", + "VIA": "Octavia AI", + "VIAC": "ViaCoin", + "VIB": "Viberate", + "VIBE": "VIBEHub", + "VIBEA": "Vibe AI", + "VIBLO": "VIBLO", + "VIC": "Viction", + "VICA": "ViCA Token", + "VICE": "VICE Token", + "VICEX": "ViceToken", + "VICS": "RoboF", + "VICT": "Victory Impact Coin", + "VICTORIUM": "Victorium", + "VID": "VideoCoin", + "VIDA": "Vidiachange", + "VIDEO": "Videocoin by Drakula", + "VIDT": "VIDT Datalink", + "VIDTV1": "VIDT Datalink", + "VIDY": "Vidy", + "VIDYA": "Vidya", + "VIDYX": "VidyX", + "VIDZ": "PureVidz", + "VIEW": "Viewly", + "VIG": "TheVig", + "VIGI": "Vigi", + "VIK": "VIKTAMA", + "VIKITA": "VIKITA", + "VIKKY": "VikkyToken", + "VILADY": "Vitalik Milady", + "VIM": "VicMove", + "VIN": "VinChain", + "VINCI": "VINCI", + "VINE": "Vine Coin", + "VINU": "Vita Inu", + "VIOR": "ViorCoin", + "VIP": "VIP Tokens", + "VIPER": "Viper Protocol", + "VIPS": "Vipstar Coin", + "VIRAL": "Viral Coin", + "VIRES": "Vires Finance", + "VIRTU": "VIRTUCLOUD", + "VIRTUAL": "Virtual Protocol", + "VIRTUALMINING": "VirtualMining Coin", + "VIRTUM": "VIRTUMATE", + "VIS": "Vigorus", + "VISIO": "Visio", + "VISION": "VisionGame", + "VISIONCITY": "Vision City", + "VISR": "Visor", + "VIST": "VISTA", + "VISTA": "Ethervista", + "VISTADOG": "VISTADOG", + "VIT": "Vision Industry Token", + "VITA": "VitaDAO", + "VITAE": "Vitae", + "VITAFAST": "Molecules of Korolchuk IP-NFT", + "VITAL": "Vital Network", + "VITALI": "Vitalik's Casper", + "VITALIK": "OFFICIAL VITALIK", + "VITAMINS": "Vitamins", + "VITARNA": "VitaRNA", + "VITASTEM": "VitaStem", + "VITE": "VITE", + "VITEX": "ViteX Coin", + "VITRA": "Vitra Studios", + "VITY": "Vitteey", + "VIU": "Viuly", + "VIVEK": "Head of D.O.G.E", + "VIVI": "LH VIVI", + "VIVID": "Vivid Coin", + "VIVO": "VIVO Coin", + "VIX": "VIXCO", + "VIX7": "VIX777", + "VIXV1": "VIXCO v1", + "VIZ": "VIZ Token", + "VIZION": "ViZion Protocol", + "VIZSLASWAP": "VizslaSwap", + "VK": "VK Token", + "VKNF": "VKENAF", + "VLC": "Volcano Uni", + "VLDY": "Validity", + "VLK": "Vulkania", + "VLR": "Velora", + "VLS": "Veles", + "VLT": "Veltor", + "VLTC": "Venus LTC", + "VLTX": "Volentix", + "VLTY": "Vaulty", + "VLUNA": "Venus Luna", + "VLX": "Velas", + "VLXPAD": "VelasPad", + "VMANTA": "Bifrost Voucher MANTA", + "VMATIC": "Venus MATIC", + "VMC": "VMS Classic", + "VME": "TrueVett", + "VMINT": "Volumint", + "VMPX": "VMPX (Ordinals)", + "VMS": "Vehicle Mining System", + "VMT": "Vemate", + "VNDC": "VNDC", + "VNDT": "Vendit ", + "VNES": "Vanesse", + "VNLNK": "VINLINK", + "VNM": "Venom", + "VNN": "VINU Network", + "VNO": "Veno Finance", + "VNST": "VNST Stablecoin", + "VNT": "VNT Chain", + "VNTR": "Venture Mind AI", + "VNTW": "Value Network Token", + "VNX": "VisionX", + "VNXAU": "VNX Gold", + "VNXLU": "VNX Exchange", + "VNY": "Vanity", + "VOCARE": "Vocare ex Machina", + "VOCO": "Provoco", + "VODCAT": "VODKA CAT", + "VODKA": "Vodka Token", + "VOID": "Nothing", + "VOIP": "Voip Finance", + "VOISE": "Voise", + "VOL": "Volume Network", + "VOLBOOST": "VolBoost", + "VOLLAR": "Vollar", + "VOLR": "Volare Network", + "VOLT": "Volt Inu", + "VOLTA": "Volta Club", + "VOLTOLD": "Volt Inu (Old)", + "VOLTV1": "Volt Inu v1", + "VOLTV2": "Volt Inu v2", + "VOLTX": "VolatilityX", + "VOLTZ": "Voltz", + "VOLX": "VolumeX", + "VON": "Vameon", + "VONE": "Vone", + "VONSPEED": "Andrea Von Speed", + "VOOI": "VOOI", + "VOOT": "VootCoin", + "VOOZ": "Vooz Coin", + "VOPO": "VOPO", + "VORTEX": "Vortex", + "VOT": "Votecoin", + "VOW": "Vow", + "VOX": "Vox.Finance", + "VOXEL": "Voxies", + "VOY": "enVoy DeFi", + "VOYACOIN": "Voyacoin", + "VP": "Torah Network", + "VPAD": "VLaunch", + "VPAY": "VPay by Virtuals", + "VPK": "Vulture Peak", + "VPND": "VaporNodes", + "VPP": "Virtue Poker Points", + "VPR": "VaporWallet", + "VPRC": "VapersCoin", + "VPS": "VPS AI", + "VPT": "Veritas Protocol", + "VR": "Victoria", + "VR1": "VR1", + "VRA": "Verasity", + "VRAV1": "Verasity v1", + "VRC": "Virtual Coin", + "VRFY": "VERIFY", + "VRGW": "Virtual Reality Game World", + "VRGX": "VROOMGO", + "VRH": "Versailles Heroes", + "VRL": "Virtual X", + "VRM": "Verium", + "VRN": "Varen", + "VRO": "VeraOne", + "VROOM": "TurboPepe", + "VRP": "Prosense.tv", + "VRS": "Veros", + "VRSC": "Verus Coin", + "VRSE": "CronosVerse", + "VRSW": "VirtuSwap", + "VRT": "Venus Reward Token", + "VRTX": "Vertex Protocol", + "VRTY": "Verity", + "VRX": "Verox", + "VS": "veSync", + "VSC": "Vyvo Coin", + "VSD": "Value Set Dollar", + "VSG": "Vitalik Smart Gas", + "VSHARE": "V3S Share", + "VSL": "vSlice", + "VSN": "Vision", + "VSO": "Verso", + "VSOL": "VSolidus", + "VSP": "Vesper Finance", + "VSTA": "Vesta Finance", + "VSTR": "Vestra DAO", + "VSUI": "Volo Staked SUI", + "VSX": "Versus-X", + "VSYNC": "Vsync", + "VSYS": "V Systems", + "VT": "Virtual Tourist", + "VTC": "Vertcoin", + "VTCN": "Versatize Coin", + "VTG": "Victory Gem", + "VTHO": "VeChainThor", + "VTIX": "Vanguard xStock", + "VTL": "Vertical", + "VTM": "Victorieum", + "VTN": "Voltroon", + "VTOS": "VTOS", + "VTRA": " E.C. Vitoria Fan Token", + "VTRAD": "VTRADING", + "VTRO": "Vitruveo DEX", + "VTRUMP": "Vote Trump", + "VTRV": "VitraVerse", + "VTRX": "Venus TRX", + "VTS": "Veritise", + "VTU": "Virtu", + "VTUSD": "Venus TUSD", + "VTX": "Vortex DeFi", + "VTY": "Victoriouscoin", + "VU": "Vu", + "VUC": "Virta Unique Coin", + "VULC": "Vulcano", + "VULPEFI": "Vulpe Finance", + "VULT": "Vultisig Token", + "VUNI": "Venus UNI", + "VUSD": "Virtual USD", + "VUZZ": "Vuzz AI", + "VV": "Virtual Versions", + "VVAIFU": "Dasha", + "VVI": "VV Coin", + "VVS": "VVS Finance", + "VVV": "Venice Token", + "VX": "Visa xStock", + "VXC": "VINX COIN", + "VXL": "Voxel X Network", + "VXR": "Vox Royale", + "VXRP": "Venus XRP", + "VXT": "Voxto Amplify", + "VXV": "Vectorspace AI", + "VY": "Valinity", + "VYBE": "Vybe", + "VYFI": "VyFinance", + "VYNC": "VYNK Chain", + "VYPER": "VYPER.WIN", + "VYVO": "Vyvo AI", + "VZ": "Vault Zero", + "VZON": "Verizon (Ondo Tokenized)", + "VZT": "Vezt", + "W": "Wormhole", + "W1": "W1", + "W12": "W12 Protocol", + "W2E": "Walk To Earn", + "W3C": "W3Coin", + "W3GG": "W3GG Token", + "W3M": "Web3Met", + "W3S": "Web3Shot", + "W3W": "Web3 Whales", + "W8BIT": "8Bit Chain", + "WA7A5": "Wrapped A7A5", + "WAAC": "Wrapped AyeAyeCoin", + "WAB": "WABnetwork", + "WABI": "WABI", + "WABU": "Warrenbuffett", + "WACME": "Wrapped Accumulate", + "WACO": "Waste Digital Coin", + "WAD": "WardenSwap", + "WADA": "Wrapped Cardano", + "WAFC": "Wrapped Arsenal FC (Kayen)", + "WAFFLES": "Waffles Davincij15's Cat", + "WAG": "WagyuSwap", + "WAGE": "Digiwage", + "WAGG": "Waggle Network", + "WAGIE": "Wagie", + "WAGIEBOT": "Wagie Bot", + "WAGM": "WAGMI", + "WAGMI": "Wagmi Coin", + "WAGMIGAMES": "WAGMI Game", + "WAGMIT": "Wagmi", + "WAGON": "Wagon Network", + "WAI": "WORLD3", + "WAIF": "Waifu Token", + "WAIFU": "Waifu", + "WAIT": "Hourglass", + "WAL": "WAL Token", + "WALE": "Waletoken", + "WALK": "Walk Token", + "WALL": "Du Rove's Wall", + "WALLET": "Ambire Wallet", + "WALLI": "WALLi", + "WALLY": "Wally Bot", + "WALTER": "walter", + "WALV": "Alvey Chain", + "WAM": "Wam", + "WAMPL": "Wrapped Ampleforth", + "WAN": "Wanchain", + "WANA": "Wanaka Farm", + "WANAKA": "Wanaka Farm WAIRERE Token", + "WANATHA": "Wrapped ANATHA", + "WAND": "WandX", + "WANK": "Wojak The Wanker", + "WANKO": "WANKO•MANKO•RUNES", + "WANNA": "Wanna Bot", + "WANUSDT": "wanUSDT", + "WAP": "Wet Ass Pussy", + "WAR": "WAR", + "WARD": "Warden", + "WARP": "WarpCoin", + "WARPED": "Warped Games", + "WARPIE": "Warpie", + "WARS": "MetaWars", + "WART": "Warthog", + "WAS": "Wasder", + "WASABI": "WasabiX", + "WASD": "WASD Studios", + "WASH": "WashingtonCoin", + "WASSIE": "WASSIE", + "WASTED": "WastedLands", + "WASTR": "Wrapped Astar", + "WAT": "WATCoin", + "WAT0X63": "Wat", + "WATC": "WATCoin", + "WATCH": "Yieldwatch", + "WATER": "Waterfall", + "WATERCOIN": "WATER", + "WATLAS": "Wrapped Star Atlas (Portal Bridge)", + "WATT": "WATTTON", + "WAVAX": "Wrapped AVAX", + "WAVES": "Waves", + "WAVESCOMM": "Waves Community Token", + "WAVL": "Wrapped Aston Villa", + "WAWA": "Wawa Cat", + "WAXE": "WAXE", + "WAXL": "Wrapped Axelar", + "WAXP": "Worldwide Asset eXchange", + "WAXS": "Axie Infinity Shards (Wormhole)", + "WAY": "WayCoin", + "WAYGU": "WAYGU CASH", + "WAZ": "MikeAI", + "WBAI": "Wrapped Balance AI", + "WBAN": "Wrapped Banano", + "WBB": "Wild Beast Coin", + "WBBC": "Wibcoin", + "WBC": "WorldBrain Coin", + "WBCH": "Wrapped Bitcoin Cash", + "WBERA": "Wrapped Bera", + "WBESC": "Wrapped BESC", + "WBET": "Wavesbet", + "WBETH": "Wrapped Beacon ETH", + "WBIND": "Wrapped BIND", + "WBLT": "Wrapped BMX Liquidity Token", + "WBN": "Wisdom Bank Network", + "WBNB": "Wrapped BNB", + "WBOND": "War Bond Token", + "WBONE": "Shibarium Wrapped BONE", + "WBONES": "Wrapped BONES", + "WBONK": "BONK (Portal Bridge)", + "WBRLY": "Wrapped BRLY", + "WBS": "Websea", + "WBT": "WhiteBIT Token", + "WBTC": "Wrapped Bitcoin", + "WBTCWXG": "WBTC-WXG", + "WBULL": "BNB Wallstreet Bull", + "WBX": "WiBX", + "WCA": "WCAPES", + "WCANTO": "Wrapped CANTO", + "WCAT": "Sol Cat Warrior", + "WCC": "Wincash Coin", + "WCCX": "Wrapped Conceal", + "WCDONALDS": "WC Donalds", + "WCELL": "Wrapped CellMates", + "WCELO": "Wrapped Celo", + "WCFGV1": "Wrapped Centrifuge", + "WCFX": "Wrapped Conflux", + "WCG": "World Crypto Gold", + "WCHZ": "Wrapped Chiliz", + "WCKB": "Wrapped Nervos Network", + "WCOIN": "WCoin", + "WCORE": "Wrapped Core", + "WCRO": "Wrapped CRO", + "WCS": "Weecoins", + "WCSOV": "Wrapped CrownSterling", + "WCT": "WalletConnect", + "WCT1WCT1": "Wrapped Car Token 1", + "WCTH": "Wrapped CTH Token", + "WCUSD": "Wrapped Celo Dollar", + "WDAI": "Dai (Wormhole)", + "WDC": "WorldCoin", + "WDOG": "Winterdog", + "WDOGE": "Wrapped Dogecoin", + "WDOT": "WDOT", + "WDR": "Wider Coin", + "WDX": "WeiDex", + "WE": "WeBuy", + "WEALTH": "WealthCoin", + "WEAPON": "MEGAWEAPON", + "WEAR": "MetaWear", + "WEAVE6": "Weave6", + "WEB": "Webcoin", + "WEB3": "WEB3 Inu", + "WEB4": "WEB4 AI", + "WEB5": "WEB5 Inu", + "WEBAI": "Web Ai", + "WEBC": "Webchain", + "WEBD": "WebDollar", + "WEBSIM": "The Css God by Virtuals", + "WEBSS": "Websser", + "WEC": "Whole Earth Coin", + "WECAN": "Wecan Group", + "WECO": "WECOIN", + "WED": "Wednesday Inu", + "WEEBS": "Weebs", + "WEETH": "Wrapped eETH", + "WEEX": "WEEX Token", + "WEF": "DOG WIF CHINESE HAT", + "WEFI": "WeFi", + "WEGEN": "WeGen Platform", + "WEGI": "Wegie", + "WEGLD": "Wrapped EGLD", + "WEHMND": "Wrapped eHMND", + "WEHODL": "HODL", + "WEIRD": "Weird Coin", + "WEIRDO": "Weirdo", + "WEL": "Welsh Corgi", + "WELA": "Wrapped Elastos", + "WELD": "Weld", + "WELF": "welf", + "WELL": "Moonwell", + "WELL3": "WELL3", + "WELLTOKEN": "Well", + "WELLV1": "Moonwell v1", + "WELON": "WrappedElon", + "WELSH": "Welshcorgicoin", + "WELT": "Fabwelt", + "WELUPS": "Welups Blockchain", + "WEMIX": "WEMIX", + "WEMIXUSD": "WEMIX", + "WEN": "Wen", + "WEND": "Wellnode", + "WENIS": "WenisCoin", + "WENL": "Wen Lambo Financial", + "WENLAMBO": "Wenlambo", + "WEOS": "Wrapped EOS", + "WEPC": "World Earn & Play Community", + "WEPE": "Wall Street Pepe", + "WERK": "Werk Family", + "WESHOWTOKEN": "WeShow Token", + "WEST": "Waves Enterprise", + "WESTARTER": "WeStarter", + "WET": "HumidiFi Token", + "WETH": "WETH", + "WETHV1": "WETH v1", + "WETHW": "Wrapped EthereumPoW", + "WEVE": "veDAO", + "WEVER": "Wrapped Ever", + "WEVERV1": "Wrapped Ever v1", + "WEVMOS": "Wrapped Evmos", + "WEWE": "WEWE", + "WEX": "WaultSwap", + "WEXO": "Wexo", + "WEXPOLY": "WaultSwap Polygon", + "WFAI": "WaifuAI", + "WFBTC": "Wrapped Fantom Bitcoin", + "WFDP": "WFDP", + "WFI": "WeFi", + "WFIL": "Wrapped Filecoin", + "WFLAMA": "WIFLAMA", + "WFLOW": "Wrapped Flow", + "WFLR": "Wrapped Flare", + "WFO": "WoofOracle", + "WFRAGSOL": "Wrapped fragSOL", + "WFT": "Windfall Token", + "WFTM": "Wrapped Fantom", + "WFTN": "Wrapped FTN", + "WFUSE": "Wrapped Fuse", + "WFX": "WebFlix", + "WGC": "Green Climate World", + "WGHOST": "Wrapped GhostbyMcAfee", + "WGL": "Wiggly Finance", + "WGLMR": "Wrapped Moonbeam", + "WGO": "WavesGO", + "WGP": "W Green Pay", + "WGR": "Wagerr", + "WGRT": "WaykiChain Governance Coin", + "WGT": "Web3Games.com", + "WHA": "WHALES DOGE", + "WHAL": "WHALEBERT", + "WHALE": "WHALE", + "WHALES": "Whales Market", + "WHAT": "What the Duck", + "WHATSONPIC": "WhatsOnPic", + "WHBAR": "Wrapped HBAR", + "WHC": "Whales Club", + "WHCHZ": "Chiliz (Portal Bridge)", + "WHEAT": "Wheat Token", + "WHEE": "WHEE (Ordinals)", + "WHEEL": "Wheelers", + "WHEN": "WhenHub", + "WHEX": "Whale Exploder", + "WHI": "White Boy Summer", + "WHINE": "Whine Coin", + "WHIRL": "Whirl Finance", + "WHISK": "Whiskers", + "WHISKEY": "WHISKEY", + "WHITE": "WhiteRock", + "WHITEHEART": "Whiteheart", + "WHITEPEPE": "The White Pepe", + "WHITEWHALE": "The White Whale", + "WHL": "WhaleCoin", + "WHO": "Truwho", + "WHOLE": "Whole Network", + "WHOREN": "elizabath whoren", + "WHT": "Wrapped Huobi Token", + "WHTETGRMOON": "WHITE TIGER MOON", + "WHTGRPXL": "White Tiger Pixel", + "WHX": "WHITEX", + "WHY": "WHY", + "WHYCAT": "WhyCat", + "WHYPAD": "Unamano", + "WHYPADV1": "Unamano v1", + "WIB": "Wibson", + "WIBE": "Wibegram", + "WIC": "Wi Coin", + "WICC": "WaykiChain", + "WICKED": "Wicked", + "WIF": "dogwifhat", + "WIF2": "DogWif2.0", + "WIFB": "dogwifball", + "WIFC": "dogwifceo", + "WIFCAT": "WIFCAT COIN", + "WIFE": "Wifejak", + "WIFEAR": "TRUMP WIF EAR", + "WIFEDOGE": "Wifedoge", + "WIFI": "WiFi Map", + "WIFICOIN": "Wifi Coin", + "WIFS": "dogwifscarf", + "WIFSA": "dogwifsaudihat", + "WIGL": "Wigl", + "WIGO": "WigoSwap", + "WIK": "Wicked Bet", + "WIKEN": "Project WITH", + "WIKI": "Wiki Token", + "WILC": "Wrapped ILCOIN", + "WILD": "Wilder World", + "WILDC": "Wild Crypto", + "WILDCOIN": "WILDCOIN", + "WIN": "WINk", + "WINB": "WINBIT CASINO", + "WINE": "WineCoin", + "WING": "Wing Finance", + "WINGS": "Wings DAO", + "WINK": "Wink", + "WINN": "Winnerz", + "WINNIE": "Winnie the Poodle", + "WINR": "JustBet", + "WINRY": "Winry Inu", + "WINSTON": "Winston", + "WINT": "WinToken", + "WINTER": "Winter", + "WINU": "Walter Inu", + "WINX": "WinX.io", + "WIOTA": "wIOTA", + "WIOTX": "Wrapped IoTeX", + "WIRE": "717ai by Virtuals", + "WIRTUAL": "Wirtual", + "WIS": "Experty Wisdom Token", + "WISC": "WisdomCoin", + "WISE": "Wise Token", + "WISH": "MyWish", + "WISP": "Whisper", + "WISTA": "Wistaverse", + "WIT": "Witnet", + "WITCH": "Witch", + "WITCOIN": "Witcoin", + "WIWI": "Wiggly Willy", + "WIX": "Wixlar", + "WIZA": "Wizardia", + "WIZZ": "Wizzwoods Token", + "WJD": "WJD", + "WJEWEL": "WJEWEL", + "WJXN": "Jax.Network", + "WKAI": "Wrapped KardiaChain", + "WKAS": "Wrapped Kaspa", + "WKAVA": "Wrapped Kava", + "WKC": "Wiki Cat", + "WKD": "Wakanda Inu", + "WKEYDAO": "WebKey DAO", + "WLAI": "Weblume AI", + "WLD": "Worldcoin", + "WLF": "Wolfs Group", + "WLFI": "World Liberty Financial", + "WLFIAI": "World Liberty Financial", + "WLFICLUB": "World Liberty Financial (wlfi.club)", + "WLFIMOON": "World Liberty Financial", + "WLFIMOONCLUB": "World Liberty Financial", + "WLFIONE": "World Liberty Financial", + "WLFISITE": "World Liberty Financial", + "WLFISPACE": "World Liberty Financial", + "WLFIWLFI": "World Liberty Financial", + "WLITI": "wLITI", + "WLK": "Wolk", + "WLKN": "Walken", + "WLO": "WOLLO", + "WLSC": "WESTLAND SMART CITY", + "WLTH": "Common Wealth", + "WLUNA": "Wrapped LUNA Token", + "WLUNC": "Wrapped LUNA Classic", + "WLXT": "Wallex Token", + "WM": "WrappedM by M^0", + "WMATIC": "Wrapped Matic", + "WMB": "WatermelonBlock", + "WMC": "Wrapped MistCoin", + "WMCOIN": "WMCoin", + "WMDR": "WaterMinder", + "WMEMO": "Wonderful Memories", + "WMETIS": "Wrapped Metis", + "WMF": "Whale Maker Fund", + "WMINIMA": "Wrapped Minima", + "WMLX": "Millix", + "WMM": "Weird Medieval Memes", + "WMN": "WebMind Network", + "WMNT": "Wrapped Mantle", + "WMOXY": "Moxy", + "WMT": "World Mobile Token v1", + "WMTON": "Walmart (Ondo Tokenized)", + "WMTX": "World Mobile Token", + "WMW": "WoopMoney", + "WMX": "Wombex Finance", + "WMXWOM": "Wombex WOM", + "WNCG": "Wrapped NCG", + "WND": "WonderHero", + "WNDR": "Wonderman Nation", + "WNE": "Winee3", + "WNEAR": "Wrapped Near", + "WNEON": "Wrapped Neon EVM", + "WNET": "Wavesnode.net", + "WNK": "The Winkyverse", + "WNKV1": "The Winkyverse v1", + "WNOW": "WalletNow", + "WNRG": "Wrapped-Energi", + "WNRZ": "WinPlay", + "WNT": "Wicrypt", + "WNXM": "Wrapped NXM", + "WNYC": "Wrapped NewYorkCoin", + "WNZ": "Winerz", + "WOA": "Wrapped Origin Axie", + "WOD": "World of Dypians", + "WOETH": "Wrapped Origin Ether", + "WOFM": "World of Masters", + "WOID": "WORLD ID", + "WOJ": "Wojak Finance", + "WOJA": "Wojak", + "WOJAK": "Wojak", + "WOJAK2": "Wojak 2.0 Coin", + "WOJAKC": "Wojak Coin", + "WOKB": "Wrapped OKB", + "WOKIE": "Wokie Plumpkin by Virtuals", + "WOKT": "Wrapped OKT", + "WOL": "World of Legends", + "WOLF": "Landwolf 0x67", + "WOLFILAND": "Wolfiland", + "WOLFOF": "Wolf of Wall Street", + "WOLFP": "Wolfpack Coin", + "WOLFY": "WOLFY", + "WOLT": "Wolt", + "WOLVERINU": "WOLVERINU", + "WOM": "WOM", + "WOMB": "Wombat Exchange", + "WOMBAT": "Wombat", + "WOME": "WAR OF MEME", + "WOMEN": "WomenCoin", + "WOMI": "Wrapped ECOMI", + "WON": "WeBlock", + "WONDER": "Wonderland", + "WONE": "Wrapped Harmony", + "WOO": "WOO Network", + "WOOD": "Mindfolk Wood", + "WOOF": "WoofWork.io", + "WOOFY": "Woofy", + "WOOL": "Wolf Game Wool", + "WOOLLY": "Miniature Woolly Mammoth", + "WOONK": "Woonkly", + "WOOO": "wooonen", + "WOOOOO": "Wooooo! Coin", + "WOOP": "Woonkly Power", + "WOOPV1": "Woonkly Power", + "WOP": "WorldPay", + "WOR": "Hollywood Capital Group WARRIOR", + "WORK": "Work X", + "WORKCHAIN": "WorkChain.io", + "WORKE": "Worken", + "WORKIE": "Workie", + "WORL": "World Record Banana", + "WORLD": "World Token", + "WORLDL": "World Liberty Financial", + "WORLDLIBERTYCTOVIP": "World Liberty Financial", + "WORLDLIBERTYICU": "World Liberty Financial", + "WORLDLIBERTYSOL": "World Liberty Financial", + "WORLDOFD": "World of Defish", + "WORM": "HealthyWorm", + "WORX": "Worx", + "WOS": "Wolf Of Solana", + "WOT": "World Of Trump", + "WOULD": "would", + "WOW": "WOWswap", + "WOWS": "Wolves of Wall Street", + "WOZX": "Efforce", + "WPAY": "WPAY", + "WPC": "WePiggy Coin", + "WPE": "OPES (Wrapped PE)", + "WPEPE": "Wrapped Pepe", + "WPI": "Wrapped Pi", + "WPKT": "Wrapped PKT", + "WPLS": "Wrapped Pulse", + "WPOKT": "wrapped POKT", + "WPOR": "Wrapped Portugal National Team", + "WPP": "Green Energy Token", + "WPR": "WePower", + "WQT": "Work Quest", + "WR": "White Rat", + "WRC": "Worldcore", + "WREACT": "Wrapped REACT", + "WRK": "BlockWRK", + "WRKX": "NFT Workx", + "WRLD": "NFT Worlds", + "WRONG": "The Wrong Token", + "WROSE": "Wrapped Rose", + "WRT": "WRT Token", + "WRTCOIN": "WRTcoin", + "WRX": "WazirX", + "WRZ": "Weriz", + "WS": "Wrapped Sonic", + "WSB": "WallStreetBets DApp", + "WSBABY": "Wall Street Baby", + "WSBC": "WSB Coin", + "WSBS": "Wall Street Bets Solana", + "WSCRT": "Secret ERC20", + "WSDM": "Wisdomise AI", + "WSDOGE": "Doge of Woof Street", + "WSG": "Wall Street Games", + "WSGV1": "Wall Street Games v1", + "WSH": "White Yorkshire", + "WSHIB": "Wrapped Shiba Inu (Wormhole)", + "WSHIBA": "wShiba", + "WSI": "WeSendit", + "WSIENNA": "Sienna ERC20", + "WSM": "Wall Street Memes", + "WSOL": "Wrapped Solana", + "WSPP": "Wolf Safe Poor People", + "WSTA": "Wrapped Statera", + "WSTETH": "Lido wstETH", + "WSTOR": "StorageChain", + "WSTORV1": "StorageChain v1", + "WSTR": "Wrapped Star", + "WSTUSDT": "wstUSDT", + "WSTUSR": "Resolv wstUSR", + "WSX": "WeAreSatoshi", + "WT": "WeToken", + "WTAO": "Wrapped TAO", + "WTC": "Waltonchain", + "WTE": "Wonder Energy Technology", + "WTF": "Waterfall Governance", + "WTFO": "WTF Opossum", + "WTFT": "WTF Token", + "WTFUEL": "Wrapped TFUEL", + "WTG": "Watergate", + "WTK": "WadzPay Token", + "WTKV1": "WadzPay Token v1", + "WTL": "Welltrado", + "WTLGX": "WisdomTree Long Term Treasury Digital Fund", + "WTN": "Wateenswap", + "WTON": "Wrapped TON Crystal", + "WTR": "Deepwaters", + "WTSIX": "WisdomTree Short-Duration Income Digital Fund", + "WTSTX": "WisdomTree 7-10 Year Treasury Digital Fund", + "WTSYX": "WisdomTree Short-Term Treasury Digital Fund", + "WTT": "Giga Watt", + "WTTSX": "WisdomTree 3-7 Year Treasury Digital Fund", + "WTWOOL": "Wolf Town Wool", + "WUF": "WUFFI", + "WUK": "WUKONG", + "WUKONG": "Sun Wukong", + "WULFON": "Terawulf (Ondo Tokenized)", + "WULFY": "Wulfy", + "WUM": "Unicorn Meat", + "WUSD": "Worldwide USD", + "WUST": "Wrapped UST Token", + "WVG0": "Wrapped Virgin Gen-0 CryptoKittties", + "WVTRS": "Vitreus", + "WW3": "WW3", + "WWAN": "Wrapped WAN", + "WWB": "Wowbit", + "WWBNB": "Wrapped BNB (Wormhole)", + "WWD": "Wolf Works DAO", + "WWDOGE": "Wrapped WDOGE", + "WWEMIX": "WWEMIX", + "WWF": "WWF", + "WWMATIC": "Wrapped Polygon (Wormhole)", + "WWROSE": "Wrapped Rose", + "WWRY": "WeWillRugYou", + "WWY": "WeWay", + "WX": "WX Token", + "WXDAI": "Wrapped XDAI", + "WXDC": "Wrapped XDC", + "WXM": "WeatherXM", + "WXPL": "Wrapped XPL", + "WXRP": "Wrapped XRP", + "WXT": "WXT", + "WYAC": "Woman Yelling At Cat", + "WYN": "Wynn", + "WYNN": "Anita Max Wynn", + "WYS": "Wysker", + "WYZ": "WYZth", + "WZEC": "Wrapped Zcash", + "WZEDX": "Wrapped Zedxion", + "WZENIQ": "Wrapped Zeniq (ETH)", + "WZETA": "Wrapped Zeta", + "WZM": "Woozoo Music", + "WZNN": "Wrapped Zenon (Zenon Bridge)", + "WZNNV1": "Wrapped Zenon (Zenon Bridge) v1", + "WZRD": "Bitcoin Wizards", + "X": "X Empire", + "X2": "X2Coin", + "X2Y2": "X2Y2", + "X314": "X314", + "X314V1": "X314 v1", + "X33": "Shadow Liquid Staking Token", + "X42": "X42 Protocol", + "X7": "X7", + "X7C": "X7 Coin", + "X7DAO": "X7DAO", + "X7R": "X7R", + "X8X": "X8Currency", + "XACT": "XactToken", + "XAEAXII": "XAEA-Xii Token", + "XAGX": "Silver Token", + "XAH": "Xahau", + "XAI": "Xai", + "XAIGAME": "xAI Game Studio", + "XALGO": "Wrapped ALGO", + "XALPHA": "XAlpha AI", + "XAMP": "Antiample", + "XAN": "Anoma", + "XAND": "Xandeum", + "XANK": "Xank", + "XAP": "Apollon", + "XAR": "Arcana Network", + "XAS": "Asch", + "XAT": "ShareAt", + "XAUC": "XauCoin", + "XAUH": "Herculis Gold Coin", + "XAUM": "Matrixdock Gold", + "XAUR": "Xaurum", + "XAUT": "Tether Gold", + "XAUT0": "XAUt0", + "XAVA": "Avalaunch", + "XAVIER": "Xavier: Renegade Angel", + "XAYA": "XAYA", + "XB": "XBANKING", + "XBASE": "ETERBASE", + "XBB": "BrickBlock", + "XBC": "BitcoinPlus", + "XBE": "XBE Token", + "XBG": "XBorg Token", + "XBI": "Bitcoin Incognito", + "XBL": "Billionaire Token", + "XBLAZE": "Trailblaze", + "XBN": "Elastic BNB", + "XBNB": "PhoenixBNB", + "XBO": "XBO", + "XBOND": "Bitacium", + "XBOT": "SocialXbotCoin", + "XBP": "Black Pearl Coin", + "XBS": "Bitstake", + "XBT": "Xbit", + "XBTC": "XenBitcoin", + "XBTC21": "Bitcoin 21", + "XBTS": "Beats", + "XBX": "BiteX", + "XBY": "XTRABYTES", + "XC": "X11 Coin", + "XCAD": "XCAD Network", + "XCAL": "3xcalibur", + "XCASH": "X-CASH", + "XCASTR": "Astar", + "XCB": "Crypto Birds", + "XCDOT": "xcDOT", + "XCE": "Cerium", + "XCEL": "XcelTrip", + "XCELTOKENPLUS": "Xceltoken Plus", + "XCEPT": "XCeption", + "XCF": "Cenfura Token", + "XCFX": "Nucleon", + "XCG": "Xchange", + "XCH": "Chia", + "XCHAT": "XChat", + "XCHF": "CryptoFranc", + "XCHNG": "Chainge Finance", + "XCI": "Cannabis Industry Coin", + "XCL": "Xcellar", + "XCLR": "ClearCoin", + "XCM": "CoinMetro", + "XCN": "Onyxcoin", + "XCO": "XCoin", + "XCOM": "X.COM", + "XCONSOL": "X-Consoles", + "XCP": "CounterParty", + "XCPO": "Copico", + "XCR": "Crypti", + "XCRE": "Creatio", + "XCRX": "xCRX", + "XCT": "C-Bits", + "XCUR": "Curate", + "XCV": "XCarnival", + "XCX": "Xeleb AI", + "XCXT": "CoinonatX", + "XD": "Data Transaction Token", + "XDAG": "Dagger", + "XDAI": "XDAI", + "XDAO": "XDAO", + "XDATA": "Streamr XDATA", + "XDB": "DigitalBits", + "XDC": "XDC Network", + "XDCE": "XinFin Coin", + "XDEF2": "Xdef Finance", + "XDEFI": "XDEFI", + "XDEN": "Xiden", + "XDG": "Decentral Games Governance", + "XDN": "DigitalNote", + "XDNA": "XDNA", + "XDOG": "XDOG", + "XDOGE": "Xdoge", + "XDOT": "DotBased", + "XDP": "DogeParty", + "XDQ": "Dirac Coin", + "XEC": "eCash", + "XED": "Exeedme", + "XEDO": "XedoAI", + "XEL": "XELIS", + "XELCOIN": "Xel", + "XELS": "XELS Coin", + "XEM": "NEM", + "XEN": "XEN Crypto", + "XENDV1": "Xend Finance", + "XENDV2": "Xend Finance", + "XENIX": "XenixCoin", + "XENO": "Xeno", + "XENOVERSE": "Xenoverse", + "XEP": "Electra Protocol", + "XERA": "XERA", + "XERS": "X Project", + "XES": "Proxeus", + "XET": "Xfinite Entertainment Token", + "XETA": "Xana", + "XETH": "Xplosive Ethereum", + "XFC": "Football Coin", + "XFI": "CrossFi", + "XFINANCE": "Xfinance", + "XFIT": "Xfit", + "XFLOKI": "XFLOKI", + "XFT": "Offshift", + "XFTV1": "Offshift v1", + "XFUEL": "XFUEL", + "XFUND": "xFund", + "XFYI": "XCredit", + "XG": "XG Sports", + "XGB": "GoldenBird", + "XGC": "Xiglute Coin", + "XGD": "X Gold", + "XGEM": "Exchange Genesis Ethlas Medium", + "XGLI": "Glitter Finance", + "XGN": "0xGen", + "XGOLD": "XGOLD COIN", + "XGOX": "Go!", + "XGP": "XGP", + "XGPT": "XGPT", + "XGR": "GoldReserve", + "XGRO": "Growth DeFi", + "XGT": "Xion Finance", + "XHI": "HiCoin", + "XHP": "XHYPE", + "XHPV1": "XHYPE v1", + "XHT": "HollaEx", + "XHUNT": "CryptoHunter World", + "XHV": "Haven Protocol", + "XI": "Xi", + "XIASI": "Xiasi Inu", + "XID": "Sphre AIR", + "XIDO": "Xido Finance", + "XIDR": "XIDR", + "XIL": "Xillion", + "XIN": "Mixin", + "XING": "Xing Xing", + "XINU": "XINU", + "XIO": "Blockzero Labs", + "XION": "XION", + "XIOS": "Xios", + "XIOT": "Xiotri", + "XIV": "Project Inverse", + "XJEWEL": "xJEWEL", + "XJO": "JouleCoin", + "XKI": "Ki", + "XL1": "XL1", + "XLA": "Scala", + "XLAB": "Dexlab", + "XLB": "LibertyCoin", + "XLC": "LeviarCoin", + "XLD": "Xcel Defi", + "XLIST": "XList", + "XLM": "Stellar", + "XLN": "LunaOne", + "XLQ": "Alqo", + "XLR": "Solaris", + "XLS": "Elis", + "XLT": "Nexalt", + "XM": "xMooney", + "XMARK": "xMARK", + "XMAS": "Elon Xmas", + "XMASGROK": "Xmas Grok", + "XMC": "Monero Classic", + "XMCC": "Monoeci", + "XMETA": "TTX METAVERSE", + "XMG": "Coin Magi", + "XMN": "xMoney", + "XMO": "Monero Original", + "XMON": "XMON", + "XMOON": "r/CryptoCurrency Moons v1", + "XMP": "Mapt.Coin", + "XMR": "Monero", + "XMRG": "Monero Gold", + "XMS": "Megastake", + "XMT": "MetalSwap", + "XMV": "MoneroV", + "XMW": "Morphware", + "XMX": "XMax", + "XMY": "MyriadCoin", + "XNA": "Neurai", + "XNAP": "SNAPX", + "XNB": "Xeonbit", + "XNC": "Xenios", + "XNET": "XNET Mobile", + "XNFT": "xNFT Protocol", + "XNG": "Enigma", + "XNK": "Ink Protocol", + "XNL": "Chronicle", + "XNN": "Xenon", + "XNO": "Xeno Token", + "XNODE": "XNODE", + "XNP": "ExenPay Token", + "XNPCS": "NPCS AI", + "XNS": "Insolar", + "XNT": "Exenium", + "XNV": "Nerva", + "XNX": "XanaxCoin", + "XNY": "Codatta", + "XO": "XOCIETY", + "XODEX": "Xodex", + "XOLO": "Xoloitzcuintli", + "XOMX": "Exxon Mobil xStock", + "XOR": "Sora", + "XOT": "Okuru", + "XOV": "XOVBank", + "XOX": "XOX Labs", + "XOXNO": "XOXNO", + "XOXO": "XO Protocol", + "XP": "Xphere", + "XPA": "XPA", + "XPARTY": "X Party", + "XPASS": "XPASS Token", + "XPAT": "Bitnation Pangea", + "XPAY": "Wallet Pay", + "XPB": "Pebble Coin", + "XPC": "eXPerience Chain", + "XPD": "PetroDollar", + "XPE": "Xpense", + "XPED": "Xpedition", + "XPET": "XPET token", + "XPH": "PharmaCoin", + "XPHX": "PhoenixCo Token", + "XPI": "XPi", + "XPIN": "XPIN Token", + "XPL": "Plasma", + "XPLA": "XPLA", + "XPLL": "ParallelChain", + "XPM": "PrimeCoin", + "XPN": "PANTHEON X", + "XPND": "Time Raiders", + "XPNET": "XP Network", + "XPO": "Opair", + "XPOKE": "PokeChain", + "XPR": "Proton", + "XPRESS": "CryptoXpress", + "XPRO": "ProCoin", + "XPROT": "X Protocol", + "XPRT": "Persistence", + "XPS": "PoisonIvyCoin", + "XPST": "PokerSports", + "XPT": "Cryptobuyer", + "XPTP": "xPTP", + "XPTX": "PlatinumBAR", + "XPX": "ProximaX", + "XPY": "PayCoin", + "XQC": "Quras Token", + "XQN": "Quotient", + "XQR": "Qredit", + "XQUOK": "XQUOK", + "XR": "Xraders", + "XRA": "Xriba", + "XRAI": "X-Ratio A", + "XRAY": "Ray Network", + "XRC": "xRhodium", + "XRD": "Radix", + "XRDOGE": "XRdoge", + "XRE": "RevolverCoin", + "XREA": "XREATORS", + "XRGB": "XRGB", + "XRISE": "Xrise", + "XRL": "Rialto.AI", + "XRLM": "xRealm.ai", + "XROCK": "xRocket", + "XROOTAI": "XRootAI", + "XRP": "XRP", + "XRP2": "XRP2.0", + "XRP20": "XRP20", + "XRPAYNET": "XRPayNet", + "XRPC": "Xrp Classic", + "XRPCHAIN": "Ripple Chain", + "XRPCV1": "XRP Classic v1", + "XRPEPE": "XRPEPE", + "XRPH": "XRP Healthcare", + "XRPHEDGE": "1X Short XRP Token", + "XRS": "Xrius", + "XRT": "Robonomics Network", + "XRUN": "XRun", + "XRUNE": "Thorstarter", + "XSAUCE": "xSAUCE", + "XSC": "Hyperspace", + "XSD": "SounDAC", + "XSEED": "XSEED", + "XSGD": "XSGD", + "XSH": "SHIELD", + "XSHIB": "XSHIB", + "XSI": "Stability Shares", + "XSLR": "NovaXSolar", + "XSN": "StakeNet", + "XSP": "XSwap", + "XSPA": "XSPA", + "XSPC": "SpectreSecurityCoin", + "XSPEC": "Spectre", + "XSPECTAR": "xSPECTAR", + "XSPT": "PoolStamp", + "XSR": "Xensor", + "XST": "StealthCoin", + "XSTAR": "StarCurve", + "XSTC": "Safe Trade Coin", + "XSTUSD": "SORA Synthetic USD", + "XSUSHI": "xSUSHI", + "XSWAP": "XSwap", + "XT": "XT.com Token", + "XT3": "Xt3ch", + "XTAG": "xHashtag", + "XTAL": "XTAL", + "XTC": "TileCoin", + "XTECH": "X-TECH", + "XTER": "Xterio", + "XTK": "xToken", + "XTM": "TORUM", + "XTMV1": "TORUM v1", + "XTN": "Neutrino Index Token", + "XTO": "Tao", + "XTP": "Tap", + "XTR": "Xtreme", + "XTRA": "ExtraCredit", + "XTRACK": "Xtrack AI", + "XTREME": "ExtremeCoin", + "XTREMEV": "Xtremeverse", + "XTRM": "XTRM COIN", + "XTRUMP": "X TRUMP", + "XTT": "XSwap Treasure", + "XTTA": "XTTA", + "XTTB20": "XTblock", + "XTUSD": "XT Stablecoin XTUSD", + "XTV": "XTV", + "XTX": "Xtock", + "XTZ": "Tezos", + "XU3O8": "Uranium", + "XUC": "Exchange Union", + "XUI": "YouSUI", + "XUN": "UltraNote", + "XUP": "UPGRADE", + "XUPS": "Xups", + "XUSD": "StraitsX XUSD", + "XUV": "XUV Coin", + "XV": "XV", + "XVC": "Vcash", + "XVE": "The Vegan Initiative", + "XVG": "Verge", + "XVM": "Volt", + "XVP": "VirtacoinPlus", + "XVR": "Xover", + "XVS": "Venus", + "XWC": "WhiteCoin", + "XWG": "X World Games", + "XWIN": "xWIN Finance", + "XWP": "Swap", + "XWT": "World Trade Funds", + "XX": "xx network", + "XXA": "Ixinium", + "XXX": "XXXCoin", + "XY": "XY Finance", + "XYM": "Symbol", + "XYO": "XY Oracle", + "XYRO": "XYRO", + "XYZ": "Universe.XYZ", + "XZK": "Mystiko Network", + "Y24": "Yield 24", + "Y2K": "Y2K", + "Y8U": "Y8U", + "YAC": "YAcCoin", + "YACHT": "YachtingVerse", + "YAE": "Cryptonovae", + "YAFA": "Free Palestine", + "YAG": "Yaki Gold", + "YAI": "Ÿ", + "YAIT": "YAITSIU", + "YAK": "Yield Yak", + "YAKS": "YakDAO", + "YAKU": "Yaku", + "YALA": "Yala Token", + "YAM": "YAM", + "YAMA": "YAMA Inu", + "YAMV1": "YAM v1", + "YAMV2": "YAM v2", + "YAOYAO": "Yaoyao's Cat", + "YAP": "Yap Stone", + "YAPSTER": "YAPSTER", + "YARL": "Yarloo", + "YAW": "Yawww", + "YAWN": "YAWN", + "YAXIS": "yAxis", + "YAY": "YAY Games", + "YAYCOIN": "YAYcoin", + "YB": "Yield Basis", + "YBC": "YbCoin", + "YBDBD": "YBDBD", + "YBNB": "Yellow BNB 4", + "YBO": "Young Boys Fan Token", + "YBR": "YieldBricks", + "YCC": "Yuan Chain Coin", + "YCE": "MYCE", + "YCO": "Y Coin", + "YCT": "Youclout", + "YDA": "YadaCoin", + "YDF": "Yieldification", + "YDOGE": "Yorkie Doge", + "YDR": "YDragon", + "YE": "Kanye West", + "YEAI": "YE AI Agent", + "YEARN": "YearnTogether", + "YEC": "Ycash", + "YEE": "Yee Token", + "YEECO": "Yeeco", + "YEED": "Yggdrash", + "YEEHAW": "YEEHAW", + "YEET": "Yeet", + "YEETI": "YEETI 液体", + "YEFI": "YeFi", + "YEL": "Yel.Finance", + "YELLOWWHALE": "The Yellow Whale", + "YELP": "Yelpro", + "YEON": "Yeon", + "YEPE": "Yellow Pepe", + "YES": "YES Money", + "YESCOIN": "YesCoin", + "YESP": "Yesports", + "YESTOKEN": "Yes Token", + "YESTOKENV1": "Yes Token v1", + "YESW": "Yes World", + "YETI": "Yeti Finance", + "YETIUSD": "YUSD Stablecoin", + "YETU": "Yetucoin", + "YFARM": "YFARM Token", + "YFBETA": "yfBeta", + "YFBT": "Yearn Finance Bit", + "YFDAI": "YfDAI.finance", + "YFF": "YFF.Finance", + "YFFC": "yffc.finance", + "YFFI": "yffi finance", + "YFFII": "YFFII Finance", + "YFI": "yearn.finance", + "YFIE": "yearn.finance (Avalanche Bridge)", + "YFIEXCHANGE": "YFIEXCHANGE.FINANCE", + "YFII": "DFI.money", + "YFIII": "Dify.Finance", + "YFIVE": "YFIVE FINANCE", + "YFL": "YF Link", + "YFO": "YFIONE", + "YFPRO": "YFPRO Finance", + "YFTE": "YFTether", + "YFV": "YFValue", + "YFX": "Your Futures Exchange", + "YGG": "Yield Guild Games", + "YIDO": "Yidocy Plus", + "YIELD": "Yield Protocol", + "YIELDX": "Yield Finance", + "YIKES": "Yikes Dog", + "YILONG": "Yi Long Ma", + "YILONGMA": "Chinese Elon Musk", + "YIN": "YIN Finance", + "YINBI": "Yinbi", + "YLAY": "Yelay", + "YLC": "YoloCash", + "YLD": "YIELD App", + "YLDY": "Yieldly", + "YMC": "YamahaCoin", + "YMS": "Yeni Malatyaspor Token", + "YNE": "yesnoerror", + "YNETH": "YieldNest Restaked ETH", + "YNG": "Young", + "YO": "Yobit Token", + "YOBASE": "All Your Base", + "YOC": "YoCoin", + "YOCO": "YocoinYOCO", + "YOD": "Year of the Dragon", + "YODA": "YODA", + "YODE": "YodeSwap", + "YOEX": "YO EXCHANGE", + "YOLO": "YoloNolo", + "YOM": "YOM", + "YONNY": "YONNY", + "YOOSHI": "YooShi", + "YOP": "Yield Optimization Platform & Protocol", + "YORAN": "YORAN THE CAVALIER", + "YORI": "YORI", + "YOSHI": "Yoshi.exchange", + "YOTD": "Year of the Dragon", + "YOTO": "yotoshi", + "YOTSUBA": "Yotsuba Koiwai", + "YOU": "YOU Chain", + "YOUC": "yOUcash", + "YOUNES": "YOUNES", + "YOURAI": "YOUR AI", + "YOURMOM": "YOUR MOM DOG", + "YOUSIM": "YouSim", + "YOVI": "YobitVirtualCoin", + "YOYOW": "Yoyow", + "YOZI": "YoZi Protocol", + "YPC": "YoungParrot", + "YPIE": "PieDAO Yearn Ecosystem Pie", + "YPRISMA": "Yearn yPRISMA", + "YSAFE": "yieldfarming.insure", + "YSEC": "Yearn Secure", + "YSR": "Ystar", + "YTA": "YottaChain", + "YTC": "Yachtscoin", + "YTJIA": "Jia Yueting", + "YTN": "YENTEN", + "YTS": "YetiSwap", + "YU": "Yala stablecoin", + "YUANG": "Yuang Coin", + "YUCHEN": "Sun Yuchen", + "YUCJ": "Yu Coin", + "YUCT": "Yucreat", + "YUDI": "Yudi", + "YUGE": "YUGE COIN", + "YUKI": "YUKI", + "YUKIE": "Yukie", + "YUKKY": "YUKKY", + "YUKO": "YUKO", + "YULI": "Yuliverse", + "YUM": "Yumerium", + "YUMMI": "Yummi Universe", + "YUMMY": "Yummy", + "YUP": "Crowdholding", + "YURI": "YURI", + "YURU": "YURU COIN", + "YUSD": "YieldFi yToken", + "YUSE": "Yuse Token", + "YUSRA": "YUSRA", + "YUSUF": "Yusuf Dikec Meme", + "YUZU": "YuzuSwap", + "YVBOOST": "Yearn Compounding veCRV yVault", + "YVS": "YVS.Finance", + "YVYFI": "YFI yVault", + "YYAVAX": "Yield Yak AVAX", + "YYE": "YYE Energy", + "YYFI": "YYFI.Protocol", + "YYOLO": "yYOLO", + "YZY": "YZY", + "Z3": "Z-Cubed", + "ZAAR": "THE•ORDZAAR•RUNES", + "ZABAKU": "Zabaku Inu", + "ZACK": "Zack Morris", + "ZAFI": "ZakumiFi", + "ZAI": "Zen AI", + "ZAIF": "Zaif Token", + "ZAIFIN": "Zero Collateral Dai", + "ZAM": "Zamio", + "ZAMA": "Zama", + "ZAMZAM": "ZAMZAM", + "ZANO": "Zano", + "ZAO": "zkTAO", + "ZAP": "ZAP", + "ZAPI": "Zapicorn", + "ZAPO": "Zapo AI", + "ZAPTOKEN": "Zap", + "ZARO": "Zaro Coin", + "ZARP": "ZARP Stablecoin", + "ZARX": "eToro South African Rand", + "ZASH": "ZIMBOCASH", + "ZAT": "zkApes", + "ZATGO": "ZatGo", + "ZAZA": "ZAZA", + "ZAZU": "Zazu", + "ZAZZLES": "Zazzles", + "ZB": "ZB", + "ZBC": "Zebec Protocol", + "ZBCN": "Zebec Network", + "ZBIT": "zbit", + "ZBT": "ZEROBASE", + "ZBU": "Zeebu", + "ZBUV1": "ZEEBU v1", + "ZCC": "ZCC Coin", + "ZCC1": "ZeroCarbon", + "ZCD": "ZChains", + "ZCG": "ZCashGOLD", + "ZCHF": "Frankencoin", + "ZCHN": "Zichain", + "ZCL": "ZClassic", + "ZCN": "Züs", + "ZCO": "Zebi Coin", + "ZCON": "Zcon Protocol", + "ZCOR": "Zrocor", + "ZCR": "ZCore", + "ZCULT": "Zkcult", + "ZCX": "Unizen", + "ZDAI": "Zydio AI", + "ZDC": "Zodiacs", + "ZDCV2": "ZodiacsV2", + "ZDEX": "Zeedex", + "ZDR": "Zloadr", + "ZEBU": "ZEBU", + "ZEC": "ZCash", + "ZECD": "ZCashDarkCoin", + "ZED": "ZedCoins", + "ZEDD": "ZedDex", + "ZEDTOKEN": "Zed Token", + "ZEDX": "ZEDX Сoin", + "ZEDXION": "Zedxion", + "ZEDXIONV1": "Zedxion v1", + "ZEE": "ZeroSwap", + "ZEEP": "ZEEPR", + "ZEFI": "ZCore Finance", + "ZEFU": "Zenfuse", + "ZEIT": "ZeitCoin", + "ZEL": "Zelcash", + "ZELIX": "ZELIX", + "ZEN": "Horizen", + "ZENAD": "Zenad", + "ZENAI": "Zen AI", + "ZENC": "Zenc Coin", + "ZEND": "zkLend", + "ZENF": "Zenland", + "ZENI": "Zennies", + "ZENIQ": "Zeniq Coin", + "ZENITH": "Zenith Chain", + "ZENIX": "ZENIX", + "ZENPROTOCOL": "Zen Protocol", + "ZENQ": "Zenqira", + "ZENT": "Zentry", + "ZENV1": "Horizen v1", + "ZEON": "Zeon Network", + "ZEP": "Zeppelin Dao", + "ZEPH": "Zephyr Protocol", + "ZER": "Zero", + "ZERA": "ZERA", + "ZERC": "zkRace Coin", + "ZEREBRO": "Zerebro", + "ZERO": "ZeroLend", + "ZEROB": "ZeroBank", + "ZEROEX": "0.exchange", + "ZES": "Zetos", + "ZESH": "Zesh", + "ZEST": "ZestCoin", + "ZET": "ZetaCoin", + "ZET2": "Zeta2Coin", + "ZETA": "ZetaChain", + "ZETH": "Zethan", + "ZETO": "ZeTo", + "ZETRIX": "Zetrix", + "ZEUM": "Colizeum", + "ZEUS": "Zeus Network", + "ZEUSPEPES": "Zeus", + "ZEX": "Zeta", + "ZEXI": "ZEXICON", + "ZEXX": "ZEXXCOIN", + "ZEXY": "ZEXY", + "ZF": "zkSwap Finance ", + "ZFI": "Zyfi", + "ZFL": "Zuflo Coin", + "ZFLOKI": "zkFloki", + "ZFM": "ZFMCOIN", + "ZGD": "ZambesiGold", + "ZGEM": "GemSwap", + "ZHC": "ZHC : Zero Hour Cash", + "ZHOA": "Chengpang Zhoa", + "ZHOUKING": "ZhouKing", + "ZIBU": "Zibu", + "ZIG": "Zignaly", + "ZIGAP": "ZIGAP", + "ZIK": "Ziktalk", + "ZIKC": "Zik coin", + "ZIL": "Zilliqa", + "ZILBERCOIN": "Zilbercoin", + "ZILLIONXO": "ZILLION AAKAR XO", + "ZILPEPE": "ZilPepe", + "ZINC": "ZINC", + "ZINU": "Zombie Inu", + "ZIP": "Zipper", + "ZIPPYSOL": "Zippy Staked SOL", + "ZIPT": "Zippie", + "ZIRVE": "Zirve Coin", + "ZIV4": "Ziv4 Labs", + "ZIX": "Coinzix Token", + "ZIXTOKEN": "ZIX Token", + "ZIZLE": "Zizle", + "ZIZY": "ZIZY", + "ZJLT": "ZJLT Distributed Factoring Network", + "ZJOE": "zJOE", + "ZK": "zkSync", + "ZKAI": "ZKCrypt AI", + "ZKARCH": "zkArchive", + "ZKB": "ZKBase", + "ZKBOB": "BOB", + "ZKC": "ZK Coin", + "ZKCRO": "Cronos zkEVM CRO", + "ZKDOGE": "zkDoge", + "ZKDX": "ZKDX", + "ZKE": "zkEra Finance", + "ZKEVM": "zkEVMChain (BSC)", + "ZKEX": "zkExchange", + "ZKF": "ZKFair", + "ZKFG": "ZKFG", + "ZKGPT": "ZKGPT", + "ZKGROK": "ZKGROK", + "ZKGUN": "zkGUN", + "ZKHIVE": "zkHive", + "ZKID": "zkSync id", + "ZKIN": "zkInfra", + "ZKJ": "Polyhedra Network", + "ZKL": "zkLink", + "ZKLAB": "zkSync Labs", + "ZKLK": "ZkLock", + "ZKML": "zKML", + "ZKP": "zkPass", + "ZKPAD": "zkLaunchpad", + "ZKPEPE": "ZKPEPEs", + "ZKS": "ZKSpace", + "ZKSHIB": "zkShib", + "ZKSP": "zkSwap", + "ZKT": "zkTube", + "ZKVAULT": "zkVAULT", + "ZKWASM": "ZKWASM Token", + "ZKX": "ZKX", + "ZKZ": "Zkzone", + "ZLA": "Zilla", + "ZLDA": "ZELDA 2.0", + "ZLDAV1": "ZELDA v1", + "ZLK": "Zenlink Network", + "ZLOT": "zLOT Finance", + "ZLP": "ZilPay Wallet", + "ZLQ": "ZLiteQubit", + "ZLW": "Zelwin", + "ZMBE": "RugZombie", + "ZMN": "ZMINE", + "ZMT": "Zipmex Token", + "ZND": "ZND Token", + "ZNE": "ZoneCoin", + "ZNN": "Zenon", + "ZNT": "Zenith Finance", + "ZNX": "ZENEX", + "ZNY": "BitZeny", + "ZNZ": "ZENZO", + "ZOA": "Zone of Avoidance", + "ZOC": "01coin", + "ZODI": "Zodium", + "ZOE": "Zoe Cash", + "ZOI": "Zoin", + "ZON": "Zon Token", + "ZONE": "Zone", + "ZONO": "Zono Swap", + "ZONX": "METAZONX", + "ZOO": "ZooKeeper", + "ZOOA": "Zoopia", + "ZOOC": "ZOO Crypto World", + "ZOOM": "ZoomCoin", + "ZOOMER": "Zoomer Coin", + "ZOON": "CryptoZoon", + "ZOOSTORY": "ZOO", + "ZOOT": "Zoo Token", + "ZOOTOPIA": "Zootopia", + "ZORA": "Zora", + "ZORACLES": "Zoracles", + "ZORKSEES": "Zorksees", + "ZORO": "Zoro Inu", + "ZORRO": "Zorro", + "ZORT": "Zort", + "ZP": "Zombie Power", + "ZPAE": "ZelaaPayAE", + "ZPAY": "ZoidPay", + "ZPC": "Zen Panda Coin", + "ZPET": "Zino Pet", + "ZPR": "ZPER", + "ZPRO": "ZAT Project", + "ZPT": "Zeepin", + "ZPTC": "Zeptacoin", + "ZRC": "Zircuit", + "ZRCOIN": "ZrCoin", + "ZRO": "LayerZero", + "ZRPY": "Zerpaay", + "ZRS": "Zaros", + "ZRX": "0x", + "ZSC": "Zeusshield", + "ZSD": "Zephyr Protocol Stable Dollar", + "ZSE": "ZSEcoin", + "ZSH": "Ziesha", + "ZSWAP": "ZygoSwap", + "ZT": "ZBG Token", + "ZTC": "Zenchain", + "ZTG": "Zeitgeist", + "ZTK": "Zefi", + "ZTX": "ZTX", + "ZUC": "Zeux", + "ZUCKPEPE": "ZuckPepe", + "ZUKI": "Zuki Moba", + "ZULU": "Zulu Network", + "ZUM": "ZumCoin", + "ZUN": "Zunami Governance Token", + "ZUNA": "ZUNA", + "ZUNO": "OFFICIAL ZUNO", + "ZUNUSD": "Zunami USD", + "ZUR": "Zurcoin", + "ZURR": "ZURRENCY", + "ZUSD": "ZUSD", + "ZUSHI": "ZUSHI", + "ZUT": "Zero Utility Token", + "ZUZALU": "Zuzalu Inu", + "ZUZU": "ZUZU", + "ZUZUAI": "ZUZUAI", + "ZVC": "ZVCHAIN", + "ZWAP": "ZilSwap", + "ZXC": "Oxcert", + "ZXT": "Zcrypt", + "ZYB": "Zyberswap", + "ZYD": "ZayedCoin", + "ZYGO": "Zygo the frog", + "ZYN": "Zynecoin", + "ZYNC": "ZynCoin", + "ZYNE": "Zynergy", + "ZYPTO": "Zypto Token", + "ZYR": "Zyrri", + "ZYRO": "Zyro", + "ZYTARA": "Zytara dollar", + "ZZ": "ZigZag", + "ZZC": "ZudgeZury", + "ZZZ": "ZZZ", + "ZZZV1": "zzz.finance", + "anyeth1": "anyeth1", + "eFIC": "FIC Network", + "ePRX": "eProxy", + "gOHM": "Governance OHM", + "redBUX": "redBUX", + "sOHM": "Staked Olympus", + "vXDEFI": "vXDEFI", + "wsOHM": "Wrapped Staked Olympus", + "修仙": "修仙", + "分红狗头": "分红狗头", + "哭哭马": "哭哭马", + "安": "安", + "币安人生": "币安人生", + "恶俗企鹅": "恶俗企鹅", + "我踏马来了": "我踏马来了", + "狗屎": "狗屎", + "老子": "老子", + "雪球": "雪球", + "黑马": "黑马" +} diff --git a/apps/api/src/assets/cryptocurrencies/custom.json b/apps/api/src/assets/cryptocurrencies/custom.json new file mode 100644 index 000000000..a26fc33df --- /dev/null +++ b/apps/api/src/assets/cryptocurrencies/custom.json @@ -0,0 +1,13 @@ +{ + "CYBER24781": "CyberConnect", + "JUP29210": "Jupiter", + "LUNA1": "Terra", + "LUNA2": "Terra", + "SGB1": "Songbird", + "SKY33038": "Sky", + "SMURFCAT": "Real Smurf Cat", + "TON11419": "Toncoin", + "UNI1": "Uniswap", + "UNI7083": "Uniswap", + "UST": "TerraUSD" +} diff --git a/apps/api/src/assets/site.webmanifest b/apps/api/src/assets/site.webmanifest new file mode 100644 index 000000000..a28719625 --- /dev/null +++ b/apps/api/src/assets/site.webmanifest @@ -0,0 +1,31 @@ +{ + "background_color": "#FFFFFF", + "categories": ["finance", "utilities"], + "description": "Open Source Wealth Management Software", + "display": "standalone", + "icons": [ + { + "sizes": "192x192", + "src": "/assets/android-chrome-192x192.png", + "type": "image/png" + }, + { + "purpose": "any", + "sizes": "512x512", + "src": "/assets/android-chrome-512x512.png", + "type": "image/png" + }, + { + "purpose": "maskable", + "sizes": "512x512", + "src": "/assets/android-chrome-512x512.png", + "type": "image/png" + } + ], + "name": "Ghostfolio", + "orientation": "portrait", + "short_name": "Ghostfolio", + "start_url": "/${languageCode}/", + "theme_color": "#FFFFFF", + "url": "${rootUrl}" +} diff --git a/apps/api/src/assets/sitemap.xml b/apps/api/src/assets/sitemap.xml new file mode 100644 index 000000000..2d4d121bf --- /dev/null +++ b/apps/api/src/assets/sitemap.xml @@ -0,0 +1,10 @@ + + + ${publicRoutes} + ${blogPosts} + ${personalFinanceTools} + diff --git a/apps/api/src/decorators/has-permission.decorator.ts b/apps/api/src/decorators/has-permission.decorator.ts new file mode 100644 index 000000000..ab2a2a675 --- /dev/null +++ b/apps/api/src/decorators/has-permission.decorator.ts @@ -0,0 +1,7 @@ +import { SetMetadata } from '@nestjs/common'; + +export const HAS_PERMISSION_KEY = 'has_permission'; + +export function HasPermission(permission: string) { + return SetMetadata(HAS_PERMISSION_KEY, permission); +} diff --git a/apps/api/src/dependencies.ts b/apps/api/src/dependencies.ts new file mode 100644 index 000000000..acb7af382 --- /dev/null +++ b/apps/api/src/dependencies.ts @@ -0,0 +1,3 @@ +// Dependencies required by .config/prisma.ts in Docker container +import 'dotenv'; +import 'dotenv-expand'; diff --git a/apps/api/src/environments/environment.prod.ts b/apps/api/src/environments/environment.prod.ts new file mode 100644 index 000000000..6d4cbb4bf --- /dev/null +++ b/apps/api/src/environments/environment.prod.ts @@ -0,0 +1,7 @@ +import { DEFAULT_HOST, DEFAULT_PORT } from '@ghostfolio/common/config'; + +export const environment = { + production: true, + rootUrl: `http://${DEFAULT_HOST}:${DEFAULT_PORT}`, + version: `${require('../../../../package.json').version}` +}; diff --git a/apps/api/src/environments/environment.ts b/apps/api/src/environments/environment.ts new file mode 100644 index 000000000..054766460 --- /dev/null +++ b/apps/api/src/environments/environment.ts @@ -0,0 +1,7 @@ +import { DEFAULT_HOST } from '@ghostfolio/common/config'; + +export const environment = { + production: false, + rootUrl: `https://${DEFAULT_HOST}:4200`, + version: 'dev' +}; diff --git a/apps/api/src/events/asset-profile-changed.event.ts b/apps/api/src/events/asset-profile-changed.event.ts new file mode 100644 index 000000000..46a8c5db4 --- /dev/null +++ b/apps/api/src/events/asset-profile-changed.event.ts @@ -0,0 +1,11 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +export class AssetProfileChangedEvent { + public constructor( + public readonly data: AssetProfileIdentifier & { currency: string } + ) {} + + public static getName(): string { + return 'assetProfile.changed'; + } +} diff --git a/apps/api/src/events/asset-profile-changed.listener.ts b/apps/api/src/events/asset-profile-changed.listener.ts new file mode 100644 index 000000000..ad80ee4a5 --- /dev/null +++ b/apps/api/src/events/asset-profile-changed.listener.ts @@ -0,0 +1,61 @@ +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; + +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { AssetProfileChangedEvent } from './asset-profile-changed.event'; + +@Injectable() +export class AssetProfileChangedListener { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly orderService: OrderService + ) {} + + @OnEvent(AssetProfileChangedEvent.getName()) + public async handleAssetProfileChanged(event: AssetProfileChangedEvent) { + Logger.log( + `Asset profile of ${event.data.symbol} (${event.data.dataSource}) has changed`, + 'AssetProfileChangedListener' + ); + + if ( + this.configurationService.get( + 'ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES' + ) === false || + event.data.currency === DEFAULT_CURRENCY + ) { + return; + } + + const existingCurrencies = this.exchangeRateDataService.getCurrencies(); + + if (!existingCurrencies.includes(event.data.currency)) { + Logger.log( + `New currency ${event.data.currency} has been detected`, + 'AssetProfileChangedListener' + ); + + await this.exchangeRateDataService.initialize(); + } + + const { dateOfFirstActivity } = + await this.orderService.getStatisticsByCurrency(event.data.currency); + + if (dateOfFirstActivity) { + await this.dataGatheringService.gatherSymbol({ + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), + date: dateOfFirstActivity, + symbol: `${DEFAULT_CURRENCY}${event.data.currency}` + }); + } + } +} diff --git a/apps/api/src/events/events.module.ts b/apps/api/src/events/events.module.ts new file mode 100644 index 000000000..ece67ebe0 --- /dev/null +++ b/apps/api/src/events/events.module.ts @@ -0,0 +1,24 @@ +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.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 { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; + +import { Module } from '@nestjs/common'; + +import { AssetProfileChangedListener } from './asset-profile-changed.listener'; +import { PortfolioChangedListener } from './portfolio-changed.listener'; + +@Module({ + imports: [ + ConfigurationModule, + DataGatheringModule, + DataProviderModule, + ExchangeRateDataModule, + OrderModule, + RedisCacheModule + ], + providers: [AssetProfileChangedListener, PortfolioChangedListener] +}) +export class EventsModule {} diff --git a/apps/api/src/events/portfolio-changed.event.ts b/apps/api/src/events/portfolio-changed.event.ts new file mode 100644 index 000000000..a3b0710fb --- /dev/null +++ b/apps/api/src/events/portfolio-changed.event.ts @@ -0,0 +1,15 @@ +export class PortfolioChangedEvent { + private userId: string; + + public constructor({ userId }: { userId: string }) { + this.userId = userId; + } + + public static getName() { + return 'portfolio.changed'; + } + + public getUserId() { + return this.userId; + } +} diff --git a/apps/api/src/events/portfolio-changed.listener.ts b/apps/api/src/events/portfolio-changed.listener.ts new file mode 100644 index 000000000..d12b9558d --- /dev/null +++ b/apps/api/src/events/portfolio-changed.listener.ts @@ -0,0 +1,23 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; + +import { Injectable, Logger } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +import { PortfolioChangedEvent } from './portfolio-changed.event'; + +@Injectable() +export class PortfolioChangedListener { + public constructor(private readonly redisCacheService: RedisCacheService) {} + + @OnEvent(PortfolioChangedEvent.getName()) + handlePortfolioChangedEvent(event: PortfolioChangedEvent) { + Logger.log( + `Portfolio of user '${event.getUserId()}' has changed`, + 'PortfolioChangedListener' + ); + + this.redisCacheService.removePortfolioSnapshotsByUserId({ + userId: event.getUserId() + }); + } +} diff --git a/apps/api/src/guards/has-permission.guard.spec.ts b/apps/api/src/guards/has-permission.guard.spec.ts new file mode 100644 index 000000000..d205a28f4 --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.spec.ts @@ -0,0 +1,50 @@ +import { HttpException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { ExecutionContextHost } from '@nestjs/core/helpers/execution-context-host'; + +import { HasPermissionGuard } from './has-permission.guard'; + +describe('HasPermissionGuard', () => { + let guard: HasPermissionGuard; + let reflector: Reflector; + + beforeEach(async () => { + reflector = new Reflector(); + guard = new HasPermissionGuard(reflector); + }); + + function setupReflectorSpy(returnValue: string) { + jest.spyOn(reflector, 'get').mockReturnValue(returnValue); + } + + function createMockExecutionContext(permissions: string[]) { + return new ExecutionContextHost([ + { + user: { + permissions // Set user permissions based on the argument + } + } + ]); + } + + it('should deny access if the user does not have any permission', () => { + setupReflectorSpy('required-permission'); + const noPermissions = createMockExecutionContext([]); + + expect(() => guard.canActivate(noPermissions)).toThrow(HttpException); + }); + + it('should deny access if the user has the wrong permission', () => { + setupReflectorSpy('required-permission'); + const wrongPermission = createMockExecutionContext(['wrong-permission']); + + expect(() => guard.canActivate(wrongPermission)).toThrow(HttpException); + }); + + it('should allow access if the user has the required permission', () => { + setupReflectorSpy('required-permission'); + const rightPermission = createMockExecutionContext(['required-permission']); + + expect(guard.canActivate(rightPermission)).toBe(true); + }); +}); diff --git a/apps/api/src/guards/has-permission.guard.ts b/apps/api/src/guards/has-permission.guard.ts new file mode 100644 index 000000000..f9ab35efc --- /dev/null +++ b/apps/api/src/guards/has-permission.guard.ts @@ -0,0 +1,38 @@ +import { HAS_PERMISSION_KEY } from '@ghostfolio/api/decorators/has-permission.decorator'; +import { hasPermission } from '@ghostfolio/common/permissions'; + +import { + CanActivate, + ExecutionContext, + HttpException, + Injectable +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +@Injectable() +export class HasPermissionGuard implements CanActivate { + public constructor(private reflector: Reflector) {} + + public canActivate(context: ExecutionContext): boolean { + const { user } = context.switchToHttp().getRequest(); + const requiredPermission = this.reflector.get( + HAS_PERMISSION_KEY, + context.getHandler() + ); + + if (!requiredPermission) { + // No specific permissions required + return true; + } + + if (!user || !hasPermission(user.permissions, requiredPermission)) { + throw new HttpException( + getReasonPhrase(StatusCodes.FORBIDDEN), + StatusCodes.FORBIDDEN + ); + } + + return true; + } +} diff --git a/apps/api/src/helper/object.helper.spec.ts b/apps/api/src/helper/object.helper.spec.ts new file mode 100644 index 000000000..eed261f3e --- /dev/null +++ b/apps/api/src/helper/object.helper.spec.ts @@ -0,0 +1,3041 @@ +import { DEFAULT_REDACTED_PATHS } from '@ghostfolio/common/config'; + +import { query, redactPaths } from './object.helper'; + +describe('query', () => { + it('should get market price from stock API response', () => { + const object = { + currency: 'USD', + market: { + previousClose: 273.04, + price: 271.86 + }, + symbol: 'AAPL' + }; + + const result = query({ + object, + pathExpression: '$.market.price' + })[0]; + + expect(result).toBe(271.86); + }); +}); + +describe('redactAttributes', () => { + it('should redact provided attributes', () => { + expect(redactPaths({ object: {}, paths: [] })).toStrictEqual({}); + + expect(redactPaths({ object: { value: 1000 }, paths: [] })).toStrictEqual({ + value: 1000 + }); + + expect( + redactPaths({ + object: { value: 1000 }, + paths: ['value'] + }) + ).toStrictEqual({ value: null }); + + expect( + redactPaths({ + object: { value: 'abc' }, + paths: ['value'], + valueMap: { abc: 'xyz' } + }) + ).toStrictEqual({ value: 'xyz' }); + + expect( + redactPaths({ + object: { data: [{ value: 'a' }, { value: 'b' }] }, + paths: ['data[*].value'], + valueMap: { a: 1, b: 2 } + }) + ).toStrictEqual({ data: [{ value: 1 }, { value: 2 }] }); + + console.time('redactAttributes execution time'); + expect( + redactPaths({ + object: { + accounts: { + '2e937c05-657c-4de9-8fb3-0813a2245f26': { + balance: 0, + currency: 'EUR', + name: 'Bondora Account', + valueInBaseCurrency: 2231.644722160232, + valueInPercentage: 0.014036487867880205 + }, + 'd804de69-0429-42dc-b6ca-b308fd7dd926': { + balance: 390, + currency: 'USD', + name: 'Coinbase Account', + valueInBaseCurrency: 37375.033270399996, + valueInPercentage: 0.23507962349569783 + }, + '65cfb79d-b6c7-4591-9d46-73426bc62094': { + balance: 0, + currency: 'EUR', + name: 'DEGIRO Account', + valueInBaseCurrency: 90452.98295843479, + valueInPercentage: 0.5689266688833119 + }, + '480269ce-e12a-4fd1-ac88-c4b0ff3f899c': { + balance: 0, + currency: 'USD', + name: 'Interactive Brokers Account', + valueInBaseCurrency: 43941, + valueInPercentage: 0.27637791413567103 + }, + '123eafcb-362e-4320-92c5-324621014ee5': { + balance: 0, + currency: 'CHF', + name: 'Pillar 3a 🇨🇭', + valueInBaseCurrency: 22363.19795483481, + valueInPercentage: 0.14065892911313693 + }, + '8c623328-6035-4b5f-b6d5-702cc1c9c56b': { + balance: 47500, + currency: 'USD', + name: 'Private Banking Account', + valueInBaseCurrency: 47500, + valueInPercentage: 0.2987631351458632 + }, + '206b2330-25a5-4d0a-b84b-c7194828f3c7': { + balance: 2000, + currency: 'USD', + name: 'Revolut Account', + valueInBaseCurrency: 2000, + valueInPercentage: 0.01257950042719424 + } + }, + hasError: false, + holdings: { + 'AAPL.US': { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 220.79, + symbol: 'AAPL.US', + tags: [], + allocationInPercentage: 0.044900865255793135, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'EOD_HISTORICAL_DATA', + dateOfFirstActivity: '2021-11-30T23:00:00.000Z', + dividend: 0, + grossPerformance: 2665.5, + grossPerformancePercent: 0.3183066634822068, + grossPerformancePercentWithCurrencyEffect: 0.3183066634822068, + grossPerformanceWithCurrencyEffect: 2665.5, + holdings: [], + investment: 0.060265768702233234, + name: 'Apple Inc', + netPerformance: 2664.5, + netPerformancePercent: 0.3181872462383568, + netPerformancePercentWithCurrencyEffect: 0.3181872462383568, + netPerformanceWithCurrencyEffect: 2664.5, + quantity: 50, + sectors: [{ name: 'Technology', weight: 1 }], + url: 'https://www.apple.com', + valueInBaseCurrency: 11039.5, + valueInPercentage: 0.0694356974830054 + }, + 'ALV.DE': { + activitiesCount: 2, + currency: 'EUR', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 1, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + marketPrice: 296.5, + symbol: 'ALV.DE', + tags: [], + allocationInPercentage: 0.026912563036519527, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { code: 'DE', weight: 1, continent: 'Europe', name: 'Germany' } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-04-22T22:00:00.000Z', + dividend: 192, + grossPerformance: 1793.7960276723945, + grossPerformancePercent: 0.3719230057375532, + grossPerformancePercentWithCurrencyEffect: 0.2650716044872953, + grossPerformanceWithCurrencyEffect: 1386.429698978564, + holdings: [], + investment: 0.03471025137190358, + name: 'Allianz SE', + netPerformance: 1789.1095737558583, + netPerformancePercent: 0.3709513233388858, + netPerformancePercentWithCurrencyEffect: 0.26409992208862787, + netPerformanceWithCurrencyEffect: 1381.3474143706258, + quantity: 20, + sectors: [{ name: 'Financial Services', weight: 1 }], + url: 'https://www.allianz.com', + valueInBaseCurrency: 6616.826601205088, + valueInPercentage: 0.04161818652826481 + }, + AMZN: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 187.99, + symbol: 'AMZN', + tags: [], + allocationInPercentage: 0.07646101417126275, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2018-09-30T22:00:00.000Z', + dividend: 0, + grossPerformance: 8689.05, + grossPerformancePercent: 0.8594552890963852, + grossPerformancePercentWithCurrencyEffect: 0.8594552890963852, + grossPerformanceWithCurrencyEffect: 8689.05, + holdings: [], + investment: 0.07275900505029173, + name: 'Amazon.com, Inc.', + netPerformance: 8608.26, + netPerformancePercent: 0.8514641516525799, + netPerformancePercentWithCurrencyEffect: 0.8514641516525799, + netPerformanceWithCurrencyEffect: 8608.26, + quantity: 100, + sectors: [{ name: 'Consumer Cyclical', weight: 1 }], + url: 'https://www.aboutamazon.com', + valueInBaseCurrency: 18799, + valueInPercentage: 0.11824101426541227 + }, + bitcoin: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 36985.0332704, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 36985.0332704, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + marketPrice: 65872, + symbol: 'bitcoin', + tags: [ + { + id: '795ebca3-6777-4325-b7ff-f55d94f460fe', + name: 'HIGHER_RISK', + userId: null + } + ], + allocationInPercentage: 0.15042891393226654, + assetClass: 'LIQUIDITY', + assetSubClass: 'CRYPTOCURRENCY', + countries: [], + dataSource: 'COINGECKO', + dateOfFirstActivity: '2017-08-15T22:00:00.000Z', + dividend: 0, + grossPerformance: 34985.0332704, + grossPerformancePercent: 17.4925166352, + grossPerformancePercentWithCurrencyEffect: 17.4925166352, + grossPerformanceWithCurrencyEffect: 34985.0332704, + holdings: [], + investment: 0.014393543993846005, + name: 'Bitcoin', + netPerformance: 34955.1332704, + netPerformancePercent: 17.477566635200002, + netPerformancePercentWithCurrencyEffect: 17.477566635200002, + netPerformanceWithCurrencyEffect: 34955.1332704, + quantity: 0.5614682, + sectors: [], + url: null, + valueInBaseCurrency: 36985.0332704, + valueInPercentage: 0.232626620912395 + }, + BONDORA_GO_AND_GROW: { + activitiesCount: 5, + currency: 'EUR', + markets: { + UNKNOWN: 2231.644722160232, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 2231.644722160232, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + marketPrice: 1, + symbol: 'BONDORA_GO_AND_GROW', + tags: [ + { + id: '795ebca3-6777-4325-b7ff-f55d94f460fe', + name: 'HIGHER_RISK', + userId: null + } + ], + allocationInPercentage: 0.009076749759365777, + assetClass: 'FIXED_INCOME', + assetSubClass: 'BOND', + countries: [], + dataSource: 'MANUAL', + dateOfFirstActivity: '2021-01-31T23:00:00.000Z', + dividend: 11.45, + grossPerformance: 0, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: -0.06153834320225245, + grossPerformanceWithCurrencyEffect: -125.68932723700505, + holdings: [], + investment: 0.016060638243523776, + name: 'Bondora Go & Grow', + netPerformance: 0, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: -0.06118537471467475, + netPerformanceWithCurrencyEffect: -125.68932723700505, + quantity: 2000, + sectors: [], + url: null, + valueInBaseCurrency: 2231.644722160232, + valueInPercentage: 0.014036487867880205 + }, + FRANKLY95P: { + activitiesCount: 6, + currency: 'CHF', + markets: { + UNKNOWN: 0, + developedMarkets: 0.79567, + emergingMarkets: 0.07075, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0.01333, + emergingMarkets: 0.07075, + europe: 0.35204, + japan: 0.03925, + northAmerica: 0.39105, + otherMarkets: 0 + }, + marketPrice: 177.62, + symbol: 'FRANKLY95P', + tags: [ + { + id: '6caface3-cb98-4605-a357-03792b6746c6', + name: 'RETIREMENT_PROVISION', + userId: null + } + ], + allocationInPercentage: 0.09095764645669335, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.37292, + continent: 'North America', + name: 'United States' + }, + { + code: 'CH', + weight: 0.30022, + continent: 'Europe', + name: 'Switzerland' + }, + { + code: 'JP', + weight: 0.03925, + continent: 'Asia', + name: 'Japan' + }, + { + code: 'CN', + weight: 0.03353, + continent: 'Asia', + name: 'China' + }, + { + code: 'GB', + weight: 0.02285, + continent: 'Europe', + name: 'United Kingdom' + }, + { + code: 'CA', + weight: 0.01813, + continent: 'North America', + name: 'Canada' + }, + { + code: 'FR', + weight: 0.01465, + continent: 'Europe', + name: 'France' + }, + { + code: 'DE', + weight: 0.01432, + continent: 'Europe', + name: 'Germany' + }, + { + code: 'TW', + weight: 0.01427, + continent: 'Asia', + name: 'Taiwan' + }, + { + code: 'AU', + weight: 0.01333, + continent: 'Oceania', + name: 'Australia' + }, + { + code: 'KR', + weight: 0.01172, + continent: 'Asia', + name: 'South Korea' + }, + { + code: 'IN', + weight: 0.01123, + continent: 'Asia', + name: 'India' + } + ], + dataSource: 'MANUAL', + dateOfFirstActivity: '2021-03-31T22:00:00.000Z', + dividend: 0, + grossPerformance: 3533.389614611676, + grossPerformancePercent: 0.27579517683678895, + grossPerformancePercentWithCurrencyEffect: 0.458553421589667, + grossPerformanceWithCurrencyEffect: 5322.44900391902, + holdings: [], + investment: 0.13551383737034509, + name: 'frankly Extreme 95 Index', + netPerformance: 3533.389614611676, + netPerformancePercent: 0.27579517683678895, + netPerformancePercentWithCurrencyEffect: 0.43609380217769156, + netPerformanceWithCurrencyEffect: 5322.44900391902, + quantity: 105.87328656807, + sectors: [], + url: 'https://www.frankly.ch', + valueInBaseCurrency: 22363.19795483481, + valueInPercentage: 0.14065892911313693 + }, + MSFT: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 428.02, + symbol: 'MSFT', + tags: [], + allocationInPercentage: 0.05222646409742627, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2023-01-02T23:00:00.000Z', + dividend: 0, + grossPerformance: 5653.2, + grossPerformancePercent: 0.7865431171216295, + grossPerformancePercentWithCurrencyEffect: 0.7865431171216295, + grossPerformanceWithCurrencyEffect: 5653.2, + holdings: [], + investment: 0.051726079050684395, + name: 'Microsoft Corporation', + netPerformance: 5653.2, + netPerformancePercent: 0.7865431171216295, + netPerformancePercentWithCurrencyEffect: 0.7865431171216295, + netPerformanceWithCurrencyEffect: 5653.2, + quantity: 30, + sectors: [{ name: 'Technology', weight: 1 }], + url: 'https://www.microsoft.com', + valueInBaseCurrency: 12840.6, + valueInPercentage: 0.08076416659271518 + }, + TSLA: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 260.46, + symbol: 'TSLA', + tags: [], + allocationInPercentage: 0.1589050142378352, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2017-01-02T23:00:00.000Z', + dividend: 0, + grossPerformance: 36920.500000005, + grossPerformancePercent: 17.184314638161936, + grossPerformancePercentWithCurrencyEffect: 17.184314638161936, + grossPerformanceWithCurrencyEffect: 36920.500000005, + holdings: [], + investment: 0.01546226463535309, + name: 'Tesla, Inc.', + netPerformance: 36890.500000005, + netPerformancePercent: 17.170351408001327, + netPerformancePercentWithCurrencyEffect: 17.170351408001327, + netPerformanceWithCurrencyEffect: 36890.500000005, + quantity: 150, + sectors: [{ name: 'Consumer Cyclical', weight: 1 }], + url: 'https://www.tesla.com', + valueInBaseCurrency: 39069, + valueInPercentage: 0.2457342510950259 + }, + VTI: { + activitiesCount: 5, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 0.9794119422809896, + emergingMarkets: 0, + otherMarkets: 0.00016100142888768383 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0.00019118919680412454, + japan: 0, + northAmerica: 0.9792207530841855, + otherMarkets: 0.00016100142888768383 + }, + marketPrice: 282.05, + symbol: 'VTI', + tags: [], + allocationInPercentage: 0.057358979326040366, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.9788987502264102, + continent: 'North America', + name: 'United States' + }, + { + code: 'CA', + weight: 0.0003220028577753677, + continent: 'North America', + name: 'Canada' + }, + { + code: 'NL', + weight: 0.0001811266074986443, + continent: 'Europe', + name: 'Netherlands' + }, + { + code: 'BM', + weight: 0.00009056330374932214, + continent: 'North America', + name: 'Bermuda' + }, + { + code: 'KY', + weight: 0.00007043812513836169, + continent: 'North America', + name: 'Cayman Islands' + }, + { + code: 'IL', + weight: 0.00001006258930548024, + continent: 'Asia', + name: 'Israel' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2019-02-28T23:00:00.000Z', + dividend: 0, + grossPerformance: 5856.3, + grossPerformancePercent: 0.8832083851170418, + grossPerformancePercentWithCurrencyEffect: 0.8832083851170418, + grossPerformanceWithCurrencyEffect: 5856.3, + holdings: [ + { + allocationInPercentage: 0.06099941636982121, + name: 'APPLE INC', + valueInBaseCurrency: 860.2442693554036 + }, + { + allocationInPercentage: 0.05862464529372787, + name: 'MICROSOFT CORP', + valueInBaseCurrency: 826.7540602547973 + }, + { + allocationInPercentage: 0.05156070760128074, + name: 'NVIDIA CORP', + valueInBaseCurrency: 727.1348789470617 + }, + { + allocationInPercentage: 0.03301535551128066, + name: 'AMAZON COM INC', + valueInBaseCurrency: 465.5990510978355 + }, + { + allocationInPercentage: 0.01962204914568647, + name: 'FACEBOOK CLASS A INC', + valueInBaseCurrency: 276.71994807704345 + }, + { + allocationInPercentage: 0.01902835637666313, + name: 'ALPHABET INC CLASS A', + valueInBaseCurrency: 268.34739580189176 + }, + { + allocationInPercentage: 0.01555676306627245, + name: 'ALPHABET INC CLASS C', + valueInBaseCurrency: 219.3892511421072 + }, + { + allocationInPercentage: 0.01463100485016827, + name: 'BERKSHIRE HATHAWAY INC CLASS B', + valueInBaseCurrency: 206.33374589949804 + }, + { + allocationInPercentage: 0.01403731208114493, + name: 'BROADCOM INC', + valueInBaseCurrency: 197.96119362434638 + }, + { + allocationInPercentage: 0.01297067761476403, + name: 'ELI LILLY', + valueInBaseCurrency: 182.91898106220972 + }, + { + allocationInPercentage: 0.0118637927911612, + name: 'TESLA INC', + valueInBaseCurrency: 167.30913783735082 + }, + { + allocationInPercentage: 0.01152166475477487, + name: 'JPMORGAN CHASE & CO', + valueInBaseCurrency: 162.48427720421262 + }, + { + allocationInPercentage: 0.0100324015375638, + name: 'EXXON MOBIL CORP', + valueInBaseCurrency: 141.4819426834935 + }, + { + allocationInPercentage: 0.01000221376964736, + name: 'UNITEDHEALTH GROUP INC', + valueInBaseCurrency: 141.0562196864519 + }, + { + allocationInPercentage: 0.007818631890358146, + name: 'VISA INC CLASS A', + valueInBaseCurrency: 110.26225623377576 + } + ], + investment: 0.05934602124102648, + name: 'Vanguard Total Stock Market Index Fund ETF Shares', + netPerformance: 5756.8, + netPerformancePercent: 0.8682024540139314, + netPerformancePercentWithCurrencyEffect: 0.8328704068843998, + netPerformanceWithCurrencyEffect: 5756.8, + quantity: 50, + sectors: [ + { name: 'Technology', weight: 0.3739157560023398 }, + { name: 'Consumer Cyclical', weight: 0.1168065366580148 }, + { name: 'Industrials', weight: 0.09138843607237156 }, + { name: 'Healthcare', weight: 0.1172291654088449 }, + { name: 'Energy', weight: 0.03762402141319062 }, + { name: 'Consumer Staples', weight: 0.05152045724405886 }, + { name: 'Financials', weight: 0.1127613757572116 }, + { name: 'Telecommunications', weight: 0.007557004568415658 }, + { name: 'Real Estate', weight: 0.02587091710438972 }, + { name: 'Communication', weight: 0.002062830807623449 }, + { name: 'Utilities', weight: 0.02208738352552914 }, + { name: 'Materials', weight: 0.020366680754292 }, + { name: 'Other', weight: 0.0003823783936082492 } + ], + url: 'https://www.vanguard.com', + valueInBaseCurrency: 14102.5, + valueInPercentage: 0.08870120238725339 + }, + 'VWRL.SW': { + activitiesCount: 5, + currency: 'CHF', + markets: { + UNKNOWN: 0, + developedMarkets: 0.881487000000001, + emergingMarkets: 0.11228900000000001, + otherMarkets: 0.0038099999999999996 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0.03252, + emergingMarkets: 0.11228900000000001, + europe: 0.170033, + japan: 0.06258100000000001, + northAmerica: 0.6163530000000008, + otherMarkets: 0.0038099999999999996 + }, + marketPrice: 117.62, + symbol: 'VWRL.SW', + tags: [], + allocationInPercentage: 0.09386983901959013, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.5903140000000008, + continent: 'North America', + name: 'United States' + }, + { + code: 'TW', + weight: 0.017327000000000002, + continent: 'Asia', + name: 'Taiwan' + }, + { + code: 'CN', + weight: 0.040376999999999996, + continent: 'Asia', + name: 'China' + }, + { + code: 'CH', + weight: 0.024700999999999997, + continent: 'Europe', + name: 'Switzerland' + }, + { + code: 'KR', + weight: 0.014147, + continent: 'Asia', + name: 'South Korea' + }, + { + code: 'NL', + weight: 0.013718000000000001, + continent: 'Europe', + name: 'Netherlands' + }, + { + code: 'JP', + weight: 0.06258100000000001, + continent: 'Asia', + name: 'Japan' + }, + { + code: 'GB', + weight: 0.03813600000000002, + continent: 'Europe', + name: 'United Kingdom' + }, + { + code: 'FR', + weight: 0.027450000000000006, + continent: 'Europe', + name: 'France' + }, + { + code: 'DK', + weight: 0.006692, + continent: 'Europe', + name: 'Denmark' + }, + { + code: 'CA', + weight: 0.026039000000000007, + continent: 'North America', + name: 'Canada' + }, + { + code: 'DE', + weight: 0.023266, + continent: 'Europe', + name: 'Germany' + }, + { + code: 'HK', + weight: 0.008724999999999998, + continent: 'Asia', + name: 'Hong Kong' + }, + { + code: 'AU', + weight: 0.019638, + continent: 'Oceania', + name: 'Australia' + }, + { + code: 'IN', + weight: 0.015436000000000004, + continent: 'Asia', + name: 'India' + }, + { + code: 'ES', + weight: 0.006828, + continent: 'Europe', + name: 'Spain' + }, + { + code: 'IT', + weight: 0.006168, + continent: 'Europe', + name: 'Italy' + }, + { + code: 'BR', + weight: 0.004955, + continent: 'South America', + name: 'Brazil' + }, + { + code: 'RU', + weight: 0.0038099999999999996, + continent: 'Asia', + name: 'Russia' + }, + { + code: 'SA', + weight: 0.0038950000000000005, + continent: 'Asia', + name: 'Saudi Arabia' + }, + { + code: 'BE', + weight: 0.0026820000000000004, + continent: 'Europe', + name: 'Belgium' + }, + { + code: 'SG', + weight: 0.0035050000000000003, + continent: 'Asia', + name: 'Singapore' + }, + { + code: 'SE', + weight: 0.010147999999999997, + continent: 'Europe', + name: 'Sweden' + }, + { + code: 'QA', + weight: 0.000719, + continent: 'Asia', + name: 'Qatar' + }, + { + code: 'LU', + weight: 0.000915, + continent: 'Europe', + name: 'Luxembourg' + }, + { + code: 'ZA', + weight: 0.003598, + continent: 'Africa', + name: 'South Africa' + }, + { + code: 'MX', + weight: 0.002607, + continent: 'North America', + name: 'Mexico' + }, + { + code: 'FI', + weight: 0.002973, + continent: 'Europe', + name: 'Finland' + }, + { + code: 'IE', + weight: 0.0017519999999999999, + continent: 'Europe', + name: 'Ireland' + }, + { + code: 'KW', + weight: 0.0008320000000000001, + continent: 'Asia', + name: 'Kuwait' + }, + { + code: 'MY', + weight: 0.0016229999999999999, + continent: 'Asia', + name: 'Malaysia' + }, + { + code: 'ID', + weight: 0.001611, + continent: 'Asia', + name: 'Indonesia' + }, + { + code: 'PT', + weight: 0.000436, + continent: 'Europe', + name: 'Portugal' + }, + { + code: 'AE', + weight: 0.0011489999999999998, + continent: 'Asia', + name: 'United Arab Emirates' + }, + { + code: 'TH', + weight: 0.0024800000000000004, + continent: 'Asia', + name: 'Thailand' + }, + { + code: 'NO', + weight: 0.001652, + continent: 'Europe', + name: 'Norway' + }, + { + code: 'PH', + weight: 0.000382, + continent: 'Asia', + name: 'Philippines' + }, + { + code: 'NZ', + weight: 0.000652, + continent: 'Oceania', + name: 'New Zealand' + }, + { + code: 'IL', + weight: 0.0016950000000000001, + continent: 'Asia', + name: 'Israel' + }, + { + code: 'PE', + weight: 0.000334, + continent: 'South America', + name: 'Peru' + }, + { + code: 'AT', + weight: 0.0008210000000000001, + continent: 'Europe', + name: 'Austria' + }, + { + code: 'CL', + weight: 0.000298, + continent: 'South America', + name: 'Chile' + }, + { + code: 'HU', + weight: 0.000266, + continent: 'Europe', + name: 'Hungary' + }, + { + code: 'PL', + weight: 0.000253, + continent: 'Europe', + name: 'Poland' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2018-02-28T23:00:00.000Z', + dividend: 0, + grossPerformance: 4534.60577952194, + grossPerformancePercent: 0.3683200415015591, + grossPerformancePercentWithCurrencyEffect: 0.5806366182968891, + grossPerformanceWithCurrencyEffect: 6402.248165662604, + holdings: [ + { + allocationInPercentage: 0.042520261085, + name: 'APPLE INC', + valueInBaseCurrency: 981.3336460398625 + }, + { + allocationInPercentage: 0.037017038038, + name: 'MICROSOFT CORP', + valueInBaseCurrency: 854.3236559815404 + }, + { + allocationInPercentage: 0.018861883836, + name: 'AMAZON COM INC', + valueInBaseCurrency: 435.31720557783655 + }, + { + allocationInPercentage: 0.017806548325, + name: 'NVIDIA CORP', + valueInBaseCurrency: 410.9609053487602 + }, + { + allocationInPercentage: 0.012188534864, + name: 'ALPHABET INC CLASS A', + valueInBaseCurrency: 281.3016442693628 + }, + { + allocationInPercentage: 0.010831709166, + name: 'ALPHABET INC CLASS C', + valueInBaseCurrency: 249.98719145833246 + }, + { + allocationInPercentage: 0.010813551981, + name: 'TESLA INC', + valueInBaseCurrency: 249.56813813873381 + }, + { + allocationInPercentage: 0.009934819182, + name: 'FACEBOOK CLASS A INC', + valueInBaseCurrency: 229.28768737165962 + }, + { + allocationInPercentage: 0.007403227621000001, + name: 'BERKSHIRE HATHAWAY INC CLASS B', + valueInBaseCurrency: 170.8605772494153 + }, + { + allocationInPercentage: 0.007076883908, + name: 'ELI LILLY', + valueInBaseCurrency: 163.32882514892185 + }, + { + allocationInPercentage: 0.006844271583, + name: 'EXXON MOBIL CORP', + valueInBaseCurrency: 157.96031857861325 + }, + { + allocationInPercentage: 0.006718061670999999, + name: 'UNITEDHEALTH GROUP INC', + valueInBaseCurrency: 155.0474946695187 + }, + { + allocationInPercentage: 0.006456949621, + name: 'JPMORGAN CHASE & CO', + valueInBaseCurrency: 149.02123722158794 + }, + { + allocationInPercentage: 0.006293890054, + name: 'TAIWAN SEMICONDUCTOR MANUFACTURING', + valueInBaseCurrency: 145.25795272326576 + }, + { + allocationInPercentage: 0.00600392555, + name: 'VISA INC CLASS A', + valueInBaseCurrency: 138.56580369427397 + } + ], + investment: 0.13346122254229614, + name: 'Vanguard FTSE All-World UCITS ETF', + netPerformance: 4438.993935069568, + netPerformancePercent: 0.3605540392890248, + netPerformancePercentWithCurrencyEffect: 0.5382257513306911, + netPerformanceWithCurrencyEffect: 6316.200702182656, + quantity: 165, + sectors: [ + { name: 'Technology', weight: 0.2729529999999999 }, + { name: 'Consumer Cyclical', weight: 0.141791 }, + { name: 'Financials', weight: 0.14711999999999992 }, + { name: 'Healthcare', weight: 0.114531 }, + { name: 'Consumer Staples', weight: 0.064498 }, + { name: 'Energy', weight: 0.036378999999999995 }, + { name: 'Telecommunications', weight: 0.017739000000000008 }, + { name: 'Utilities', weight: 0.02524900000000001 }, + { name: 'Industrials', weight: 0.095292 }, + { name: 'Materials', weight: 0.04762400000000001 }, + { name: 'Real Estate', weight: 0.027565000000000003 }, + { name: 'Communication', weight: 0.0035989999999999998 }, + { name: 'Information & Communication', weight: 0.000576 }, + { name: 'Communication Services', weight: 0.000574 }, + { name: 'Electric Appliances', weight: 0.000345 }, + { name: 'Chemicals', weight: 0.000326 }, + { name: 'Services', weight: 0.000257 }, + { + name: 'Transportation Equipment', + weight: 0.00041299999999999996 + } + ], + url: 'https://www.vanguard.com', + valueInBaseCurrency: 23079.20085622547, + valueInPercentage: 0.145162408515095 + }, + 'XDWD.DE': { + activitiesCount: 1, + currency: 'EUR', + markets: { + UNKNOWN: 0, + developedMarkets: 0.9688723314999987, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0.0288497227, + emergingMarkets: 0, + europe: 0.1665952994, + japan: 0.060962362, + northAmerica: 0.7124649473999988, + otherMarkets: 0 + }, + marketPrice: 105.72, + symbol: 'XDWD.DE', + tags: [], + allocationInPercentage: 0.03598477442100562, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.6842147911999988, + continent: 'North America', + name: 'United States' + }, + { + code: 'SG', + weight: 0.0035432595, + continent: 'Asia', + name: 'Singapore' + }, + { + code: 'NZ', + weight: 0.0006406316, + continent: 'Oceania', + name: 'New Zealand' + }, + { + code: 'NL', + weight: 0.0120495328, + continent: 'Europe', + name: 'Netherlands' + }, + { + code: 'JP', + weight: 0.060962362, + continent: 'Asia', + name: 'Japan' + }, + { + code: 'IT', + weight: 0.007030094800000001, + continent: 'Europe', + name: 'Italy' + }, + { + code: 'FR', + weight: 0.0320340333, + continent: 'Europe', + name: 'France' + }, + { + code: 'ES', + weight: 0.006727091600000001, + continent: 'Europe', + name: 'Spain' + }, + { + code: 'CA', + weight: 0.0282501562, + continent: 'North America', + name: 'Canada' + }, + { + code: 'BE', + weight: 0.0026160271, + continent: 'Europe', + name: 'Belgium' + }, + { + code: 'AU', + weight: 0.0183846018, + continent: 'Oceania', + name: 'Australia' + }, + { + code: 'AT', + weight: 0.0004905628, + continent: 'Europe', + name: 'Austria' + }, + { + code: 'GB', + weight: 0.03339169199999999, + continent: 'Europe', + name: 'United Kingdom' + }, + { + code: 'DE', + weight: 0.0221912394, + continent: 'Europe', + name: 'Germany' + }, + { + code: 'SE', + weight: 0.006880960399999999, + continent: 'Europe', + name: 'Sweden' + }, + { + code: 'CH', + weight: 0.0262900458, + continent: 'Europe', + name: 'Switzerland' + }, + { + code: 'IL', + weight: 0.001658592, + continent: 'Asia', + name: 'Israel' + }, + { + code: 'HK', + weight: 0.0062812298, + continent: 'Asia', + name: 'Hong Kong' + }, + { + code: 'FI', + weight: 0.0023597206, + continent: 'Europe', + name: 'Finland' + }, + { + code: 'DK', + weight: 0.0087064137, + continent: 'Europe', + name: 'Denmark' + }, + { + code: 'NO', + weight: 0.0014517355, + continent: 'Europe', + name: 'Norway' + }, + { + code: 'PT', + weight: 0.0004820743, + continent: 'Europe', + name: 'Portugal' + }, + { + code: 'IE', + weight: 0.0022354833, + continent: 'Europe', + name: 'Ireland' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-08-18T22:00:00.000Z', + dividend: 0, + grossPerformance: 2281.298817228297, + grossPerformancePercent: 0.3474381850624522, + grossPerformancePercentWithCurrencyEffect: 0.28744846894552306, + grossPerformanceWithCurrencyEffect: 1975.348026988124, + holdings: [ + { + allocationInPercentage: 0.051778373, + name: 'APPLE INC', + valueInBaseCurrency: 458.1016731945994 + }, + { + allocationInPercentage: 0.0403267055, + name: 'MICROSOFT CORP', + valueInBaseCurrency: 356.78469974280296 + }, + { + allocationInPercentage: 0.0221895862, + name: 'AMAZON COM INC', + valueInBaseCurrency: 196.3191575315778 + }, + { + allocationInPercentage: 0.0208100035, + name: 'NVIDIA CORP', + valueInBaseCurrency: 184.1134989416425 + }, + { + allocationInPercentage: 0.0139820061, + name: 'ALPHABET INC CLASS A', + valueInBaseCurrency: 123.70377858390985 + }, + { + allocationInPercentage: 0.0126263246, + name: 'ALPHABET INC CLASS C', + valueInBaseCurrency: 111.70958240727516 + }, + { + allocationInPercentage: 0.0121596126, + name: 'TESLA INC', + valueInBaseCurrency: 107.58041542669048 + }, + { + allocationInPercentage: 0.0114079282, + name: 'FACEBOOK CLASS A INC', + valueInBaseCurrency: 100.92999631533141 + }, + { + allocationInPercentage: 0.0081570352, + name: 'BERKSHIRE HATHAWAY INC CLASS B', + valueInBaseCurrency: 72.16819024860523 + }, + { + allocationInPercentage: 0.0079471416, + name: 'EXXON MOBIL CORP', + valueInBaseCurrency: 70.31118695201964 + }, + { + allocationInPercentage: 0.0078190388, + name: 'ELI LILLY', + valueInBaseCurrency: 69.1778159397456 + }, + { + allocationInPercentage: 0.0077121293, + name: 'UNITEDHEALTH GROUP INC', + valueInBaseCurrency: 68.23194958681098 + }, + { + allocationInPercentage: 0.0074484861, + name: 'JPMORGAN CHASE & CO', + valueInBaseCurrency: 65.89940447098863 + }, + { + allocationInPercentage: 0.006978079, + name: 'VISA INC CLASS A', + valueInBaseCurrency: 61.73754562709217 + } + ], + investment: 0.04725441287200783, + name: 'Xtrackers MSCI World UCITS ETF 1C', + netPerformance: 2247.935728632002, + netPerformancePercent: 0.3423570396805166, + netPerformancePercentWithCurrencyEffect: 0.28236732356358746, + netPerformanceWithCurrencyEffect: 1940.4303579469001, + quantity: 75, + sectors: [ + { name: 'Real Estate', weight: 0.0227030317 }, + { name: 'Telecommunications', weight: 0.0121560434 }, + { name: 'Consumer Cyclical', weight: 0.11961483 }, + { name: 'Technology', weight: 0.2874777003999999 }, + { name: 'Financials', weight: 0.1235808743 }, + { name: 'Healthcare', weight: 0.1235932822 }, + { name: 'Consumer Staples', weight: 0.0678151631 }, + { name: 'Industrials', weight: 0.100454506 }, + { name: 'Materials', weight: 0.03695810040000001 }, + { name: 'Energy', weight: 0.0446714376 }, + { name: 'Utilities', weight: 0.02511086069999999 }, + { name: 'Communication', weight: 0.0019910151 }, + { name: 'Chemicals', weight: 0.0002828541 }, + { name: 'Information & Communication', weight: 0.0007891258 }, + { name: 'Banks', weight: 0.0002609199 }, + { name: 'Land Transportation', weight: 0.0001578684 }, + { name: 'Electric Appliances', weight: 0.0005693792 }, + { name: 'Transportation Equipment', weight: 0.000423318 }, + { name: 'Metal Products', weight: 0.0000542923 }, + { name: 'Real Estate ex REIT', weight: 0.0000483797 }, + { name: 'Wholesale Trade', weight: 0.0000686654 }, + { name: 'Other Financing Business', weight: 0.0000906838 } + ], + url: null, + valueInBaseCurrency: 8847.35550100424, + valueInPercentage: 0.055647656152211074 + }, + USD: { + activitiesCount: 0, + currency: 'USD', + allocationInPercentage: 0.20291717628620132, + assetClass: 'LIQUIDITY', + assetSubClass: 'CASH', + countries: [], + dividend: 0, + grossPerformance: 0, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: 0, + holdings: [], + investment: 0.35904695492648864, + marketPrice: 0, + name: 'USD', + netPerformance: 0, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: 0, + quantity: 0, + sectors: [], + symbol: 'USD', + tags: [], + valueInBaseCurrency: 49890, + valueInPercentage: 0.3137956381563603 + } + }, + platforms: { + 'a5b14588-49a0-48e4-b9f7-e186b27860b7': { + balance: 0, + currency: 'EUR', + name: 'Bondora', + valueInBaseCurrency: 2231.644722160232, + valueInPercentage: 0.014036487867880205 + }, + '8dc24b88-bb92-4152-af25-fe6a31643e26': { + balance: 390, + currency: 'USD', + name: 'Coinbase', + valueInBaseCurrency: 37375.033270399996, + valueInPercentage: 0.23507962349569783 + }, + '94c1a2f4-a666-47be-84cd-4c8952e74c81': { + balance: 0, + currency: 'EUR', + name: 'DEGIRO', + valueInBaseCurrency: 90452.98295843479, + valueInPercentage: 0.5689266688833119 + }, + '9da3a8a7-4795-43e3-a6db-ccb914189737': { + balance: 0, + currency: 'USD', + name: 'Interactive Brokers', + valueInBaseCurrency: 43941, + valueInPercentage: 0.27637791413567103 + }, + 'cbbb4642-1f1e-412d-91a7-27ed695a048d': { + balance: 0, + currency: 'CHF', + name: 'frankly', + valueInBaseCurrency: 22363.19795483481, + valueInPercentage: 0.14065892911313693 + }, + '43e8fcd1-5b79-4100-b678-d2229bd1660d': { + balance: 47500, + currency: 'USD', + name: 'J.P. Morgan', + valueInBaseCurrency: 47500, + valueInPercentage: 0.2987631351458632 + }, + '747b9016-8ba1-4d13-8255-aec49a468ead': { + balance: 2000, + currency: 'USD', + name: 'Revolut', + valueInBaseCurrency: 2000, + valueInPercentage: 0.01257950042719424 + } + }, + summary: { + activityCount: 29, + annualizedPerformancePercent: 0.16690880197786, + annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, + cash: null, + excludedAccountsAndActivities: null, + netPerformance: null, + netPerformancePercentage: 2.3039314216696174, + netPerformancePercentageWithCurrencyEffect: 2.3589806001456606, + netPerformanceWithCurrencyEffect: null, + totalBuy: null, + totalSell: null, + committedFunds: null, + currentValueInBaseCurrency: null, + dividendInBaseCurrency: null, + emergencyFund: null, + fees: null, + filteredValueInBaseCurrency: null, + filteredValueInPercentage: 0.9646870292294938, + fireWealth: null, + grossPerformance: null, + grossPerformanceWithCurrencyEffect: null, + interestInBaseCurrency: null, + items: null, + liabilities: null, + totalInvestment: null, + totalInvestmentValueWithCurrencyEffect: null, + totalValueInBaseCurrency: null, + currentNetWorth: null + } + }, + paths: DEFAULT_REDACTED_PATHS + }) + ).toStrictEqual({ + accounts: { + '2e937c05-657c-4de9-8fb3-0813a2245f26': { + balance: null, + currency: 'EUR', + name: 'Bondora Account', + valueInBaseCurrency: null, + valueInPercentage: 0.014036487867880205 + }, + 'd804de69-0429-42dc-b6ca-b308fd7dd926': { + balance: null, + currency: 'USD', + name: 'Coinbase Account', + valueInBaseCurrency: null, + valueInPercentage: 0.23507962349569783 + }, + '65cfb79d-b6c7-4591-9d46-73426bc62094': { + balance: null, + currency: 'EUR', + name: 'DEGIRO Account', + valueInBaseCurrency: null, + valueInPercentage: 0.5689266688833119 + }, + '480269ce-e12a-4fd1-ac88-c4b0ff3f899c': { + balance: null, + currency: 'USD', + name: 'Interactive Brokers Account', + valueInBaseCurrency: null, + valueInPercentage: 0.27637791413567103 + }, + '123eafcb-362e-4320-92c5-324621014ee5': { + balance: null, + currency: 'CHF', + name: 'Pillar 3a 🇨🇭', + valueInBaseCurrency: null, + valueInPercentage: 0.14065892911313693 + }, + '8c623328-6035-4b5f-b6d5-702cc1c9c56b': { + balance: null, + currency: 'USD', + name: 'Private Banking Account', + valueInBaseCurrency: null, + valueInPercentage: 0.2987631351458632 + }, + '206b2330-25a5-4d0a-b84b-c7194828f3c7': { + balance: null, + currency: 'USD', + name: 'Revolut Account', + valueInBaseCurrency: null, + valueInPercentage: 0.01257950042719424 + } + }, + hasError: false, + holdings: { + 'AAPL.US': { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 220.79, + symbol: 'AAPL.US', + tags: [], + allocationInPercentage: 0.044900865255793135, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'EOD_HISTORICAL_DATA', + dateOfFirstActivity: '2021-11-30T23:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.3183066634822068, + grossPerformancePercentWithCurrencyEffect: 0.3183066634822068, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'Apple Inc', + netPerformance: null, + netPerformancePercent: 0.3181872462383568, + netPerformancePercentWithCurrencyEffect: 0.3181872462383568, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [{ name: 'Technology', weight: 1 }], + url: 'https://www.apple.com', + valueInBaseCurrency: null, + valueInPercentage: 0.0694356974830054 + }, + 'ALV.DE': { + activitiesCount: 2, + currency: 'EUR', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 1, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + marketPrice: 296.5, + symbol: 'ALV.DE', + tags: [], + allocationInPercentage: 0.026912563036519527, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { code: 'DE', weight: 1, continent: 'Europe', name: 'Germany' } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-04-22T22:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.3719230057375532, + grossPerformancePercentWithCurrencyEffect: 0.2650716044872953, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'Allianz SE', + netPerformance: null, + netPerformancePercent: 0.3709513233388858, + netPerformancePercentWithCurrencyEffect: 0.26409992208862787, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [{ name: 'Financial Services', weight: 1 }], + url: 'https://www.allianz.com', + valueInBaseCurrency: null, + valueInPercentage: 0.04161818652826481 + }, + AMZN: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 187.99, + symbol: 'AMZN', + tags: [], + allocationInPercentage: 0.07646101417126275, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2018-09-30T22:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.8594552890963852, + grossPerformancePercentWithCurrencyEffect: 0.8594552890963852, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'Amazon.com, Inc.', + netPerformance: null, + netPerformancePercent: 0.8514641516525799, + netPerformancePercentWithCurrencyEffect: 0.8514641516525799, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [{ name: 'Consumer Cyclical', weight: 1 }], + url: 'https://www.aboutamazon.com', + valueInBaseCurrency: null, + valueInPercentage: 0.11824101426541227 + }, + bitcoin: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 36985.0332704, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 36985.0332704, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + marketPrice: 65872, + symbol: 'bitcoin', + tags: [ + { + id: '795ebca3-6777-4325-b7ff-f55d94f460fe', + name: 'HIGHER_RISK', + userId: null + } + ], + allocationInPercentage: 0.15042891393226654, + assetClass: 'LIQUIDITY', + assetSubClass: 'CRYPTOCURRENCY', + countries: [], + dataSource: 'COINGECKO', + dateOfFirstActivity: '2017-08-15T22:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 17.4925166352, + grossPerformancePercentWithCurrencyEffect: 17.4925166352, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'Bitcoin', + netPerformance: null, + netPerformancePercent: 17.477566635200002, + netPerformancePercentWithCurrencyEffect: 17.477566635200002, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [], + url: null, + valueInBaseCurrency: null, + valueInPercentage: 0.232626620912395 + }, + BONDORA_GO_AND_GROW: { + activitiesCount: 5, + currency: 'EUR', + markets: { + UNKNOWN: 2231.644722160232, + developedMarkets: 0, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 2231.644722160232, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 0, + otherMarkets: 0 + }, + marketPrice: 1, + symbol: 'BONDORA_GO_AND_GROW', + tags: [ + { + id: '795ebca3-6777-4325-b7ff-f55d94f460fe', + name: 'HIGHER_RISK', + userId: null + } + ], + allocationInPercentage: 0.009076749759365777, + assetClass: 'FIXED_INCOME', + assetSubClass: 'BOND', + countries: [], + dataSource: 'MANUAL', + dateOfFirstActivity: '2021-01-31T23:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: -0.06153834320225245, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'Bondora Go & Grow', + netPerformance: null, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: -0.06118537471467475, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [], + url: null, + valueInBaseCurrency: null, + valueInPercentage: 0.014036487867880205 + }, + FRANKLY95P: { + activitiesCount: 6, + currency: 'CHF', + markets: { + UNKNOWN: 0, + developedMarkets: 0.79567, + emergingMarkets: 0.07075, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0.01333, + emergingMarkets: 0.07075, + europe: 0.35204, + japan: 0.03925, + northAmerica: 0.39105, + otherMarkets: 0 + }, + marketPrice: 177.62, + symbol: 'FRANKLY95P', + tags: [ + { + id: '6caface3-cb98-4605-a357-03792b6746c6', + name: 'RETIREMENT_PROVISION', + userId: null + } + ], + allocationInPercentage: 0.09095764645669335, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.37292, + continent: 'North America', + name: 'United States' + }, + { + code: 'CH', + weight: 0.30022, + continent: 'Europe', + name: 'Switzerland' + }, + { code: 'JP', weight: 0.03925, continent: 'Asia', name: 'Japan' }, + { code: 'CN', weight: 0.03353, continent: 'Asia', name: 'China' }, + { + code: 'GB', + weight: 0.02285, + continent: 'Europe', + name: 'United Kingdom' + }, + { + code: 'CA', + weight: 0.01813, + continent: 'North America', + name: 'Canada' + }, + { + code: 'FR', + weight: 0.01465, + continent: 'Europe', + name: 'France' + }, + { + code: 'DE', + weight: 0.01432, + continent: 'Europe', + name: 'Germany' + }, + { code: 'TW', weight: 0.01427, continent: 'Asia', name: 'Taiwan' }, + { + code: 'AU', + weight: 0.01333, + continent: 'Oceania', + name: 'Australia' + }, + { + code: 'KR', + weight: 0.01172, + continent: 'Asia', + name: 'South Korea' + }, + { code: 'IN', weight: 0.01123, continent: 'Asia', name: 'India' } + ], + dataSource: 'MANUAL', + dateOfFirstActivity: '2021-03-31T22:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.27579517683678895, + grossPerformancePercentWithCurrencyEffect: 0.458553421589667, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'frankly Extreme 95 Index', + netPerformance: null, + netPerformancePercent: 0.27579517683678895, + netPerformancePercentWithCurrencyEffect: 0.43609380217769156, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [], + url: 'https://www.frankly.ch', + valueInBaseCurrency: null, + valueInPercentage: 0.14065892911313693 + }, + MSFT: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 428.02, + symbol: 'MSFT', + tags: [], + allocationInPercentage: 0.05222646409742627, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2023-01-02T23:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.7865431171216295, + grossPerformancePercentWithCurrencyEffect: 0.7865431171216295, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'Microsoft Corporation', + netPerformance: null, + netPerformancePercent: 0.7865431171216295, + netPerformancePercentWithCurrencyEffect: 0.7865431171216295, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [{ name: 'Technology', weight: 1 }], + url: 'https://www.microsoft.com', + valueInBaseCurrency: null, + valueInPercentage: 0.08076416659271518 + }, + TSLA: { + activitiesCount: 1, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 1, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0, + japan: 0, + northAmerica: 1, + otherMarkets: 0 + }, + marketPrice: 260.46, + symbol: 'TSLA', + tags: [], + allocationInPercentage: 0.1589050142378352, + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2017-01-02T23:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 17.184314638161936, + grossPerformancePercentWithCurrencyEffect: 17.184314638161936, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + name: 'Tesla, Inc.', + netPerformance: null, + netPerformancePercent: 17.170351408001327, + netPerformancePercentWithCurrencyEffect: 17.170351408001327, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [{ name: 'Consumer Cyclical', weight: 1 }], + url: 'https://www.tesla.com', + valueInBaseCurrency: null, + valueInPercentage: 0.2457342510950259 + }, + VTI: { + activitiesCount: 5, + currency: 'USD', + markets: { + UNKNOWN: 0, + developedMarkets: 0.9794119422809896, + emergingMarkets: 0, + otherMarkets: 0.00016100142888768383 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0, + emergingMarkets: 0, + europe: 0.00019118919680412454, + japan: 0, + northAmerica: 0.9792207530841855, + otherMarkets: 0.00016100142888768383 + }, + marketPrice: 282.05, + symbol: 'VTI', + tags: [], + allocationInPercentage: 0.057358979326040366, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.9788987502264102, + continent: 'North America', + name: 'United States' + }, + { + code: 'CA', + weight: 0.0003220028577753677, + continent: 'North America', + name: 'Canada' + }, + { + code: 'NL', + weight: 0.0001811266074986443, + continent: 'Europe', + name: 'Netherlands' + }, + { + code: 'BM', + weight: 0.00009056330374932214, + continent: 'North America', + name: 'Bermuda' + }, + { + code: 'KY', + weight: 0.00007043812513836169, + continent: 'North America', + name: 'Cayman Islands' + }, + { + code: 'IL', + weight: 0.00001006258930548024, + continent: 'Asia', + name: 'Israel' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2019-02-28T23:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.8832083851170418, + grossPerformancePercentWithCurrencyEffect: 0.8832083851170418, + grossPerformanceWithCurrencyEffect: null, + holdings: [ + { + allocationInPercentage: 0.06099941636982121, + name: 'APPLE INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.05862464529372787, + name: 'MICROSOFT CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.05156070760128074, + name: 'NVIDIA CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.03301535551128066, + name: 'AMAZON COM INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01962204914568647, + name: 'FACEBOOK CLASS A INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01902835637666313, + name: 'ALPHABET INC CLASS A', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01555676306627245, + name: 'ALPHABET INC CLASS C', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01463100485016827, + name: 'BERKSHIRE HATHAWAY INC CLASS B', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01403731208114493, + name: 'BROADCOM INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01297067761476403, + name: 'ELI LILLY', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0118637927911612, + name: 'TESLA INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01152166475477487, + name: 'JPMORGAN CHASE & CO', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0100324015375638, + name: 'EXXON MOBIL CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.01000221376964736, + name: 'UNITEDHEALTH GROUP INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.007818631890358146, + name: 'VISA INC CLASS A', + valueInBaseCurrency: null + } + ], + investment: null, + name: 'Vanguard Total Stock Market Index Fund ETF Shares', + netPerformance: null, + netPerformancePercent: 0.8682024540139314, + netPerformancePercentWithCurrencyEffect: 0.8328704068843998, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [ + { name: 'Technology', weight: 0.3739157560023398 }, + { name: 'Consumer Cyclical', weight: 0.1168065366580148 }, + { name: 'Industrials', weight: 0.09138843607237156 }, + { name: 'Healthcare', weight: 0.1172291654088449 }, + { name: 'Energy', weight: 0.03762402141319062 }, + { name: 'Consumer Staples', weight: 0.05152045724405886 }, + { name: 'Financials', weight: 0.1127613757572116 }, + { name: 'Telecommunications', weight: 0.007557004568415658 }, + { name: 'Real Estate', weight: 0.02587091710438972 }, + { name: 'Communication', weight: 0.002062830807623449 }, + { name: 'Utilities', weight: 0.02208738352552914 }, + { name: 'Materials', weight: 0.020366680754292 }, + { name: 'Other', weight: 0.0003823783936082492 } + ], + url: 'https://www.vanguard.com', + valueInBaseCurrency: null, + valueInPercentage: 0.08870120238725339 + }, + 'VWRL.SW': { + activitiesCount: 5, + currency: 'CHF', + markets: { + UNKNOWN: 0, + developedMarkets: 0.881487000000001, + emergingMarkets: 0.11228900000000001, + otherMarkets: 0.0038099999999999996 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0.03252, + emergingMarkets: 0.11228900000000001, + europe: 0.170033, + japan: 0.06258100000000001, + northAmerica: 0.6163530000000008, + otherMarkets: 0.0038099999999999996 + }, + marketPrice: 117.62, + symbol: 'VWRL.SW', + tags: [], + allocationInPercentage: 0.09386983901959013, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.5903140000000008, + continent: 'North America', + name: 'United States' + }, + { + code: 'TW', + weight: 0.017327000000000002, + continent: 'Asia', + name: 'Taiwan' + }, + { + code: 'CN', + weight: 0.040376999999999996, + continent: 'Asia', + name: 'China' + }, + { + code: 'CH', + weight: 0.024700999999999997, + continent: 'Europe', + name: 'Switzerland' + }, + { + code: 'KR', + weight: 0.014147, + continent: 'Asia', + name: 'South Korea' + }, + { + code: 'NL', + weight: 0.013718000000000001, + continent: 'Europe', + name: 'Netherlands' + }, + { + code: 'JP', + weight: 0.06258100000000001, + continent: 'Asia', + name: 'Japan' + }, + { + code: 'GB', + weight: 0.03813600000000002, + continent: 'Europe', + name: 'United Kingdom' + }, + { + code: 'FR', + weight: 0.027450000000000006, + continent: 'Europe', + name: 'France' + }, + { + code: 'DK', + weight: 0.006692, + continent: 'Europe', + name: 'Denmark' + }, + { + code: 'CA', + weight: 0.026039000000000007, + continent: 'North America', + name: 'Canada' + }, + { + code: 'DE', + weight: 0.023266, + continent: 'Europe', + name: 'Germany' + }, + { + code: 'HK', + weight: 0.008724999999999998, + continent: 'Asia', + name: 'Hong Kong' + }, + { + code: 'AU', + weight: 0.019638, + continent: 'Oceania', + name: 'Australia' + }, + { + code: 'IN', + weight: 0.015436000000000004, + continent: 'Asia', + name: 'India' + }, + { + code: 'ES', + weight: 0.006828, + continent: 'Europe', + name: 'Spain' + }, + { + code: 'IT', + weight: 0.006168, + continent: 'Europe', + name: 'Italy' + }, + { + code: 'BR', + weight: 0.004955, + continent: 'South America', + name: 'Brazil' + }, + { + code: 'RU', + weight: 0.0038099999999999996, + continent: 'Asia', + name: 'Russia' + }, + { + code: 'SA', + weight: 0.0038950000000000005, + continent: 'Asia', + name: 'Saudi Arabia' + }, + { + code: 'BE', + weight: 0.0026820000000000004, + continent: 'Europe', + name: 'Belgium' + }, + { + code: 'SG', + weight: 0.0035050000000000003, + continent: 'Asia', + name: 'Singapore' + }, + { + code: 'SE', + weight: 0.010147999999999997, + continent: 'Europe', + name: 'Sweden' + }, + { code: 'QA', weight: 0.000719, continent: 'Asia', name: 'Qatar' }, + { + code: 'LU', + weight: 0.000915, + continent: 'Europe', + name: 'Luxembourg' + }, + { + code: 'ZA', + weight: 0.003598, + continent: 'Africa', + name: 'South Africa' + }, + { + code: 'MX', + weight: 0.002607, + continent: 'North America', + name: 'Mexico' + }, + { + code: 'FI', + weight: 0.002973, + continent: 'Europe', + name: 'Finland' + }, + { + code: 'IE', + weight: 0.0017519999999999999, + continent: 'Europe', + name: 'Ireland' + }, + { + code: 'KW', + weight: 0.0008320000000000001, + continent: 'Asia', + name: 'Kuwait' + }, + { + code: 'MY', + weight: 0.0016229999999999999, + continent: 'Asia', + name: 'Malaysia' + }, + { + code: 'ID', + weight: 0.001611, + continent: 'Asia', + name: 'Indonesia' + }, + { + code: 'PT', + weight: 0.000436, + continent: 'Europe', + name: 'Portugal' + }, + { + code: 'AE', + weight: 0.0011489999999999998, + continent: 'Asia', + name: 'United Arab Emirates' + }, + { + code: 'TH', + weight: 0.0024800000000000004, + continent: 'Asia', + name: 'Thailand' + }, + { + code: 'NO', + weight: 0.001652, + continent: 'Europe', + name: 'Norway' + }, + { + code: 'PH', + weight: 0.000382, + continent: 'Asia', + name: 'Philippines' + }, + { + code: 'NZ', + weight: 0.000652, + continent: 'Oceania', + name: 'New Zealand' + }, + { + code: 'IL', + weight: 0.0016950000000000001, + continent: 'Asia', + name: 'Israel' + }, + { + code: 'PE', + weight: 0.000334, + continent: 'South America', + name: 'Peru' + }, + { + code: 'AT', + weight: 0.0008210000000000001, + continent: 'Europe', + name: 'Austria' + }, + { + code: 'CL', + weight: 0.000298, + continent: 'South America', + name: 'Chile' + }, + { + code: 'HU', + weight: 0.000266, + continent: 'Europe', + name: 'Hungary' + }, + { + code: 'PL', + weight: 0.000253, + continent: 'Europe', + name: 'Poland' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2018-02-28T23:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.3683200415015591, + grossPerformancePercentWithCurrencyEffect: 0.5806366182968891, + grossPerformanceWithCurrencyEffect: null, + holdings: [ + { + allocationInPercentage: 0.042520261085, + name: 'APPLE INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.037017038038, + name: 'MICROSOFT CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.018861883836, + name: 'AMAZON COM INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.017806548325, + name: 'NVIDIA CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.012188534864, + name: 'ALPHABET INC CLASS A', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.010831709166, + name: 'ALPHABET INC CLASS C', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.010813551981, + name: 'TESLA INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.009934819182, + name: 'FACEBOOK CLASS A INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.007403227621000001, + name: 'BERKSHIRE HATHAWAY INC CLASS B', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.007076883908, + name: 'ELI LILLY', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.006844271583, + name: 'EXXON MOBIL CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.006718061670999999, + name: 'UNITEDHEALTH GROUP INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.006456949621, + name: 'JPMORGAN CHASE & CO', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.006293890054, + name: 'TAIWAN SEMICONDUCTOR MANUFACTURING', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.00600392555, + name: 'VISA INC CLASS A', + valueInBaseCurrency: null + } + ], + investment: null, + name: 'Vanguard FTSE All-World UCITS ETF', + netPerformance: null, + netPerformancePercent: 0.3605540392890248, + netPerformancePercentWithCurrencyEffect: 0.5382257513306911, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [ + { name: 'Technology', weight: 0.2729529999999999 }, + { name: 'Consumer Cyclical', weight: 0.141791 }, + { name: 'Financials', weight: 0.14711999999999992 }, + { name: 'Healthcare', weight: 0.114531 }, + { name: 'Consumer Staples', weight: 0.064498 }, + { name: 'Energy', weight: 0.036378999999999995 }, + { name: 'Telecommunications', weight: 0.017739000000000008 }, + { name: 'Utilities', weight: 0.02524900000000001 }, + { name: 'Industrials', weight: 0.095292 }, + { name: 'Materials', weight: 0.04762400000000001 }, + { name: 'Real Estate', weight: 0.027565000000000003 }, + { name: 'Communication', weight: 0.0035989999999999998 }, + { name: 'Information & Communication', weight: 0.000576 }, + { name: 'Communication Services', weight: 0.000574 }, + { name: 'Electric Appliances', weight: 0.000345 }, + { name: 'Chemicals', weight: 0.000326 }, + { name: 'Services', weight: 0.000257 }, + { name: 'Transportation Equipment', weight: 0.00041299999999999996 } + ], + url: 'https://www.vanguard.com', + valueInBaseCurrency: null, + valueInPercentage: 0.145162408515095 + }, + 'XDWD.DE': { + activitiesCount: 1, + currency: 'EUR', + markets: { + UNKNOWN: 0, + developedMarkets: 0.9688723314999987, + emergingMarkets: 0, + otherMarkets: 0 + }, + marketsAdvanced: { + UNKNOWN: 0, + asiaPacific: 0.0288497227, + emergingMarkets: 0, + europe: 0.1665952994, + japan: 0.060962362, + northAmerica: 0.7124649473999988, + otherMarkets: 0 + }, + marketPrice: 105.72, + symbol: 'XDWD.DE', + tags: [], + allocationInPercentage: 0.03598477442100562, + assetClass: 'EQUITY', + assetSubClass: 'ETF', + countries: [ + { + code: 'US', + weight: 0.6842147911999988, + continent: 'North America', + name: 'United States' + }, + { + code: 'SG', + weight: 0.0035432595, + continent: 'Asia', + name: 'Singapore' + }, + { + code: 'NZ', + weight: 0.0006406316, + continent: 'Oceania', + name: 'New Zealand' + }, + { + code: 'NL', + weight: 0.0120495328, + continent: 'Europe', + name: 'Netherlands' + }, + { + code: 'JP', + weight: 0.060962362, + continent: 'Asia', + name: 'Japan' + }, + { + code: 'IT', + weight: 0.007030094800000001, + continent: 'Europe', + name: 'Italy' + }, + { + code: 'FR', + weight: 0.0320340333, + continent: 'Europe', + name: 'France' + }, + { + code: 'ES', + weight: 0.006727091600000001, + continent: 'Europe', + name: 'Spain' + }, + { + code: 'CA', + weight: 0.0282501562, + continent: 'North America', + name: 'Canada' + }, + { + code: 'BE', + weight: 0.0026160271, + continent: 'Europe', + name: 'Belgium' + }, + { + code: 'AU', + weight: 0.0183846018, + continent: 'Oceania', + name: 'Australia' + }, + { + code: 'AT', + weight: 0.0004905628, + continent: 'Europe', + name: 'Austria' + }, + { + code: 'GB', + weight: 0.03339169199999999, + continent: 'Europe', + name: 'United Kingdom' + }, + { + code: 'DE', + weight: 0.0221912394, + continent: 'Europe', + name: 'Germany' + }, + { + code: 'SE', + weight: 0.006880960399999999, + continent: 'Europe', + name: 'Sweden' + }, + { + code: 'CH', + weight: 0.0262900458, + continent: 'Europe', + name: 'Switzerland' + }, + { + code: 'IL', + weight: 0.001658592, + continent: 'Asia', + name: 'Israel' + }, + { + code: 'HK', + weight: 0.0062812298, + continent: 'Asia', + name: 'Hong Kong' + }, + { + code: 'FI', + weight: 0.0023597206, + continent: 'Europe', + name: 'Finland' + }, + { + code: 'DK', + weight: 0.0087064137, + continent: 'Europe', + name: 'Denmark' + }, + { + code: 'NO', + weight: 0.0014517355, + continent: 'Europe', + name: 'Norway' + }, + { + code: 'PT', + weight: 0.0004820743, + continent: 'Europe', + name: 'Portugal' + }, + { + code: 'IE', + weight: 0.0022354833, + continent: 'Europe', + name: 'Ireland' + } + ], + dataSource: 'YAHOO', + dateOfFirstActivity: '2021-08-18T22:00:00.000Z', + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0.3474381850624522, + grossPerformancePercentWithCurrencyEffect: 0.28744846894552306, + grossPerformanceWithCurrencyEffect: null, + holdings: [ + { + allocationInPercentage: 0.051778373, + name: 'APPLE INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0403267055, + name: 'MICROSOFT CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0221895862, + name: 'AMAZON COM INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0208100035, + name: 'NVIDIA CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0139820061, + name: 'ALPHABET INC CLASS A', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0126263246, + name: 'ALPHABET INC CLASS C', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0121596126, + name: 'TESLA INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0114079282, + name: 'FACEBOOK CLASS A INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0081570352, + name: 'BERKSHIRE HATHAWAY INC CLASS B', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0079471416, + name: 'EXXON MOBIL CORP', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0078190388, + name: 'ELI LILLY', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0077121293, + name: 'UNITEDHEALTH GROUP INC', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.0074484861, + name: 'JPMORGAN CHASE & CO', + valueInBaseCurrency: null + }, + { + allocationInPercentage: 0.006978079, + name: 'VISA INC CLASS A', + valueInBaseCurrency: null + } + ], + investment: null, + name: 'Xtrackers MSCI World UCITS ETF 1C', + netPerformance: null, + netPerformancePercent: 0.3423570396805166, + netPerformancePercentWithCurrencyEffect: 0.28236732356358746, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [ + { name: 'Real Estate', weight: 0.0227030317 }, + { name: 'Telecommunications', weight: 0.0121560434 }, + { name: 'Consumer Cyclical', weight: 0.11961483 }, + { name: 'Technology', weight: 0.2874777003999999 }, + { name: 'Financials', weight: 0.1235808743 }, + { name: 'Healthcare', weight: 0.1235932822 }, + { name: 'Consumer Staples', weight: 0.0678151631 }, + { name: 'Industrials', weight: 0.100454506 }, + { name: 'Materials', weight: 0.03695810040000001 }, + { name: 'Energy', weight: 0.0446714376 }, + { name: 'Utilities', weight: 0.02511086069999999 }, + { name: 'Communication', weight: 0.0019910151 }, + { name: 'Chemicals', weight: 0.0002828541 }, + { name: 'Information & Communication', weight: 0.0007891258 }, + { name: 'Banks', weight: 0.0002609199 }, + { name: 'Land Transportation', weight: 0.0001578684 }, + { name: 'Electric Appliances', weight: 0.0005693792 }, + { name: 'Transportation Equipment', weight: 0.000423318 }, + { name: 'Metal Products', weight: 0.0000542923 }, + { name: 'Real Estate ex REIT', weight: 0.0000483797 }, + { name: 'Wholesale Trade', weight: 0.0000686654 }, + { name: 'Other Financing Business', weight: 0.0000906838 } + ], + url: null, + valueInBaseCurrency: null, + valueInPercentage: 0.055647656152211074 + }, + USD: { + activitiesCount: 0, + currency: 'USD', + allocationInPercentage: 0.20291717628620132, + assetClass: 'LIQUIDITY', + assetSubClass: 'CASH', + countries: [], + dividend: null, + grossPerformance: null, + grossPerformancePercent: 0, + grossPerformancePercentWithCurrencyEffect: 0, + grossPerformanceWithCurrencyEffect: null, + holdings: [], + investment: null, + marketPrice: 0, + name: 'USD', + netPerformance: null, + netPerformancePercent: 0, + netPerformancePercentWithCurrencyEffect: 0, + netPerformanceWithCurrencyEffect: null, + quantity: null, + sectors: [], + symbol: 'USD', + tags: [], + valueInBaseCurrency: null, + valueInPercentage: 0.3137956381563603 + } + }, + platforms: { + 'a5b14588-49a0-48e4-b9f7-e186b27860b7': { + balance: null, + currency: 'EUR', + name: 'Bondora', + valueInBaseCurrency: null, + valueInPercentage: 0.014036487867880205 + }, + '8dc24b88-bb92-4152-af25-fe6a31643e26': { + balance: null, + currency: 'USD', + name: 'Coinbase', + valueInBaseCurrency: null, + valueInPercentage: 0.23507962349569783 + }, + '94c1a2f4-a666-47be-84cd-4c8952e74c81': { + balance: null, + currency: 'EUR', + name: 'DEGIRO', + valueInBaseCurrency: null, + valueInPercentage: 0.5689266688833119 + }, + '9da3a8a7-4795-43e3-a6db-ccb914189737': { + balance: null, + currency: 'USD', + name: 'Interactive Brokers', + valueInBaseCurrency: null, + valueInPercentage: 0.27637791413567103 + }, + 'cbbb4642-1f1e-412d-91a7-27ed695a048d': { + balance: null, + currency: 'CHF', + name: 'frankly', + valueInBaseCurrency: null, + valueInPercentage: 0.14065892911313693 + }, + '43e8fcd1-5b79-4100-b678-d2229bd1660d': { + balance: null, + currency: 'USD', + name: 'J.P. Morgan', + valueInBaseCurrency: null, + valueInPercentage: 0.2987631351458632 + }, + '747b9016-8ba1-4d13-8255-aec49a468ead': { + balance: null, + currency: 'USD', + name: 'Revolut', + valueInBaseCurrency: null, + valueInPercentage: 0.01257950042719424 + } + }, + summary: { + activityCount: 29, + annualizedPerformancePercent: 0.16690880197786, + annualizedPerformancePercentWithCurrencyEffect: 0.1694019484552876, + cash: null, + excludedAccountsAndActivities: null, + netPerformance: null, + netPerformancePercentage: 2.3039314216696174, + netPerformancePercentageWithCurrencyEffect: 2.3589806001456606, + netPerformanceWithCurrencyEffect: null, + totalBuy: null, + totalSell: null, + committedFunds: null, + currentValueInBaseCurrency: null, + dividendInBaseCurrency: null, + emergencyFund: null, + fees: null, + filteredValueInBaseCurrency: null, + filteredValueInPercentage: 0.9646870292294938, + fireWealth: null, + grossPerformance: null, + grossPerformanceWithCurrencyEffect: null, + interestInBaseCurrency: null, + items: null, + liabilities: null, + totalInvestment: null, + totalInvestmentValueWithCurrencyEffect: null, + totalValueInBaseCurrency: null, + currentNetWorth: null + } + }); + console.timeEnd('redactAttributes execution time'); + }); +}); diff --git a/apps/api/src/helper/object.helper.ts b/apps/api/src/helper/object.helper.ts new file mode 100644 index 000000000..350d5fe04 --- /dev/null +++ b/apps/api/src/helper/object.helper.ts @@ -0,0 +1,70 @@ +import fastRedact from 'fast-redact'; +import jsonpath from 'jsonpath'; +import { cloneDeep, isObject } from 'lodash'; + +export function hasNotDefinedValuesInObject(aObject: Object): boolean { + for (const key in aObject) { + if (aObject[key] === null || aObject[key] === undefined) { + return true; + } else if (isObject(aObject[key])) { + return hasNotDefinedValuesInObject(aObject[key]); + } + } + + return false; +} + +export function nullifyValuesInObject(aObject: T, keys: string[]): T { + const object = cloneDeep(aObject); + + if (object) { + keys.forEach((key) => { + object[key] = null; + }); + } + + return object; +} + +export function nullifyValuesInObjects(aObjects: T[], keys: string[]): T[] { + return aObjects.map((object) => { + return nullifyValuesInObject(object, keys); + }); +} + +export function query({ + object, + pathExpression +}: { + object: object; + pathExpression: string; +}) { + return jsonpath.query(object, pathExpression); +} + +export function redactPaths({ + object, + paths, + valueMap +}: { + object: any; + paths: fastRedact.RedactOptions['paths']; + valueMap?: { [key: string]: any }; +}): any { + const redact = fastRedact({ + paths, + censor: (value) => { + if (valueMap) { + if (valueMap[value]) { + return valueMap[value]; + } else { + return value; + } + } else { + return null; + } + } + }); + + return JSON.parse(redact(object)); +} diff --git a/apps/api/src/helper/portfolio.helper.ts b/apps/api/src/helper/portfolio.helper.ts new file mode 100644 index 000000000..6ebe48d3c --- /dev/null +++ b/apps/api/src/helper/portfolio.helper.ts @@ -0,0 +1,19 @@ +import { Type as ActivityType } from '@prisma/client'; + +export function getFactor(activityType: ActivityType) { + let factor: number; + + switch (activityType) { + case 'BUY': + factor = 1; + break; + case 'SELL': + factor = -1; + break; + default: + factor = 0; + break; + } + + return factor; +} diff --git a/apps/api/src/helper/string.helper.ts b/apps/api/src/helper/string.helper.ts new file mode 100644 index 000000000..75f9d00fd --- /dev/null +++ b/apps/api/src/helper/string.helper.ts @@ -0,0 +1,14 @@ +import { randomBytes } from 'node:crypto'; + +export function getRandomString(length: number) { + const bytes = randomBytes(length); + const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; + const result = []; + + for (let i = 0; i < length; i++) { + const randomByte = bytes[i]; + result.push(characters[randomByte % characters.length]); + } + + return result.join(''); +} diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts new file mode 100644 index 000000000..d863f0ec3 --- /dev/null +++ b/apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts @@ -0,0 +1,80 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; + +import { PerformanceLoggingService } from './performance-logging.service'; + +@Injectable() +export class PerformanceLoggingInterceptor implements NestInterceptor { + public constructor( + private readonly performanceLoggingService: PerformanceLoggingService + ) {} + + public intercept( + context: ExecutionContext, + next: CallHandler + ): Observable { + const startTime = performance.now(); + + const className = context.getClass().name; + const methodName = context.getHandler().name; + + return next.handle().pipe( + tap(() => { + return this.performanceLoggingService.logPerformance({ + className, + methodName, + startTime + }); + }) + ); + } +} + +export function LogPerformance( + target: any, + propertyKey: string, + descriptor: PropertyDescriptor +) { + const originalMethod = descriptor.value; + + descriptor.value = async function (...args: any[]) { + const startTime = performance.now(); + const performanceLoggingService = new PerformanceLoggingService(); + + const result = originalMethod.apply(this, args); + + if (result instanceof Promise) { + // Handle async method + return result + .then((res: any) => { + performanceLoggingService.logPerformance({ + startTime, + className: target.constructor.name, + methodName: propertyKey + }); + + return res; + }) + .catch((error: any) => { + throw error; + }); + } else { + // Handle sync method + performanceLoggingService.logPerformance({ + startTime, + className: target.constructor.name, + methodName: propertyKey + }); + + return result; + } + }; + + return descriptor; +} diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.module.ts b/apps/api/src/interceptors/performance-logging/performance-logging.module.ts new file mode 100644 index 000000000..a26b381e5 --- /dev/null +++ b/apps/api/src/interceptors/performance-logging/performance-logging.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; + +import { PerformanceLoggingInterceptor } from './performance-logging.interceptor'; +import { PerformanceLoggingService } from './performance-logging.service'; + +@Module({ + exports: [PerformanceLoggingInterceptor, PerformanceLoggingService], + providers: [PerformanceLoggingInterceptor, PerformanceLoggingService] +}) +export class PerformanceLoggingModule {} diff --git a/apps/api/src/interceptors/performance-logging/performance-logging.service.ts b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts new file mode 100644 index 000000000..1b1faf8e0 --- /dev/null +++ b/apps/api/src/interceptors/performance-logging/performance-logging.service.ts @@ -0,0 +1,21 @@ +import { Injectable, Logger } from '@nestjs/common'; + +@Injectable() +export class PerformanceLoggingService { + public logPerformance({ + className, + methodName, + startTime + }: { + className: string; + methodName: string; + startTime: number; + }) { + const endTime = performance.now(); + + Logger.debug( + `Completed execution of ${methodName}() in ${((endTime - startTime) / 1000).toFixed(3)} seconds`, + className + ); + } +} diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts new file mode 100644 index 000000000..60b994cac --- /dev/null +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.interceptor.ts @@ -0,0 +1,55 @@ +import { redactPaths } from '@ghostfolio/api/helper/object.helper'; +import { + DEFAULT_REDACTED_PATHS, + HEADER_KEY_IMPERSONATION +} from '@ghostfolio/common/config'; +import { + hasReadRestrictedAccessPermission, + isRestrictedView +} from '@ghostfolio/common/permissions'; +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class RedactValuesInResponseInterceptor implements NestInterceptor< + T, + any +> { + public intercept( + context: ExecutionContext, + next: CallHandler + ): Observable { + return next.handle().pipe( + map((data: any) => { + const { headers, user }: { headers: Headers; user: UserWithSettings } = + context.switchToHttp().getRequest(); + + const impersonationId = + headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()]; + + if ( + hasReadRestrictedAccessPermission({ + impersonationId, + user + }) || + isRestrictedView(user) + ) { + data = redactPaths({ + object: data, + paths: DEFAULT_REDACTED_PATHS + }); + } + + return data; + }) + ); + } +} diff --git a/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts new file mode 100644 index 000000000..90cf254b3 --- /dev/null +++ b/apps/api/src/interceptors/redact-values-in-response/redact-values-in-response.module.ts @@ -0,0 +1,4 @@ +import { Module } from '@nestjs/common'; + +@Module({}) +export class RedactValuesInResponseModule {} diff --git a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts new file mode 100644 index 000000000..17c5ebe57 --- /dev/null +++ b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts @@ -0,0 +1,89 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { decodeDataSource } from '@ghostfolio/common/helper'; + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor +} from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { Observable } from 'rxjs'; + +@Injectable() +export class TransformDataSourceInRequestInterceptor< + T +> implements NestInterceptor { + public constructor( + private readonly configurationService: ConfigurationService + ) {} + + public intercept( + context: ExecutionContext, + next: CallHandler + ): Observable { + const http = context.switchToHttp(); + const request = http.getRequest(); + + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (request.body?.activities) { + const dataSourceGhostfolioDataProvider = this.configurationService.get( + 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' + )?.[0]; + + request.body.activities = request.body.activities.map((activity) => { + if (DataSource[activity.dataSource]) { + if ( + activity.dataSource === 'GHOSTFOLIO' && + dataSourceGhostfolioDataProvider + ) { + return { + ...activity, + dataSource: dataSourceGhostfolioDataProvider + }; + } else { + return activity; + } + } else { + return { + ...activity, + dataSource: decodeDataSource(activity.dataSource) + }; + } + }); + } + + for (const type of ['body', 'params', 'query']) { + const dataSourceValue = request[type]?.dataSource; + + if (dataSourceValue && !DataSource[dataSourceValue]) { + // In Express 5, request.query is read-only, so request[type].dataSource cannot be directly modified + Object.defineProperty(request, type, { + configurable: true, + enumerable: true, + value: { + ...request[type], + dataSource: decodeDataSource(dataSourceValue) + }, + writable: true + }); + } + } + } else { + if (request.body?.activities) { + request.body.activities = request.body.activities.map((activity) => { + if (DataSource[activity.dataSource]) { + return activity; + } else { + return { + ...activity, + dataSource: decodeDataSource(activity.dataSource) + }; + } + }); + } + } + + return next.handle(); + } +} diff --git a/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts new file mode 100644 index 000000000..4a7d23803 --- /dev/null +++ b/apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.module.ts @@ -0,0 +1,11 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [ConfigurationService], + imports: [ConfigurationModule], + providers: [ConfigurationService] +}) +export class TransformDataSourceInRequestModule {} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts new file mode 100644 index 000000000..eaa6dd08c --- /dev/null +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts @@ -0,0 +1,81 @@ +import { redactPaths } from '@ghostfolio/api/helper/object.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { encodeDataSource } from '@ghostfolio/common/helper'; + +import { + CallHandler, + ExecutionContext, + Injectable, + NestInterceptor +} from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable() +export class TransformDataSourceInResponseInterceptor< + T +> implements NestInterceptor { + private encodedDataSourceMap: { + [dataSource: string]: string; + } = {}; + + public constructor( + private readonly configurationService: ConfigurationService + ) { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + this.encodedDataSourceMap = Object.keys(DataSource).reduce( + (encodedDataSourceMap, dataSource) => { + if (!['GHOSTFOLIO', 'MANUAL'].includes(dataSource)) { + encodedDataSourceMap[dataSource] = encodeDataSource( + DataSource[dataSource] + ); + } + + return encodedDataSourceMap; + }, + {} + ); + } + } + + public intercept( + context: ExecutionContext, + next: CallHandler + ): Observable { + const isExportMode = context.getClass().name === 'ExportController'; + + return next.handle().pipe( + map((data: any) => { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + const valueMap = this.encodedDataSourceMap; + + if (isExportMode) { + for (const dataSource of this.configurationService.get( + 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER' + )) { + valueMap[dataSource] = 'GHOSTFOLIO'; + } + } + + data = redactPaths({ + valueMap, + object: data, + paths: [ + 'activities[*].SymbolProfile.dataSource', + 'benchmarks[*].dataSource', + 'fearAndGreedIndex.CRYPTOCURRENCIES.dataSource', + 'fearAndGreedIndex.STOCKS.dataSource', + 'holdings[*].dataSource', + 'items[*].dataSource', + 'SymbolProfile.dataSource', + 'watchlist[*].dataSource' + ] + }); + } + + return data; + }) + ); + } +} diff --git a/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts new file mode 100644 index 000000000..fadf0bd80 --- /dev/null +++ b/apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.module.ts @@ -0,0 +1,11 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [ConfigurationService], + imports: [ConfigurationModule], + providers: [ConfigurationService] +}) +export class TransformDataSourceInResponseModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 000000000..a8de3dc5e --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,123 @@ +import { + DEFAULT_HOST, + DEFAULT_PORT, + STORYBOOK_PATH, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; + +import { + Logger, + LogLevel, + ValidationPipe, + VersioningType +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; +import type { NestExpressApplication } from '@nestjs/platform-express'; +import { NextFunction, Request, Response } from 'express'; +import helmet from 'helmet'; + +import { AppModule } from './app/app.module'; +import { environment } from './environments/environment'; + +async function bootstrap() { + const configApp = await NestFactory.create(AppModule); + const configService = configApp.get(ConfigService); + let customLogLevels: LogLevel[]; + + try { + customLogLevels = JSON.parse( + configService.get('LOG_LEVELS') + ) as LogLevel[]; + } catch {} + + const app = await NestFactory.create(AppModule, { + logger: + customLogLevels ?? + (environment.production + ? ['error', 'log', 'warn'] + : ['debug', 'error', 'log', 'verbose', 'warn']) + }); + + app.enableCors(); + app.enableVersioning({ + defaultVersion: '1', + type: VersioningType.URI + }); + app.setGlobalPrefix('api', { + exclude: [ + 'sitemap.xml', + ...SUPPORTED_LANGUAGE_CODES.map((languageCode) => { + // Exclude language-specific routes with an optional wildcard + return `/${languageCode}{/*wildcard}`; + }) + ] + }); + app.useGlobalPipes( + new ValidationPipe({ + forbidNonWhitelisted: true, + transform: true, + whitelist: true + }) + ); + + // Support 10mb csv/json files for importing activities + app.useBodyParser('json', { limit: '10mb' }); + + if (configService.get('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { + app.use((req: Request, res: Response, next: NextFunction) => { + if (req.path.startsWith(STORYBOOK_PATH)) { + next(); + } else { + helmet({ + contentSecurityPolicy: { + directives: { + connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to 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 + scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers + styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles + } + }, + crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity) + })(req, res, next); + } + }); + } + + const HOST = configService.get('HOST') || DEFAULT_HOST; + const PORT = configService.get('PORT') || DEFAULT_PORT; + + await app.listen(PORT, HOST, () => { + logLogo(); + + let address = app.getHttpServer().address(); + + if (typeof address === 'object') { + const addressObject = address; + let host = addressObject.address; + + if (addressObject.family === 'IPv6') { + host = `[${addressObject.address}]`; + } + + address = `${host}:${addressObject.port}`; + } + + Logger.log(`Listening at http://${address}`); + Logger.log(''); + }); +} + +function logLogo() { + Logger.log(' ________ __ ____ ___'); + Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___'); + Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\'); + Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /'); + Logger.log( + `\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}` + ); + Logger.log(''); +} + +bootstrap(); diff --git a/apps/api/src/middlewares/html-template.middleware.ts b/apps/api/src/middlewares/html-template.middleware.ts new file mode 100644 index 000000000..c958718f6 --- /dev/null +++ b/apps/api/src/middlewares/html-template.middleware.ts @@ -0,0 +1,174 @@ +import { environment } from '@ghostfolio/api/environments/environment'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + DEFAULT_LANGUAGE_CODE, + STORYBOOK_PATH, + SUPPORTED_LANGUAGE_CODES +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; + +import { Injectable, Logger, NestMiddleware } from '@nestjs/common'; +import { format } from 'date-fns'; +import { NextFunction, Request, Response } from 'express'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const title = 'Ghostfolio'; + +const locales = { + '/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt': { + featureGraphicPath: 'assets/images/blog/ghostfolio-x-sackgeld.png', + title: `Ghostfolio auf Sackgeld.com vorgestellt - ${title}` + }, + '/en/blog/2022/08/500-stars-on-github': { + featureGraphicPath: 'assets/images/blog/500-stars-on-github.jpg', + title: `500 Stars - ${title}` + }, + '/en/blog/2022/10/hacktoberfest-2022': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2022.png', + title: `Hacktoberfest 2022 - ${title}` + }, + '/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}` + }, + '/en/blog/2023/02/ghostfolio-meets-umbrel': { + featureGraphicPath: 'assets/images/blog/ghostfolio-x-umbrel.png', + title: `Ghostfolio meets Umbrel - ${title}` + }, + '/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}` + }, + '/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio': { + featureGraphicPath: 'assets/images/blog/20230520.jpg', + title: `Unlock your Financial Potential with Ghostfolio - ${title}` + }, + '/en/blog/2023/07/exploring-the-path-to-fire': { + featureGraphicPath: 'assets/images/blog/20230701.jpg', + title: `Exploring the Path to FIRE - ${title}` + }, + '/en/blog/2023/08/ghostfolio-joins-oss-friends': { + featureGraphicPath: 'assets/images/blog/ghostfolio-joins-oss-friends.png', + title: `Ghostfolio joins OSS Friends - ${title}` + }, + '/en/blog/2023/09/ghostfolio-2': { + featureGraphicPath: 'assets/images/blog/ghostfolio-2.jpg', + title: `Announcing Ghostfolio 2.0 - ${title}` + }, + '/en/blog/2023/09/hacktoberfest-2023': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', + title: `Hacktoberfest 2023 - ${title}` + }, + '/en/blog/2023/11/black-week-2023': { + featureGraphicPath: 'assets/images/blog/black-week-2023.jpg', + title: `Black Week 2023 - ${title}` + }, + '/en/blog/2023/11/hacktoberfest-2023-debriefing': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2023.png', + title: `Hacktoberfest 2023 Debriefing - ${title}` + }, + '/en/blog/2024/09/hacktoberfest-2024': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2024.png', + title: `Hacktoberfest 2024 - ${title}` + }, + '/en/blog/2024/11/black-weeks-2024': { + featureGraphicPath: 'assets/images/blog/black-weeks-2024.jpg', + title: `Black Weeks 2024 - ${title}` + }, + '/en/blog/2025/09/hacktoberfest-2025': { + featureGraphicPath: 'assets/images/blog/hacktoberfest-2025.png', + title: `Hacktoberfest 2025 - ${title}` + }, + '/en/blog/2025/11/black-weeks-2025': { + featureGraphicPath: 'assets/images/blog/black-weeks-2025.jpg', + title: `Black Weeks 2025 - ${title}` + } +}; + +@Injectable() +export class HtmlTemplateMiddleware implements NestMiddleware { + private indexHtmlMap: { [languageCode: string]: string } = {}; + + public constructor(private readonly i18nService: I18nService) { + try { + this.indexHtmlMap = SUPPORTED_LANGUAGE_CODES.reduce( + (map, languageCode) => ({ + ...map, + [languageCode]: readFileSync( + join(__dirname, '..', 'client', languageCode, 'index.html'), + 'utf8' + ) + }), + {} + ); + } catch (error) { + Logger.error( + 'Failed to initialize index HTML map', + error, + 'HTMLTemplateMiddleware' + ); + } + } + + public use(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 || environment.rootUrl; + + if ( + path.startsWith('/api/') || + path.startsWith(STORYBOOK_PATH) || + this.isFileRequest(path) || + !environment.production + ) { + // Skip + next(); + } else { + const indexHtml = interpolate(this.indexHtmlMap[languageCode], { + currentDate, + languageCode, + path, + rootUrl, + description: this.i18nService.getTranslation({ + languageCode, + id: 'metaDescription' + }), + featureGraphicPath: + locales[path]?.featureGraphicPath ?? 'assets/cover.png', + keywords: this.i18nService.getTranslation({ + languageCode, + id: 'metaKeywords' + }), + title: + locales[path]?.title ?? + `${title} – ${this.i18nService.getTranslation({ + languageCode, + id: 'slogan' + })}` + }); + + return response.send(indexHtml); + } + } + + private isFileRequest(filename: string) { + if (filename === '/assets/LICENSE') { + return true; + } else if ( + filename.endsWith('-de.fi') || + filename.endsWith('-markets.sh') || + filename.includes('auth/ey') + ) { + return false; + } + + return filename.split('.').pop() !== filename; + } +} diff --git a/apps/api/src/models/interfaces/evaluation-result.interface.ts b/apps/api/src/models/interfaces/evaluation-result.interface.ts new file mode 100644 index 000000000..df908bcde --- /dev/null +++ b/apps/api/src/models/interfaces/evaluation-result.interface.ts @@ -0,0 +1,4 @@ +export interface EvaluationResult { + evaluation: string; + value: boolean; +} diff --git a/apps/api/src/models/interfaces/rule.interface.ts b/apps/api/src/models/interfaces/rule.interface.ts new file mode 100644 index 000000000..7c794614e --- /dev/null +++ b/apps/api/src/models/interfaces/rule.interface.ts @@ -0,0 +1,9 @@ +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +import { EvaluationResult } from './evaluation-result.interface'; + +export interface RuleInterface { + evaluate(aRuleSettings: T): EvaluationResult; + + getSettings(aUserSettings: UserSettings): T; +} diff --git a/apps/api/src/models/rule.ts b/apps/api/src/models/rule.ts new file mode 100644 index 000000000..9c27e0018 --- /dev/null +++ b/apps/api/src/models/rule.ts @@ -0,0 +1,82 @@ +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; +import { groupBy } from '@ghostfolio/common/helper'; +import { + PortfolioPosition, + PortfolioReportRule, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +import { Big } from 'big.js'; + +import { EvaluationResult } from './interfaces/evaluation-result.interface'; +import { RuleInterface } from './interfaces/rule.interface'; + +export abstract class Rule implements RuleInterface { + private key: string; + private languageCode: string; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + { + key, + languageCode = DEFAULT_LANGUAGE_CODE + }: { + key: string; + languageCode?: string; // TODO: Make mandatory + } + ) { + this.key = key; + this.languageCode = languageCode; + } + + public getKey() { + return this.key; + } + + public getLanguageCode() { + return this.languageCode; + } + + public groupCurrentHoldingsByAttribute( + holdings: PortfolioPosition[], + attribute: keyof PortfolioPosition, + baseCurrency: string + ) { + return Array.from(groupBy(attribute, holdings).entries()).map( + ([attributeValue, objs]) => ({ + groupKey: attributeValue, + investment: objs.reduce( + (previousValue, currentValue) => + previousValue + currentValue.investment, + 0 + ), + value: objs.reduce( + (previousValue, currentValue) => + previousValue + + this.exchangeRateDataService.toCurrency( + new Big(currentValue.quantity) + .mul(currentValue.marketPrice) + .toNumber(), + currentValue.currency, + baseCurrency + ), + 0 + ) + }) + ); + } + + public abstract evaluate(aRuleSettings: T): EvaluationResult; + + public abstract getCategoryName(): string; + + public abstract getConfiguration(): Partial< + PortfolioReportRule['configuration'] + >; + + public abstract getName(): string; + + public abstract getSettings(aUserSettings: UserSettings): T; +} diff --git a/apps/api/src/models/rules/account-cluster-risk/current-investment.ts b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts new file mode 100644 index 000000000..0004d394e --- /dev/null +++ b/apps/api/src/models/rules/account-cluster-risk/current-investment.ts @@ -0,0 +1,144 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + PortfolioDetails, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +import { Account } from '@prisma/client'; + +export class AccountClusterRiskCurrentInvestment extends Rule { + private accounts: PortfolioDetails['accounts']; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + accounts: PortfolioDetails['accounts'] + ) { + super(exchangeRateDataService, { + languageCode, + key: AccountClusterRiskCurrentInvestment.name + }); + + this.accounts = accounts; + } + + public evaluate(ruleSettings: Settings) { + const accounts: { + [symbol: string]: Pick & { + investment: number; + }; + } = {}; + + for (const [accountId, account] of Object.entries(this.accounts)) { + accounts[accountId] = { + investment: account.valueInBaseCurrency, + name: account.name + }; + } + + if (Object.keys(accounts).length === 0) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskCurrentInvestment.false.invalid', + languageCode: this.getLanguageCode() + }), + value: false + }; + } + + let maxAccount: (typeof accounts)[0]; + let totalInvestment = 0; + + for (const account of Object.values(accounts)) { + if (!maxAccount) { + maxAccount = account; + } + + // Calculate total investment + totalInvestment += account.investment; + + // Find maximum + if (account.investment > maxAccount?.investment) { + maxAccount = account; + } + } + + const maxInvestmentRatio = maxAccount?.investment / totalInvestment || 0; + + if (maxInvestmentRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskCurrentInvestment.false', + languageCode: this.getLanguageCode(), + placeholders: { + maxAccountName: maxAccount.name, + maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3), + thresholdMax: ruleSettings.thresholdMax * 100 + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskCurrentInvestment.true', + languageCode: this.getLanguageCode(), + placeholders: { + maxAccountName: maxAccount.name, + maxInvestmentRatio: (maxInvestmentRatio * 100).toPrecision(3), + thresholdMax: ruleSettings.thresholdMax * 100 + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.accountClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskCurrentInvestment', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/account-cluster-risk/single-account.ts b/apps/api/src/models/rules/account-cluster-risk/single-account.ts new file mode 100644 index 000000000..9988ea3cc --- /dev/null +++ b/apps/api/src/models/rules/account-cluster-risk/single-account.ts @@ -0,0 +1,84 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + PortfolioDetails, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +export class AccountClusterRiskSingleAccount extends Rule { + private accounts: PortfolioDetails['accounts']; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + accounts: PortfolioDetails['accounts'] + ) { + super(exchangeRateDataService, { + languageCode, + key: AccountClusterRiskSingleAccount.name + }); + + this.accounts = accounts; + } + + public evaluate() { + const accountIds: string[] = Object.keys(this.accounts); + + if (accountIds.length === 0) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskSingleAccount.false.invalid', + languageCode: this.getLanguageCode() + }), + value: false + }; + } else if (accountIds.length === 1) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskSingleAccount.false', + languageCode: this.getLanguageCode() + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskSingleAccount.true', + languageCode: this.getLanguageCode(), + placeholders: { + accountsLength: accountIds.length + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.accountClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return undefined; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.accountClusterRiskSingleAccount', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ locale, xRayRules }: UserSettings): RuleSettings { + return { + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true + }; + } +} diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts new file mode 100644 index 000000000..f70756e91 --- /dev/null +++ b/apps/api/src/models/rules/asset-class-cluster-risk/equity.ts @@ -0,0 +1,134 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + PortfolioPosition, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +export class AssetClassClusterRiskEquity extends Rule { + private holdings: PortfolioPosition[]; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + holdings: PortfolioPosition[] + ) { + super(exchangeRateDataService, { + languageCode, + key: AssetClassClusterRiskEquity.name + }); + + this.holdings = holdings; + } + + public evaluate(ruleSettings: Settings) { + const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( + this.holdings, + 'assetClass', + ruleSettings.baseCurrency + ); + let totalValue = 0; + + const equityValueInBaseCurrency = + holdingsGroupedByAssetClass.find(({ groupKey }) => { + return groupKey === 'EQUITY'; + })?.value ?? 0; + + for (const { value } of holdingsGroupedByAssetClass) { + totalValue += value; + } + + const equityValueRatio = totalValue + ? equityValueInBaseCurrency / totalValue + : 0; + + if (equityValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskEquity.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + equityValueRatio: (equityValueRatio * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3) + } + }), + value: false + }; + } else if (equityValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskEquity.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + equityValueRatio: (equityValueRatio * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskEquity.true', + languageCode: this.getLanguageCode(), + placeholders: { + equityValueRatio: (equityValueRatio * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskEquity', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.82, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.78 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts new file mode 100644 index 000000000..3bd835e4d --- /dev/null +++ b/apps/api/src/models/rules/asset-class-cluster-risk/fixed-income.ts @@ -0,0 +1,134 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + PortfolioPosition, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +export class AssetClassClusterRiskFixedIncome extends Rule { + private holdings: PortfolioPosition[]; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + holdings: PortfolioPosition[] + ) { + super(exchangeRateDataService, { + languageCode, + key: AssetClassClusterRiskFixedIncome.name + }); + + this.holdings = holdings; + } + + public evaluate(ruleSettings: Settings) { + const holdingsGroupedByAssetClass = this.groupCurrentHoldingsByAttribute( + this.holdings, + 'assetClass', + ruleSettings.baseCurrency + ); + let totalValue = 0; + + const fixedIncomeValueInBaseCurrency = + holdingsGroupedByAssetClass.find(({ groupKey }) => { + return groupKey === 'FIXED_INCOME'; + })?.value ?? 0; + + for (const { value } of holdingsGroupedByAssetClass) { + totalValue += value; + } + + const fixedIncomeValueRatio = totalValue + ? fixedIncomeValueInBaseCurrency / totalValue + : 0; + + if (fixedIncomeValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskFixedIncome.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3) + } + }), + value: false + }; + } else if (fixedIncomeValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskFixedIncome.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskFixedIncome.true', + languageCode: this.getLanguageCode(), + placeholders: { + fixedIncomeValueRatio: (fixedIncomeValueRatio * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.assetClassClusterRiskFixedIncome', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.22, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.18 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts new file mode 100644 index 000000000..d3176582f --- /dev/null +++ b/apps/api/src/models/rules/currency-cluster-risk/base-currency-current-investment.ts @@ -0,0 +1,118 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + PortfolioPosition, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule { + private holdings: PortfolioPosition[]; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + holdings: PortfolioPosition[], + languageCode: string + ) { + super(exchangeRateDataService, { + key: CurrencyClusterRiskBaseCurrencyCurrentInvestment.name, + languageCode + }); + + this.holdings = holdings; + } + + public evaluate(ruleSettings: Settings) { + const holdingsGroupedByCurrency = this.groupCurrentHoldingsByAttribute( + this.holdings, + 'currency', + ruleSettings.baseCurrency + ); + + let maxItem = holdingsGroupedByCurrency[0]; + let totalValue = 0; + + const baseCurrencyValue = + holdingsGroupedByCurrency.find(({ groupKey }) => { + return groupKey === ruleSettings.baseCurrency; + })?.value ?? 0; + + for (const groupItem of holdingsGroupedByCurrency) { + // Calculate total value + totalValue += groupItem.value; + + // Find maximum + if (groupItem.investment > maxItem.investment) { + maxItem = groupItem; + } + } + + const baseCurrencyValueRatio = totalValue + ? baseCurrencyValue / totalValue + : 0; + + if (maxItem?.groupKey !== ruleSettings.baseCurrency) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment.false', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency, + baseCurrencyValueRatio: (baseCurrencyValueRatio * 100).toPrecision( + 3 + ) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment.true', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency, + baseCurrencyValueRatio: (baseCurrencyValueRatio * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.currencyClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return undefined; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.currencyClusterRiskBaseCurrencyCurrentInvestment', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; +} diff --git a/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts new file mode 100644 index 000000000..c73160b52 --- /dev/null +++ b/apps/api/src/models/rules/currency-cluster-risk/current-investment.ts @@ -0,0 +1,121 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { + PortfolioPosition, + RuleSettings, + UserSettings +} from '@ghostfolio/common/interfaces'; + +export class CurrencyClusterRiskCurrentInvestment extends Rule { + private holdings: PortfolioPosition[]; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + holdings: PortfolioPosition[], + languageCode: string + ) { + super(exchangeRateDataService, { + key: CurrencyClusterRiskCurrentInvestment.name, + languageCode + }); + + this.holdings = holdings; + } + + public evaluate(ruleSettings: Settings) { + const holdingsGroupedByCurrency = this.groupCurrentHoldingsByAttribute( + this.holdings, + 'currency', + ruleSettings.baseCurrency + ); + + let maxItem = holdingsGroupedByCurrency[0]; + let totalValue = 0; + + holdingsGroupedByCurrency.forEach((groupItem) => { + // Calculate total value + totalValue += groupItem.value; + + // Find maximum + if (groupItem.value > maxItem.value) { + maxItem = groupItem; + } + }); + + const maxValueRatio = maxItem?.value / totalValue || 0; + + if (maxValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.currencyClusterRiskCurrentInvestment.false', + languageCode: this.getLanguageCode(), + placeholders: { + currency: maxItem.groupKey as string, + maxValueRatio: (maxValueRatio * 100).toPrecision(3), + thresholdMax: ruleSettings.thresholdMax * 100 + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.currencyClusterRiskCurrentInvestment.true', + languageCode: this.getLanguageCode(), + placeholders: { + currency: maxItem.groupKey as string, + maxValueRatio: (maxValueRatio * 100).toPrecision(3), + thresholdMax: ruleSettings.thresholdMax * 100 + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.currencyClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.currencyClusterRiskCurrentInvestment', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.5 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts b/apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts new file mode 100644 index 000000000..df9b78eef --- /dev/null +++ b/apps/api/src/models/rules/economic-market-cluster-risk/developed-markets.ts @@ -0,0 +1,125 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +export class EconomicMarketClusterRiskDevelopedMarkets extends Rule { + private currentValueInBaseCurrency: number; + private developedMarketsValueInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + currentValueInBaseCurrency: number, + developedMarketsValueInBaseCurrency: number, + languageCode: string + ) { + super(exchangeRateDataService, { + languageCode, + key: EconomicMarketClusterRiskDevelopedMarkets.name + }); + + this.currentValueInBaseCurrency = currentValueInBaseCurrency; + this.developedMarketsValueInBaseCurrency = + developedMarketsValueInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const developedMarketsValueRatio = this.currentValueInBaseCurrency + ? this.developedMarketsValueInBaseCurrency / + this.currentValueInBaseCurrency + : 0; + + if (developedMarketsValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskDevelopedMarkets.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + developedMarketsValueRatio: ( + developedMarketsValueRatio * 100 + ).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3) + } + }), + value: false + }; + } else if (developedMarketsValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskDevelopedMarkets.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + developedMarketsValueRatio: ( + developedMarketsValueRatio * 100 + ).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskDevelopedMarkets.true', + languageCode: this.getLanguageCode(), + placeholders: { + developedMarketsValueRatio: ( + developedMarketsValueRatio * 100 + ).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskDevelopedMarkets', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.72, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.68 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts b/apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts new file mode 100644 index 000000000..4583dc50a --- /dev/null +++ b/apps/api/src/models/rules/economic-market-cluster-risk/emerging-markets.ts @@ -0,0 +1,125 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +export class EconomicMarketClusterRiskEmergingMarkets extends Rule { + private currentValueInBaseCurrency: number; + private emergingMarketsValueInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + currentValueInBaseCurrency: number, + emergingMarketsValueInBaseCurrency: number, + languageCode: string + ) { + super(exchangeRateDataService, { + languageCode, + key: EconomicMarketClusterRiskEmergingMarkets.name + }); + + this.currentValueInBaseCurrency = currentValueInBaseCurrency; + this.emergingMarketsValueInBaseCurrency = + emergingMarketsValueInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const emergingMarketsValueRatio = this.currentValueInBaseCurrency + ? this.emergingMarketsValueInBaseCurrency / + this.currentValueInBaseCurrency + : 0; + + if (emergingMarketsValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskEmergingMarkets.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + emergingMarketsValueRatio: ( + emergingMarketsValueRatio * 100 + ).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3) + } + }), + value: false + }; + } else if (emergingMarketsValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskEmergingMarkets.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + emergingMarketsValueRatio: ( + emergingMarketsValueRatio * 100 + ).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskEmergingMarkets.true', + languageCode: this.getLanguageCode(), + placeholders: { + emergingMarketsValueRatio: ( + emergingMarketsValueRatio * 100 + ).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRisk.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.economicMarketClusterRiskEmergingMarkets', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.32, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.28 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts new file mode 100644 index 000000000..b956263f8 --- /dev/null +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-setup.ts @@ -0,0 +1,76 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +export class EmergencyFundSetup extends Rule { + private emergencyFund: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + emergencyFund: number + ) { + super(exchangeRateDataService, { + languageCode, + key: EmergencyFundSetup.name + }); + + this.emergencyFund = emergencyFund; + } + + public evaluate() { + if (!this.emergencyFund) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.emergencyFundSetup.false', + languageCode: this.getLanguageCode() + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.emergencyFundSetup.true', + languageCode: this.getLanguageCode() + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.emergencyFund.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return undefined; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.emergencyFundSetup', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; +} diff --git a/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts new file mode 100644 index 000000000..54c2decc9 --- /dev/null +++ b/apps/api/src/models/rules/fees/fee-ratio-initial-investment.ts @@ -0,0 +1,104 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +/** + * @deprecated This rule is deprecated in favor of FeeRatioTotalInvestmentVolume + */ +export class FeeRatioInitialInvestment extends Rule { + private fees: number; + private totalInvestment: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + totalInvestment: number, + fees: number + ) { + super(exchangeRateDataService, { + languageCode, + key: FeeRatioInitialInvestment.name + }); + + this.fees = fees; + this.totalInvestment = totalInvestment; + } + + public evaluate(ruleSettings: Settings) { + const feeRatio = this.totalInvestment + ? this.fees / this.totalInvestment + : 0; + + if (feeRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.feeRatioInitialInvestment.false', + languageCode: this.getLanguageCode(), + placeholders: { + feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2), + thresholdMax: (feeRatio * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.feeRatioInitialInvestment.true', + languageCode: this.getLanguageCode(), + placeholders: { + feeRatio: (feeRatio * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toFixed(2) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.fees.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 0.1, + min: 0, + step: 0.0025, + unit: '%' + }, + thresholdMax: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.feeRatioInitialInvestment', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts b/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts new file mode 100644 index 000000000..07bf5fa2c --- /dev/null +++ b/apps/api/src/models/rules/fees/fee-ratio-total-investment-volume.ts @@ -0,0 +1,102 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +export class FeeRatioTotalInvestmentVolume extends Rule { + private fees: number; + private totalInvestmentVolumeInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + totalInvestmentVolumeInBaseCurrency: number, + fees: number + ) { + super(exchangeRateDataService, { + languageCode, + key: FeeRatioTotalInvestmentVolume.name + }); + + this.fees = fees; + this.totalInvestmentVolumeInBaseCurrency = + totalInvestmentVolumeInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const feeRatio = this.totalInvestmentVolumeInBaseCurrency + ? this.fees / this.totalInvestmentVolumeInBaseCurrency + : 0; + + if (feeRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.feeRatioTotalInvestmentVolume.false', + languageCode: this.getLanguageCode(), + placeholders: { + feeRatio: (ruleSettings.thresholdMax * 100).toFixed(2), + thresholdMax: (feeRatio * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.feeRatioTotalInvestmentVolume.true', + languageCode: this.getLanguageCode(), + placeholders: { + feeRatio: (feeRatio * 100).toPrecision(3), + thresholdMax: (ruleSettings.thresholdMax * 100).toFixed(2) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.fees.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 0.1, + min: 0, + step: 0.0025, + unit: '%' + }, + thresholdMax: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.feeRatioTotalInvestmentVolume', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.01 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/liquidity/buying-power.ts b/apps/api/src/models/rules/liquidity/buying-power.ts new file mode 100644 index 000000000..541750d7e --- /dev/null +++ b/apps/api/src/models/rules/liquidity/buying-power.ts @@ -0,0 +1,109 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { RuleSettings, UserSettings } from '@ghostfolio/common/interfaces'; + +export class BuyingPower extends Rule { + private buyingPower: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + buyingPower: number, + languageCode: string + ) { + super(exchangeRateDataService, { + languageCode, + key: BuyingPower.name + }); + + this.buyingPower = buyingPower; + } + + public evaluate(ruleSettings: Settings) { + if (this.buyingPower === 0) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.liquidityBuyingPower.false.zero', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency + } + }), + value: false + }; + } else if (this.buyingPower < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.liquidityBuyingPower.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency, + thresholdMin: ruleSettings.thresholdMin.toLocaleString( + ruleSettings.locale + ) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.liquidityBuyingPower.true', + languageCode: this.getLanguageCode(), + placeholders: { + baseCurrency: ruleSettings.baseCurrency, + thresholdMin: ruleSettings.thresholdMin.toLocaleString( + ruleSettings.locale + ) + } + }), + value: true + }; + } + + public getCategoryName() { + return this.i18nService.getTranslation({ + id: 'rule.liquidity.category', + languageCode: this.getLanguageCode() + }); + } + + public getConfiguration() { + return { + threshold: { + max: 200000, + min: 0, + step: 1000, + unit: '' + }, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.liquidityBuyingPower', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0 + }; + } +} + +interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; +} diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts b/apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts new file mode 100644 index 000000000..1242df759 --- /dev/null +++ b/apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts @@ -0,0 +1,110 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +import { Settings } from './interfaces/rule-settings.interface'; + +export class RegionalMarketClusterRiskAsiaPacific extends Rule { + private asiaPacificValueInBaseCurrency: number; + private currentValueInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + currentValueInBaseCurrency: number, + asiaPacificValueInBaseCurrency: number + ) { + super(exchangeRateDataService, { + languageCode, + key: RegionalMarketClusterRiskAsiaPacific.name + }); + + this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency; + this.currentValueInBaseCurrency = currentValueInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const asiaPacificMarketValueRatio = this.currentValueInBaseCurrency + ? this.asiaPacificValueInBaseCurrency / this.currentValueInBaseCurrency + : 0; + + if (asiaPacificMarketValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskAsiaPacific.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + valueRatio: (asiaPacificMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } else if (asiaPacificMarketValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskAsiaPacific.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (asiaPacificMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskAsiaPacific.true', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (asiaPacificMarketValueRatio * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskAsiaPacific', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.03, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.02 + }; + } +} diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts b/apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts new file mode 100644 index 000000000..8486d843b --- /dev/null +++ b/apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts @@ -0,0 +1,112 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +import { Settings } from './interfaces/rule-settings.interface'; + +export class RegionalMarketClusterRiskEmergingMarkets extends Rule { + private currentValueInBaseCurrency: number; + private emergingMarketsValueInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + currentValueInBaseCurrency: number, + emergingMarketsValueInBaseCurrency: number + ) { + super(exchangeRateDataService, { + languageCode, + key: RegionalMarketClusterRiskEmergingMarkets.name + }); + + this.currentValueInBaseCurrency = currentValueInBaseCurrency; + this.emergingMarketsValueInBaseCurrency = + emergingMarketsValueInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const emergingMarketsValueRatio = this.currentValueInBaseCurrency + ? this.emergingMarketsValueInBaseCurrency / + this.currentValueInBaseCurrency + : 0; + + if (emergingMarketsValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEmergingMarkets.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + valueRatio: (emergingMarketsValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } else if (emergingMarketsValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEmergingMarkets.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (emergingMarketsValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEmergingMarkets.true', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (emergingMarketsValueRatio * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEmergingMarkets', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.12, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.08 + }; + } +} diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/europe.ts b/apps/api/src/models/rules/regional-market-cluster-risk/europe.ts new file mode 100644 index 000000000..459848db4 --- /dev/null +++ b/apps/api/src/models/rules/regional-market-cluster-risk/europe.ts @@ -0,0 +1,110 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +import { Settings } from './interfaces/rule-settings.interface'; + +export class RegionalMarketClusterRiskEurope extends Rule { + private currentValueInBaseCurrency: number; + private europeValueInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + currentValueInBaseCurrency: number, + europeValueInBaseCurrency: number + ) { + super(exchangeRateDataService, { + languageCode, + key: RegionalMarketClusterRiskEurope.name + }); + + this.currentValueInBaseCurrency = currentValueInBaseCurrency; + this.europeValueInBaseCurrency = europeValueInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const europeMarketValueRatio = this.currentValueInBaseCurrency + ? this.europeValueInBaseCurrency / this.currentValueInBaseCurrency + : 0; + + if (europeMarketValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEurope.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + valueRatio: (europeMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } else if (europeMarketValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEurope.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (europeMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEurope.true', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (europeMarketValueRatio * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskEurope', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.15, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.11 + }; + } +} diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/interfaces/rule-settings.interface.ts b/apps/api/src/models/rules/regional-market-cluster-risk/interfaces/rule-settings.interface.ts new file mode 100644 index 000000000..621b4df0b --- /dev/null +++ b/apps/api/src/models/rules/regional-market-cluster-risk/interfaces/rule-settings.interface.ts @@ -0,0 +1,7 @@ +import { RuleSettings } from '@ghostfolio/common/interfaces'; + +export interface Settings extends RuleSettings { + baseCurrency: string; + thresholdMin: number; + thresholdMax: number; +} diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/japan.ts b/apps/api/src/models/rules/regional-market-cluster-risk/japan.ts new file mode 100644 index 000000000..d9c1cff6b --- /dev/null +++ b/apps/api/src/models/rules/regional-market-cluster-risk/japan.ts @@ -0,0 +1,110 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +import { Settings } from './interfaces/rule-settings.interface'; + +export class RegionalMarketClusterRiskJapan extends Rule { + private currentValueInBaseCurrency: number; + private japanValueInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + currentValueInBaseCurrency: number, + japanValueInBaseCurrency: number + ) { + super(exchangeRateDataService, { + languageCode, + key: RegionalMarketClusterRiskJapan.name + }); + + this.currentValueInBaseCurrency = currentValueInBaseCurrency; + this.japanValueInBaseCurrency = japanValueInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const japanMarketValueRatio = this.currentValueInBaseCurrency + ? this.japanValueInBaseCurrency / this.currentValueInBaseCurrency + : 0; + + if (japanMarketValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskJapan.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + valueRatio: (japanMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } else if (japanMarketValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskJapan.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (japanMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskJapan.true', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (japanMarketValueRatio * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskJapan', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.06, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.04 + }; + } +} diff --git a/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts b/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts new file mode 100644 index 000000000..6180a2cc5 --- /dev/null +++ b/apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts @@ -0,0 +1,110 @@ +import { Rule } from '@ghostfolio/api/models/rule'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; +import { UserSettings } from '@ghostfolio/common/interfaces'; + +import { Settings } from './interfaces/rule-settings.interface'; + +export class RegionalMarketClusterRiskNorthAmerica extends Rule { + private currentValueInBaseCurrency: number; + private northAmericaValueInBaseCurrency: number; + + public constructor( + protected exchangeRateDataService: ExchangeRateDataService, + private i18nService: I18nService, + languageCode: string, + currentValueInBaseCurrency: number, + northAmericaValueInBaseCurrency: number + ) { + super(exchangeRateDataService, { + languageCode, + key: RegionalMarketClusterRiskNorthAmerica.name + }); + + this.currentValueInBaseCurrency = currentValueInBaseCurrency; + this.northAmericaValueInBaseCurrency = northAmericaValueInBaseCurrency; + } + + public evaluate(ruleSettings: Settings) { + const northAmericaMarketValueRatio = this.currentValueInBaseCurrency + ? this.northAmericaValueInBaseCurrency / this.currentValueInBaseCurrency + : 0; + + if (northAmericaMarketValueRatio > ruleSettings.thresholdMax) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskNorthAmerica.false.max', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + valueRatio: (northAmericaMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } else if (northAmericaMarketValueRatio < ruleSettings.thresholdMin) { + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskNorthAmerica.false.min', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (northAmericaMarketValueRatio * 100).toPrecision(3) + } + }), + value: false + }; + } + + return { + evaluation: this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskNorthAmerica.true', + languageCode: this.getLanguageCode(), + placeholders: { + thresholdMax: (ruleSettings.thresholdMax * 100).toPrecision(3), + thresholdMin: (ruleSettings.thresholdMin * 100).toPrecision(3), + valueRatio: (northAmericaMarketValueRatio * 100).toPrecision(3) + } + }), + value: true + }; + } + + public getCategoryName() { + return 'Regional Market Cluster Risk'; // TODO: Replace hardcoded text with i18n translation + } + + public getConfiguration() { + return { + threshold: { + max: 1, + min: 0, + step: 0.01, + unit: '%' + }, + thresholdMax: true, + thresholdMin: true + }; + } + + public getName() { + return this.i18nService.getTranslation({ + id: 'rule.regionalMarketClusterRiskNorthAmerica', + languageCode: this.getLanguageCode() + }); + } + + public getSettings({ + baseCurrency, + locale, + xRayRules + }: UserSettings): Settings { + return { + baseCurrency, + locale, + isActive: xRayRules?.[this.getKey()]?.isActive ?? true, + thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.69, + thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.65 + }; + } +} diff --git a/apps/api/src/services/api-key/api-key.module.ts b/apps/api/src/services/api-key/api-key.module.ts new file mode 100644 index 000000000..8681e3ad7 --- /dev/null +++ b/apps/api/src/services/api-key/api-key.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { ApiKeyService } from './api-key.service'; + +@Module({ + exports: [ApiKeyService], + imports: [PrismaModule], + providers: [ApiKeyService] +}) +export class ApiKeyModule {} diff --git a/apps/api/src/services/api-key/api-key.service.ts b/apps/api/src/services/api-key/api-key.service.ts new file mode 100644 index 000000000..b911191dc --- /dev/null +++ b/apps/api/src/services/api-key/api-key.service.ts @@ -0,0 +1,63 @@ +import { getRandomString } from '@ghostfolio/api/helper/string.helper'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { ApiKeyResponse } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { pbkdf2Sync } from 'node:crypto'; + +@Injectable() +export class ApiKeyService { + private readonly algorithm = 'sha256'; + private readonly iterations = 100000; + private readonly keyLength = 64; + + public constructor(private readonly prismaService: PrismaService) {} + + public async create({ userId }: { userId: string }): Promise { + const apiKey = this.generateApiKey(); + const hashedKey = this.hashApiKey(apiKey); + + await this.prismaService.apiKey.deleteMany({ where: { userId } }); + + await this.prismaService.apiKey.create({ + data: { + hashedKey, + userId + } + }); + + return { apiKey }; + } + + public async getUserByApiKey(apiKey: string) { + const hashedKey = this.hashApiKey(apiKey); + + const { user } = await this.prismaService.apiKey.findUnique({ + include: { user: true }, + where: { hashedKey } + }); + + return user; + } + + public hashApiKey(apiKey: string): string { + return pbkdf2Sync( + apiKey, + '', + this.iterations, + this.keyLength, + this.algorithm + ).toString('hex'); + } + + private generateApiKey(): string { + return getRandomString(32) + .split('') + .reduce((acc, char, index) => { + const chunkIndex = Math.floor(index / 4); + acc[chunkIndex] = (acc[chunkIndex] || '') + char; + return acc; + }, []) + .join('-'); + } +} diff --git a/apps/api/src/services/api/api.module.ts b/apps/api/src/services/api/api.module.ts new file mode 100644 index 000000000..5e8a34971 --- /dev/null +++ b/apps/api/src/services/api/api.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { ApiService } from './api.service'; + +@Module({ + exports: [ApiService], + providers: [ApiService] +}) +export class ApiModule {} diff --git a/apps/api/src/services/api/api.service.ts b/apps/api/src/services/api/api.service.ts new file mode 100644 index 000000000..052119246 --- /dev/null +++ b/apps/api/src/services/api/api.service.ts @@ -0,0 +1,92 @@ +import { Filter } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class ApiService { + public buildFiltersFromQueryParams({ + filterByAccounts, + filterByAssetClasses, + filterByAssetSubClasses, + filterByDataSource, + filterByHoldingType, + filterBySearchQuery, + filterBySymbol, + filterByTags + }: { + filterByAccounts?: string; + filterByAssetClasses?: string; + filterByAssetSubClasses?: string; + filterByDataSource?: string; + filterByHoldingType?: string; + filterBySearchQuery?: string; + filterBySymbol?: string; + filterByTags?: string; + }): Filter[] { + const accountIds = filterByAccounts?.split(',') ?? []; + const assetClasses = filterByAssetClasses?.split(',') ?? []; + const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; + const dataSource = filterByDataSource; + const holdingType = filterByHoldingType; + const searchQuery = filterBySearchQuery?.toLowerCase(); + const symbol = filterBySymbol; + const tagIds = filterByTags?.split(',') ?? []; + + const filters = [ + ...accountIds.map((accountId) => { + return { + id: accountId, + type: 'ACCOUNT' + } as Filter; + }), + ...assetClasses.map((assetClass) => { + return { + id: assetClass, + type: 'ASSET_CLASS' + } as Filter; + }), + ...assetSubClasses.map((assetClass) => { + return { + id: assetClass, + type: 'ASSET_SUB_CLASS' + } as Filter; + }), + ...tagIds.map((tagId) => { + return { + id: tagId, + type: 'TAG' + } as Filter; + }) + ]; + + if (dataSource) { + filters.push({ + id: dataSource, + type: 'DATA_SOURCE' + }); + } + + if (holdingType) { + filters.push({ + id: holdingType, + type: 'HOLDING_TYPE' + }); + } + + if (searchQuery) { + filters.push({ + id: searchQuery, + type: 'SEARCH_QUERY' + }); + } + + if (symbol) { + filters.push({ + id: symbol, + type: 'SYMBOL' + }); + } + + return filters; + } +} diff --git a/apps/api/src/services/benchmark/benchmark.module.ts b/apps/api/src/services/benchmark/benchmark.module.ts new file mode 100644 index 000000000..870ef244f --- /dev/null +++ b/apps/api/src/services/benchmark/benchmark.module.ts @@ -0,0 +1,24 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { BenchmarkService } from './benchmark.service'; + +@Module({ + exports: [BenchmarkService], + imports: [ + DataProviderModule, + MarketDataModule, + PrismaModule, + PropertyModule, + RedisCacheModule, + SymbolProfileModule + ], + providers: [BenchmarkService] +}) +export class BenchmarkModule {} diff --git a/apps/api/src/services/benchmark/benchmark.service.spec.ts b/apps/api/src/services/benchmark/benchmark.service.spec.ts new file mode 100644 index 000000000..833dbcdfc --- /dev/null +++ b/apps/api/src/services/benchmark/benchmark.service.spec.ts @@ -0,0 +1,15 @@ +import { BenchmarkService } from './benchmark.service'; + +describe('BenchmarkService', () => { + let benchmarkService: BenchmarkService; + + beforeAll(async () => { + benchmarkService = new BenchmarkService(null, null, null, null, null, null); + }); + + it('calculateChangeInPercentage', async () => { + expect(benchmarkService.calculateChangeInPercentage(1, 2)).toEqual(1); + expect(benchmarkService.calculateChangeInPercentage(2, 2)).toEqual(0); + expect(benchmarkService.calculateChangeInPercentage(2, 1)).toEqual(-0.5); + }); +}); diff --git a/apps/api/src/services/benchmark/benchmark.service.ts b/apps/api/src/services/benchmark/benchmark.service.ts new file mode 100644 index 000000000..4b1d9a65f --- /dev/null +++ b/apps/api/src/services/benchmark/benchmark.service.ts @@ -0,0 +1,317 @@ +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + CACHE_TTL_INFINITE, + PROPERTY_BENCHMARKS +} from '@ghostfolio/common/config'; +import { calculateBenchmarkTrend } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + Benchmark, + BenchmarkProperty, + BenchmarkResponse +} from '@ghostfolio/common/interfaces'; +import { BenchmarkTrend } from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; +import { SymbolProfile } from '@prisma/client'; +import { Big } from 'big.js'; +import { addHours, isAfter, subDays } from 'date-fns'; +import { uniqBy } from 'lodash'; +import ms from 'ms'; + +import { BenchmarkValue } from './interfaces/benchmark-value.interface'; + +@Injectable() +export class BenchmarkService { + private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; + + public constructor( + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, + private readonly redisCacheService: RedisCacheService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public calculateChangeInPercentage(baseValue: number, currentValue: number) { + if (baseValue && currentValue) { + return new Big(currentValue).div(baseValue).minus(1).toNumber(); + } + + return 0; + } + + public async getBenchmarkTrends({ + dataSource, + symbol + }: AssetProfileIdentifier) { + const historicalData = await this.marketDataService.marketDataItems({ + orderBy: { + date: 'desc' + }, + where: { + dataSource, + symbol, + date: { gte: subDays(new Date(), 400) } + } + }); + + const fiftyDayAverage = calculateBenchmarkTrend({ + historicalData, + days: 50 + }); + const twoHundredDayAverage = calculateBenchmarkTrend({ + historicalData, + days: 200 + }); + + return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage }; + } + + public async getBenchmarks({ + enableSharing = false, + useCache = true + } = {}): Promise { + if (useCache) { + try { + const cachedBenchmarkValue = await this.redisCacheService.get( + this.CACHE_KEY_BENCHMARKS + ); + + const { benchmarks, expiration }: BenchmarkValue = + JSON.parse(cachedBenchmarkValue); + + Logger.debug('Fetched benchmarks from cache', 'BenchmarkService'); + + if (isAfter(new Date(), new Date(expiration))) { + this.calculateAndCacheBenchmarks({ + enableSharing + }); + } + + return benchmarks; + } catch {} + } + + return this.calculateAndCacheBenchmarks({ enableSharing }); + } + + public async getBenchmarkAssetProfiles({ + enableSharing = false + } = {}): Promise[]> { + const symbolProfileIds: string[] = ( + (await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) ?? [] + ) + .filter((benchmark) => { + if (enableSharing) { + return benchmark.enableSharing; + } + + return true; + }) + .map(({ symbolProfileId }) => { + return symbolProfileId; + }); + + const assetProfiles = + await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds); + + return assetProfiles + .map(({ dataSource, id, name, symbol }) => { + return { + dataSource, + id, + name, + symbol + }; + }) + .sort((a, b) => { + return a.name?.localeCompare(b?.name) ?? 0; + }); + } + + public async addBenchmark({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise> { + const assetProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + dataSource, + symbol + } + }); + + if (!assetProfile) { + return; + } + + let benchmarks = + (await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) ?? []; + + benchmarks.push({ symbolProfileId: assetProfile.id }); + + benchmarks = uniqBy(benchmarks, 'symbolProfileId'); + + await this.propertyService.put({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify(benchmarks) + }); + + return { + dataSource, + symbol, + id: assetProfile.id, + name: assetProfile.name + }; + } + + public async deleteBenchmark({ + dataSource, + symbol + }: AssetProfileIdentifier): Promise> { + const assetProfile = await this.prismaService.symbolProfile.findFirst({ + where: { + dataSource, + symbol + } + }); + + if (!assetProfile) { + return null; + } + + let benchmarks = + (await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) ?? []; + + benchmarks = benchmarks.filter(({ symbolProfileId }) => { + return symbolProfileId !== assetProfile.id; + }); + + await this.propertyService.put({ + key: PROPERTY_BENCHMARKS, + value: JSON.stringify(benchmarks) + }); + + return { + dataSource, + symbol, + id: assetProfile.id, + name: assetProfile.name + }; + } + + public getMarketCondition( + aPerformanceInPercent: number + ): Benchmark['marketCondition'] { + if (aPerformanceInPercent >= 0) { + return 'ALL_TIME_HIGH'; + } else if (aPerformanceInPercent <= -0.2) { + return 'BEAR_MARKET'; + } else { + return 'NEUTRAL_MARKET'; + } + } + + private async calculateAndCacheBenchmarks({ + enableSharing = false + }): Promise { + Logger.debug('Calculate benchmarks', 'BenchmarkService'); + + const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({ + enableSharing + }); + + const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] = + []; + const promisesBenchmarkTrends: Promise<{ + trend50d: BenchmarkTrend; + trend200d: BenchmarkTrend; + }>[] = []; + + const quotes = await this.dataProviderService.getQuotes({ + items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + requestTimeout: ms('30 seconds'), + useCache: false + }); + + for (const { dataSource, symbol } of benchmarkAssetProfiles) { + promisesAllTimeHighs.push( + this.marketDataService.getMax({ dataSource, symbol }) + ); + promisesBenchmarkTrends.push( + this.getBenchmarkTrends({ dataSource, symbol }) + ); + } + + const [allTimeHighs, benchmarkTrends] = await Promise.all([ + Promise.all(promisesAllTimeHighs), + Promise.all(promisesBenchmarkTrends) + ]); + let storeInCache = true; + + const benchmarks = allTimeHighs.map((allTimeHigh, index) => { + const { marketPrice } = + quotes[benchmarkAssetProfiles[index].symbol] ?? {}; + + let performancePercentFromAllTimeHigh = 0; + + if (allTimeHigh?.marketPrice && marketPrice) { + performancePercentFromAllTimeHigh = this.calculateChangeInPercentage( + allTimeHigh.marketPrice, + marketPrice + ); + } else { + storeInCache = false; + } + + return { + dataSource: benchmarkAssetProfiles[index].dataSource, + marketCondition: this.getMarketCondition( + performancePercentFromAllTimeHigh + ), + name: benchmarkAssetProfiles[index].name, + performances: { + allTimeHigh: { + date: allTimeHigh?.date, + performancePercent: + performancePercentFromAllTimeHigh >= 0 + ? 0 + : performancePercentFromAllTimeHigh + } + }, + symbol: benchmarkAssetProfiles[index].symbol, + trend50d: benchmarkTrends[index].trend50d, + trend200d: benchmarkTrends[index].trend200d + }; + }); + + if (!enableSharing && storeInCache) { + const expiration = addHours(new Date(), 2); + + await this.redisCacheService.set( + this.CACHE_KEY_BENCHMARKS, + JSON.stringify({ + benchmarks, + expiration: expiration.getTime() + } as BenchmarkValue), + CACHE_TTL_INFINITE + ); + } + + return benchmarks; + } +} diff --git a/apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts b/apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts new file mode 100644 index 000000000..eda302f90 --- /dev/null +++ b/apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts @@ -0,0 +1,6 @@ +import { BenchmarkResponse } from '@ghostfolio/common/interfaces'; + +export interface BenchmarkValue { + benchmarks: BenchmarkResponse['benchmarks']; + expiration: number; +} diff --git a/apps/api/src/services/configuration/configuration.module.ts b/apps/api/src/services/configuration/configuration.module.ts new file mode 100644 index 000000000..2d3650a41 --- /dev/null +++ b/apps/api/src/services/configuration/configuration.module.ts @@ -0,0 +1,9 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Module } from '@nestjs/common'; + +@Module({ + providers: [ConfigurationService], + exports: [ConfigurationService] +}) +export class ConfigurationModule {} diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts new file mode 100644 index 000000000..5f9d1055d --- /dev/null +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -0,0 +1,116 @@ +import { environment } from '@ghostfolio/api/environments/environment'; +import { Environment } from '@ghostfolio/api/services/interfaces/environment.interface'; +import { + CACHE_TTL_NO_CACHE, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, + DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, + DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY, + DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT +} from '@ghostfolio/common/config'; + +import { Injectable } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; +import ms from 'ms'; + +@Injectable() +export class ConfigurationService { + private readonly environmentConfiguration: Environment; + + public constructor() { + this.environmentConfiguration = cleanEnv(process.env, { + ACCESS_TOKEN_SALT: str(), + API_KEY_ALPHA_VANTAGE: str({ default: '' }), + API_KEY_BETTER_UPTIME: str({ default: '' }), + API_KEY_COINGECKO_DEMO: str({ default: '' }), + API_KEY_COINGECKO_PRO: str({ default: '' }), + API_KEY_EOD_HISTORICAL_DATA: str({ default: '' }), + API_KEY_FINANCIAL_MODELING_PREP: str({ default: '' }), + API_KEY_OPEN_FIGI: str({ default: '' }), + API_KEY_RAPID_API: str({ default: '' }), + CACHE_QUOTES_TTL: num({ default: ms('1 minute') }), + CACHE_TTL: num({ default: CACHE_TTL_NO_CACHE }), + DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }), + DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }), + DATA_SOURCES: json({ + default: [DataSource.COINGECKO, DataSource.MANUAL, DataSource.YAHOO] + }), + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: json({ + default: [] + }), + ENABLE_FEATURE_AUTH_GOOGLE: bool({ default: false }), + ENABLE_FEATURE_AUTH_OIDC: bool({ default: false }), + ENABLE_FEATURE_AUTH_TOKEN: bool({ default: true }), + ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), + ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: bool({ default: true }), + ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), + ENABLE_FEATURE_STATISTICS: bool({ default: false }), + ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), + ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), + GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), + GOOGLE_SECRET: str({ default: 'dummySecret' }), + GOOGLE_SHEETS_ACCOUNT: str({ default: '' }), + GOOGLE_SHEETS_ID: str({ default: '' }), + GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }), + HOST: host({ default: DEFAULT_HOST }), + JWT_SECRET_KEY: str(), + MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), + MAX_CHART_ITEMS: num({ default: 365 }), + OIDC_AUTHORIZATION_URL: str({ default: '' }), + OIDC_CALLBACK_URL: str({ default: '' }), + OIDC_CLIENT_ID: str({ + default: undefined, + requiredWhen: (env) => { + return env.ENABLE_FEATURE_AUTH_OIDC === true; + } + }), + OIDC_CLIENT_SECRET: str({ + default: undefined, + requiredWhen: (env) => { + return env.ENABLE_FEATURE_AUTH_OIDC === true; + } + }), + OIDC_ISSUER: str({ + default: undefined, + requiredWhen: (env) => { + return env.ENABLE_FEATURE_AUTH_OIDC === true; + } + }), + OIDC_SCOPE: json({ default: ['openid'] }), + OIDC_TOKEN_URL: str({ default: '' }), + OIDC_USER_INFO_URL: str({ default: '' }), + PORT: port({ default: DEFAULT_PORT }), + PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: num({ + default: DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY + }), + PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: num({ + default: DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY + }), + PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: num({ + default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY + }), + PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: num({ + default: DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT + }), + REDIS_DB: num({ default: 0 }), + REDIS_HOST: str({ default: 'localhost' }), + REDIS_PASSWORD: str({ default: '' }), + REDIS_PORT: port({ default: 6379 }), + REQUEST_TIMEOUT: num({ default: ms('3 seconds') }), + ROOT_URL: url({ + default: environment.rootUrl + }), + STRIPE_SECRET_KEY: str({ default: '' }), + TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), + TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), + TWITTER_API_KEY: str({ default: 'dummyApiKey' }), + TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }) + }); + } + + public get(key: K): Environment[K] { + return this.environmentConfiguration[key]; + } +} diff --git a/apps/api/src/services/cron/cron.module.ts b/apps/api/src/services/cron/cron.module.ts new file mode 100644 index 000000000..06f9d2caa --- /dev/null +++ b/apps/api/src/services/cron/cron.module.ts @@ -0,0 +1,23 @@ +import { UserModule } from '@ghostfolio/api/app/user/user.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringModule } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.module'; +import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module'; + +import { Module } from '@nestjs/common'; + +import { CronService } from './cron.service'; + +@Module({ + imports: [ + ConfigurationModule, + DataGatheringModule, + ExchangeRateDataModule, + PropertyModule, + TwitterBotModule, + UserModule + ], + providers: [CronService] +}) +export class CronModule {} diff --git a/apps/api/src/services/cron/cron.service.ts b/apps/api/src/services/cron/cron.service.ts new file mode 100644 index 000000000..ee91a811e --- /dev/null +++ b/apps/api/src/services/cron/cron.service.ts @@ -0,0 +1,92 @@ +import { UserService } from '@ghostfolio/api/app/user/user.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; +import { + DATA_GATHERING_QUEUE_PRIORITY_LOW, + GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, + PROPERTY_IS_DATA_GATHERING_ENABLED +} from '@ghostfolio/common/config'; +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; + +import { Injectable } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +@Injectable() +export class CronService { + private static readonly EVERY_HOUR_AT_RANDOM_MINUTE = `${new Date().getMinutes()} * * * *`; + private static readonly EVERY_SUNDAY_AT_LUNCH_TIME = '0 12 * * 0'; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly dataGatheringService: DataGatheringService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly propertyService: PropertyService, + private readonly twitterBotService: TwitterBotService, + private readonly userService: UserService + ) {} + + @Cron(CronService.EVERY_HOUR_AT_RANDOM_MINUTE) + public async runEveryHourAtRandomMinute() { + if (await this.isDataGatheringEnabled()) { + await this.dataGatheringService.gather7Days(); + } + } + + @Cron(CronExpression.EVERY_12_HOURS) + public async runEveryTwelveHours() { + await this.exchangeRateDataService.loadCurrencies(); + } + + @Cron(CronExpression.EVERY_DAY_AT_5PM) + public async runEveryDayAtFivePm() { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + this.twitterBotService.tweetFearAndGreedIndex(); + } + } + + @Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT) + public async runEveryDayAtMidnight() { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + this.userService.resetAnalytics(); + } + } + + @Cron(CronService.EVERY_SUNDAY_AT_LUNCH_TIME) + public async runEverySundayAtTwelvePm() { + if (await this.isDataGatheringEnabled()) { + const assetProfileIdentifiers = + await this.dataGatheringService.getActiveAssetProfileIdentifiers({ + maxAge: '60 days' + }); + + await this.dataGatheringService.addJobsToQueue( + assetProfileIdentifiers.map(({ dataSource, symbol }) => { + return { + data: { + dataSource, + symbol + }, + name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + opts: { + ...GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS, + jobId: getAssetProfileIdentifier({ dataSource, symbol }), + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + } + }; + }) + ); + } + } + + private async isDataGatheringEnabled() { + return (await this.propertyService.getByKey( + PROPERTY_IS_DATA_GATHERING_ENABLED + )) === false + ? false + : true; + } +} diff --git a/apps/api/src/services/cryptocurrency/cryptocurrency.module.ts b/apps/api/src/services/cryptocurrency/cryptocurrency.module.ts new file mode 100644 index 000000000..e882f4da5 --- /dev/null +++ b/apps/api/src/services/cryptocurrency/cryptocurrency.module.ts @@ -0,0 +1,12 @@ +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +import { CryptocurrencyService } from './cryptocurrency.service'; + +@Module({ + exports: [CryptocurrencyService], + imports: [PropertyModule], + providers: [CryptocurrencyService] +}) +export class CryptocurrencyModule {} diff --git a/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts b/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts new file mode 100644 index 000000000..933029ea2 --- /dev/null +++ b/apps/api/src/services/cryptocurrency/cryptocurrency.service.ts @@ -0,0 +1,39 @@ +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_CURRENCY, + PROPERTY_CUSTOM_CRYPTOCURRENCIES +} from '@ghostfolio/common/config'; + +import { Injectable, OnModuleInit } from '@nestjs/common'; + +const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json'); +const customCryptocurrencies = require('../../assets/cryptocurrencies/custom.json'); + +@Injectable() +export class CryptocurrencyService implements OnModuleInit { + private combinedCryptocurrencies: string[]; + + public constructor(private readonly propertyService: PropertyService) {} + + public async onModuleInit() { + const customCryptocurrenciesFromDatabase = + await this.propertyService.getByKey>( + PROPERTY_CUSTOM_CRYPTOCURRENCIES + ); + + this.combinedCryptocurrencies = [ + ...Object.keys(cryptocurrencies), + ...Object.keys(customCryptocurrencies), + ...Object.keys(customCryptocurrenciesFromDatabase ?? {}) + ]; + } + + public isCryptocurrency(aSymbol = '') { + const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3); + + return ( + aSymbol.endsWith(DEFAULT_CURRENCY) && + this.combinedCryptocurrencies.includes(cryptocurrencySymbol) + ); + } +} diff --git a/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts new file mode 100644 index 000000000..6030e62d4 --- /dev/null +++ b/apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts @@ -0,0 +1,142 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupResponse +} from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import Alphavantage from 'alphavantage'; +import { format, isAfter, isBefore, parse } from 'date-fns'; + +import { AlphaVantageHistoricalResponse } from './interfaces/interfaces'; + +@Injectable() +export class AlphaVantageService implements DataProviderInterface { + public alphaVantage; + + public constructor( + private readonly configurationService: ConfigurationService + ) { + this.alphaVantage = Alphavantage({ + key: this.configurationService.get('API_KEY_ALPHA_VANTAGE') + }); + } + + public canHandle() { + return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE'); + } + + public async getAssetProfile({ + symbol + }: GetAssetProfileParams): Promise> { + return { + symbol, + dataSource: this.getName() + }; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.ALPHA_VANTAGE, + isPremium: false, + name: 'Alpha Vantage', + url: 'https://www.alphavantage.co' + }; + } + + public async getDividends({}: GetDividendsParams) { + return {}; + } + + public async getHistorical({ + from, + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + try { + const historicalData: { + [symbol: string]: AlphaVantageHistoricalResponse[]; + } = await this.alphaVantage.crypto.daily( + symbol + .substring(0, symbol.length - DEFAULT_CURRENCY.length) + .toLowerCase(), + 'usd' + ); + + const response: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = {}; + + response[symbol] = {}; + + for (const [key, timeSeries] of Object.entries( + historicalData['Time Series (Digital Currency Daily)'] + ).sort()) { + if ( + isAfter(from, parse(key, DATE_FORMAT, new Date())) && + isBefore(to, parse(key, DATE_FORMAT, new Date())) + ) { + response[symbol][key] = { + marketPrice: parseFloat(timeSeries['4a. close (USD)']) + }; + } + } + + return response; + } catch (error) { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getName(): DataSource { + return DataSource.ALPHA_VANTAGE; + } + + public async getQuotes({}: GetQuotesParams): Promise<{ + [symbol: string]: DataProviderResponse; + }> { + return {}; + } + + public getTestSymbol() { + return undefined; + } + + public async search({ query }: GetSearchParams): Promise { + const result = await this.alphaVantage.data.search(query); + + return { + items: result?.bestMatches?.map((bestMatch) => { + return { + assetClass: undefined, + assetSubClass: undefined, + currency: bestMatch['8. currency'], + dataProviderInfo: this.getDataProviderInfo(), + dataSource: this.getName(), + name: bestMatch['2. name'], + symbol: bestMatch['1. symbol'] + }; + }) + }; + } +} diff --git a/apps/api/src/services/data-provider/alpha-vantage/interfaces/interfaces.ts b/apps/api/src/services/data-provider/alpha-vantage/interfaces/interfaces.ts new file mode 100644 index 000000000..897351df1 --- /dev/null +++ b/apps/api/src/services/data-provider/alpha-vantage/interfaces/interfaces.ts @@ -0,0 +1 @@ +export interface AlphaVantageHistoricalResponse {} diff --git a/apps/api/src/services/data-provider/coingecko/coingecko.service.ts b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts new file mode 100644 index 000000000..d0d96acac --- /dev/null +++ b/apps/api/src/services/data-provider/coingecko/coingecko.service.ts @@ -0,0 +1,264 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupItem, + LookupResponse +} from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + DataSource, + SymbolProfile +} from '@prisma/client'; +import { format, fromUnixTime, getUnixTime } from 'date-fns'; + +@Injectable() +export class CoinGeckoService implements DataProviderInterface { + private readonly apiUrl: string; + private readonly headers: HeadersInit = {}; + + public constructor( + private readonly configurationService: ConfigurationService + ) { + const apiKeyDemo = this.configurationService.get('API_KEY_COINGECKO_DEMO'); + const apiKeyPro = this.configurationService.get('API_KEY_COINGECKO_PRO'); + + this.apiUrl = 'https://api.coingecko.com/api/v3'; + + if (apiKeyDemo) { + this.headers['x-cg-demo-api-key'] = apiKeyDemo; + } + + if (apiKeyPro) { + this.apiUrl = 'https://pro-api.coingecko.com/api/v3'; + this.headers['x-cg-pro-api-key'] = apiKeyPro; + } + } + + public canHandle() { + return true; + } + + public async getAssetProfile({ + symbol + }: GetAssetProfileParams): Promise> { + const response: Partial = { + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CRYPTOCURRENCY, + currency: DEFAULT_CURRENCY, + dataSource: this.getName() + }; + + try { + const { name } = await fetch(`${this.apiUrl}/coins/${symbol}`, { + headers: this.headers, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }).then((res) => res.json()); + + response.name = name; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'CoinGeckoService'); + } + + return response; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.COINGECKO, + isPremium: false, + name: 'CoinGecko', + url: 'https://coingecko.com' + }; + } + + public async getDividends({}: GetDividendsParams) { + return {}; + } + + public async getHistorical({ + from, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + try { + const queryParams = new URLSearchParams({ + from: getUnixTime(from).toString(), + to: getUnixTime(to).toString(), + vs_currency: DEFAULT_CURRENCY.toLowerCase() + }); + + const { error, prices, status } = await fetch( + `${this.apiUrl}/coins/${symbol}/market_chart/range?${queryParams.toString()}`, + { + headers: this.headers, + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + if (error?.status) { + throw new Error(error.status.error_message); + } + + if (status) { + throw new Error(status.error_message); + } + + const result: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = { + [symbol]: {} + }; + + for (const [timestamp, marketPrice] of prices) { + result[symbol][format(fromUnixTime(timestamp / 1000), DATE_FORMAT)] = { + marketPrice + }; + } + + return result; + } catch (error) { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getMaxNumberOfSymbolsPerRequest() { + return 50; + } + + public getName(): DataSource { + return DataSource.COINGECKO; + } + + public async getQuotes({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { + const response: { [symbol: string]: DataProviderResponse } = {}; + + if (symbols.length <= 0) { + return response; + } + + try { + const queryParams = new URLSearchParams({ + ids: symbols.join(','), + vs_currencies: DEFAULT_CURRENCY.toLowerCase() + }); + + const quotes = await fetch( + `${this.apiUrl}/simple/price?${queryParams.toString()}`, + { + headers: this.headers, + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + for (const symbol in quotes) { + response[symbol] = { + currency: DEFAULT_CURRENCY, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: DataSource.COINGECKO, + marketPrice: quotes[symbol][DEFAULT_CURRENCY.toLowerCase()], + marketState: 'open' + }; + } + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the quotes for ${symbols.join( + ', ' + )} was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'CoinGeckoService'); + } + + return response; + } + + public getTestSymbol() { + return 'bitcoin'; + } + + public async search({ + query, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: GetSearchParams): Promise { + let items: LookupItem[] = []; + + try { + const queryParams = new URLSearchParams({ + query + }); + + const { coins } = await fetch( + `${this.apiUrl}/search?${queryParams.toString()}`, + { + headers: this.headers, + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + items = coins.map(({ id: symbol, name }) => { + return { + name, + symbol, + assetClass: AssetClass.LIQUIDITY, + assetSubClass: AssetSubClass.CRYPTOCURRENCY, + currency: DEFAULT_CURRENCY, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: this.getName() + }; + }); + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'CoinGeckoService'); + } + + return { items }; + } +} diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts new file mode 100644 index 000000000..cadf8cf1d --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.module.ts @@ -0,0 +1,40 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; +import { OpenFigiDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/openfigi/openfigi.service'; +import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.service'; +import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; + +import { Module } from '@nestjs/common'; + +import { DataEnhancerService } from './data-enhancer.service'; + +@Module({ + exports: [ + DataEnhancerService, + OpenFigiDataEnhancerService, + TrackinsightDataEnhancerService, + YahooFinanceDataEnhancerService, + 'DataEnhancers' + ], + imports: [ConfigurationModule, CryptocurrencyModule], + providers: [ + DataEnhancerService, + OpenFigiDataEnhancerService, + TrackinsightDataEnhancerService, + YahooFinanceDataEnhancerService, + { + inject: [ + OpenFigiDataEnhancerService, + TrackinsightDataEnhancerService, + YahooFinanceDataEnhancerService + ], + provide: 'DataEnhancers', + useFactory: (openfigi, trackinsight, yahooFinance) => [ + openfigi, + trackinsight, + yahooFinance + ] + } + ] +}) +export class DataEnhancerModule {} diff --git a/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts new file mode 100644 index 000000000..aa0b3c597 --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/data-enhancer.service.ts @@ -0,0 +1,48 @@ +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'; +import ms from 'ms'; + +@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({ + requestTimeout: ms('30 seconds'), + response: { + assetClass: 'EQUITY', + assetSubClass: 'ETF' + }, + symbol: dataEnhancer.getTestSymbol() + }); + + if ( + (assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 && + (assetProfile.holdings as unknown as Prisma.JsonArray)?.length > 0 && + (assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0 + ) { + return true; + } + } catch {} + + return false; + } +} diff --git a/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts b/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts new file mode 100644 index 000000000..bb9d0606c --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/openfigi/openfigi.service.ts @@ -0,0 +1,86 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { parseSymbol } from '@ghostfolio/common/helper'; + +import { Injectable } from '@nestjs/common'; +import { SymbolProfile } from '@prisma/client'; + +@Injectable() +export class OpenFigiDataEnhancerService implements DataEnhancerInterface { + private static baseUrl = 'https://api.openfigi.com'; + + public constructor( + private readonly configurationService: ConfigurationService + ) {} + + public async enhance({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + response, + symbol + }: { + requestTimeout?: number; + response: Partial; + symbol: string; + }): Promise> { + if ( + !( + response.assetClass === 'EQUITY' && + (response.assetSubClass === 'ETF' || response.assetSubClass === 'STOCK') + ) + ) { + return response; + } + + const headers: HeadersInit = {}; + const { exchange, ticker } = parseSymbol({ + symbol, + dataSource: response.dataSource + }); + + if (this.configurationService.get('API_KEY_OPEN_FIGI')) { + headers['X-OPENFIGI-APIKEY'] = + this.configurationService.get('API_KEY_OPEN_FIGI'); + } + + const mappings = (await fetch( + `${OpenFigiDataEnhancerService.baseUrl}/v3/mapping`, + { + body: JSON.stringify([ + { exchCode: exchange, idType: 'TICKER', idValue: ticker } + ]), + headers: { + 'Content-Type': 'application/json', + ...headers + }, + method: 'POST', + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json())) as any[]; + + if (mappings?.length === 1 && mappings[0].data?.length === 1) { + const { compositeFIGI, figi, shareClassFIGI } = mappings[0].data[0]; + + if (figi) { + response.figi = figi; + } + + if (compositeFIGI) { + response.figiComposite = compositeFIGI; + } + + if (shareClassFIGI) { + response.figiShareClass = shareClassFIGI; + } + } + + return response; + } + + public getName() { + return 'OPENFIGI'; + } + + public getTestSymbol() { + return undefined; + } +} diff --git a/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts new file mode 100644 index 000000000..1e297b93b --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts @@ -0,0 +1,214 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { Holding } from '@ghostfolio/common/interfaces'; +import { Country } from '@ghostfolio/common/interfaces/country.interface'; +import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; + +import { Injectable, Logger } from '@nestjs/common'; +import { SymbolProfile } from '@prisma/client'; +import { countries } from 'countries-list'; + +@Injectable() +export class TrackinsightDataEnhancerService implements DataEnhancerInterface { + private static baseUrl = 'https://www.trackinsight.com/data-api'; + private static countriesMapping = { + 'Russian Federation': 'Russia' + }; + private static holdingsWeightTreshold = 0.85; + private static sectorsMapping = { + 'Consumer Discretionary': 'Consumer Cyclical', + 'Consumer Defensive': 'Consumer Staples', + 'Health Care': 'Healthcare', + 'Information Technology': 'Technology' + }; + + public constructor( + private readonly configurationService: ConfigurationService + ) {} + + public async enhance({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + response, + symbol + }: { + requestTimeout?: number; + response: Partial; + symbol: string; + }): Promise> { + if ( + !( + response.assetClass === 'EQUITY' && + ['ETF', 'MUTUALFUND'].includes(response.assetSubClass) + ) + ) { + return response; + } + + let trackinsightSymbol = await this.searchTrackinsightSymbol({ + requestTimeout, + symbol + }); + + if (!trackinsightSymbol) { + trackinsightSymbol = await this.searchTrackinsightSymbol({ + requestTimeout, + symbol: symbol.split('.')?.[0] + }); + } + + if (!trackinsightSymbol) { + return response; + } + + const profile = await fetch( + `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()) + .catch(() => { + return {}; + }); + + const cusip = profile?.cusip; + + if (cusip) { + response.cusip = cusip; + } + + const isin = profile?.isins?.[0]; + + if (isin) { + response.isin = isin; + } + + const holdings = await fetch( + `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()) + .catch(() => { + return {}; + }); + + if ( + holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold + ) { + // Skip if data is inaccurate + return response; + } + + if ( + !response.countries || + (response.countries as unknown as Country[]).length === 0 + ) { + response.countries = []; + + for (const [name, value] of Object.entries( + holdings?.countries ?? {} + )) { + let countryCode: string; + + for (const [code, country] of Object.entries(countries)) { + if ( + country.name === name || + country.name === + TrackinsightDataEnhancerService.countriesMapping[name] + ) { + countryCode = code; + break; + } + } + + response.countries.push({ + code: countryCode, + weight: value.weight + }); + } + } + + if ( + !response.holdings || + (response.holdings as unknown as Holding[]).length === 0 + ) { + response.holdings = []; + + for (const { label, weight } of holdings?.topHoldings ?? []) { + if (label?.toLowerCase() === 'other') { + continue; + } + + response.holdings.push({ + weight, + name: label + }); + } + } + + if ( + !response.sectors || + (response.sectors as unknown as Sector[]).length === 0 + ) { + response.sectors = []; + + for (const [name, value] of Object.entries( + holdings?.sectors ?? {} + )) { + response.sectors.push({ + name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name, + weight: value.weight + }); + } + } + + return Promise.resolve(response); + } + + public getName() { + return 'TRACKINSIGHT'; + } + + public getTestSymbol() { + return 'QQQ'; + } + + private async searchTrackinsightSymbol({ + requestTimeout, + symbol + }: { + requestTimeout: number; + symbol: string; + }) { + return fetch( + `https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ) + .then((res) => res.json()) + .then((jsonRes) => { + if ( + jsonRes['results']?.['count'] === 1 || + // Allow exact match + jsonRes['results']?.['docs']?.[0]?.['ticker'] === symbol || + // Allow EXCHANGE:SYMBOL + jsonRes['results']?.['docs']?.[0]?.['ticker']?.endsWith(`:${symbol}`) + ) { + return jsonRes['results']['docs'][0]['ticker']; + } + + return undefined; + }) + .catch(({ message }) => { + Logger.error( + `Failed to search Trackinsight symbol for ${symbol} (${message})`, + 'TrackinsightDataEnhancerService' + ); + + return undefined; + }); + } +} diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts new file mode 100644 index 000000000..9335d86d0 --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.spec.ts @@ -0,0 +1,94 @@ +import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; + +import { YahooFinanceDataEnhancerService } from './yahoo-finance.service'; + +jest.mock( + '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service', + () => { + return { + CryptocurrencyService: jest.fn().mockImplementation(() => { + return { + isCryptocurrency: (symbol: string) => { + switch (symbol) { + case 'BTCUSD': + return true; + case 'DOGEUSD': + return true; + default: + return false; + } + } + }; + }) + }; + } +); + +describe('YahooFinanceDataEnhancerService', () => { + let cryptocurrencyService: CryptocurrencyService; + let yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService; + + beforeAll(async () => { + cryptocurrencyService = new CryptocurrencyService(null); + + yahooFinanceDataEnhancerService = new YahooFinanceDataEnhancerService( + cryptocurrencyService + ); + }); + + it('convertFromYahooFinanceSymbol', async () => { + expect( + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'BRK-B' + ) + ).toEqual('BRK-B'); + expect( + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'BTC-USD' + ) + ).toEqual('BTCUSD'); + expect( + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'USD.AX' + ) + ).toEqual('USD.AX'); + expect( + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'EURUSD=X' + ) + ).toEqual('EURUSD'); + expect( + await yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + 'USDCHF=X' + ) + ).toEqual('USDCHF'); + }); + + it('convertToYahooFinanceSymbol', async () => { + expect( + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'BTCUSD' + ) + ).toEqual('BTC-USD'); + expect( + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'DOGEUSD' + ) + ).toEqual('DOGE-USD'); + expect( + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'EURUSD' + ) + ).toEqual('EURUSD=X'); + expect( + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'USD.AX' + ) + ).toEqual('USD.AX'); + expect( + await yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + 'USDCHF' + ) + ).toEqual('USDCHF=X'); + }); +}); diff --git a/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts new file mode 100644 index 000000000..72136dc04 --- /dev/null +++ b/apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts @@ -0,0 +1,372 @@ +import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; +import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; +import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { + DEFAULT_CURRENCY, + REPLACE_NAME_PARTS, + UNKNOWN_KEY +} from '@ghostfolio/common/config'; +import { isCurrency } from '@ghostfolio/common/helper'; + +import { Injectable, Logger } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + DataSource, + Prisma, + SymbolProfile +} from '@prisma/client'; +import { isISIN } from 'class-validator'; +import { countries } from 'countries-list'; +import YahooFinance from 'yahoo-finance2'; +import type { Price } from 'yahoo-finance2/esm/src/modules/quoteSummary-iface'; + +@Injectable() +export class YahooFinanceDataEnhancerService implements DataEnhancerInterface { + private readonly yahooFinance = new YahooFinance({ + suppressNotices: ['yahooSurvey'] + }); + + public constructor( + private readonly cryptocurrencyService: CryptocurrencyService + ) {} + + public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) { + let symbol = aYahooFinanceSymbol.replace( + new RegExp(`-${DEFAULT_CURRENCY}$`), + DEFAULT_CURRENCY + ); + + if (symbol.includes('=X') && !symbol.includes(DEFAULT_CURRENCY)) { + symbol = `${DEFAULT_CURRENCY}${symbol}`; + } + + if (symbol.includes(`${DEFAULT_CURRENCY}ZAC`)) { + symbol = `${DEFAULT_CURRENCY}ZAc`; + } + + return symbol.replace('=X', ''); + } + + /** + * Converts a symbol to a Yahoo Finance symbol + * + * Currency: USDCHF -> USDCHF=X + * Cryptocurrency: BTCUSD -> BTC-USD + * DOGEUSD -> DOGE-USD + */ + public convertToYahooFinanceSymbol(aSymbol: string) { + if ( + aSymbol.includes(DEFAULT_CURRENCY) && + aSymbol.length > DEFAULT_CURRENCY.length + ) { + if ( + isCurrency( + aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) + ) && + isCurrency(aSymbol.substring(aSymbol.length - DEFAULT_CURRENCY.length)) + ) { + return `${aSymbol}=X`; + } else if ( + this.cryptocurrencyService.isCryptocurrency( + aSymbol.replace(new RegExp(`-${DEFAULT_CURRENCY}$`), DEFAULT_CURRENCY) + ) + ) { + // Add a dash before the last three characters + // BTCUSD -> BTC-USD + // DOGEUSD -> DOGE-USD + // SOL1USD -> SOL1-USD + return aSymbol.replace( + new RegExp(`-?${DEFAULT_CURRENCY}$`), + `-${DEFAULT_CURRENCY}` + ); + } + } + + return aSymbol; + } + + public async enhance({ + response, + symbol + }: { + requestTimeout?: number; + response: Partial; + symbol: string; + }): Promise> { + if (response.dataSource !== 'YAHOO' && !response.isin) { + return response; + } + + try { + let yahooSymbol: string; + + if (response.dataSource === 'YAHOO') { + yahooSymbol = symbol; + } else { + const { quotes } = await this.yahooFinance.search(response.isin); + yahooSymbol = quotes[0].symbol as string; + } + + const { countries, sectors, url } = + await this.getAssetProfile(yahooSymbol); + + if ((countries as unknown as Prisma.JsonArray)?.length > 0) { + response.countries = countries; + } + + if ((sectors as unknown as Prisma.JsonArray)?.length > 0) { + response.sectors = sectors; + } + + if (url) { + response.url = url; + } + } catch (error) { + Logger.error(error, 'YahooFinanceDataEnhancerService'); + } + + return response; + } + + public formatName({ + longName, + quoteType, + shortName, + symbol + }: { + longName?: Price['longName']; + quoteType?: Price['quoteType']; + shortName?: Price['shortName']; + symbol?: Price['symbol']; + }) { + let name = longName; + + if (name) { + name = name.replace('&', '&'); + + for (const part of REPLACE_NAME_PARTS) { + name = name.replace(part, ''); + } + + name = name.trim(); + } + + if (quoteType === 'FUTURE') { + // "Gold Jun 22" -> "Gold" + name = shortName?.slice(0, -7); + } + + return name || shortName || symbol; + } + + public async getAssetProfile( + aSymbol: string + ): Promise> { + let response: Partial = {}; + + try { + let symbol = aSymbol; + + if (isISIN(symbol)) { + try { + const { quotes } = await this.yahooFinance.search(symbol); + + if (quotes?.[0]?.symbol) { + symbol = quotes[0].symbol as string; + } + } catch {} + } else if (symbol?.endsWith(`-${DEFAULT_CURRENCY}`)) { + throw new Error(`${symbol} is not valid`); + } else { + symbol = this.convertToYahooFinanceSymbol(symbol); + } + + const assetProfile = await this.yahooFinance.quoteSummary(symbol, { + modules: ['price', 'summaryProfile', 'topHoldings'] + }); + + const { assetClass, assetSubClass } = this.parseAssetClass({ + quoteType: assetProfile.price.quoteType, + shortName: assetProfile.price.shortName + }); + + response.assetClass = assetClass; + response.assetSubClass = assetSubClass; + response.currency = assetProfile.price.currency; + response.dataSource = this.getName(); + response.name = this.formatName({ + longName: assetProfile.price.longName, + quoteType: assetProfile.price.quoteType, + shortName: assetProfile.price.shortName, + symbol: assetProfile.price.symbol + }); + response.symbol = this.convertFromYahooFinanceSymbol( + assetProfile.price.symbol + ); + + if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) { + response.holdings = + assetProfile.topHoldings?.holdings + ?.filter(({ holdingName }) => { + return !holdingName?.includes('ETF'); + }) + ?.map(({ holdingName, holdingPercent }) => { + return { + name: this.formatName({ longName: holdingName }), + weight: holdingPercent + }; + }) ?? []; + + response.sectors = ( + assetProfile.topHoldings?.sectorWeightings ?? [] + ).flatMap((sectorWeighting) => { + return Object.entries(sectorWeighting).map(([sector, weight]) => { + return { + name: this.parseSector(sector), + weight: weight as number + }; + }); + }); + } else if ( + assetSubClass === 'STOCK' && + assetProfile.summaryProfile?.country + ) { + // Add country if asset is stock and country available + + try { + const [code] = Object.entries(countries).find(([, country]) => { + return country.name === assetProfile.summaryProfile?.country; + }); + + if (code) { + response.countries = [{ code, weight: 1 }]; + } + } catch {} + + if (assetProfile.summaryProfile?.sector) { + response.sectors = [ + { name: assetProfile.summaryProfile?.sector, weight: 1 } + ]; + } + } + + const url = assetProfile.summaryProfile?.website; + + if (url) { + response.url = url; + } + } catch (error) { + response = undefined; + + if (error.message === `Quote not found for symbol: ${aSymbol}`) { + throw new AssetProfileDelistedError( + `No data found, ${aSymbol} (${this.getName()}) may be delisted` + ); + } else { + Logger.error(error, 'YahooFinanceService'); + } + } + + return response; + } + + public getName() { + return DataSource.YAHOO; + } + + public getTestSymbol() { + return 'AAPL'; + } + + public parseAssetClass({ + quoteType, + shortName + }: { + quoteType: string; + shortName: string; + }): { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + } { + let assetClass: AssetClass; + let assetSubClass: AssetSubClass; + + switch (quoteType?.toLowerCase()) { + case 'cryptocurrency': + assetClass = AssetClass.LIQUIDITY; + assetSubClass = AssetSubClass.CRYPTOCURRENCY; + break; + case 'equity': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.STOCK; + break; + case 'etf': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.ETF; + break; + case 'future': + assetClass = AssetClass.COMMODITY; + assetSubClass = AssetSubClass.COMMODITY; + + if ( + shortName?.toLowerCase()?.startsWith('gold') || + shortName?.toLowerCase()?.startsWith('palladium') || + shortName?.toLowerCase()?.startsWith('platinum') || + shortName?.toLowerCase()?.startsWith('silver') + ) { + assetSubClass = AssetSubClass.PRECIOUS_METAL; + } + + break; + case 'mutualfund': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.MUTUALFUND; + break; + } + + return { assetClass, assetSubClass }; + } + + private parseSector(aString: string) { + let sector = UNKNOWN_KEY; + + switch (aString) { + case 'basic_materials': + sector = 'Basic Materials'; + break; + case 'communication_services': + sector = 'Communication Services'; + break; + case 'consumer_cyclical': + sector = 'Consumer Cyclical'; + break; + case 'consumer_defensive': + sector = 'Consumer Staples'; + break; + case 'energy': + sector = 'Energy'; + break; + case 'financial_services': + sector = 'Financial Services'; + break; + case 'healthcare': + sector = 'Healthcare'; + break; + case 'industrials': + sector = 'Industrials'; + break; + case 'realestate': + sector = 'Real Estate'; + break; + case 'technology': + sector = 'Technology'; + break; + case 'utilities': + sector = 'Utilities'; + break; + } + + return sector; + } +} diff --git a/apps/api/src/services/data-provider/data-provider.module.ts b/apps/api/src/services/data-provider/data-provider.module.ts new file mode 100644 index 000000000..71b54f01e --- /dev/null +++ b/apps/api/src/services/data-provider/data-provider.module.ts @@ -0,0 +1,85 @@ +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module'; +import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; +import { CoinGeckoService } from '@ghostfolio/api/services/data-provider/coingecko/coingecko.service'; +import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service'; +import { FinancialModelingPrepService } from '@ghostfolio/api/services/data-provider/financial-modeling-prep/financial-modeling-prep.service'; +import { GhostfolioService } from '@ghostfolio/api/services/data-provider/ghostfolio/ghostfolio.service'; +import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service'; +import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service'; +import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service'; +import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; + +import { Module } from '@nestjs/common'; + +import { DataEnhancerModule } from './data-enhancer/data-enhancer.module'; +import { YahooFinanceDataEnhancerService } from './data-enhancer/yahoo-finance/yahoo-finance.service'; +import { DataProviderService } from './data-provider.service'; + +@Module({ + imports: [ + ConfigurationModule, + CryptocurrencyModule, + DataEnhancerModule, + MarketDataModule, + PrismaModule, + PropertyModule, + RedisCacheModule, + SymbolProfileModule + ], + providers: [ + AlphaVantageService, + CoinGeckoService, + DataProviderService, + EodHistoricalDataService, + FinancialModelingPrepService, + GhostfolioService, + GoogleSheetsService, + ManualService, + RapidApiService, + YahooFinanceService, + { + inject: [ + AlphaVantageService, + CoinGeckoService, + EodHistoricalDataService, + FinancialModelingPrepService, + GhostfolioService, + GoogleSheetsService, + ManualService, + RapidApiService, + YahooFinanceService + ], + provide: 'DataProviderInterfaces', + useFactory: ( + alphaVantageService, + coinGeckoService, + eodHistoricalDataService, + financialModelingPrepService, + ghostfolioService, + googleSheetsService, + manualService, + rapidApiService, + yahooFinanceService + ) => [ + alphaVantageService, + coinGeckoService, + eodHistoricalDataService, + financialModelingPrepService, + ghostfolioService, + googleSheetsService, + manualService, + rapidApiService, + yahooFinanceService + ] + }, + YahooFinanceDataEnhancerService + ], + exports: [DataProviderService, ManualService, YahooFinanceService] +}) +export class DataProviderModule {} diff --git a/apps/api/src/services/data-provider/data-provider.service.ts b/apps/api/src/services/data-provider/data-provider.service.ts new file mode 100644 index 000000000..8ee8761c0 --- /dev/null +++ b/apps/api/src/services/data-provider/data-provider.service.ts @@ -0,0 +1,866 @@ +import { ImportDataDto } from '@ghostfolio/api/app/import/import-data.dto'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_CURRENCY, + DERIVED_CURRENCIES, + PROPERTY_API_KEY_GHOSTFOLIO, + PROPERTY_DATA_SOURCE_MAPPING +} from '@ghostfolio/common/config'; +import { CreateOrderDto } from '@ghostfolio/common/dtos'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + getCurrencyFromSymbol, + getStartOfUtcDate, + isCurrency, + isDerivedCurrency +} from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + DataProviderHistoricalResponse, + DataProviderResponse, + LookupItem, + LookupResponse +} from '@ghostfolio/common/interfaces'; +import type { Granularity, UserWithSettings } from '@ghostfolio/common/types'; + +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { DataSource, MarketData, SymbolProfile } from '@prisma/client'; +import { Big } from 'big.js'; +import { eachDayOfInterval, format, isValid } from 'date-fns'; +import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash'; +import ms from 'ms'; + +import { AssetProfileInvalidError } from './errors/asset-profile-invalid.error'; + +@Injectable() +export class DataProviderService implements OnModuleInit { + private dataProviderMapping: { [dataProviderName: string]: string }; + + public constructor( + private readonly configurationService: ConfigurationService, + @Inject('DataProviderInterfaces') + private readonly dataProviderInterfaces: DataProviderInterface[], + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, + private readonly redisCacheService: RedisCacheService + ) {} + + public async onModuleInit() { + this.dataProviderMapping = + (await this.propertyService.getByKey<{ + [dataProviderName: string]: string; + }>(PROPERTY_DATA_SOURCE_MAPPING)) ?? {}; + } + + public async checkQuote(dataSource: DataSource) { + const dataProvider = this.getDataProvider(dataSource); + const symbol = dataProvider.getTestSymbol(); + + const quotes = await this.getQuotes({ + items: [ + { + dataSource, + symbol + } + ], + requestTimeout: ms('30 seconds'), + useCache: false + }); + + if (quotes[symbol]?.marketPrice > 0) { + return true; + } + + return false; + } + + public async getAssetProfiles(items: AssetProfileIdentifier[]): Promise<{ + [symbol: string]: Partial; + }> { + const response: { + [symbol: string]: Partial; + } = {}; + + const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => { + return dataSource; + }); + + const promises = []; + + for (const [dataSource, assetProfileIdentifiers] of Object.entries( + itemsGroupedByDataSource + )) { + const symbols = assetProfileIdentifiers.map(({ symbol }) => { + return symbol; + }); + + for (const symbol of symbols) { + const promise = Promise.resolve( + this.getDataProvider(DataSource[dataSource]).getAssetProfile({ + symbol + }) + ); + + promises.push( + promise.then((assetProfile) => { + if (isCurrency(assetProfile?.currency)) { + response[symbol] = assetProfile; + } + }) + ); + } + } + + try { + await Promise.all(promises); + + if (isEmpty(response)) { + throw new AssetProfileInvalidError( + 'No valid asset profiles have been found' + ); + } + } catch (error) { + Logger.error(error, 'DataProviderService'); + + throw error; + } + + return response; + } + + public getDataProvider(providerName: DataSource) { + for (const dataProviderInterface of this.dataProviderInterfaces) { + if (this.dataProviderMapping[dataProviderInterface.getName()]) { + const mappedDataProviderInterface = this.dataProviderInterfaces.find( + (currentDataProviderInterface) => { + return ( + currentDataProviderInterface.getName() === + this.dataProviderMapping[dataProviderInterface.getName()] + ); + } + ); + + if (mappedDataProviderInterface) { + return mappedDataProviderInterface; + } + } + + if (dataProviderInterface.getName() === providerName) { + return dataProviderInterface; + } + } + + throw new Error('No data provider has been found.'); + } + + public getDataSourceForExchangeRates(): DataSource { + return DataSource[ + this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES') + ]; + } + + public getDataSourceForImport(): DataSource { + return DataSource[this.configurationService.get('DATA_SOURCE_IMPORT')]; + } + + public async getDataSources(): Promise { + const dataSources: DataSource[] = this.configurationService + .get('DATA_SOURCES') + .map((dataSource) => { + return DataSource[dataSource]; + }); + + const ghostfolioApiKey = await this.propertyService.getByKey( + PROPERTY_API_KEY_GHOSTFOLIO + ); + + if (ghostfolioApiKey) { + dataSources.push('GHOSTFOLIO'); + } + + return dataSources.sort(); + } + + public async validateActivities({ + activitiesDto, + assetProfilesWithMarketDataDto, + maxActivitiesToImport, + user + }: { + activitiesDto: Pick< + Partial, + 'currency' | 'dataSource' | 'symbol' | 'type' + >[]; + assetProfilesWithMarketDataDto?: ImportDataDto['assetProfiles']; + maxActivitiesToImport: number; + user: UserWithSettings; + }) { + if (activitiesDto?.length > maxActivitiesToImport) { + throw new Error(`Too many activities (${maxActivitiesToImport} at most)`); + } + + const assetProfiles: { + [assetProfileIdentifier: string]: Partial; + } = {}; + + const dataSources = await this.getDataSources(); + + for (const [ + index, + { currency, dataSource, symbol, type } + ] of activitiesDto.entries()) { + const activityPath = + maxActivitiesToImport === 1 ? 'activity' : `activities.${index}`; + + if (!dataSources.includes(dataSource)) { + throw new Error( + `${activityPath}.dataSource ("${dataSource}") is not valid` + ); + } + + if ( + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + user.subscription.type === 'Basic' + ) { + const dataProvider = this.getDataProvider(DataSource[dataSource]); + + if (dataProvider.getDataProviderInfo().isPremium) { + throw new Error( + `${activityPath}.dataSource ("${dataSource}") is not valid` + ); + } + } + + const assetProfileIdentifier = getAssetProfileIdentifier({ + dataSource, + symbol + }); + + if (!assetProfiles[assetProfileIdentifier]) { + if ( + (dataSource === DataSource.MANUAL && type === 'BUY') || + ['FEE', 'INTEREST', 'LIABILITY'].includes(type) + ) { + const assetProfileInImport = assetProfilesWithMarketDataDto?.find( + (assetProfile) => { + return ( + assetProfile.dataSource === dataSource && + assetProfile.symbol === symbol + ); + } + ); + + assetProfiles[assetProfileIdentifier] = { + currency, + dataSource, + symbol, + name: assetProfileInImport?.name ?? symbol + }; + + continue; + } + + let assetProfile: Partial = { currency }; + + try { + assetProfile = ( + await this.getAssetProfiles([ + { + dataSource, + symbol + } + ]) + )?.[symbol]; + } catch {} + + if (!assetProfile?.name) { + const assetProfileInImport = assetProfilesWithMarketDataDto?.find( + (profile) => { + return ( + profile.dataSource === dataSource && profile.symbol === symbol + ); + } + ); + + if (assetProfileInImport) { + Object.assign(assetProfile, assetProfileInImport); + } + } + + if (!assetProfile?.name) { + throw new Error( + `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` + ); + } + + assetProfiles[assetProfileIdentifier] = assetProfile; + } + } + + return assetProfiles; + } + + public async getDividends({ + dataSource, + from, + granularity = 'day', + symbol, + to + }: { + dataSource: DataSource; + from: Date; + granularity: Granularity; + symbol: string; + to: Date; + }) { + return this.getDataProvider(DataSource[dataSource]).getDividends({ + from, + granularity, + symbol, + to, + requestTimeout: ms('30 seconds') + }); + } + + public async getHistorical( + aItems: AssetProfileIdentifier[], + aGranularity: Granularity = 'month', + from: Date, + to: Date + ): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + let response: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = {}; + + if (isEmpty(aItems) || !isValid(from) || !isValid(to)) { + return response; + } + + const granularityQuery = + aGranularity === 'month' + ? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')` + : ''; + + const rangeQuery = + from && to + ? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format( + to, + DATE_FORMAT + )}'` + : ''; + + const dataSources = aItems.map(({ dataSource }) => { + return dataSource; + }); + const symbols = aItems.map(({ symbol }) => { + return symbol; + }); + + try { + const queryRaw = ` + SELECT * + FROM "MarketData" + WHERE "dataSource" IN ('${dataSources.join(`','`)}') + AND "symbol" IN ('${symbols.join( + `','` + )}') ${granularityQuery} ${rangeQuery} + ORDER BY date;`; + + const marketDataByGranularity: MarketData[] = + await this.prismaService.$queryRawUnsafe(queryRaw); + + response = marketDataByGranularity.reduce((r, marketData) => { + const { date, marketPrice, symbol } = marketData; + + r[symbol] = { + ...(r[symbol] || {}), + [format(new Date(date), DATE_FORMAT)]: { marketPrice } + }; + + return r; + }, {}); + } catch (error) { + Logger.error(error, 'DataProviderService'); + } finally { + return response; + } + } + + public async getHistoricalRaw({ + assetProfileIdentifiers, + from, + to + }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; + from: Date; + to: Date; + }): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { + if ( + this.hasCurrency({ + assetProfileIdentifiers, + currency: `${DEFAULT_CURRENCY}${currency}` + }) + ) { + // Skip derived currency + assetProfileIdentifiers = assetProfileIdentifiers.filter( + ({ symbol }) => { + return symbol !== `${DEFAULT_CURRENCY}${currency}`; + } + ); + // Add root currency + assetProfileIdentifiers.push({ + dataSource: this.getDataSourceForExchangeRates(), + symbol: `${DEFAULT_CURRENCY}${rootCurrency}` + }); + } + } + + assetProfileIdentifiers = uniqWith( + assetProfileIdentifiers, + (obj1, obj2) => { + return ( + obj1.dataSource === obj2.dataSource && obj1.symbol === obj2.symbol + ); + } + ); + + const result: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = {}; + + const promises: Promise<{ + data: { [date: string]: DataProviderHistoricalResponse }; + symbol: string; + }>[] = []; + for (const { dataSource, symbol } of assetProfileIdentifiers) { + const dataProvider = this.getDataProvider(dataSource); + if (dataProvider.canHandle(symbol)) { + if (symbol === `${DEFAULT_CURRENCY}USX`) { + const data: { + [date: string]: DataProviderHistoricalResponse; + } = {}; + + for (const date of eachDayOfInterval({ end: to, start: from })) { + data[format(date, DATE_FORMAT)] = { marketPrice: 100 }; + } + + promises.push( + Promise.resolve({ + data, + symbol + }) + ); + } else { + promises.push( + dataProvider + .getHistorical({ + from, + symbol, + to, + requestTimeout: ms('30 seconds') + }) + .then((data) => { + return { symbol, data: data?.[symbol] }; + }) + ); + } + } + } + + try { + const allData = await Promise.all(promises); + + for (const { data, symbol } of allData) { + const currency = DERIVED_CURRENCIES.find(({ rootCurrency }) => { + return `${DEFAULT_CURRENCY}${rootCurrency}` === symbol; + }); + + if (currency) { + // Add derived currency + result[`${DEFAULT_CURRENCY}${currency.currency}`] = + this.transformHistoricalData({ + allData, + currency: `${DEFAULT_CURRENCY}${currency.rootCurrency}`, + factor: currency.factor + }); + } + + result[symbol] = data; + } + } catch (error) { + Logger.error(error, 'DataProviderService'); + + throw error; + } + + return result; + } + + public async getQuotes({ + items, + requestTimeout, + useCache = true, + user + }: { + items: AssetProfileIdentifier[]; + requestTimeout?: number; + useCache?: boolean; + user?: UserWithSettings; + }): Promise<{ + [symbol: string]: DataProviderResponse; + }> { + const response: { + [symbol: string]: DataProviderResponse; + } = {}; + const startTimeTotal = performance.now(); + + if ( + items.some(({ symbol }) => { + return symbol === `${DEFAULT_CURRENCY}USX`; + }) + ) { + response[`${DEFAULT_CURRENCY}USX`] = { + currency: 'USX', + dataSource: this.getDataSourceForExchangeRates(), + marketPrice: 100, + marketState: 'open' + }; + } + + // Get items from cache + const itemsToFetch: AssetProfileIdentifier[] = []; + + for (const { dataSource, symbol } of items) { + if (useCache) { + const quoteString = await this.redisCacheService.get( + this.redisCacheService.getQuoteKey({ dataSource, symbol }) + ); + + if (quoteString) { + try { + const cachedDataProviderResponse = JSON.parse(quoteString); + response[symbol] = cachedDataProviderResponse; + continue; + } catch {} + } + } + + itemsToFetch.push({ dataSource, symbol }); + } + + const numberOfItemsInCache = Object.keys(response)?.length; + + if (numberOfItemsInCache) { + Logger.debug( + `Fetched ${numberOfItemsInCache} quote${ + numberOfItemsInCache > 1 ? 's' : '' + } from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed( + 3 + )} seconds`, + 'DataProviderService' + ); + } + + const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => { + return dataSource; + }); + + const promises: Promise[] = []; + + for (const [dataSource, assetProfileIdentifiers] of Object.entries( + itemsGroupedByDataSource + )) { + const dataProvider = this.getDataProvider(DataSource[dataSource]); + + const symbols = assetProfileIdentifiers + .filter(({ symbol }) => { + if (isCurrency(getCurrencyFromSymbol(symbol))) { + // Keep non-derived currencies + return !isDerivedCurrency(getCurrencyFromSymbol(symbol)); + } else if ( + dataProvider.getDataProviderInfo().isPremium && + this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') && + user?.subscription.type === 'Basic' + ) { + // Skip symbols of Premium data providers for users without subscription + return false; + } + + return true; + }) + .map(({ symbol }) => { + return symbol; + }); + + const maximumNumberOfSymbolsPerRequest = + dataProvider.getMaxNumberOfSymbolsPerRequest?.() ?? + Number.MAX_SAFE_INTEGER; + + for ( + let i = 0; + i < symbols.length; + i += maximumNumberOfSymbolsPerRequest + ) { + const startTimeDataSource = performance.now(); + + const symbolsChunk = symbols.slice( + i, + i + maximumNumberOfSymbolsPerRequest + ); + + const promise = Promise.resolve( + dataProvider.getQuotes({ requestTimeout, symbols: symbolsChunk }) + ); + + promises.push( + promise.then(async (result) => { + for (const [symbol, dataProviderResponse] of Object.entries( + result + )) { + if ( + [ + ...DERIVED_CURRENCIES.map(({ currency }) => { + return `${DEFAULT_CURRENCY}${currency}`; + }), + `${DEFAULT_CURRENCY}USX` + ].includes(symbol) + ) { + continue; + } + + response[symbol] = dataProviderResponse; + + this.redisCacheService.set( + this.redisCacheService.getQuoteKey({ + symbol, + dataSource: DataSource[dataSource] + }), + JSON.stringify(response[symbol]), + this.configurationService.get('CACHE_QUOTES_TTL') + ); + + for (const { + currency, + factor, + rootCurrency + } of DERIVED_CURRENCIES) { + if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) { + response[`${DEFAULT_CURRENCY}${currency}`] = { + ...dataProviderResponse, + currency, + marketPrice: new Big( + result[`${DEFAULT_CURRENCY}${rootCurrency}`].marketPrice + ) + .mul(factor) + .toNumber(), + marketState: 'open' + }; + + this.redisCacheService.set( + this.redisCacheService.getQuoteKey({ + dataSource: DataSource[dataSource], + symbol: `${DEFAULT_CURRENCY}${currency}` + }), + JSON.stringify(response[`${DEFAULT_CURRENCY}${currency}`]), + this.configurationService.get('CACHE_QUOTES_TTL') + ); + } + } + } + + Logger.debug( + `Fetched ${symbolsChunk.length} quote${ + symbolsChunk.length > 1 ? 's' : '' + } from ${dataSource} in ${( + (performance.now() - startTimeDataSource) / + 1000 + ).toFixed(3)} seconds`, + 'DataProviderService' + ); + + try { + await this.marketDataService.updateMany({ + data: Object.keys(response) + .filter((symbol) => { + return ( + isNumber(response[symbol].marketPrice) && + response[symbol].marketPrice > 0 && + response[symbol].marketState === 'open' + ); + }) + .map((symbol) => { + return { + symbol, + dataSource: response[symbol].dataSource, + date: getStartOfUtcDate(new Date()), + marketPrice: response[symbol].marketPrice, + state: 'INTRADAY' + }; + }) + }); + } catch {} + }) + ); + } + } + + await Promise.all(promises); + + Logger.debug('--------------------------------------------------------'); + Logger.debug( + `Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${( + (performance.now() - startTimeTotal) / + 1000 + ).toFixed(3)} seconds`, + 'DataProviderService' + ); + Logger.debug('========================================================'); + + return response; + } + + public async search({ + includeIndices = false, + query, + user + }: { + includeIndices?: boolean; + query: string; + user: UserWithSettings; + }): Promise { + let lookupItems: LookupItem[] = []; + const promises: Promise[] = []; + + if (query?.length < 2) { + return { items: lookupItems }; + } + + const dataSources = await this.getDataSources(); + + const dataProviderServices = dataSources.map((dataSource) => { + return this.getDataProvider(DataSource[dataSource]); + }); + + for (const dataProviderService of dataProviderServices) { + promises.push( + dataProviderService.search({ + includeIndices, + query, + userId: user.id + }) + ); + } + + const searchResults = await Promise.all(promises); + + for (const { items } of searchResults) { + if (items?.length > 0) { + lookupItems = lookupItems.concat(items); + } + } + + const filteredItems = lookupItems + .filter(({ currency }) => { + if (includeIndices) { + return true; + } + + return currency ? isCurrency(currency) : false; + }) + .map((lookupItem) => { + if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { + if (user.subscription.type === 'Premium') { + lookupItem.dataProviderInfo.isPremium = false; + } + + lookupItem.dataProviderInfo.dataSource = undefined; + lookupItem.dataProviderInfo.name = undefined; + lookupItem.dataProviderInfo.url = undefined; + } else { + lookupItem.dataProviderInfo.isPremium = false; + } + + if ( + lookupItem.assetSubClass === 'CRYPTOCURRENCY' && + user?.settings?.settings.isExperimentalFeatures + ) { + // Remove DEFAULT_CURRENCY at the end of cryptocurrency names + lookupItem.name = lookupItem.name.replace( + new RegExp(` ${DEFAULT_CURRENCY}$`), + '' + ); + } + + return lookupItem; + }) + .sort(({ name: name1 }, { name: name2 }) => { + return name1?.toLowerCase().localeCompare(name2?.toLowerCase()); + }); + + return { + items: filteredItems + }; + } + + private hasCurrency({ + assetProfileIdentifiers, + currency + }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; + currency: string; + }) { + return assetProfileIdentifiers.some(({ dataSource, symbol }) => { + return ( + dataSource === this.getDataSourceForExchangeRates() && + symbol === currency + ); + }); + } + + private transformHistoricalData({ + allData, + currency, + factor + }: { + allData: { + data: { + [date: string]: DataProviderHistoricalResponse; + }; + symbol: string; + }[]; + currency: string; + factor: number; + }) { + const rootData = allData.find(({ symbol }) => { + return symbol === currency; + })?.data; + + const data: { + [date: string]: DataProviderHistoricalResponse; + } = {}; + + for (const date in rootData) { + if (isNumber(rootData[date].marketPrice)) { + data[date] = { + marketPrice: new Big(factor) + .mul(rootData[date].marketPrice) + .toNumber() + }; + } + } + + return data; + } +} diff --git a/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts new file mode 100644 index 000000000..cd20fca44 --- /dev/null +++ b/apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts @@ -0,0 +1,511 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + DEFAULT_CURRENCY, + REPLACE_NAME_PARTS +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupItem, + LookupResponse +} from '@ghostfolio/common/interfaces'; +import { MarketState } from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + DataSource, + SymbolProfile +} from '@prisma/client'; +import { addDays, format, isSameDay, isToday } from 'date-fns'; +import { isNumber } from 'lodash'; + +@Injectable() +export class EodHistoricalDataService implements DataProviderInterface { + private apiKey: string; + private readonly URL = 'https://eodhistoricaldata.com/api'; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly symbolProfileService: SymbolProfileService + ) { + this.apiKey = this.configurationService.get('API_KEY_EOD_HISTORICAL_DATA'); + } + + public canHandle() { + return true; + } + + public async getAssetProfile({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol + }: GetAssetProfileParams): Promise> { + const [searchResult] = await this.getSearchResult({ + requestTimeout, + query: symbol + }); + + if (!searchResult) { + return undefined; + } + + return { + symbol, + assetClass: searchResult.assetClass, + assetSubClass: searchResult.assetSubClass, + currency: this.convertCurrency(searchResult.currency), + dataSource: this.getName(), + isin: searchResult.isin, + name: searchResult.name + }; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.EOD_HISTORICAL_DATA, + isPremium: true, + name: 'EOD Historical Data', + url: 'https://eodhd.com' + }; + } + + public async getDividends({ + from, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetDividendsParams): Promise<{ + [date: string]: DataProviderHistoricalResponse; + }> { + symbol = this.convertToEodSymbol(symbol); + + if (isSameDay(from, to)) { + to = addDays(to, 1); + } + + try { + const queryParams = new URLSearchParams({ + api_token: this.apiKey, + fmt: 'json', + from: format(from, DATE_FORMAT), + to: format(to, DATE_FORMAT) + }); + + const response: { + [date: string]: DataProviderHistoricalResponse; + } = {}; + + const historicalResult = await fetch( + `${this.URL}/div/${symbol}?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + for (const { date, value } of historicalResult) { + response[date] = { + marketPrice: value + }; + } + + return response; + } catch (error) { + Logger.error( + `Could not get dividends for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, + 'EodHistoricalDataService' + ); + + return {}; + } + } + + public async getHistorical({ + from, + granularity = 'day', + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + symbol = this.convertToEodSymbol(symbol); + + try { + const queryParams = new URLSearchParams({ + api_token: this.apiKey, + fmt: 'json', + from: format(from, DATE_FORMAT), + period: granularity, + to: format(to, DATE_FORMAT) + }); + + const response = await fetch( + `${this.URL}/eod/${symbol}?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + return response.reduce( + (result, { adjusted_close, date }) => { + if (isNumber(adjusted_close)) { + result[this.convertFromEodSymbol(symbol)][date] = { + marketPrice: adjusted_close + }; + } else { + Logger.error( + `Could not get historical market data for ${symbol} (${this.getName()}) at ${date}`, + 'EodHistoricalDataService' + ); + } + + return result; + }, + { [this.convertFromEodSymbol(symbol)]: {} } + ); + } catch (error) { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getMaxNumberOfSymbolsPerRequest() { + // It is not recommended using more than 15-20 tickers per request + // https://eodhistoricaldata.com/financial-apis/live-realtime-stocks-api + return 20; + } + + public getName(): DataSource { + return DataSource.EOD_HISTORICAL_DATA; + } + + public async getQuotes({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { + const response: { [symbol: string]: DataProviderResponse } = {}; + + if (symbols.length <= 0) { + return response; + } + + const eodHistoricalDataSymbols = symbols.map((symbol) => { + return this.convertToEodSymbol(symbol); + }); + + try { + const queryParams = new URLSearchParams({ + api_token: this.apiKey, + fmt: 'json', + s: eodHistoricalDataSymbols.join(',') + }); + + const realTimeResponse = await fetch( + `${this.URL}/real-time/${eodHistoricalDataSymbols[0]}?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + const quotes: { + close: number; + code: string; + previousClose: number; + timestamp: number; + }[] = + eodHistoricalDataSymbols.length === 1 + ? [realTimeResponse] + : realTimeResponse; + + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + symbols.map((symbol) => { + return { + symbol, + dataSource: this.getName() + }; + }) + ); + + for (const { close, code, previousClose, timestamp } of quotes) { + let currency: string; + + if (this.isForex(code)) { + currency = this.convertFromEodSymbol(code)?.replace( + DEFAULT_CURRENCY, + '' + ); + } + + if (!currency) { + currency = symbolProfiles.find(({ symbol }) => { + return symbol === code; + })?.currency; + } + + if (!currency) { + const { items } = await this.search({ query: code }); + + if (items.length === 1) { + currency = items[0].currency; + } + } + + if (isNumber(close) || isNumber(previousClose)) { + const marketPrice: number = isNumber(close) ? close : previousClose; + let marketState: MarketState = 'closed'; + + if (this.isForex(code) || isToday(new Date(timestamp * 1000))) { + marketState = 'open'; + } else if (!isNumber(close)) { + marketState = 'delayed'; + } + + response[this.convertFromEodSymbol(code)] = { + currency, + marketPrice, + marketState, + dataSource: this.getName() + }; + } else { + Logger.error( + `Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`, + 'EodHistoricalDataService' + ); + } + } + + return response; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the quotes for ${symbols.join( + ', ' + )} was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'EodHistoricalDataService'); + } + + return {}; + } + + public getTestSymbol() { + return 'AAPL.US'; + } + + public async search({ + query, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: GetSearchParams): Promise { + const searchResult = await this.getSearchResult({ query, requestTimeout }); + + return { + items: searchResult + .filter(({ currency, symbol }) => { + // Remove 'NA' currency and exchange rates + return currency?.length === 3 && !this.isForex(symbol); + }) + .map( + ({ + assetClass, + assetSubClass, + currency, + dataSource, + name, + symbol + }) => { + return { + assetClass, + assetSubClass, + dataSource, + name, + symbol, + currency: this.convertCurrency(currency), + dataProviderInfo: this.getDataProviderInfo() + }; + } + ) + }; + } + + private convertCurrency(aCurrency: string) { + let currency = aCurrency; + + if (currency === 'GBX') { + currency = 'GBp'; + } + + return currency; + } + + private convertFromEodSymbol(aEodSymbol: string) { + let symbol = aEodSymbol; + + if (this.isForex(symbol)) { + symbol = symbol.replace('GBX', 'GBp'); + symbol = symbol.replace('.FOREX', ''); + } + + return symbol; + } + + /** + * Converts a symbol to a EOD symbol + * + * Currency: USDCHF -> USDCHF.FOREX + */ + private convertToEodSymbol(aSymbol: string) { + if ( + aSymbol.startsWith(DEFAULT_CURRENCY) && + aSymbol.length > DEFAULT_CURRENCY.length + ) { + if ( + isCurrency( + aSymbol.substring(0, aSymbol.length - DEFAULT_CURRENCY.length) + ) + ) { + let symbol = aSymbol; + symbol = symbol.replace('GBp', 'GBX'); + + return `${symbol}.FOREX`; + } + } + + return aSymbol; + } + + private formatName({ name }: { name: string }) { + if (name) { + for (const part of REPLACE_NAME_PARTS) { + name = name.replace(part, ''); + } + + name = name.trim(); + } + + return name; + } + + private async getSearchResult({ + query, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: { + query: string; + requestTimeout?: number; + }) { + let searchResult: (LookupItem & { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + isin: string; + })[] = []; + + try { + const queryParams = new URLSearchParams({ + api_token: this.apiKey + }); + + const response = await fetch( + `${this.URL}/search/${query}?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + searchResult = response.map( + ({ Code, Currency, Exchange, ISIN: isin, Name: name, Type }) => { + const { assetClass, assetSubClass } = this.parseAssetClass({ + Exchange, + Type + }); + + return { + assetClass, + assetSubClass, + isin, + currency: this.convertCurrency(Currency), + dataSource: this.getName(), + name: this.formatName({ name }), + symbol: `${Code}.${Exchange}` + }; + } + ); + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'EodHistoricalDataService'); + } + + return searchResult; + } + + private isForex(aCode: string) { + return aCode?.endsWith('.FOREX') || false; + } + + private parseAssetClass({ + Exchange, + Type + }: { + Exchange: string; + Type: string; + }): { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + } { + let assetClass: AssetClass; + let assetSubClass: AssetSubClass; + + switch (Type?.toLowerCase()) { + case 'common stock': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.STOCK; + break; + case 'currency': + assetClass = AssetClass.LIQUIDITY; + + if (Exchange?.toLowerCase() === 'cc') { + assetSubClass = AssetSubClass.CRYPTOCURRENCY; + } + + break; + case 'etf': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.ETF; + break; + case 'fund': + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.MUTUALFUND; + break; + } + + return { assetClass, assetSubClass }; + } +} diff --git a/apps/api/src/services/data-provider/errors/asset-profile-delisted.error.ts b/apps/api/src/services/data-provider/errors/asset-profile-delisted.error.ts new file mode 100644 index 000000000..dabe0aa5b --- /dev/null +++ b/apps/api/src/services/data-provider/errors/asset-profile-delisted.error.ts @@ -0,0 +1,7 @@ +export class AssetProfileDelistedError extends Error { + public constructor(message: string) { + super(message); + + this.name = 'AssetProfileDelistedError'; + } +} diff --git a/apps/api/src/services/data-provider/errors/asset-profile-invalid.error.ts b/apps/api/src/services/data-provider/errors/asset-profile-invalid.error.ts new file mode 100644 index 000000000..bfbea6040 --- /dev/null +++ b/apps/api/src/services/data-provider/errors/asset-profile-invalid.error.ts @@ -0,0 +1,7 @@ +export class AssetProfileInvalidError extends Error { + public constructor(message: string) { + super(message); + + this.name = 'AssetProfileInvalidError'; + } +} diff --git a/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts new file mode 100644 index 000000000..2b4193af5 --- /dev/null +++ b/apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts @@ -0,0 +1,657 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { + DEFAULT_CURRENCY, + REPLACE_NAME_PARTS +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, isCurrency, parseDate } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupItem, + LookupResponse +} from '@ghostfolio/common/interfaces'; +import { MarketState } from '@ghostfolio/common/types'; + +import { Injectable, Logger } from '@nestjs/common'; +import { + AssetClass, + AssetSubClass, + DataSource, + SymbolProfile +} from '@prisma/client'; +import { isISIN } from 'class-validator'; +import { countries } from 'countries-list'; +import { + addDays, + addYears, + format, + isAfter, + isBefore, + isSameDay, + parseISO +} from 'date-fns'; +import { uniqBy } from 'lodash'; + +@Injectable() +export class FinancialModelingPrepService implements DataProviderInterface { + private static countriesMapping = { + 'Korea (the Republic of)': 'South Korea', + 'Russian Federation': 'Russia', + 'Taiwan (Province of China)': 'Taiwan' + }; + + private apiKey: string; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly cryptocurrencyService: CryptocurrencyService, + private readonly prismaService: PrismaService + ) { + this.apiKey = this.configurationService.get( + 'API_KEY_FINANCIAL_MODELING_PREP' + ); + } + + public canHandle() { + return true; + } + + public async getAssetProfile({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol + }: GetAssetProfileParams): Promise> { + let response: Partial = { + symbol, + dataSource: this.getName() + }; + + try { + if ( + isCurrency(symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length)) + ) { + response.assetClass = AssetClass.LIQUIDITY; + response.assetSubClass = AssetSubClass.CASH; + response.currency = symbol.substring( + symbol.length - DEFAULT_CURRENCY.length + ); + } else if (this.cryptocurrencyService.isCryptocurrency(symbol)) { + const queryParams = new URLSearchParams({ + symbol, + apikey: this.apiKey + }); + + const [quote] = await fetch( + `${this.getUrl({ version: 'stable' })}/quote?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + response.assetClass = AssetClass.LIQUIDITY; + response.assetSubClass = AssetSubClass.CRYPTOCURRENCY; + response.currency = symbol.substring( + symbol.length - DEFAULT_CURRENCY.length + ); + response.name = quote.name; + } else { + const queryParams = new URLSearchParams({ + symbol, + apikey: this.apiKey + }); + + const [assetProfile] = await fetch( + `${this.getUrl({ version: 'stable' })}/profile?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + if (!assetProfile) { + throw new Error(`${symbol} not found`); + } + + const { assetClass, assetSubClass } = + this.parseAssetClass(assetProfile); + + response.assetClass = assetClass; + response.assetSubClass = assetSubClass; + + if ( + assetSubClass === AssetSubClass.ETF || + assetSubClass === AssetSubClass.MUTUALFUND + ) { + const queryParams = new URLSearchParams({ + symbol, + apikey: this.apiKey + }); + + const etfCountryWeightings = await fetch( + `${this.getUrl({ version: 'stable' })}/etf/country-weightings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + response.countries = etfCountryWeightings + .filter(({ country: countryName }) => { + return countryName.toLowerCase() !== 'other'; + }) + .map(({ country: countryName, weightPercentage }) => { + let countryCode: string; + + for (const [code, country] of Object.entries(countries)) { + if ( + country.name === countryName || + country.name === + FinancialModelingPrepService.countriesMapping[countryName] + ) { + countryCode = code; + break; + } + } + + return { + code: countryCode, + weight: parseFloat(weightPercentage.slice(0, -1)) / 100 + }; + }); + + const etfHoldings = await fetch( + `${this.getUrl({ version: 'stable' })}/etf/holdings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + const sortedTopHoldings = etfHoldings + .sort((a, b) => { + return b.weightPercentage - a.weightPercentage; + }) + .slice(0, 10); + + response.holdings = sortedTopHoldings.map( + ({ name, weightPercentage }) => { + return { name, weight: weightPercentage / 100 }; + } + ); + + const [etfInformation] = await fetch( + `${this.getUrl({ version: 'stable' })}/etf/info?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + if (etfInformation?.website) { + response.url = etfInformation.website; + } + + const etfSectorWeightings = await fetch( + `${this.getUrl({ version: 'stable' })}/etf/sector-weightings?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + response.sectors = etfSectorWeightings.map( + ({ sector, weightPercentage }) => { + return { + name: sector, + weight: weightPercentage / 100 + }; + } + ); + } else if (assetSubClass === AssetSubClass.STOCK) { + if (assetProfile.country) { + response.countries = [{ code: assetProfile.country, weight: 1 }]; + } + + if (assetProfile.sector) { + response.sectors = [{ name: assetProfile.sector, weight: 1 }]; + } + } + + response.currency = assetProfile.currency; + + if (assetProfile.isin) { + response.isin = assetProfile.isin; + } + + response.name = this.formatName({ name: assetProfile.companyName }); + + if (assetProfile.website) { + response.url = assetProfile.website; + } + } + } catch (error) { + let message = error; + response = undefined; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'FinancialModelingPrepService'); + } + + return response; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: this.getName(), + isPremium: true, + name: 'Financial Modeling Prep', + url: 'https://financialmodelingprep.com/developer/docs' + }; + } + + public async getDividends({ + from, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetDividendsParams) { + if (isSameDay(from, to)) { + to = addDays(to, 1); + } + + try { + const queryParams = new URLSearchParams({ + symbol, + apikey: this.apiKey + }); + + const response: { + [date: string]: DataProviderHistoricalResponse; + } = {}; + + const dividends = await fetch( + `${this.getUrl({ version: 'stable' })}/dividends?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + dividends + .filter(({ date }) => { + return ( + (isSameDay(parseISO(date), from) || + isAfter(parseISO(date), from)) && + isBefore(parseISO(date), to) + ); + }) + .forEach(({ adjDividend, date }) => { + response[date] = { + marketPrice: adjDividend + }; + }); + + return response; + } catch (error) { + Logger.error( + `Could not get dividends for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, + 'FinancialModelingPrepService' + ); + + return {}; + } + } + + public async getHistorical({ + from, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + const MAX_YEARS_PER_REQUEST = 5; + const result: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = { + [symbol]: {} + }; + + let currentFrom = from; + + try { + while (isBefore(currentFrom, to) || isSameDay(currentFrom, to)) { + const currentTo = isBefore( + addYears(currentFrom, MAX_YEARS_PER_REQUEST), + to + ) + ? addYears(currentFrom, MAX_YEARS_PER_REQUEST) + : to; + + const queryParams = new URLSearchParams({ + symbol, + apikey: this.apiKey, + from: format(currentFrom, DATE_FORMAT), + to: format(currentTo, DATE_FORMAT) + }); + + const historical = await fetch( + `${this.getUrl({ version: 'stable' })}/historical-price-eod/full?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + for (const { close, date } of historical) { + if ( + (isSameDay(parseDate(date), currentFrom) || + isAfter(parseDate(date), currentFrom)) && + isBefore(parseDate(date), currentTo) + ) { + result[symbol][date] = { + marketPrice: close + }; + } + } + + currentFrom = addYears(currentFrom, MAX_YEARS_PER_REQUEST); + } + + return result; + } catch (error) { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getMaxNumberOfSymbolsPerRequest() { + return 20; + } + + public getName(): DataSource { + return DataSource.FINANCIAL_MODELING_PREP; + } + + public async getQuotes({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { + const response: { [symbol: string]: DataProviderResponse } = {}; + + if (symbols.length <= 0) { + return response; + } + + try { + const currencyBySymbolMap: { + [symbol: string]: Pick; + } = {}; + + const queryParams = new URLSearchParams({ + symbols: symbols.join(','), + apikey: this.apiKey + }); + + const [assetProfileResolutions, quotes] = await Promise.all([ + this.prismaService.assetProfileResolution.findMany({ + where: { + dataSourceTarget: this.getDataProviderInfo().dataSource, + symbolTarget: { in: symbols } + } + }), + fetch( + `${this.getUrl({ version: 'stable' })}/batch-quote-short?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then( + (res) => res.json() as unknown as { price: number; symbol: string }[] + ) + ]); + + for (const { currency, symbolTarget } of assetProfileResolutions) { + currencyBySymbolMap[symbolTarget] = { currency }; + } + + const resolvedSymbols = assetProfileResolutions.map( + ({ symbolTarget }) => { + return symbolTarget; + } + ); + + const symbolsToFetch = quotes + .map(({ symbol }) => { + return symbol; + }) + .filter((symbol) => { + return !resolvedSymbols.includes(symbol); + }); + + if (symbolsToFetch.length > 0) { + await Promise.all( + symbolsToFetch.map(async (symbol) => { + const assetProfile = await this.getAssetProfile({ + requestTimeout, + symbol + }); + + if (assetProfile?.currency) { + currencyBySymbolMap[symbol] = { + currency: assetProfile.currency + }; + } + }) + ); + } + + for (const { price, symbol } of quotes) { + let marketState: MarketState = 'delayed'; + + if ( + isCurrency( + symbol.substring(0, symbol.length - DEFAULT_CURRENCY.length) + ) + ) { + marketState = 'open'; + } + + response[symbol] = { + marketState, + currency: currencyBySymbolMap[symbol]?.currency, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: this.getDataProviderInfo().dataSource, + marketPrice: price + }; + } + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the quotes for ${symbols.join( + ', ' + )} was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'FinancialModelingPrepService'); + } + + return response; + } + + public getTestSymbol() { + return 'AAPL'; + } + + public async search({ + includeIndices = false, + query, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: GetSearchParams): Promise { + const assetProfileBySymbolMap: { + [symbol: string]: Partial; + } = {}; + + let items: LookupItem[] = []; + + try { + if (isISIN(query?.toUpperCase())) { + const queryParams = new URLSearchParams({ + apikey: this.apiKey, + isin: query.toUpperCase() + }); + + const result = await fetch( + `${this.getUrl({ version: 'stable' })}/search-isin?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()); + + await Promise.all( + result.map(({ symbol }) => { + return this.getAssetProfile({ symbol }).then((assetProfile) => { + assetProfileBySymbolMap[symbol] = assetProfile; + }); + }) + ); + + items = result.map(({ assetClass, assetSubClass, name, symbol }) => { + return { + assetClass, + assetSubClass, + symbol, + currency: assetProfileBySymbolMap[symbol]?.currency, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: this.getName(), + name: this.formatName({ name }) + }; + }); + } else { + const queryParams = new URLSearchParams({ + query, + apikey: this.apiKey + }); + + const [nameResults, symbolResults] = await Promise.all([ + fetch( + `${this.getUrl({ version: 'stable' })}/search-name?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()), + fetch( + `${this.getUrl({ version: 'stable' })}/search-symbol?${queryParams.toString()}`, + { + signal: AbortSignal.timeout(requestTimeout) + } + ).then((res) => res.json()) + ]); + + const result = uniqBy( + [...nameResults, ...symbolResults], + ({ exchange, symbol }) => { + return `${exchange}-${symbol}`; + } + ); + + items = result + .filter(({ exchange, symbol }) => { + if ( + exchange === 'FOREX' || + (includeIndices === false && symbol.startsWith('^')) + ) { + return false; + } + + return true; + }) + .map(({ currency, name, symbol }) => { + return { + currency, + symbol, + assetClass: undefined, // TODO + assetSubClass: undefined, // TODO + dataProviderInfo: this.getDataProviderInfo(), + dataSource: this.getName(), + name: this.formatName({ name }) + }; + }); + } + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'FinancialModelingPrepService'); + } + + return { items }; + } + + private formatName({ name }: { name: string }) { + if (name) { + for (const part of REPLACE_NAME_PARTS) { + name = name.replace(part, ''); + } + + name = name.trim(); + } + + return name; + } + + private getUrl({ version }: { version: number | 'stable' }) { + const baseUrl = 'https://financialmodelingprep.com'; + + if (version === 'stable') { + return `${baseUrl}/stable`; + } + + return `${baseUrl}/api/v${version}`; + } + + private parseAssetClass(profile: any): { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + } { + let assetClass: AssetClass; + let assetSubClass: AssetSubClass; + + if (profile) { + if (profile.isEtf) { + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.ETF; + } else if (profile.isFund) { + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.MUTUALFUND; + } else { + assetClass = AssetClass.EQUITY; + assetSubClass = AssetSubClass.STOCK; + } + } + + return { assetClass, assetSubClass }; + } +} diff --git a/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts new file mode 100644 index 000000000..2b49e89c2 --- /dev/null +++ b/apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts @@ -0,0 +1,354 @@ +import { environment } from '@ghostfolio/api/environments/environment'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + HEADER_KEY_TOKEN, + PROPERTY_API_KEY_GHOSTFOLIO +} from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DataProviderGhostfolioAssetProfileResponse, + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + DividendsResponse, + HistoricalResponse, + LookupResponse, + QuotesResponse +} from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import { format } from 'date-fns'; +import { StatusCodes } from 'http-status-codes'; + +@Injectable() +export class GhostfolioService implements DataProviderInterface { + private readonly URL = environment.production + ? 'https://ghostfol.io/api' + : `${this.configurationService.get('ROOT_URL')}/api`; + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly propertyService: PropertyService + ) {} + + public canHandle() { + return true; + } + + public async getAssetProfile({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol + }: GetAssetProfileParams): Promise> { + let assetProfile: DataProviderGhostfolioAssetProfileResponse; + + try { + const response = await fetch( + `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + assetProfile = + (await response.json()) as DataProviderGhostfolioAssetProfileResponse; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + Logger.error(message, 'GhostfolioService'); + } + + return assetProfile; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.GHOSTFOLIO, + isPremium: true, + name: 'Ghostfolio', + url: 'https://ghostfol.io' + }; + } + + public async getDividends({ + from, + granularity = 'day', + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetDividendsParams): Promise<{ + [date: string]: DataProviderHistoricalResponse; + }> { + let dividends: { + [date: string]: DataProviderHistoricalResponse; + } = {}; + + try { + const queryParams = new URLSearchParams({ + granularity, + from: format(from, DATE_FORMAT), + to: format(to, DATE_FORMAT) + }); + + const response = await fetch( + `${this.URL}/v2/data-providers/ghostfolio/dividends/${symbol}?${queryParams.toString()}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + dividends = ((await response.json()) as DividendsResponse).dividends; + } catch (error) { + let message = error; + + if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + Logger.error(message, 'GhostfolioService'); + } + + return dividends; + } + + public async getHistorical({ + from, + granularity = 'day', + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + try { + const queryParams = new URLSearchParams({ + granularity, + from: format(from, DATE_FORMAT), + to: format(to, DATE_FORMAT) + }); + + const response = await fetch( + `${this.URL}/v2/data-providers/ghostfolio/historical/${symbol}?${queryParams.toString()}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + const { historicalData } = (await response.json()) as HistoricalResponse; + + return { + [symbol]: historicalData + }; + } catch (error) { + if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + error.name = 'RequestError'; + error.message = + 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + error.name = 'RequestError'; + error.message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + Logger.error(error.message, 'GhostfolioService'); + + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getMaxNumberOfSymbolsPerRequest() { + return 20; + } + + public getName(): DataSource { + return DataSource.GHOSTFOLIO; + } + + public async getQuotes({ + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'), + symbols + }: GetQuotesParams): Promise<{ + [symbol: string]: DataProviderResponse; + }> { + let quotes: { [symbol: string]: DataProviderResponse } = {}; + + if (symbols.length <= 0) { + return quotes; + } + + try { + const queryParams = new URLSearchParams({ + symbols: symbols.join(',') + }); + + const response = await fetch( + `${this.URL}/v2/data-providers/ghostfolio/quotes?${queryParams.toString()}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + quotes = ((await response.json()) as QuotesResponse).quotes; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to get the quotes for ${symbols.join( + ', ' + )} was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + Logger.error(message, 'GhostfolioService'); + } + + return quotes; + } + + public getTestSymbol() { + return 'AAPL'; + } + + public async search({ + query, + requestTimeout = this.configurationService.get('REQUEST_TIMEOUT') + }: GetSearchParams): Promise { + let searchResult: LookupResponse = { items: [] }; + + try { + const queryParams = new URLSearchParams({ + query + }); + + const response = await fetch( + `${this.URL}/v2/data-providers/ghostfolio/lookup?${queryParams.toString()}`, + { + headers: await this.getRequestHeaders(), + signal: AbortSignal.timeout(requestTimeout) + } + ); + + if (!response.ok) { + throw new Response(await response.text(), { + status: response.status, + statusText: response.statusText + }); + } + + searchResult = (await response.json()) as LookupResponse; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( + requestTimeout / 1000 + ).toFixed(3)} seconds`; + } else if (error?.status === StatusCodes.TOO_MANY_REQUESTS) { + message = 'RequestError: The daily request limit has been exceeded'; + } else if ( + [StatusCodes.FORBIDDEN, StatusCodes.UNAUTHORIZED].includes( + error?.status + ) + ) { + message = + 'RequestError: The API key is invalid. Please update it in the Settings section of the Admin Control panel.'; + } + + Logger.error(message, 'GhostfolioService'); + } + + return searchResult; + } + + private async getRequestHeaders() { + const apiKey = await this.propertyService.getByKey( + PROPERTY_API_KEY_GHOSTFOLIO + ); + + return { + [HEADER_KEY_TOKEN]: `Api-Key ${apiKey}` + }; + } +} diff --git a/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts new file mode 100644 index 000000000..ba1e5bbe5 --- /dev/null +++ b/apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts @@ -0,0 +1,218 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupResponse +} from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import { format } from 'date-fns'; +import { GoogleSpreadsheet } from 'google-spreadsheet'; + +@Injectable() +export class GoogleSheetsService implements DataProviderInterface { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public canHandle() { + return true; + } + + public async getAssetProfile({}: GetAssetProfileParams): Promise< + Partial + > { + return undefined; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.GOOGLE_SHEETS, + isPremium: false, + name: 'Google Sheets', + url: 'https://docs.google.com/spreadsheets' + }; + } + + public async getDividends({}: GetDividendsParams) { + return {}; + } + + public async getHistorical({ + from, + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + try { + const sheet = await this.getSheet({ + symbol, + sheetId: this.configurationService.get('GOOGLE_SHEETS_ID') + }); + + const rows = await sheet.getRows(); + + const historicalData: { + [date: string]: DataProviderHistoricalResponse; + } = {}; + + rows + .filter((_row, index) => { + return index >= 1; + }) + .forEach((row) => { + const date = parseDate(row._rawData[0]); + const close = parseFloat(row._rawData[1]); + + historicalData[format(date, DATE_FORMAT)] = { marketPrice: close }; + }); + + return { + [symbol]: historicalData + }; + } catch (error) { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getName(): DataSource { + return DataSource.GOOGLE_SHEETS; + } + + public async getQuotes({ + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { + const response: { [symbol: string]: DataProviderResponse } = {}; + + if (symbols.length <= 0) { + return response; + } + + try { + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + symbols.map((symbol) => { + return { + symbol, + dataSource: this.getName() + }; + }) + ); + + const sheet = await this.getSheet({ + sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'), + symbol: 'Overview' + }); + + const rows = await sheet.getRows(); + + for (const row of rows) { + const marketPrice = parseFloat(row['marketPrice']); + const symbol = row['symbol']; + + if (symbols.includes(symbol)) { + response[symbol] = { + marketPrice, + currency: symbolProfiles.find((symbolProfile) => { + return symbolProfile.symbol === symbol; + })?.currency, + dataSource: this.getName(), + marketState: 'delayed' + }; + } + } + + return response; + } catch (error) { + Logger.error(error, 'GoogleSheetsService'); + } + + return {}; + } + + public getTestSymbol() { + return 'INDEXSP:.INX'; + } + + public async search({ query }: GetSearchParams): Promise { + const items = await this.prismaService.symbolProfile.findMany({ + select: { + assetClass: true, + assetSubClass: true, + currency: true, + dataSource: true, + name: true, + symbol: true + }, + where: { + OR: [ + { + dataSource: this.getName(), + name: { + mode: 'insensitive', + startsWith: query + } + }, + { + dataSource: this.getName(), + symbol: { + mode: 'insensitive', + startsWith: query + } + } + ] + } + }); + + return { + items: items.map((item) => { + return { ...item, dataProviderInfo: this.getDataProviderInfo() }; + }) + }; + } + + private async getSheet({ + sheetId, + symbol + }: { + sheetId: string; + symbol: string; + }) { + const doc = new GoogleSpreadsheet(sheetId); + + await doc.useServiceAccountAuth({ + client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'), + private_key: this.configurationService + .get('GOOGLE_SHEETS_PRIVATE_KEY') + .replace(/\\n/g, '\n') + }); + + await doc.loadInfo(); + + const sheet = doc.sheetsByTitle[symbol]; + + await sheet.loadCells(); + + return sheet; + } +} diff --git a/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts new file mode 100644 index 000000000..73e0cc69d --- /dev/null +++ b/apps/api/src/services/data-provider/interfaces/data-enhancer.interface.ts @@ -0,0 +1,17 @@ +import { SymbolProfile } from '@prisma/client'; + +export interface DataEnhancerInterface { + enhance({ + requestTimeout, + response, + symbol + }: { + requestTimeout?: number; + response: Partial; + symbol: string; + }): Promise>; + + getName(): string; + + getTestSymbol(): string; +} diff --git a/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts new file mode 100644 index 000000000..a55c9f328 --- /dev/null +++ b/apps/api/src/services/data-provider/interfaces/data-provider.interface.ts @@ -0,0 +1,85 @@ +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupResponse +} from '@ghostfolio/common/interfaces'; +import { Granularity } from '@ghostfolio/common/types'; + +import { DataSource, SymbolProfile } from '@prisma/client'; + +export interface DataProviderInterface { + canHandle(symbol: string): boolean; + + getAssetProfile({ + symbol + }: GetAssetProfileParams): Promise>; + + getDataProviderInfo(): DataProviderInfo; + + getDividends({ + from, + granularity, + requestTimeout, + symbol, + to + }: GetDividendsParams): Promise<{ + [date: string]: DataProviderHistoricalResponse; + }>; + + getHistorical({ + from, + granularity, + requestTimeout, + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }>; // TODO: Return only one symbol + + getMaxNumberOfSymbolsPerRequest?(): number; + + getName(): DataSource; + + getQuotes({ + requestTimeout, + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }>; + + getTestSymbol(): string; + + search({ includeIndices, query }: GetSearchParams): Promise; +} + +export interface GetAssetProfileParams { + requestTimeout?: number; + symbol: string; +} + +export interface GetDividendsParams { + from: Date; + granularity?: Granularity; + requestTimeout?: number; + symbol: string; + to: Date; +} + +export interface GetHistoricalParams { + from: Date; + granularity?: Granularity; + requestTimeout?: number; + symbol: string; + to: Date; +} + +export interface GetQuotesParams { + requestTimeout?: number; + symbols: string[]; +} + +export interface GetSearchParams { + includeIndices?: boolean; + query: string; + requestTimeout?: number; + userId?: string; +} diff --git a/apps/api/src/services/data-provider/manual/manual.service.ts b/apps/api/src/services/data-provider/manual/manual.service.ts new file mode 100644 index 000000000..51e65e631 --- /dev/null +++ b/apps/api/src/services/data-provider/manual/manual.service.ts @@ -0,0 +1,348 @@ +import { query } from '@ghostfolio/api/helper/object.helper'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + DATE_FORMAT, + extractNumberFromString, + getYesterday +} from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupResponse, + ScraperConfiguration +} from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import * as cheerio from 'cheerio'; +import { addDays, format, isBefore } from 'date-fns'; + +@Injectable() +export class ManualService implements DataProviderInterface { + public constructor( + private readonly configurationService: ConfigurationService, + private readonly prismaService: PrismaService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public canHandle() { + return true; + } + + public async getAssetProfile({ + symbol + }: GetAssetProfileParams): Promise> { + const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ + { symbol, dataSource: this.getName() } + ]); + + if (!symbolProfile) { + return undefined; + } + + return { + symbol, + currency: symbolProfile.currency, + dataSource: this.getName(), + name: symbolProfile.name + }; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.MANUAL, + isPremium: false + }; + } + + public async getDividends({}: GetDividendsParams) { + return {}; + } + + public async getHistorical({ + from, + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + try { + const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( + [{ symbol, dataSource: this.getName() }] + ); + const { defaultMarketPrice, selector, url } = + symbolProfile?.scraperConfiguration ?? {}; + + if (defaultMarketPrice) { + const historical: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = { + [symbol]: {} + }; + let date = from; + + while (isBefore(date, to)) { + historical[symbol][format(date, DATE_FORMAT)] = { + marketPrice: defaultMarketPrice + }; + + date = addDays(date, 1); + } + + return historical; + } else if (!selector || !url) { + return {}; + } + + const value = await this.scrape({ + symbol, + scraperConfiguration: symbolProfile.scraperConfiguration + }); + + return { + [symbol]: { + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: value + } + } + }; + } catch (error) { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + + public getName(): DataSource { + return DataSource.MANUAL; + } + + public async getQuotes({ + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { + const response: { [symbol: string]: DataProviderResponse } = {}; + + if (symbols.length <= 0) { + return response; + } + + try { + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + symbols.map((symbol) => { + return { symbol, dataSource: this.getName() }; + }) + ); + + const marketData = await this.prismaService.marketData.findMany({ + distinct: ['symbol'], + orderBy: { + date: 'desc' + }, + take: symbols.length, + where: { + symbol: { + in: symbols + } + } + }); + + const symbolProfilesWithScraperConfigurationAndInstantMode = + symbolProfiles.filter(({ scraperConfiguration }) => { + return ( + scraperConfiguration?.mode === 'instant' && + scraperConfiguration?.selector && + scraperConfiguration?.url + ); + }); + + const scraperResultPromises = + symbolProfilesWithScraperConfigurationAndInstantMode.map( + async ({ scraperConfiguration, symbol }) => { + try { + const marketPrice = await this.scrape({ + scraperConfiguration, + symbol + }); + return { marketPrice, symbol }; + } catch (error) { + Logger.error( + `Could not get quote for ${symbol} (${this.getName()}): [${error.name}] ${error.message}`, + 'ManualService' + ); + return { symbol, marketPrice: undefined }; + } + } + ); + + // Wait for all scraping requests to complete concurrently + const scraperResults = await Promise.all(scraperResultPromises); + + for (const { currency, symbol } of symbolProfiles) { + let { marketPrice } = + scraperResults.find((result) => { + return result.symbol === symbol; + }) ?? {}; + + marketPrice = + marketPrice ?? + marketData.find((marketDataItem) => { + return marketDataItem.symbol === symbol; + })?.marketPrice ?? + 0; + + response[symbol] = { + currency, + marketPrice, + dataSource: this.getName(), + marketState: 'delayed' + }; + } + + return response; + } catch (error) { + Logger.error(error, 'ManualService'); + } + + return {}; + } + + public getTestSymbol() { + return undefined; + } + + public async search({ + query, + userId + }: GetSearchParams): Promise { + const items = await this.prismaService.symbolProfile.findMany({ + select: { + assetClass: true, + assetSubClass: true, + currency: true, + dataSource: true, + name: true, + symbol: true, + userId: true + }, + where: { + AND: [ + { + dataSource: this.getName() + }, + { + OR: [ + { + name: { + mode: 'insensitive', + startsWith: query + } + }, + { + symbol: { + mode: 'insensitive', + startsWith: query + } + } + ] + }, + { + OR: [{ userId }, { userId: null }] + } + ] + } + }); + + return { + items: items.map((item) => { + return { ...item, dataProviderInfo: this.getDataProviderInfo() }; + }) + }; + } + + public async test({ + scraperConfiguration, + symbol + }: { + scraperConfiguration: ScraperConfiguration; + symbol: string; + }) { + return this.scrape({ scraperConfiguration, symbol }); + } + + private async scrape({ + scraperConfiguration, + symbol + }: { + scraperConfiguration: ScraperConfiguration; + symbol: string; + }): Promise { + let locale = scraperConfiguration.locale; + + const response = await fetch(scraperConfiguration.url, { + headers: scraperConfiguration.headers as HeadersInit, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + }); + + if (!response.ok) { + throw new Error( + `Failed to scrape the market price for ${symbol} (${this.getName()}): ${response.status} ${response.statusText} at ${scraperConfiguration.url}` + ); + } + + let value: string; + + if (response.headers.get('content-type')?.includes('application/json')) { + const object = await response.json(); + + value = String( + query({ + object, + pathExpression: scraperConfiguration.selector + })[0] + ); + } else { + const $ = cheerio.load(await response.text()); + + if (!locale) { + try { + locale = $('html').attr('lang'); + } catch {} + } + + value = $(scraperConfiguration.selector).first().text(); + + const lines = value?.split('\n') ?? []; + + const lineWithDigits = lines.find((line) => { + return /\d/.test(line); + }); + + if (lineWithDigits) { + value = lineWithDigits; + } + + return extractNumberFromString({ + locale, + value + }); + } + + return extractNumberFromString({ locale, value }); + } +} diff --git a/apps/api/src/services/data-provider/rapid-api/interfaces/interfaces.ts b/apps/api/src/services/data-provider/rapid-api/interfaces/interfaces.ts new file mode 100644 index 000000000..f87a22639 --- /dev/null +++ b/apps/api/src/services/data-provider/rapid-api/interfaces/interfaces.ts @@ -0,0 +1 @@ +export interface RapidApiResponse {} diff --git a/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts new file mode 100644 index 000000000..d6bc8d0e4 --- /dev/null +++ b/apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts @@ -0,0 +1,174 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { + ghostfolioFearAndGreedIndexSymbol, + ghostfolioFearAndGreedIndexSymbolStocks +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupResponse +} from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import { format } from 'date-fns'; + +@Injectable() +export class RapidApiService implements DataProviderInterface { + public constructor( + private readonly configurationService: ConfigurationService + ) {} + + public canHandle() { + return !!this.configurationService.get('API_KEY_RAPID_API'); + } + + public async getAssetProfile({}: GetAssetProfileParams): Promise< + Partial + > { + return undefined; + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.RAPID_API, + isPremium: false, + name: 'Rapid API', + url: 'https://rapidapi.com' + }; + } + + public async getDividends({}: GetDividendsParams) { + return {}; + } + + public async getHistorical({ + from, + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + try { + if ( + [ + ghostfolioFearAndGreedIndexSymbol, + ghostfolioFearAndGreedIndexSymbolStocks + ].includes(symbol) + ) { + const fgi = await this.getFearAndGreedIndex(); + + return { + [symbol]: { + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: fgi.previousClose.value + } + } + }; + } + } catch (error) { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + + return {}; + } + + public getName(): DataSource { + return DataSource.RAPID_API; + } + + public async getQuotes({ + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { + if (symbols.length <= 0) { + return {}; + } + + try { + const symbol = symbols[0]; + + if ( + [ + ghostfolioFearAndGreedIndexSymbol, + ghostfolioFearAndGreedIndexSymbolStocks + ].includes(symbol) + ) { + const fgi = await this.getFearAndGreedIndex(); + + return { + [symbol]: { + currency: undefined, + dataSource: this.getName(), + marketPrice: fgi.now.value, + marketState: 'open' + } + }; + } + } catch (error) { + Logger.error(error, 'RapidApiService'); + } + + return {}; + } + + public getTestSymbol() { + return undefined; + } + + public async search({}: GetSearchParams): Promise { + return { items: [] }; + } + + private async getFearAndGreedIndex(): Promise<{ + now: { value: number; valueText: string }; + previousClose: { value: number; valueText: string }; + oneWeekAgo: { value: number; valueText: string }; + oneMonthAgo: { value: number; valueText: string }; + oneYearAgo: { value: number; valueText: string }; + }> { + try { + const { fgi } = await fetch( + `https://fear-and-greed-index.p.rapidapi.com/v1/fgi`, + { + headers: { + useQueryString: 'true', + 'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com', + 'x-rapidapi-key': this.configurationService.get('API_KEY_RAPID_API') + }, + signal: AbortSignal.timeout( + this.configurationService.get('REQUEST_TIMEOUT') + ) + } + ).then((res) => res.json()); + + return fgi; + } catch (error) { + let message = error; + + if (['AbortError', 'TimeoutError'].includes(error?.name)) { + message = `RequestError: The operation was aborted because the request to the data provider took more than ${( + this.configurationService.get('REQUEST_TIMEOUT') / 1000 + ).toFixed(3)} seconds`; + } + + Logger.error(message, 'RapidApiService'); + + return undefined; + } + } +} diff --git a/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts new file mode 100644 index 000000000..de8807098 --- /dev/null +++ b/apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts @@ -0,0 +1,383 @@ +import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; +import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; +import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; +import { + DataProviderInterface, + GetAssetProfileParams, + GetDividendsParams, + GetHistoricalParams, + GetQuotesParams, + GetSearchParams +} from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface'; +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + LookupItem, + LookupResponse +} from '@ghostfolio/common/interfaces'; + +import { Injectable, Logger } from '@nestjs/common'; +import { DataSource, SymbolProfile } from '@prisma/client'; +import { addDays, format, isSameDay } from 'date-fns'; +import { uniqBy } from 'lodash'; +import YahooFinance from 'yahoo-finance2'; +import { ChartResultArray } from 'yahoo-finance2/esm/src/modules/chart'; +import { + HistoricalDividendsResult, + HistoricalHistoryResult +} from 'yahoo-finance2/esm/src/modules/historical'; +import { + Quote, + QuoteResponseArray +} from 'yahoo-finance2/esm/src/modules/quote'; +import { + Price, + QuoteSummaryResult +} from 'yahoo-finance2/esm/src/modules/quoteSummary'; +import { SearchQuoteNonYahoo } from 'yahoo-finance2/esm/src/modules/search'; + +@Injectable() +export class YahooFinanceService implements DataProviderInterface { + private readonly yahooFinance = new YahooFinance({ + suppressNotices: ['yahooSurvey'] + }); + + public constructor( + private readonly cryptocurrencyService: CryptocurrencyService, + private readonly yahooFinanceDataEnhancerService: YahooFinanceDataEnhancerService + ) {} + + public canHandle() { + return true; + } + + public async getAssetProfile({ + symbol + }: GetAssetProfileParams): Promise> { + return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol); + } + + public getDataProviderInfo(): DataProviderInfo { + return { + dataSource: DataSource.YAHOO, + isPremium: false, + name: 'Yahoo Finance', + url: 'https://finance.yahoo.com' + }; + } + + public async getDividends({ + from, + granularity = 'day', + symbol, + to + }: GetDividendsParams) { + if (isSameDay(from, to)) { + to = addDays(to, 1); + } + + try { + const historicalResult = this.convertToDividendResult( + await this.yahooFinance.chart( + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + symbol + ), + { + events: 'dividends', + interval: granularity === 'month' ? '1mo' : '1d', + period1: format(from, DATE_FORMAT), + period2: format(to, DATE_FORMAT) + } + ) + ); + const response: { + [date: string]: DataProviderHistoricalResponse; + } = {}; + + for (const historicalItem of historicalResult) { + response[format(historicalItem.date, DATE_FORMAT)] = { + marketPrice: historicalItem.dividends + }; + } + + return response; + } catch (error) { + Logger.error( + `Could not get dividends for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`, + 'YahooFinanceService' + ); + + return {}; + } + } + + public async getHistorical({ + from, + symbol, + to + }: GetHistoricalParams): Promise<{ + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + }> { + if (isSameDay(from, to)) { + to = addDays(to, 1); + } + + try { + const historicalResult = this.convertToHistoricalResult( + await this.yahooFinance.chart( + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol( + symbol + ), + { + interval: '1d', + period1: format(from, DATE_FORMAT), + period2: format(to, DATE_FORMAT) + } + ) + ); + + const response: { + [symbol: string]: { [date: string]: DataProviderHistoricalResponse }; + } = {}; + + response[symbol] = {}; + + for (const historicalItem of historicalResult) { + response[symbol][format(historicalItem.date, DATE_FORMAT)] = { + marketPrice: historicalItem.close + }; + } + + return response; + } catch (error) { + if (error.message === 'No data found, symbol may be delisted') { + throw new AssetProfileDelistedError( + `No data found, ${symbol} (${this.getName()}) may be delisted` + ); + } else { + throw new Error( + `Could not get historical market data for ${symbol} (${this.getName()}) from ${format( + from, + DATE_FORMAT + )} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}` + ); + } + } + } + + public getMaxNumberOfSymbolsPerRequest() { + return 50; + } + + public getName(): DataSource { + return DataSource.YAHOO; + } + + public async getQuotes({ + symbols + }: GetQuotesParams): Promise<{ [symbol: string]: DataProviderResponse }> { + const response: { [symbol: string]: DataProviderResponse } = {}; + + if (symbols.length <= 0) { + return response; + } + + const yahooFinanceSymbols = symbols.map((symbol) => + this.yahooFinanceDataEnhancerService.convertToYahooFinanceSymbol(symbol) + ); + + try { + let quotes: Price[] | Quote[] = []; + + try { + quotes = await this.yahooFinance.quote(yahooFinanceSymbols); + } catch (error) { + Logger.error(error, 'YahooFinanceService'); + + Logger.warn( + 'Fallback to yahooFinance.quoteSummary()', + 'YahooFinanceService' + ); + + quotes = await this.getQuotesWithQuoteSummary(yahooFinanceSymbols); + } + + for (const quote of quotes) { + // Convert symbols back + const symbol = + this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + quote.symbol + ); + + response[symbol] = { + currency: quote.currency, + dataSource: this.getName(), + marketState: + quote.marketState === 'REGULAR' || + this.cryptocurrencyService.isCryptocurrency(symbol) + ? 'open' + : 'closed', + marketPrice: quote.regularMarketPrice || 0 + }; + } + + return response; + } catch (error) { + Logger.error(error, 'YahooFinanceService'); + + return {}; + } + } + + public getTestSymbol() { + return 'AAPL'; + } + + public async search({ + includeIndices = false, + query + }: GetSearchParams): Promise { + const items: LookupItem[] = []; + + try { + const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND']; + + if (includeIndices) { + quoteTypes.push('INDEX'); + } + + const searchResult = await this.yahooFinance.search(query); + + const quotes = searchResult.quotes + .filter( + (quote): quote is Exclude => { + // Filter out undefined symbols + return !!quote.symbol; + } + ) + .filter(({ quoteType, symbol }) => { + return ( + (quoteType === 'CRYPTOCURRENCY' && + this.cryptocurrencyService.isCryptocurrency( + symbol.replace( + new RegExp(`-${DEFAULT_CURRENCY}$`), + DEFAULT_CURRENCY + ) + )) || + quoteTypes.includes(quoteType) + ); + }) + .filter(({ quoteType, symbol }) => { + if (quoteType === 'CRYPTOCURRENCY') { + // Only allow cryptocurrencies in base currency to avoid having redundancy in the database. + // Transactions need to be converted manually to the base currency before + return symbol.includes(DEFAULT_CURRENCY); + } else if (quoteType === 'FUTURE') { + // Allow GC=F, but not MGC=F + return symbol.length === 4; + } + + return true; + }); + + let marketData: QuoteResponseArray = []; + + try { + marketData = await this.yahooFinance.quote( + uniqBy(quotes, ({ symbol }) => { + return symbol; + }).map(({ symbol }) => { + return symbol; + }) + ); + } catch (error) { + if (error?.result?.length > 0) { + marketData = error.result; + } + } + + for (const { + currency, + longName, + quoteType, + shortName, + symbol + } of marketData) { + const { assetClass, assetSubClass } = + this.yahooFinanceDataEnhancerService.parseAssetClass({ + quoteType, + shortName + }); + + items.push({ + assetClass, + assetSubClass, + currency, + dataProviderInfo: this.getDataProviderInfo(), + dataSource: this.getName(), + name: this.yahooFinanceDataEnhancerService.formatName({ + longName, + quoteType, + shortName, + symbol + }), + symbol: + this.yahooFinanceDataEnhancerService.convertFromYahooFinanceSymbol( + symbol + ) + }); + } + } catch (error) { + Logger.error(error, 'YahooFinanceService'); + } + + return { items }; + } + + private convertToDividendResult( + result: ChartResultArray + ): HistoricalDividendsResult { + return result.events.dividends.map(({ amount: dividends, date }) => { + return { date, dividends }; + }); + } + + private convertToHistoricalResult( + result: ChartResultArray + ): HistoricalHistoryResult { + return result.quotes; + } + + private async getQuotesWithQuoteSummary(aYahooFinanceSymbols: string[]) { + const quoteSummaryPromises = aYahooFinanceSymbols.map((symbol) => { + return this.yahooFinance.quoteSummary(symbol); + }); + + const settledResults = await Promise.allSettled(quoteSummaryPromises); + + return settledResults + .filter( + (result): result is PromiseFulfilledResult => { + if (result.status === 'rejected') { + Logger.error( + `Could not get quote summary: ${result.reason}`, + 'YahooFinanceService' + ); + + return false; + } + + return true; + } + ) + .map(({ value }) => { + return value.price; + }); + } +} diff --git a/apps/api/src/services/demo/demo.module.ts b/apps/api/src/services/demo/demo.module.ts new file mode 100644 index 000000000..8f86de058 --- /dev/null +++ b/apps/api/src/services/demo/demo.module.ts @@ -0,0 +1,13 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +import { DemoService } from './demo.service'; + +@Module({ + exports: [DemoService], + imports: [PrismaModule, PropertyModule], + providers: [DemoService] +}) +export class DemoModule {} diff --git a/apps/api/src/services/demo/demo.service.ts b/apps/api/src/services/demo/demo.service.ts new file mode 100644 index 000000000..a24716d96 --- /dev/null +++ b/apps/api/src/services/demo/demo.service.ts @@ -0,0 +1,59 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + PROPERTY_DEMO_ACCOUNT_ID, + PROPERTY_DEMO_USER_ID, + TAG_ID_DEMO +} from '@ghostfolio/common/config'; + +import { Injectable } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + +@Injectable() +export class DemoService { + public constructor( + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService + ) {} + + public async syncDemoUserAccount() { + const [demoAccountId, demoUserId] = await Promise.all([ + this.propertyService.getByKey(PROPERTY_DEMO_ACCOUNT_ID), + this.propertyService.getByKey(PROPERTY_DEMO_USER_ID) + ]); + + let activities = await this.prismaService.order.findMany({ + orderBy: { + date: 'asc' + }, + where: { + tags: { + some: { + id: TAG_ID_DEMO + } + } + } + }); + + activities = activities.map((activity) => { + return { + ...activity, + accountId: demoAccountId, + accountUserId: demoUserId, + comment: null, + id: randomUUID(), + userId: demoUserId + }; + }); + + await this.prismaService.order.deleteMany({ + where: { + userId: demoUserId + } + }); + + return this.prismaService.order.createMany({ + data: activities + }); + } +} diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.module.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.module.ts new file mode 100644 index 000000000..3063ddaf5 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.module.ts @@ -0,0 +1,21 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [ExchangeRateDataService], + imports: [ + ConfigurationModule, + DataProviderModule, + MarketDataModule, + PrismaModule, + PropertyModule + ], + providers: [ExchangeRateDataService] +}) +export class ExchangeRateDataModule {} diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts new file mode 100644 index 000000000..742be36b4 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.mock.ts @@ -0,0 +1,48 @@ +import { ExchangeRateDataService } from './exchange-rate-data.service'; + +export const ExchangeRateDataServiceMock: Partial = { + getExchangeRatesByCurrency: ({ targetCurrency }) => { + if (targetCurrency === 'CHF') { + return Promise.resolve({ + CHFCHF: { + '2015-01-01': 1, + '2017-12-31': 1, + '2018-01-01': 1, + '2023-01-03': 1, + '2023-07-10': 1 + }, + USDCHF: { + '2015-01-01': 0.9941099999999999, + '2017-12-31': 0.9787, + '2018-01-01': 0.97373, + '2023-01-03': 0.9238, + '2023-07-10': 0.8854, + '2023-12-31': 0.85, + '2024-01-01': 0.86, + '2024-12-31': 0.9, + '2025-01-01': 0.91 + } + }); + } else if (targetCurrency === 'EUR') { + return Promise.resolve({ + EUREUR: { + '2021-12-12': 1 + }, + USDEUR: { + '2021-12-12': 0.8855 + } + }); + } else if (targetCurrency === 'USD') { + return Promise.resolve({ + USDUSD: { + '2018-01-01': 1, + '2021-11-16': 1, + '2021-12-12': 1, + '2023-07-10': 1 + } + }); + } + + return Promise.resolve({}); + } +}; diff --git a/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts new file mode 100644 index 000000000..024bdf4e1 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts @@ -0,0 +1,556 @@ +import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { PropertyService } from '@ghostfolio/api/services/property/property.service'; +import { + DEFAULT_CURRENCY, + DERIVED_CURRENCIES, + PROPERTY_CURRENCIES +} from '@ghostfolio/common/config'; +import { + DATE_FORMAT, + getYesterday, + resetHours +} from '@ghostfolio/common/helper'; + +import { Injectable, Logger } from '@nestjs/common'; +import { + eachDayOfInterval, + format, + isBefore, + isToday, + subDays +} from 'date-fns'; +import { isNumber } from 'lodash'; +import ms from 'ms'; + +import { ExchangeRatesByCurrency } from './interfaces/exchange-rate-data.interface'; + +@Injectable() +export class ExchangeRateDataService { + private currencies: string[] = []; + private currencyPairs: DataGatheringItem[] = []; + private derivedCurrencyFactors: { [currencyPair: string]: number } = {}; + private exchangeRates: { [currencyPair: string]: number } = {}; + + public constructor( + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService + ) {} + + public getCurrencies() { + return this.currencies?.length > 0 ? this.currencies : [DEFAULT_CURRENCY]; + } + + public getCurrencyPairs() { + return this.currencyPairs; + } + + @LogPerformance + public async getExchangeRatesByCurrency({ + currencies, + endDate = new Date(), + startDate, + targetCurrency + }: { + currencies: string[]; + endDate?: Date; + startDate: Date; + targetCurrency: string; + }): Promise { + if (!startDate) { + return {}; + } + + const exchangeRatesByCurrency: { + [currency: string]: { [dateString: string]: number }; + } = {}; + + for (const currency of currencies) { + exchangeRatesByCurrency[`${currency}${targetCurrency}`] = + await this.getExchangeRates({ + startDate, + currencyFrom: currency, + currencyTo: targetCurrency + }); + + const dateStrings = Object.keys( + exchangeRatesByCurrency[`${currency}${targetCurrency}`] + ); + const lastDateString = dateStrings.reduce((a, b) => { + return a > b ? a : b; + }, undefined); + + let previousExchangeRate = + exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[ + lastDateString + ] ?? 1; + + // Start from the most recent date and fill in missing exchange rates + // using the latest available rate + for ( + let date = endDate; + !isBefore(date, startDate); + date = subDays(resetHours(date), 1) + ) { + const dateString = format(date, DATE_FORMAT); + + // Check if the exchange rate for the current date is missing + if ( + isNaN( + exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] + ) + ) { + // If missing, fill with the previous exchange rate + exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] = + previousExchangeRate; + + if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) { + Logger.error( + `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, + 'ExchangeRateDataService' + ); + } + } else { + // If available, update the previous exchange rate + previousExchangeRate = + exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString]; + } + } + } + + return exchangeRatesByCurrency; + } + + public hasCurrencyPair(currency1: string, currency2: string) { + return this.currencyPairs.some(({ symbol }) => { + return ( + symbol === `${currency1}${currency2}` || + symbol === `${currency2}${currency1}` + ); + }); + } + + public async initialize() { + this.currencies = await this.prepareCurrencies(); + this.currencyPairs = []; + this.derivedCurrencyFactors = {}; + this.exchangeRates = {}; + + for (const { currency, factor, rootCurrency } of DERIVED_CURRENCIES) { + this.derivedCurrencyFactors[`${currency}${rootCurrency}`] = 1 / factor; + this.derivedCurrencyFactors[`${rootCurrency}${currency}`] = factor; + } + + for (const { + currency1, + currency2, + dataSource + } of this.prepareCurrencyPairs(this.currencies)) { + this.currencyPairs.push({ + dataSource, + symbol: `${currency1}${currency2}` + }); + } + + await this.loadCurrencies(); + } + + public async loadCurrencies() { + const result = await this.dataProviderService.getHistorical( + this.currencyPairs, + 'day', + getYesterday(), + getYesterday() + ); + + const quotes = await this.dataProviderService.getQuotes({ + items: this.currencyPairs.map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }), + requestTimeout: ms('30 seconds') + }); + + for (const symbol of Object.keys(quotes)) { + if (isNumber(quotes[symbol].marketPrice)) { + result[symbol] = { + [format(getYesterday(), DATE_FORMAT)]: { + marketPrice: quotes[symbol].marketPrice + } + }; + } + } + + const resultExtended = result; + + for (const symbol of Object.keys(result)) { + const [currency1, currency2] = symbol.match(/.{1,3}/g); + const [date] = Object.keys(result[symbol]); + + // Calculate the opposite direction + resultExtended[`${currency2}${currency1}`] = { + [date]: { + marketPrice: 1 / result[symbol][date].marketPrice + } + }; + } + + for (const symbol of Object.keys(resultExtended)) { + const [currency1, currency2] = symbol.match(/.{1,3}/g); + const date = format(getYesterday(), DATE_FORMAT); + + this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice; + + if (!this.exchangeRates[symbol]) { + // Not found, calculate indirectly via base currency + this.exchangeRates[symbol] = + resultExtended[`${currency1}${DEFAULT_CURRENCY}`]?.[date] + ?.marketPrice * + resultExtended[`${DEFAULT_CURRENCY}${currency2}`]?.[date] + ?.marketPrice; + + // Calculate the opposite direction + this.exchangeRates[`${currency2}${currency1}`] = + 1 / this.exchangeRates[symbol]; + } + } + } + + public toCurrency( + aValue: number, + aFromCurrency: string, + aToCurrency: string + ) { + if (aValue === 0) { + return 0; + } + + let factor: number; + + if (aFromCurrency === aToCurrency) { + factor = 1; + } else { + if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) { + factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`]; + } else { + // Calculate indirectly via base currency + const factor1 = + this.exchangeRates[`${aFromCurrency}${DEFAULT_CURRENCY}`]; + const factor2 = this.exchangeRates[`${DEFAULT_CURRENCY}${aToCurrency}`]; + + factor = factor1 * factor2; + + this.exchangeRates[`${aFromCurrency}${aToCurrency}`] = factor; + } + } + + if (isNumber(factor) && !isNaN(factor)) { + return factor * aValue; + } + + // Fallback with error, if currencies are not available + Logger.error( + `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`, + 'ExchangeRateDataService' + ); + + return aValue; + } + + public async toCurrencyAtDate( + aValue: number, + aFromCurrency: string, + aToCurrency: string, + aDate: Date + ) { + if (aValue === 0) { + return 0; + } + + if (isToday(aDate)) { + return this.toCurrency(aValue, aFromCurrency, aToCurrency); + } + + const derivedCurrencyFactor = + this.derivedCurrencyFactors[`${aFromCurrency}${aToCurrency}`]; + let factor: number; + + if (aFromCurrency === aToCurrency) { + factor = 1; + } else if (derivedCurrencyFactor) { + factor = derivedCurrencyFactor; + } else { + const dataSource = + this.dataProviderService.getDataSourceForExchangeRates(); + const symbol = `${aFromCurrency}${aToCurrency}`; + + const marketData = await this.marketDataService.get({ + dataSource, + symbol, + date: aDate + }); + + if (marketData?.marketPrice) { + factor = marketData?.marketPrice; + } else { + // Calculate indirectly via base currency + + let marketPriceBaseCurrencyFromCurrency: number; + let marketPriceBaseCurrencyToCurrency: number; + + try { + if (aFromCurrency === DEFAULT_CURRENCY) { + marketPriceBaseCurrencyFromCurrency = 1; + } else { + marketPriceBaseCurrencyFromCurrency = ( + await this.marketDataService.get({ + dataSource, + date: aDate, + symbol: `${DEFAULT_CURRENCY}${aFromCurrency}` + }) + )?.marketPrice; + } + } catch {} + + try { + if (aToCurrency === DEFAULT_CURRENCY) { + marketPriceBaseCurrencyToCurrency = 1; + } else { + marketPriceBaseCurrencyToCurrency = ( + await this.marketDataService.get({ + dataSource, + date: aDate, + symbol: `${DEFAULT_CURRENCY}${aToCurrency}` + }) + )?.marketPrice; + } + } catch {} + + // Calculate the opposite direction + factor = + (1 / marketPriceBaseCurrencyFromCurrency) * + marketPriceBaseCurrencyToCurrency; + } + } + + if (isNumber(factor) && !isNaN(factor)) { + return factor * aValue; + } + + Logger.error( + `No exchange rate has been found for ${aFromCurrency}${aToCurrency} at ${format( + aDate, + DATE_FORMAT + )}`, + 'ExchangeRateDataService' + ); + + return undefined; + } + + private async getExchangeRates({ + currencyFrom, + currencyTo, + endDate = new Date(), + startDate + }: { + currencyFrom: string; + currencyTo: string; + endDate?: Date; + startDate: Date; + }) { + const dates = eachDayOfInterval({ end: endDate, start: startDate }); + const factors: { [dateString: string]: number } = {}; + + if (currencyFrom === currencyTo) { + for (const date of dates) { + factors[format(date, DATE_FORMAT)] = 1; + } + + return factors; + } + + const derivedCurrencyFactor = + this.derivedCurrencyFactors[`${currencyFrom}${currencyTo}`]; + + if (derivedCurrencyFactor) { + for (const date of dates) { + factors[format(date, DATE_FORMAT)] = derivedCurrencyFactor; + } + + return factors; + } + + const dataSource = this.dataProviderService.getDataSourceForExchangeRates(); + const symbol = `${currencyFrom}${currencyTo}`; + + const marketData = await this.marketDataService.getRange({ + assetProfileIdentifiers: [ + { + dataSource, + symbol + } + ], + dateQuery: { gte: startDate, lt: endDate } + }); + + if (marketData?.length > 0) { + for (const { date, marketPrice } of marketData) { + factors[format(date, DATE_FORMAT)] = marketPrice; + } + } else { + // Calculate indirectly via base currency + + const marketPriceBaseCurrencyFromCurrency: { + [dateString: string]: number; + } = {}; + const marketPriceBaseCurrencyToCurrency: { + [dateString: string]: number; + } = {}; + + try { + if (currencyFrom === DEFAULT_CURRENCY) { + for (const date of dates) { + marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] = 1; + } + } else { + const marketData = await this.marketDataService.getRange({ + assetProfileIdentifiers: [ + { + dataSource, + symbol: `${DEFAULT_CURRENCY}${currencyFrom}` + } + ], + dateQuery: { gte: startDate, lt: endDate } + }); + + for (const { date, marketPrice } of marketData) { + marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)] = + marketPrice; + } + } + } catch {} + + try { + if (currencyTo === DEFAULT_CURRENCY) { + for (const date of dates) { + marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = 1; + } + } else { + const marketData = await this.marketDataService.getRange({ + assetProfileIdentifiers: [ + { + dataSource, + symbol: `${DEFAULT_CURRENCY}${currencyTo}` + } + ], + dateQuery: { + gte: startDate, + lt: endDate + } + }); + + for (const { date, marketPrice } of marketData) { + marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)] = + marketPrice; + } + } + } catch {} + + for (const date of dates) { + try { + const factor = + (1 / + marketPriceBaseCurrencyFromCurrency[format(date, DATE_FORMAT)]) * + marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)]; + + if (isNaN(factor)) { + throw new Error('Exchange rate is not a number'); + } else { + factors[format(date, DATE_FORMAT)] = factor; + } + } catch { + let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( + date, + DATE_FORMAT + )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`; + + if (DEFAULT_CURRENCY !== currencyTo) { + errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`; + } + + Logger.error(`${errorMessage}.`, 'ExchangeRateDataService'); + } + } + } + + return factors; + } + + private async prepareCurrencies(): Promise { + let currencies: string[] = [DEFAULT_CURRENCY]; + + ( + await this.prismaService.account.findMany({ + distinct: ['currency'], + orderBy: [{ currency: 'asc' }], + select: { currency: true }, + where: { + currency: { + not: null + } + } + }) + ).forEach(({ currency }) => { + currencies.push(currency); + }); + + ( + await this.prismaService.symbolProfile.findMany({ + distinct: ['currency'], + orderBy: [{ currency: 'asc' }], + select: { currency: true } + }) + ).forEach(({ currency }) => { + currencies.push(currency); + }); + + const customCurrencies = + await this.propertyService.getByKey(PROPERTY_CURRENCIES); + + if (customCurrencies?.length > 0) { + currencies = currencies.concat(customCurrencies); + } + + // Add derived currencies + currencies.push('USX'); + + for (const { currency, rootCurrency } of DERIVED_CURRENCIES) { + if (currencies.includes(currency) || currencies.includes(rootCurrency)) { + currencies.push(currency); + currencies.push(rootCurrency); + } + } + + return Array.from(new Set(currencies)).filter(Boolean).sort(); + } + + private prepareCurrencyPairs(aCurrencies: string[]) { + return aCurrencies + .filter((currency) => { + return currency !== DEFAULT_CURRENCY; + }) + .map((currency) => { + return { + currency1: DEFAULT_CURRENCY, + currency2: currency, + dataSource: this.dataProviderService.getDataSourceForExchangeRates(), + symbol: `${DEFAULT_CURRENCY}${currency}` + }; + }); + } +} diff --git a/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts new file mode 100644 index 000000000..8e0d2c0d4 --- /dev/null +++ b/apps/api/src/services/exchange-rate-data/interfaces/exchange-rate-data.interface.ts @@ -0,0 +1,5 @@ +export interface ExchangeRatesByCurrency { + [currency: string]: { + [dateString: string]: number; + }; +} diff --git a/apps/api/src/services/i18n/i18n.module.ts b/apps/api/src/services/i18n/i18n.module.ts new file mode 100644 index 000000000..68211de40 --- /dev/null +++ b/apps/api/src/services/i18n/i18n.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; + +import { I18nService } from './i18n.service'; + +@Module({ + exports: [I18nService], + providers: [I18nService] +}) +export class I18nModule {} diff --git a/apps/api/src/services/i18n/i18n.service.ts b/apps/api/src/services/i18n/i18n.service.ts new file mode 100644 index 000000000..cf340d7c6 --- /dev/null +++ b/apps/api/src/services/i18n/i18n.service.ts @@ -0,0 +1,76 @@ +import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config'; + +import { Injectable, Logger } from '@nestjs/common'; +import * as cheerio from 'cheerio'; +import { readFileSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +@Injectable() +export class I18nService { + private localesPath = join(__dirname, 'assets', 'locales'); + private translations: { [locale: string]: cheerio.CheerioAPI } = {}; + + public constructor() { + this.loadFiles(); + } + + public getTranslation({ + id, + languageCode, + placeholders + }: { + id: string; + languageCode: string; + placeholders?: Record; + }): string { + const $ = this.translations[languageCode]; + + if (!$) { + Logger.warn(`Translation not found for locale '${languageCode}'`); + } + + let translatedText = $( + `trans-unit[id="${id}"] > ${ + languageCode === DEFAULT_LANGUAGE_CODE ? 'source' : 'target' + }` + ).text(); + + if (!translatedText) { + Logger.warn( + `Translation not found for id '${id}' in locale '${languageCode}'` + ); + } + + if (placeholders) { + for (const [key, value] of Object.entries(placeholders)) { + translatedText = translatedText.replace(`\${${key}}`, String(value)); + } + } + + return translatedText.trim(); + } + + private loadFiles() { + try { + const files = readdirSync(this.localesPath, 'utf-8'); + + for (const file of files) { + const xmlData = readFileSync(join(this.localesPath, file), 'utf8'); + this.translations[this.parseLanguageCode(file)] = + this.parseXml(xmlData); + } + } catch (error) { + Logger.error(error, 'I18nService'); + } + } + + private parseLanguageCode(aFileName: string) { + const match = /\.([a-zA-Z]+)\.xlf$/.exec(aFileName); + + return match ? match[1] : DEFAULT_LANGUAGE_CODE; + } + + private parseXml(xmlData: string): cheerio.CheerioAPI { + return cheerio.load(xmlData, { xmlMode: true }); + } +} diff --git a/apps/api/src/services/impersonation/impersonation.module.ts b/apps/api/src/services/impersonation/impersonation.module.ts new file mode 100644 index 000000000..e4f503790 --- /dev/null +++ b/apps/api/src/services/impersonation/impersonation.module.ts @@ -0,0 +1,11 @@ +import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +@Module({ + imports: [PrismaModule], + providers: [ImpersonationService], + exports: [ImpersonationService] +}) +export class ImpersonationModule {} diff --git a/apps/api/src/services/impersonation/impersonation.service.ts b/apps/api/src/services/impersonation/impersonation.service.ts new file mode 100644 index 000000000..71c543a43 --- /dev/null +++ b/apps/api/src/services/impersonation/impersonation.service.ts @@ -0,0 +1,50 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; +import type { RequestWithUser } from '@ghostfolio/common/types'; + +import { Inject, Injectable } from '@nestjs/common'; +import { REQUEST } from '@nestjs/core'; + +@Injectable() +export class ImpersonationService { + public constructor( + private readonly prismaService: PrismaService, + @Inject(REQUEST) private readonly request: RequestWithUser + ) {} + + public async validateImpersonationId(aId = '') { + if (this.request.user) { + const accessObject = await this.prismaService.access.findFirst({ + where: { + granteeUserId: this.request.user.id, + id: aId + } + }); + + if (accessObject?.userId) { + return accessObject.userId; + } else if ( + hasPermission( + this.request.user.permissions, + permissions.impersonateAllUsers + ) + ) { + return aId; + } + } else { + // Public access + const accessObject = await this.prismaService.access.findFirst({ + where: { + granteeUserId: null, + user: { id: aId } + } + }); + + if (accessObject?.userId) { + return accessObject.userId; + } + } + + return null; + } +} diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts new file mode 100644 index 000000000..57c58898e --- /dev/null +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -0,0 +1,60 @@ +import { CleanedEnvAccessors } from 'envalid'; + +export interface Environment extends CleanedEnvAccessors { + ACCESS_TOKEN_SALT: string; + API_KEY_ALPHA_VANTAGE: string; + API_KEY_BETTER_UPTIME: string; + API_KEY_COINGECKO_DEMO: string; + API_KEY_COINGECKO_PRO: string; + API_KEY_EOD_HISTORICAL_DATA: string; + API_KEY_FINANCIAL_MODELING_PREP: string; + API_KEY_OPEN_FIGI: string; + API_KEY_RAPID_API: string; + CACHE_QUOTES_TTL: number; + CACHE_TTL: number; + DATA_SOURCE_EXCHANGE_RATES: string; + DATA_SOURCE_IMPORT: string; + DATA_SOURCES: string[]; + DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER: string[]; + ENABLE_FEATURE_AUTH_GOOGLE: boolean; + ENABLE_FEATURE_AUTH_OIDC: boolean; + ENABLE_FEATURE_AUTH_TOKEN: boolean; + ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean; + ENABLE_FEATURE_GATHER_NEW_EXCHANGE_RATES: boolean; + ENABLE_FEATURE_READ_ONLY_MODE: boolean; + ENABLE_FEATURE_STATISTICS: boolean; + ENABLE_FEATURE_SUBSCRIPTION: boolean; + ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; + GOOGLE_CLIENT_ID: string; + GOOGLE_SECRET: string; + GOOGLE_SHEETS_ACCOUNT: string; + GOOGLE_SHEETS_ID: string; + GOOGLE_SHEETS_PRIVATE_KEY: string; + JWT_SECRET_KEY: string; + MAX_ACTIVITIES_TO_IMPORT: number; + MAX_CHART_ITEMS: number; + OIDC_AUTHORIZATION_URL: string; + OIDC_CALLBACK_URL: string; + OIDC_CLIENT_ID: string; + OIDC_CLIENT_SECRET: string; + OIDC_ISSUER: string; + OIDC_SCOPE: string[]; + OIDC_TOKEN_URL: string; + OIDC_USER_INFO_URL: string; + PORT: number; + PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY: number; + PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY: number; + PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY: number; + PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT: number; + REDIS_DB: number; + REDIS_HOST: string; + REDIS_PASSWORD: string; + REDIS_PORT: number; + REQUEST_TIMEOUT: number; + ROOT_URL: string; + STRIPE_SECRET_KEY: string; + TWITTER_ACCESS_TOKEN: string; + TWITTER_ACCESS_TOKEN_SECRET: string; + TWITTER_API_KEY: string; + TWITTER_API_SECRET: string; +} diff --git a/apps/api/src/services/interfaces/interfaces.ts b/apps/api/src/services/interfaces/interfaces.ts new file mode 100644 index 000000000..87eaa3a75 --- /dev/null +++ b/apps/api/src/services/interfaces/interfaces.ts @@ -0,0 +1,6 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +export interface DataGatheringItem extends AssetProfileIdentifier { + date?: Date; + force?: boolean; +} diff --git a/apps/api/src/services/market-data/market-data.module.ts b/apps/api/src/services/market-data/market-data.module.ts new file mode 100644 index 000000000..77428ce49 --- /dev/null +++ b/apps/api/src/services/market-data/market-data.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { MarketDataService } from './market-data.service'; + +@Module({ + exports: [MarketDataService], + imports: [PrismaModule], + providers: [MarketDataService] +}) +export class MarketDataModule {} diff --git a/apps/api/src/services/market-data/market-data.service.ts b/apps/api/src/services/market-data/market-data.service.ts new file mode 100644 index 000000000..87b08e1bd --- /dev/null +++ b/apps/api/src/services/market-data/market-data.service.ts @@ -0,0 +1,263 @@ +import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { UpdateMarketDataDto } from '@ghostfolio/common/dtos'; +import { resetHours } from '@ghostfolio/common/helper'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@nestjs/common'; +import { + DataSource, + MarketData, + MarketDataState, + Prisma +} from '@prisma/client'; + +@Injectable() +export class MarketDataService { + public constructor(private readonly prismaService: PrismaService) {} + + public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) { + return this.prismaService.marketData.deleteMany({ + where: { + dataSource, + symbol + } + }); + } + + public async get({ + dataSource, + date = new Date(), + symbol + }: DataGatheringItem): Promise { + return await this.prismaService.marketData.findFirst({ + where: { + dataSource, + symbol, + date: resetHours(date) + } + }); + } + + public async getMax({ dataSource, symbol }: AssetProfileIdentifier) { + return this.prismaService.marketData.findFirst({ + select: { + date: true, + marketPrice: true + }, + orderBy: [ + { + marketPrice: 'desc' + } + ], + where: { + dataSource, + symbol + } + }); + } + + public async getRange({ + assetProfileIdentifiers, + dateQuery, + skip, + take + }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; + dateQuery: DateQuery; + skip?: number; + take?: number; + }): Promise { + return this.prismaService.marketData.findMany({ + skip, + take, + orderBy: [ + { + date: 'asc' + }, + { + symbol: 'asc' + } + ], + where: { + date: dateQuery, + OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + }) + } + }); + } + + public async getRangeCount({ + assetProfileIdentifiers, + dateQuery + }: { + assetProfileIdentifiers: AssetProfileIdentifier[]; + dateQuery: DateQuery; + }): Promise { + return this.prismaService.marketData.count({ + where: { + date: dateQuery, + OR: assetProfileIdentifiers.map(({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + }) + } + }); + } + + public async marketDataItems(params: { + select?: Prisma.MarketDataSelectScalar; + skip?: number; + take?: number; + cursor?: Prisma.MarketDataWhereUniqueInput; + where?: Prisma.MarketDataWhereInput; + orderBy?: Prisma.MarketDataOrderByWithRelationInput; + }): Promise { + const { select, skip, take, cursor, where, orderBy } = params; + + return this.prismaService.marketData.findMany({ + select, + cursor, + orderBy, + skip, + take, + where + }); + } + + /** + * Atomically replace market data for a symbol within a date range. + * Deletes existing data in the range and inserts new data within a single + * transaction to prevent data loss if the operation fails. + */ + public async replaceForSymbol({ + data, + dataSource, + symbol + }: AssetProfileIdentifier & { data: Prisma.MarketDataUpdateInput[] }) { + await this.prismaService.$transaction(async (prisma) => { + if (data.length > 0) { + let minTime = Infinity; + let maxTime = -Infinity; + + for (const { date } of data) { + const time = (date as Date).getTime(); + + if (time < minTime) { + minTime = time; + } + + if (time > maxTime) { + maxTime = time; + } + } + + const minDate = new Date(minTime); + const maxDate = new Date(maxTime); + + await prisma.marketData.deleteMany({ + where: { + dataSource, + symbol, + date: { + gte: minDate, + lte: maxDate + } + } + }); + + await prisma.marketData.createMany({ + data: data.map(({ date, marketPrice, state }) => ({ + dataSource, + symbol, + date: date as Date, + marketPrice: marketPrice as number, + state: state as MarketDataState + })), + skipDuplicates: true + }); + } + }); + } + + public async updateAssetProfileIdentifier( + oldAssetProfileIdentifier: AssetProfileIdentifier, + newAssetProfileIdentifier: AssetProfileIdentifier + ) { + return this.prismaService.marketData.updateMany({ + data: { + dataSource: newAssetProfileIdentifier.dataSource, + symbol: newAssetProfileIdentifier.symbol + }, + where: { + dataSource: oldAssetProfileIdentifier.dataSource, + symbol: oldAssetProfileIdentifier.symbol + } + }); + } + + public async updateMarketData(params: { + data: { + state: MarketDataState; + } & UpdateMarketDataDto; + where: Prisma.MarketDataWhereUniqueInput; + }): Promise { + const { data, where } = params; + + return this.prismaService.marketData.upsert({ + where, + create: { + dataSource: where.dataSource_date_symbol.dataSource, + date: where.dataSource_date_symbol.date, + marketPrice: data.marketPrice, + state: data.state, + symbol: where.dataSource_date_symbol.symbol + }, + update: { marketPrice: data.marketPrice, state: data.state } + }); + } + + /** + * Upsert market data by imitating missing upsertMany functionality + * with $transaction + */ + public async updateMany({ + data + }: { + data: Prisma.MarketDataUpdateInput[]; + }): Promise { + const upsertPromises = data.map( + ({ dataSource, date, marketPrice, symbol, state }) => { + return this.prismaService.marketData.upsert({ + create: { + dataSource: dataSource as DataSource, + date: date as Date, + marketPrice: marketPrice as number, + state: state as MarketDataState, + symbol: symbol as string + }, + update: { + marketPrice: marketPrice as number, + state: state as MarketDataState + }, + where: { + dataSource_date_symbol: { + dataSource: dataSource as DataSource, + date: date as Date, + symbol: symbol as string + } + } + }); + } + ); + + return this.prismaService.$transaction(upsertPromises); + } +} diff --git a/apps/api/src/services/prisma/prisma.module.ts b/apps/api/src/services/prisma/prisma.module.ts new file mode 100644 index 000000000..24da61047 --- /dev/null +++ b/apps/api/src/services/prisma/prisma.module.ts @@ -0,0 +1,10 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Module } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Module({ + exports: [PrismaService], + providers: [ConfigService, PrismaService] +}) +export class PrismaModule {} diff --git a/apps/api/src/services/prisma/prisma.service.ts b/apps/api/src/services/prisma/prisma.service.ts new file mode 100644 index 000000000..4673cbd19 --- /dev/null +++ b/apps/api/src/services/prisma/prisma.service.ts @@ -0,0 +1,47 @@ +import { + Injectable, + Logger, + LogLevel, + OnModuleDestroy, + OnModuleInit +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Prisma, PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService + extends PrismaClient + implements OnModuleInit, OnModuleDestroy +{ + public constructor(configService: ConfigService) { + let customLogLevels: LogLevel[]; + + try { + customLogLevels = JSON.parse( + configService.get('LOG_LEVELS') + ) as LogLevel[]; + } catch {} + + const log: Prisma.LogDefinition[] = + customLogLevels?.includes('debug') || customLogLevels?.includes('verbose') + ? [{ emit: 'stdout', level: 'query' }] + : []; + + super({ + log, + errorFormat: 'colorless' + }); + } + + public async onModuleInit() { + try { + await this.$connect(); + } catch (error) { + Logger.error(error, 'PrismaService'); + } + } + + public async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/api/src/services/property/interfaces/interfaces.ts b/apps/api/src/services/property/interfaces/interfaces.ts new file mode 100644 index 000000000..be9bc530d --- /dev/null +++ b/apps/api/src/services/property/interfaces/interfaces.ts @@ -0,0 +1 @@ +export type PropertyValue = boolean | object | string | string[]; diff --git a/apps/api/src/services/property/property.module.ts b/apps/api/src/services/property/property.module.ts new file mode 100644 index 000000000..239c560c1 --- /dev/null +++ b/apps/api/src/services/property/property.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { PropertyService } from './property.service'; + +@Module({ + exports: [PropertyService], + imports: [PrismaModule], + providers: [PropertyService] +}) +export class PropertyModule {} diff --git a/apps/api/src/services/property/property.service.ts b/apps/api/src/services/property/property.service.ts new file mode 100644 index 000000000..212635f49 --- /dev/null +++ b/apps/api/src/services/property/property.service.ts @@ -0,0 +1,61 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { + PROPERTY_CURRENCIES, + PROPERTY_IS_USER_SIGNUP_ENABLED +} from '@ghostfolio/common/config'; + +import { Injectable } from '@nestjs/common'; + +import { PropertyValue } from './interfaces/interfaces'; + +@Injectable() +export class PropertyService { + public constructor(private readonly prismaService: PrismaService) {} + + public async delete({ key }: { key: string }) { + return this.prismaService.property.delete({ + where: { key } + }); + } + + public async get() { + const response: { + [key: string]: PropertyValue; + } = { + [PROPERTY_CURRENCIES]: [] + }; + + const properties = await this.prismaService.property.findMany(); + + for (const property of properties) { + let value = property.value; + + try { + value = JSON.parse(property.value); + } catch {} + + response[property.key] = value; + } + + return response; + } + + public async getByKey(aKey: string) { + const properties = await this.get(); + return properties[aKey] as TValue; + } + + public async isUserSignupEnabled() { + return ( + (await this.getByKey(PROPERTY_IS_USER_SIGNUP_ENABLED)) ?? true + ); + } + + public async put({ key, value }: { key: string; value: string }) { + return this.prismaService.property.upsert({ + create: { key, value }, + update: { value }, + where: { key } + }); + } +} diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.module.ts b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts new file mode 100644 index 000000000..b51823476 --- /dev/null +++ b/apps/api/src/services/queues/data-gathering/data-gathering.module.ts @@ -0,0 +1,39 @@ +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 { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; +import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; +import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service'; +import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module'; +import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config'; + +import { BullModule } from '@nestjs/bull'; +import { Module } from '@nestjs/common'; +import ms from 'ms'; + +import { DataGatheringProcessor } from './data-gathering.processor'; + +@Module({ + imports: [ + BullModule.registerQueue({ + limiter: { + duration: ms('4 seconds'), + max: 1 + }, + name: DATA_GATHERING_QUEUE + }), + ConfigurationModule, + DataEnhancerModule, + DataProviderModule, + ExchangeRateDataModule, + MarketDataModule, + PrismaModule, + PropertyModule, + SymbolProfileModule + ], + providers: [DataGatheringProcessor, DataGatheringService], + exports: [BullModule, DataEnhancerModule, DataGatheringService] +}) +export class DataGatheringModule {} diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts new file mode 100644 index 000000000..1a4038652 --- /dev/null +++ b/apps/api/src/services/queues/data-gathering/data-gathering.processor.ts @@ -0,0 +1,205 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error'; +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; +import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; +import { + DATA_GATHERING_QUEUE, + DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY, + DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY, + GATHER_ASSET_PROFILE_PROCESS_JOB_NAME, + GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME +} from '@ghostfolio/common/config'; +import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper'; +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +import { Process, Processor } from '@nestjs/bull'; +import { Injectable, Logger } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { Job } from 'bull'; +import { + addDays, + format, + getDate, + getMonth, + getYear, + isBefore, + parseISO +} from 'date-fns'; + +import { DataGatheringService } from './data-gathering.service'; + +@Injectable() +@Processor(DATA_GATHERING_QUEUE) +export class DataGatheringProcessor { + public constructor( + private readonly dataGatheringService: DataGatheringService, + private readonly dataProviderService: DataProviderService, + private readonly marketDataService: MarketDataService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + @Process({ + concurrency: parseInt( + process.env.PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY ?? + DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY.toString(), + 10 + ), + name: GATHER_ASSET_PROFILE_PROCESS_JOB_NAME + }) + public async gatherAssetProfile(job: Job) { + const { dataSource, symbol } = job.data; + + try { + Logger.log( + `Asset profile data gathering has been started for ${symbol} (${dataSource})`, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + ); + + await this.dataGatheringService.gatherAssetProfiles([job.data]); + + Logger.log( + `Asset profile data gathering has been completed for ${symbol} (${dataSource})`, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + ); + } catch (error) { + if (error instanceof AssetProfileDelistedError) { + await this.symbolProfileService.updateSymbolProfile( + { + dataSource, + symbol + }, + { + isActive: false + } + ); + + Logger.log( + `Asset profile data gathering has been discarded for ${symbol} (${dataSource})`, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + ); + + return job.discard(); + } + + Logger.error( + error, + `DataGatheringProcessor (${GATHER_ASSET_PROFILE_PROCESS_JOB_NAME})` + ); + + throw error; + } + } + + @Process({ + concurrency: parseInt( + process.env.PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY ?? + DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(), + 10 + ), + name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME + }) + public async gatherHistoricalMarketData(job: Job) { + const { dataSource, date, force, symbol } = job.data; + + try { + let currentDate = parseISO(date as unknown as string); + + Logger.log( + `Historical market data gathering has been started for ${symbol} (${dataSource}) at ${format( + currentDate, + DATE_FORMAT + )}${force ? ' (forced update)' : ''}`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + + const historicalData = await this.dataProviderService.getHistoricalRaw({ + assetProfileIdentifiers: [{ dataSource, symbol }], + from: currentDate, + to: new Date() + }); + + const data: Prisma.MarketDataUpdateInput[] = []; + let lastMarketPrice: number; + + while ( + isBefore( + currentDate, + new Date( + Date.UTC( + getYear(new Date()), + getMonth(new Date()), + getDate(new Date()), + 0 + ) + ) + ) + ) { + if ( + historicalData[symbol]?.[format(currentDate, DATE_FORMAT)] + ?.marketPrice + ) { + lastMarketPrice = + historicalData[symbol]?.[format(currentDate, DATE_FORMAT)] + ?.marketPrice; + } + + if (lastMarketPrice) { + data.push({ + dataSource, + symbol, + date: getStartOfUtcDate(currentDate), + marketPrice: lastMarketPrice, + state: 'CLOSE' + }); + } + + currentDate = addDays(currentDate, 1); + } + + if (force) { + await this.marketDataService.replaceForSymbol({ + data, + dataSource, + symbol + }); + } else { + await this.marketDataService.updateMany({ data }); + } + + Logger.log( + `Historical market data gathering has been completed for ${symbol} (${dataSource}) at ${format( + currentDate, + DATE_FORMAT + )}`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + } catch (error) { + if (error instanceof AssetProfileDelistedError) { + await this.symbolProfileService.updateSymbolProfile( + { + dataSource, + symbol + }, + { + isActive: false + } + ); + + Logger.log( + `Historical market data gathering has been discarded for ${symbol} (${dataSource})`, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + + return job.discard(); + } + + Logger.error( + error, + `DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})` + ); + + throw error; + } + } +} diff --git a/apps/api/src/services/queues/data-gathering/data-gathering.service.ts b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts new file mode 100644 index 000000000..cec63c3eb --- /dev/null +++ b/apps/api/src/services/queues/data-gathering/data-gathering.service.ts @@ -0,0 +1,487 @@ +import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; +import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; +import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; +import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; +import { 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 { + DATA_GATHERING_QUEUE, + DATA_GATHERING_QUEUE_PRIORITY_HIGH, + DATA_GATHERING_QUEUE_PRIORITY_LOW, + DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, + GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, + PROPERTY_BENCHMARKS +} from '@ghostfolio/common/config'; +import { + DATE_FORMAT, + getAssetProfileIdentifier, + resetHours +} from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + BenchmarkProperty +} from '@ghostfolio/common/interfaces'; + +import { InjectQueue } from '@nestjs/bull'; +import { Inject, Injectable, Logger } from '@nestjs/common'; +import { DataSource } from '@prisma/client'; +import { JobOptions, Queue } from 'bull'; +import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; +import { isEmpty } from 'lodash'; +import ms, { StringValue } from 'ms'; + +@Injectable() +export class DataGatheringService { + public constructor( + @Inject('DataEnhancers') + private readonly dataEnhancers: DataEnhancerInterface[], + @InjectQueue(DATA_GATHERING_QUEUE) + private readonly dataGatheringQueue: Queue, + private readonly dataProviderService: DataProviderService, + private readonly exchangeRateDataService: ExchangeRateDataService, + private readonly prismaService: PrismaService, + private readonly propertyService: PropertyService, + private readonly symbolProfileService: SymbolProfileService + ) {} + + public async addJobToQueue({ + data, + name, + opts + }: { + data: any; + name: string; + opts?: JobOptions; + }) { + return this.dataGatheringQueue.add(name, data, opts); + } + + public async addJobsToQueue( + jobs: { data: any; name: string; opts?: JobOptions }[] + ) { + return this.dataGatheringQueue.addBulk(jobs); + } + + public async gather7Days() { + await this.gatherSymbols({ + dataGatheringItems: await this.getCurrencies7D(), + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: true + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_MEDIUM + }); + + await this.gatherSymbols({ + dataGatheringItems: await this.getSymbols7D({ + withUserSubscription: false + }), + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + }); + } + + public async gatherMax() { + const dataGatheringItems = await this.getSymbolsMax(); + await this.gatherSymbols({ + dataGatheringItems, + priority: DATA_GATHERING_QUEUE_PRIORITY_LOW + }); + } + + public async gatherSymbol({ dataSource, date, symbol }: DataGatheringItem) { + const dataGatheringItems = (await this.getSymbolsMax()) + .filter((dataGatheringItem) => { + return ( + dataGatheringItem.dataSource === dataSource && + dataGatheringItem.symbol === symbol + ); + }) + .map((item) => ({ + ...item, + date: date ?? item.date + })); + + await this.gatherSymbols({ + dataGatheringItems, + force: true, + priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH + }); + } + + public async gatherSymbolForDate({ + dataSource, + date, + symbol + }: { + dataSource: DataSource; + date: Date; + symbol: string; + }) { + try { + const historicalData = await this.dataProviderService.getHistoricalRaw({ + assetProfileIdentifiers: [{ dataSource, symbol }], + from: date, + to: date + }); + + const marketPrice = + historicalData[symbol][format(date, DATE_FORMAT)].marketPrice; + + if (marketPrice) { + return await this.prismaService.marketData.upsert({ + create: { + dataSource, + date, + marketPrice, + symbol + }, + update: { marketPrice }, + where: { dataSource_date_symbol: { dataSource, date, symbol } } + }); + } + } catch (error) { + Logger.error(error, 'DataGatheringService'); + } finally { + return undefined; + } + } + + public async gatherAssetProfiles( + aAssetProfileIdentifiers?: AssetProfileIdentifier[] + ) { + let assetProfileIdentifiers = aAssetProfileIdentifiers?.filter( + (dataGatheringItem) => { + return dataGatheringItem.dataSource !== 'MANUAL'; + } + ); + + if (!assetProfileIdentifiers) { + assetProfileIdentifiers = await this.getActiveAssetProfileIdentifiers(); + } + + if (assetProfileIdentifiers.length <= 0) { + return; + } + + const assetProfiles = await this.dataProviderService.getAssetProfiles( + assetProfileIdentifiers + ); + const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( + assetProfileIdentifiers + ); + + for (const [symbol, assetProfile] of Object.entries(assetProfiles)) { + const symbolMapping = symbolProfiles.find((symbolProfile) => { + return symbolProfile.symbol === symbol; + })?.symbolMapping; + + for (const dataEnhancer of this.dataEnhancers) { + try { + assetProfiles[symbol] = await dataEnhancer.enhance({ + response: assetProfile, + symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol + }); + } catch (error) { + Logger.error( + `Failed to enhance data for ${symbol} (${ + assetProfile.dataSource + }) by ${dataEnhancer.getName()}`, + error, + 'DataGatheringService' + ); + } + } + + const { + assetClass, + assetSubClass, + countries, + currency, + cusip, + dataSource, + figi, + figiComposite, + figiShareClass, + holdings, + isin, + name, + sectors, + url + } = assetProfile; + + try { + await this.prismaService.symbolProfile.upsert({ + create: { + assetClass, + assetSubClass, + countries, + currency, + cusip, + dataSource, + figi, + figiComposite, + figiShareClass, + holdings, + isin, + name, + sectors, + symbol, + url + }, + update: { + assetClass, + assetSubClass, + countries, + currency, + cusip, + figi, + figiComposite, + figiShareClass, + holdings, + isin, + name, + sectors, + url + }, + where: { + dataSource_symbol: { + dataSource, + symbol + } + } + }); + } catch (error) { + Logger.error( + `${symbol}: ${error?.meta?.cause}`, + error, + 'DataGatheringService' + ); + + if (assetProfileIdentifiers.length === 1) { + throw error; + } + } + } + } + + public async gatherSymbols({ + dataGatheringItems, + force = false, + priority + }: { + dataGatheringItems: DataGatheringItem[]; + force?: boolean; + priority: number; + }) { + await this.addJobsToQueue( + dataGatheringItems.map(({ dataSource, date, symbol }) => { + return { + data: { + dataSource, + date, + force, + symbol + }, + name: GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, + opts: { + ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, + priority, + jobId: `${getAssetProfileIdentifier({ + dataSource, + symbol + })}-${format(date, DATE_FORMAT)}` + } + }; + }) + ); + } + + /** + * Returns active asset profile identifiers + * + * @param {StringValue} maxAge - Optional. Specifies the maximum allowed age + * of a profile’s last update timestamp. Only asset profiles considered stale + * are returned. + */ + public async getActiveAssetProfileIdentifiers({ + maxAge + }: { + maxAge?: StringValue; + } = {}): Promise { + return this.prismaService.symbolProfile.findMany({ + orderBy: [{ symbol: 'asc' }, { dataSource: 'asc' }], + select: { + dataSource: true, + symbol: true + }, + where: { + dataSource: { + notIn: ['MANUAL', 'RAPID_API'] + }, + isActive: true, + ...(maxAge && { + updatedAt: { + lt: subMilliseconds(new Date(), ms(maxAge)) + } + }) + } + }); + } + + private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise< + AssetProfileIdentifier[] + > { + return ( + await this.prismaService.marketData.groupBy({ + _count: true, + by: ['dataSource', 'symbol'], + orderBy: [{ symbol: 'asc' }], + where: { + date: { gt: subDays(resetHours(new Date()), 7) }, + state: 'CLOSE' + } + }) + ) + .filter(({ _count }) => { + return _count >= 6; + }) + .map(({ dataSource, symbol }) => { + return { dataSource, symbol }; + }); + } + + private async getCurrencies7D(): Promise { + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); + + return this.exchangeRateDataService + .getCurrencyPairs() + .filter(({ dataSource, symbol }) => { + return !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }); + }) + .map(({ dataSource, symbol }) => { + return { + dataSource, + symbol, + date: subDays(resetHours(new Date()), 7) + }; + }); + } + + private getEarliestDate(aStartDate: Date) { + return min([aStartDate, subYears(new Date(), 10)]); + } + + private async getSymbols7D({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }): Promise { + const symbolProfiles = + await this.symbolProfileService.getActiveSymbolProfilesByUserSubscription( + { + withUserSubscription + } + ); + + const assetProfileIdentifiersWithCompleteMarketData = + await this.getAssetProfileIdentifiersWithCompleteMarketData(); + + return symbolProfiles + .filter(({ dataSource, scraperConfiguration, symbol }) => { + const manualDataSourceWithScraperConfiguration = + dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); + + return ( + !assetProfileIdentifiersWithCompleteMarketData.some((item) => { + return item.dataSource === dataSource && item.symbol === symbol; + }) && + (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) + ); + }) + .map((symbolProfile) => { + return { + ...symbolProfile, + date: subDays(resetHours(new Date()), 7) + }; + }); + } + + private async getSymbolsMax(): Promise { + const benchmarkAssetProfileIdMap: { [key: string]: boolean } = {}; + ( + (await this.propertyService.getByKey( + PROPERTY_BENCHMARKS + )) ?? [] + ).forEach(({ symbolProfileId }) => { + benchmarkAssetProfileIdMap[symbolProfileId] = true; + }); + const startDate = + ( + await this.prismaService.order.findFirst({ + orderBy: [{ date: 'asc' }] + }) + )?.date ?? new Date(); + + const currencyPairsToGather = this.exchangeRateDataService + .getCurrencyPairs() + .map(({ dataSource, symbol }) => { + return { + dataSource, + symbol, + date: this.getEarliestDate(startDate) + }; + }); + + const symbolProfilesToGather = ( + await this.prismaService.symbolProfile.findMany({ + orderBy: [{ symbol: 'asc' }], + select: { + activities: { + orderBy: [{ date: 'asc' }], + select: { date: true }, + take: 1 + }, + dataSource: true, + id: true, + scraperConfiguration: true, + symbol: true + }, + where: { + isActive: true + } + }) + ) + .filter((symbolProfile) => { + const manualDataSourceWithScraperConfiguration = + symbolProfile.dataSource === 'MANUAL' && + !isEmpty(symbolProfile.scraperConfiguration); + + return ( + symbolProfile.dataSource !== 'MANUAL' || + manualDataSourceWithScraperConfiguration + ); + }) + .map((symbolProfile) => { + let date = symbolProfile.activities?.[0]?.date ?? startDate; + + if (benchmarkAssetProfileIdMap[symbolProfile.id]) { + date = this.getEarliestDate(startDate); + } + + return { + ...symbolProfile, + date + }; + }); + + return [...currencyPairsToGather, ...symbolProfilesToGather]; + } +} diff --git a/apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts b/apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts new file mode 100644 index 000000000..3486974f7 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/interfaces/portfolio-snapshot-queue-job.interface.ts @@ -0,0 +1,9 @@ +import { Filter } from '@ghostfolio/common/interfaces'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +export interface PortfolioSnapshotQueueJob { + calculationType: PerformanceCalculationType; + filters: Filter[]; + userCurrency: string; + userId: string; +} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts new file mode 100644 index 000000000..958636334 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.module.ts @@ -0,0 +1,49 @@ +import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module'; +import { OrderModule } from '@ghostfolio/api/app/order/order.module'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; +import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; +import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; +import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module'; +import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; +import { + DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT, + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE +} from '@ghostfolio/common/config'; + +import { BullModule } from '@nestjs/bull'; +import { Module } from '@nestjs/common'; + +import { PortfolioSnapshotProcessor } from './portfolio-snapshot.processor'; + +@Module({ + exports: [BullModule, PortfolioSnapshotService], + imports: [ + AccountBalanceModule, + BullModule.registerQueue({ + name: PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE, + settings: { + lockDuration: parseInt( + process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT ?? + DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT.toString(), + 10 + ) + } + }), + ConfigurationModule, + DataProviderModule, + ExchangeRateDataModule, + MarketDataModule, + OrderModule, + RedisCacheModule + ], + providers: [ + CurrentRateService, + PortfolioCalculatorFactory, + PortfolioSnapshotProcessor, + PortfolioSnapshotService + ] +}) +export class PortfolioSnapshotQueueModule {} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts new file mode 100644 index 000000000..58a0a8f8a --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts @@ -0,0 +1,112 @@ +import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service'; +import { OrderService } from '@ghostfolio/api/app/order/order.service'; +import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; +import { PortfolioSnapshotValue } from '@ghostfolio/api/app/portfolio/interfaces/snapshot-value.interface'; +import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + CACHE_TTL_INFINITE, + DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY, + PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME, + PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE +} from '@ghostfolio/common/config'; + +import { Process, Processor } from '@nestjs/bull'; +import { Injectable, Logger } from '@nestjs/common'; +import { Job } from 'bull'; +import { addMilliseconds } from 'date-fns'; + +import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; + +@Injectable() +@Processor(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) +export class PortfolioSnapshotProcessor { + public constructor( + private readonly accountBalanceService: AccountBalanceService, + private readonly calculatorFactory: PortfolioCalculatorFactory, + private readonly configurationService: ConfigurationService, + private readonly orderService: OrderService, + private readonly redisCacheService: RedisCacheService + ) {} + + @Process({ + concurrency: parseInt( + process.env.PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY ?? + DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY.toString(), + 10 + ), + name: PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME + }) + public async calculatePortfolioSnapshot(job: Job) { + try { + const startTime = performance.now(); + + Logger.log( + `Portfolio snapshot calculation of user '${job.data.userId}' has been started`, + `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ); + + const { activities } = + await this.orderService.getOrdersForPortfolioCalculator({ + filters: job.data.filters, + userCurrency: job.data.userCurrency, + userId: job.data.userId, + withCash: true + }); + + const accountBalanceItems = + await this.accountBalanceService.getAccountBalanceItems({ + filters: job.data.filters, + userCurrency: job.data.userCurrency, + userId: job.data.userId + }); + + const portfolioCalculator = this.calculatorFactory.createCalculator({ + accountBalanceItems, + activities, + calculationType: job.data.calculationType, + currency: job.data.userCurrency, + filters: job.data.filters, + userId: job.data.userId + }); + + const snapshot = await portfolioCalculator.computeSnapshot(); + + Logger.log( + `Portfolio snapshot calculation of user '${job.data.userId}' has been completed in ${( + (performance.now() - startTime) / + 1000 + ).toFixed(3)} seconds`, + `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ); + + const expiration = addMilliseconds( + new Date(), + (snapshot?.errors?.length ?? 0) === 0 + ? this.configurationService.get('CACHE_QUOTES_TTL') + : 0 + ); + + this.redisCacheService.set( + this.redisCacheService.getPortfolioSnapshotKey({ + filters: job.data.filters, + userId: job.data.userId + }), + JSON.stringify({ + expiration: expiration.getTime(), + portfolioSnapshot: snapshot + } as unknown as PortfolioSnapshotValue), + CACHE_TTL_INFINITE + ); + + return snapshot; + } catch (error) { + Logger.error( + error, + `PortfolioSnapshotProcessor (${PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME})` + ); + + throw new Error(error); + } + } +} diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts new file mode 100644 index 000000000..898718106 --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock.ts @@ -0,0 +1,32 @@ +import { Job, JobOptions } from 'bull'; +import { setTimeout } from 'timers/promises'; + +import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; + +export const PortfolioSnapshotServiceMock = { + addJobToQueue({ + opts + }: { + data: PortfolioSnapshotQueueJob; + name: string; + opts?: JobOptions; + }): Promise> { + const mockJob: Partial> = { + finished: async () => { + await setTimeout(100); + + return Promise.resolve(); + } + }; + + this.jobsStore.set(opts?.jobId, mockJob); + + return Promise.resolve(mockJob as Job); + }, + getJob(jobId: string): Promise> { + const job = this.jobsStore.get(jobId); + + return Promise.resolve(job as Job); + }, + jobsStore: new Map>>() +}; diff --git a/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts new file mode 100644 index 000000000..d7449a9cc --- /dev/null +++ b/apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.service.ts @@ -0,0 +1,31 @@ +import { PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE } from '@ghostfolio/common/config'; + +import { InjectQueue } from '@nestjs/bull'; +import { Injectable } from '@nestjs/common'; +import { JobOptions, Queue } from 'bull'; + +import { PortfolioSnapshotQueueJob } from './interfaces/portfolio-snapshot-queue-job.interface'; + +@Injectable() +export class PortfolioSnapshotService { + public constructor( + @InjectQueue(PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE) + private readonly portfolioSnapshotQueue: Queue + ) {} + + public async addJobToQueue({ + data, + name, + opts + }: { + data: PortfolioSnapshotQueueJob; + name: string; + opts?: JobOptions; + }) { + return this.portfolioSnapshotQueue.add(name, data, opts); + } + + public async getJob(jobId: string) { + return this.portfolioSnapshotQueue.getJob(jobId); + } +} diff --git a/apps/api/src/services/symbol-profile/symbol-profile.module.ts b/apps/api/src/services/symbol-profile/symbol-profile.module.ts new file mode 100644 index 000000000..2360cad10 --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { SymbolProfileService } from './symbol-profile.service'; + +@Module({ + imports: [PrismaModule], + providers: [SymbolProfileService], + exports: [SymbolProfileService] +}) +export class SymbolProfileModule {} diff --git a/apps/api/src/services/symbol-profile/symbol-profile.service.ts b/apps/api/src/services/symbol-profile/symbol-profile.service.ts new file mode 100644 index 000000000..4c2c42589 --- /dev/null +++ b/apps/api/src/services/symbol-profile/symbol-profile.service.ts @@ -0,0 +1,341 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { + AssetProfileIdentifier, + EnhancedSymbolProfile, + Holding, + ScraperConfiguration +} from '@ghostfolio/common/interfaces'; +import { Country } from '@ghostfolio/common/interfaces/country.interface'; +import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; + +import { Injectable } from '@nestjs/common'; +import { Prisma, SymbolProfile, SymbolProfileOverrides } from '@prisma/client'; +import { continents, countries } from 'countries-list'; + +@Injectable() +export class SymbolProfileService { + public constructor(private readonly prismaService: PrismaService) {} + + public async add( + assetProfile: Prisma.SymbolProfileCreateInput + ): Promise { + return this.prismaService.symbolProfile.create({ data: assetProfile }); + } + + public async delete({ dataSource, symbol }: AssetProfileIdentifier) { + return this.prismaService.symbolProfile.delete({ + where: { dataSource_symbol: { dataSource, symbol } } + }); + } + + public async deleteById(id: string) { + return this.prismaService.symbolProfile.delete({ + where: { id } + }); + } + + public async getActiveSymbolProfilesByUserSubscription({ + withUserSubscription = false + }: { + withUserSubscription?: boolean; + }) { + return this.prismaService.symbolProfile.findMany({ + include: { + activities: { + include: { + user: true + } + } + }, + orderBy: [{ symbol: 'asc' }], + where: { + activities: withUserSubscription + ? { + some: { + user: { + subscriptions: { some: { expiresAt: { gt: new Date() } } } + } + } + } + : { + every: { + user: { + subscriptions: { none: { expiresAt: { gt: new Date() } } } + } + } + }, + isActive: true + } + }); + } + + public async getSymbolProfiles( + aAssetProfileIdentifiers: AssetProfileIdentifier[] + ): Promise { + return this.prismaService.symbolProfile + .findMany({ + include: { + _count: { + select: { activities: true, watchedBy: true } + }, + activities: { + orderBy: { + date: 'asc' + }, + select: { date: true }, + take: 1 + }, + SymbolProfileOverrides: true + }, + where: { + OR: aAssetProfileIdentifiers.map(({ dataSource, symbol }) => { + return { + dataSource, + symbol + }; + }) + } + }) + .then((symbolProfiles) => { + return this.enhanceSymbolProfiles(symbolProfiles); + }); + } + + public async getSymbolProfilesByIds( + symbolProfileIds: string[] + ): Promise { + return this.prismaService.symbolProfile + .findMany({ + include: { + _count: { + select: { activities: true, watchedBy: true } + }, + SymbolProfileOverrides: true + }, + where: { + id: { + in: symbolProfileIds.map((symbolProfileId) => { + return symbolProfileId; + }) + } + } + }) + .then((symbolProfiles) => { + return this.enhanceSymbolProfiles(symbolProfiles); + }); + } + + public updateAssetProfileIdentifier( + oldAssetProfileIdentifier: AssetProfileIdentifier, + newAssetProfileIdentifier: AssetProfileIdentifier + ) { + return this.prismaService.symbolProfile.update({ + data: { + dataSource: newAssetProfileIdentifier.dataSource, + symbol: newAssetProfileIdentifier.symbol + }, + where: { + dataSource_symbol: { + dataSource: oldAssetProfileIdentifier.dataSource, + symbol: oldAssetProfileIdentifier.symbol + } + } + }); + } + + public updateSymbolProfile( + { dataSource, symbol }: AssetProfileIdentifier, + { + assetClass, + assetSubClass, + comment, + countries, + currency, + holdings, + isActive, + name, + scraperConfiguration, + sectors, + symbolMapping, + SymbolProfileOverrides, + url + }: Prisma.SymbolProfileUpdateInput + ) { + return this.prismaService.symbolProfile.update({ + data: { + assetClass, + assetSubClass, + comment, + countries, + currency, + holdings, + isActive, + name, + scraperConfiguration, + sectors, + symbolMapping, + SymbolProfileOverrides, + url + }, + where: { dataSource_symbol: { dataSource, symbol } } + }); + } + + private enhanceSymbolProfiles( + symbolProfiles: (SymbolProfile & { + _count: { activities: number; watchedBy?: number }; + activities?: { + date: Date; + }[]; + SymbolProfileOverrides: SymbolProfileOverrides; + })[] + ): EnhancedSymbolProfile[] { + return symbolProfiles.map((symbolProfile) => { + const item = { + ...symbolProfile, + activitiesCount: 0, + countries: this.getCountries( + symbolProfile?.countries as unknown as Prisma.JsonArray + ), + dateOfFirstActivity: undefined as Date, + holdings: this.getHoldings( + symbolProfile?.holdings as unknown as Prisma.JsonArray + ), + scraperConfiguration: this.getScraperConfiguration(symbolProfile), + sectors: this.getSectors( + symbolProfile?.sectors as unknown as Prisma.JsonArray + ), + symbolMapping: this.getSymbolMapping(symbolProfile), + watchedByCount: 0 + }; + + item.activitiesCount = symbolProfile._count.activities; + item.watchedByCount = symbolProfile._count.watchedBy ?? 0; + delete item._count; + + item.dateOfFirstActivity = symbolProfile.activities?.[0]?.date; + delete item.activities; + + if (item.SymbolProfileOverrides) { + item.assetClass = + item.SymbolProfileOverrides.assetClass ?? item.assetClass; + item.assetSubClass = + item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass; + + if ( + (item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray) + ?.length > 0 + ) { + item.countries = this.getCountries( + item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray + ); + } + + if ( + (item.SymbolProfileOverrides.holdings as unknown as Holding[]) + ?.length > 0 + ) { + item.holdings = this.getHoldings( + item.SymbolProfileOverrides.holdings as unknown as Prisma.JsonArray + ); + } + + item.name = item.SymbolProfileOverrides.name ?? item.name; + + if ( + (item.SymbolProfileOverrides.sectors as unknown as Sector[])?.length > + 0 + ) { + item.sectors = this.getSectors( + item.SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray + ); + } + + item.url = item.SymbolProfileOverrides.url ?? item.url; + + delete item.SymbolProfileOverrides; + } + + return item; + }); + } + + private getCountries(aCountries: Prisma.JsonArray = []): Country[] { + if (aCountries === null) { + return []; + } + + return aCountries.map((country: Pick) => { + const { code, weight } = country; + + return { + code, + weight, + continent: continents[countries[code]?.continent] ?? UNKNOWN_KEY, + name: countries[code]?.name ?? UNKNOWN_KEY + }; + }); + } + + private getHoldings(aHoldings: Prisma.JsonArray = []): Holding[] { + if (aHoldings === null) { + return []; + } + + return aHoldings.map((holding) => { + const { name, weight } = holding as Prisma.JsonObject; + + return { + allocationInPercentage: weight as number, + name: (name as string) ?? UNKNOWN_KEY, + valueInBaseCurrency: undefined + }; + }); + } + + private getScraperConfiguration( + symbolProfile: SymbolProfile + ): ScraperConfiguration { + const scraperConfiguration = + symbolProfile.scraperConfiguration as Prisma.JsonObject; + + if (scraperConfiguration) { + return { + defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number, + headers: + scraperConfiguration.headers as ScraperConfiguration['headers'], + locale: scraperConfiguration.locale as string, + mode: + (scraperConfiguration.mode as ScraperConfiguration['mode']) ?? 'lazy', + selector: scraperConfiguration.selector as string, + url: scraperConfiguration.url as string + }; + } + + return null; + } + + private getSectors(aSectors: Prisma.JsonArray = []): Sector[] { + if (aSectors === null) { + return []; + } + + return aSectors.map((sector) => { + const { name, weight } = sector as Prisma.JsonObject; + + return { + name: (name as string) ?? UNKNOWN_KEY, + weight: weight as number + }; + }); + } + + private getSymbolMapping(symbolProfile: SymbolProfile) { + return ( + (symbolProfile['symbolMapping'] as { + [key: string]: string; + }) ?? {} + ); + } +} diff --git a/apps/api/src/services/tag/tag.module.ts b/apps/api/src/services/tag/tag.module.ts new file mode 100644 index 000000000..ea129e3ec --- /dev/null +++ b/apps/api/src/services/tag/tag.module.ts @@ -0,0 +1,12 @@ +import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; + +import { Module } from '@nestjs/common'; + +import { TagService } from './tag.service'; + +@Module({ + exports: [TagService], + imports: [PrismaModule], + providers: [TagService] +}) +export class TagModule {} diff --git a/apps/api/src/services/tag/tag.service.ts b/apps/api/src/services/tag/tag.service.ts new file mode 100644 index 000000000..f4cbd4cb1 --- /dev/null +++ b/apps/api/src/services/tag/tag.service.ts @@ -0,0 +1,121 @@ +import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; + +import { Injectable } from '@nestjs/common'; +import { Prisma, Tag } from '@prisma/client'; + +@Injectable() +export class TagService { + public constructor(private readonly prismaService: PrismaService) {} + + public async createTag(data: Prisma.TagCreateInput) { + return this.prismaService.tag.create({ + data + }); + } + + public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise { + return this.prismaService.tag.delete({ where }); + } + + public async getTag( + tagWhereUniqueInput: Prisma.TagWhereUniqueInput + ): Promise { + return this.prismaService.tag.findUnique({ + where: tagWhereUniqueInput + }); + } + + public async getTags({ + cursor, + orderBy, + skip, + take, + where + }: { + cursor?: Prisma.TagWhereUniqueInput; + orderBy?: Prisma.TagOrderByWithRelationInput; + skip?: number; + take?: number; + where?: Prisma.TagWhereInput; + } = {}) { + return this.prismaService.tag.findMany({ + cursor, + orderBy, + skip, + take, + where + }); + } + + public async getTagsForUser(userId: string) { + const tags = await this.prismaService.tag.findMany({ + include: { + _count: { + select: { + activities: { + where: { + userId + } + } + } + } + }, + orderBy: { + name: 'asc' + }, + where: { + OR: [ + { + userId + }, + { + userId: null + } + ] + } + }); + + return tags + .map(({ _count, id, name, userId }) => ({ + id, + name, + userId, + isUsed: _count.activities > 0 + })) + .sort((a, b) => { + return a.name.toLowerCase().localeCompare(b.name.toLowerCase()); + }); + } + + public async getTagsWithActivityCount() { + const tagsWithOrderCount = await this.prismaService.tag.findMany({ + include: { + _count: { + select: { activities: true } + } + } + }); + + return tagsWithOrderCount.map(({ _count, id, name, userId }) => { + return { + id, + name, + userId, + activityCount: _count.activities + }; + }); + } + + public async updateTag({ + data, + where + }: { + data: Prisma.TagUpdateInput; + where: Prisma.TagWhereUniqueInput; + }): Promise { + return this.prismaService.tag.update({ + data, + where + }); + } +} diff --git a/apps/api/src/services/twitter-bot/twitter-bot.module.ts b/apps/api/src/services/twitter-bot/twitter-bot.module.ts new file mode 100644 index 000000000..80d53169c --- /dev/null +++ b/apps/api/src/services/twitter-bot/twitter-bot.module.ts @@ -0,0 +1,13 @@ +import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module'; +import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module'; +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service'; + +import { Module } from '@nestjs/common'; + +@Module({ + exports: [TwitterBotService], + imports: [BenchmarkModule, ConfigurationModule, SymbolModule], + providers: [TwitterBotService] +}) +export class TwitterBotModule {} diff --git a/apps/api/src/services/twitter-bot/twitter-bot.service.ts b/apps/api/src/services/twitter-bot/twitter-bot.service.ts new file mode 100644 index 000000000..ee951820d --- /dev/null +++ b/apps/api/src/services/twitter-bot/twitter-bot.service.ts @@ -0,0 +1,106 @@ +import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service'; +import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; +import { + ghostfolioFearAndGreedIndexDataSourceStocks, + ghostfolioFearAndGreedIndexSymbol +} from '@ghostfolio/common/config'; +import { + resolveFearAndGreedIndex, + resolveMarketCondition +} from '@ghostfolio/common/helper'; + +import { Injectable, Logger } from '@nestjs/common'; +import { isWeekend } from 'date-fns'; +import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; + +@Injectable() +export class TwitterBotService { + private twitterClient: TwitterApiReadWrite; + + public constructor( + private readonly benchmarkService: BenchmarkService, + private readonly configurationService: ConfigurationService, + private readonly symbolService: SymbolService + ) { + this.twitterClient = new TwitterApi({ + accessSecret: this.configurationService.get( + 'TWITTER_ACCESS_TOKEN_SECRET' + ), + accessToken: this.configurationService.get('TWITTER_ACCESS_TOKEN'), + appKey: this.configurationService.get('TWITTER_API_KEY'), + appSecret: this.configurationService.get('TWITTER_API_SECRET') + }).readWrite; + } + + public async tweetFearAndGreedIndex() { + if ( + !this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') || + isWeekend(new Date()) + ) { + return; + } + + try { + const symbolItem = await this.symbolService.get({ + dataGatheringItem: { + dataSource: ghostfolioFearAndGreedIndexDataSourceStocks, + symbol: ghostfolioFearAndGreedIndexSymbol + } + }); + + if (symbolItem?.marketPrice) { + const { emoji, text } = resolveFearAndGreedIndex( + symbolItem.marketPrice + ); + + let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${ + symbolItem.marketPrice + }/100)`; + + const benchmarkListing = await this.getBenchmarkListing(); + + if (benchmarkListing?.length > 1) { + status += '\n\n'; + status += '± from ATH in %\n'; + status += benchmarkListing; + } + + const { data: createdTweet } = + await this.twitterClient.v2.tweet(status); + + Logger.log( + `Fear & Greed Index has been posted: https://x.com/ghostfolio_/status/${createdTweet.id}`, + 'TwitterBotService' + ); + } + } catch (error) { + Logger.error(error, 'TwitterBotService'); + } + } + + private async getBenchmarkListing() { + const benchmarks = await this.benchmarkService.getBenchmarks({ + enableSharing: true, + useCache: false + }); + + return benchmarks + .map(({ marketCondition, name, performances }) => { + let changeFormAllTimeHigh = ( + performances.allTimeHigh.performancePercent * 100 + ).toFixed(1); + + if (Math.abs(parseFloat(changeFormAllTimeHigh)) === 0) { + changeFormAllTimeHigh = '0.0'; + } + + return `${name} ${changeFormAllTimeHigh}%${ + marketCondition !== 'NEUTRAL_MARKET' + ? ' ' + resolveMarketCondition(marketCondition).emoji + : '' + }`; + }) + .join('\n'); + } +} diff --git a/apps/api/tsconfig.app.json b/apps/api/tsconfig.app.json new file mode 100644 index 000000000..d38ef826f --- /dev/null +++ b/apps/api/tsconfig.app.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"], + "emitDecoratorMetadata": true, + "moduleResolution": "node10", + "target": "es2021", + "module": "commonjs" + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"], + "include": ["**/*.ts"] +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 000000000..63dbe35fb --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/apps/api/tsconfig.spec.json b/apps/api/tsconfig.spec.json new file mode 100644 index 000000000..934e28503 --- /dev/null +++ b/apps/api/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] +} diff --git a/apps/api/webpack.config.js b/apps/api/webpack.config.js new file mode 100644 index 000000000..2cc38b985 --- /dev/null +++ b/apps/api/webpack.config.js @@ -0,0 +1,6 @@ +const { composePlugins, withNx } = require('@nx/webpack'); + +module.exports = composePlugins(withNx(), (config, { options, context }) => { + // Customize webpack config here + return config; +}); diff --git a/apps/client/tsconfig.app.json b/apps/client/tsconfig.app.json new file mode 100644 index 000000000..56a5535d4 --- /dev/null +++ b/apps/client/tsconfig.app.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"], + "typeRoots": ["../../node_modules/@types"], + "target": "ES2022", + "useDefineForClassFields": false + }, + "files": ["src/main.ts", "src/polyfills.ts"], + "exclude": ["jest.config.ts"] +} diff --git a/apps/client/tsconfig.editor.json b/apps/client/tsconfig.editor.json new file mode 100644 index 000000000..1bf3c0a74 --- /dev/null +++ b/apps/client/tsconfig.editor.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "include": ["**/*.ts"], + "compilerOptions": { + "types": ["jest", "node"] + }, + "exclude": ["jest.config.ts"] +} diff --git a/apps/client/tsconfig.json b/apps/client/tsconfig.json new file mode 100644 index 000000000..d207f5966 --- /dev/null +++ b/apps/client/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.spec.json" + }, + { + "path": "./tsconfig.editor.json" + } + ], + "angularCompilerOptions": { + "strictInjectionParameters": true, + // TODO: Enable stricter rules for this project + "strictInputAccessModifiers": false, + "strictTemplates": false + }, + "compilerOptions": { + "lib": ["dom", "es2022"], + "module": "preserve", + "target": "es2020" + } +} diff --git a/apps/client/tsconfig.spec.json b/apps/client/tsconfig.spec.json new file mode 100644 index 000000000..36a2e401c --- /dev/null +++ b/apps/client/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "preserve", + "isolatedModules": true, + "types": ["jest", "node"], + "target": "es2016" + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] +} diff --git a/libs/common/.babelrc b/libs/common/.babelrc new file mode 100644 index 000000000..9d2089bb9 --- /dev/null +++ b/libs/common/.babelrc @@ -0,0 +1,3 @@ +{ + "presets": [] +} diff --git a/libs/common/README.md b/libs/common/README.md new file mode 100644 index 000000000..722fbd648 --- /dev/null +++ b/libs/common/README.md @@ -0,0 +1,7 @@ +# @ghostfolio/common + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test common` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/common/eslint.config.cjs b/libs/common/eslint.config.cjs new file mode 100644 index 000000000..990c264b4 --- /dev/null +++ b/libs/common/eslint.config.cjs @@ -0,0 +1,30 @@ +const baseConfig = require('../../eslint.config.cjs'); + +module.exports = [ + { + ignores: ['**/dist'] + }, + ...baseConfig, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {}, + languageOptions: { + parserOptions: { + project: ['libs/common/tsconfig.*?.json'] + } + } + }, + { + files: ['**/*.ts', '**/*.tsx'], + // Override or add rules here + rules: { + '@typescript-eslint/prefer-nullish-coalescing': 'error' + } + }, + { + files: ['**/*.js', '**/*.jsx'], + // Override or add rules here + rules: {} + } +]; diff --git a/libs/common/jest.config.ts b/libs/common/jest.config.ts new file mode 100644 index 000000000..5002d4d3b --- /dev/null +++ b/libs/common/jest.config.ts @@ -0,0 +1,12 @@ +/* eslint-disable */ +export default { + displayName: 'common', + + globals: {}, + transform: { + '^.+\\.[tj]sx?$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }] + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/common', + preset: '../../jest.preset.js' +}; diff --git a/libs/common/project.json b/libs/common/project.json new file mode 100644 index 000000000..3bed072ff --- /dev/null +++ b/libs/common/project.json @@ -0,0 +1,22 @@ +{ + "name": "common", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/common/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "lintFilePatterns": ["libs/common/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/libs/common"], + "options": { + "jestConfig": "libs/common/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/libs/common/src/lib/calculation-helper.spec.ts b/libs/common/src/lib/calculation-helper.spec.ts new file mode 100644 index 000000000..69621ec0a --- /dev/null +++ b/libs/common/src/lib/calculation-helper.spec.ts @@ -0,0 +1,50 @@ +import { Big } from 'big.js'; + +import { getAnnualizedPerformancePercent } from './calculation-helper'; + +describe('CalculationHelper', () => { + describe('annualized performance percentage', () => { + it('Get annualized performance', async () => { + expect( + getAnnualizedPerformancePercent({ + daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day + netPerformancePercentage: new Big(0) + }).toNumber() + ).toEqual(0); + + expect( + getAnnualizedPerformancePercent({ + daysInMarket: 0, + netPerformancePercentage: new Big(0) + }).toNumber() + ).toEqual(0); + + /** + * Source: https://www.readyratios.com/reference/analysis/annualized_rate.html + */ + expect( + getAnnualizedPerformancePercent({ + daysInMarket: 65, // < 1 year + netPerformancePercentage: new Big(0.1025) + }).toNumber() + ).toBeCloseTo(0.729705); + + expect( + getAnnualizedPerformancePercent({ + daysInMarket: 365, // 1 year + netPerformancePercentage: new Big(0.05) + }).toNumber() + ).toBeCloseTo(0.05); + + /** + * Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation + */ + expect( + getAnnualizedPerformancePercent({ + daysInMarket: 575, // > 1 year + netPerformancePercentage: new Big(0.2374) + }).toNumber() + ).toBeCloseTo(0.145); + }); + }); +}); diff --git a/libs/common/src/lib/calculation-helper.ts b/libs/common/src/lib/calculation-helper.ts new file mode 100644 index 000000000..76b38f9b2 --- /dev/null +++ b/libs/common/src/lib/calculation-helper.ts @@ -0,0 +1,83 @@ +import { Big } from 'big.js'; +import { + endOfDay, + endOfYear, + max, + startOfMonth, + startOfWeek, + startOfYear, + subDays, + subYears +} from 'date-fns'; +import { isFinite, isNumber } from 'lodash'; + +import { resetHours } from './helper'; +import { DateRange } from './types'; + +export function getAnnualizedPerformancePercent({ + daysInMarket, + netPerformancePercentage +}: { + daysInMarket: number; + netPerformancePercentage: Big; +}): Big { + if (isNumber(daysInMarket) && daysInMarket > 0) { + const exponent = new Big(365).div(daysInMarket).toNumber(); + const growthFactor = Math.pow( + netPerformancePercentage.plus(1).toNumber(), + exponent + ); + + if (isFinite(growthFactor)) { + return new Big(growthFactor).minus(1); + } + } + + return new Big(0); +} + +export function getIntervalFromDateRange( + aDateRange: DateRange, + portfolioStart = new Date(0) +) { + let endDate = endOfDay(new Date()); + let startDate = portfolioStart; + + switch (aDateRange) { + case '1d': + startDate = max([startDate, subDays(resetHours(new Date()), 1)]); + break; + case 'mtd': + startDate = max([ + startDate, + subDays(startOfMonth(resetHours(new Date())), 1) + ]); + break; + case 'wtd': + startDate = max([ + startDate, + subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1) + ]); + break; + case 'ytd': + startDate = max([ + startDate, + subDays(startOfYear(resetHours(new Date())), 1) + ]); + break; + case '1y': + startDate = max([startDate, subYears(resetHours(new Date()), 1)]); + break; + case '5y': + startDate = max([startDate, subYears(resetHours(new Date()), 5)]); + break; + case 'max': + break; + default: + // '2024', '2023', '2022', etc. + endDate = endOfYear(new Date(aDateRange)); + startDate = max([startDate, new Date(aDateRange)]); + } + + return { endDate, startDate }; +} diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts new file mode 100644 index 000000000..1f385e901 --- /dev/null +++ b/libs/common/src/lib/chart-helper.ts @@ -0,0 +1,165 @@ +import type { ElementRef } from '@angular/core'; +import type { + Chart, + ChartType, + ControllerDatasetOptions, + Plugin, + Point, + TooltipOptions, + TooltipPosition +} from 'chart.js'; +import { format } from 'date-fns'; + +import { + DATE_FORMAT, + DATE_FORMAT_MONTHLY, + DATE_FORMAT_YEARLY, + getBackgroundColor, + getLocale, + getTextColor +} from './helper'; +import { ColorScheme, GroupBy } from './types'; + +export function formatGroupedDate({ + date, + groupBy +}: { + date: number; + groupBy: GroupBy; +}) { + if (groupBy === 'month') { + return format(date, DATE_FORMAT_MONTHLY); + } else if (groupBy === 'year') { + return format(date, DATE_FORMAT_YEARLY); + } + + return format(date, DATE_FORMAT); +} + +export function getTooltipOptions({ + colorScheme, + currency = '', + groupBy, + locale = getLocale(), + unit = '' +}: { + colorScheme: ColorScheme; + currency?: string; + groupBy?: GroupBy; + locale?: string; + unit?: string; +}): Partial> { + return { + backgroundColor: getBackgroundColor(colorScheme), + bodyColor: `rgb(${getTextColor(colorScheme)})`, + borderWidth: 1, + borderColor: `rgba(${getTextColor(colorScheme)}, 0.1)`, + // @ts-expect-error: no need to set all attributes in callbacks + callbacks: { + label: (context) => { + let label = (context.dataset as ControllerDatasetOptions).label ?? ''; + + if (label) { + label += ': '; + } + + const yPoint = (context.parsed as Point).y; + + if (yPoint !== null) { + if (currency) { + label += `${yPoint.toLocaleString(locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + })} ${currency}`; + } else if (unit) { + label += `${yPoint.toFixed(2)} ${unit}`; + } else { + label += yPoint.toFixed(2); + } + } + + return label; + }, + title: (contexts) => { + const xPoint = (contexts[0].parsed as Point).x; + + if (groupBy && xPoint !== null) { + return formatGroupedDate({ groupBy, date: xPoint }); + } + + return contexts[0].label; + } + }, + caretSize: 0, + cornerRadius: 2, + footerColor: `rgb(${getTextColor(colorScheme)})`, + itemSort: (a, b) => { + // Reverse order + return b.datasetIndex - a.datasetIndex; + }, + titleColor: `rgb(${getTextColor(colorScheme)})`, + usePointStyle: true + }; +} + +export function getTooltipPositionerMapTop( + chart: Chart, + position: TooltipPosition +) { + if (!position || !chart?.chartArea) { + return false; + } + + return { + x: position.x, + y: chart.chartArea.top + }; +} + +export function getVerticalHoverLinePlugin( + chartCanvas: ElementRef, + colorScheme: ColorScheme +): Plugin { + return { + afterDatasetsDraw: (chart, _, options) => { + const active = chart.getActiveElements(); + + if (!active || active.length === 0) { + return; + } + + const color = options.color ?? `rgb(${getTextColor(colorScheme)})`; + const width = options.width ?? 1; + + const { + chartArea: { bottom, top } + } = chart; + const xValue = active[0].element.x; + + const context = chartCanvas.nativeElement.getContext('2d'); + + if (context) { + context.lineWidth = width; + context.strokeStyle = color; + + context.beginPath(); + context.moveTo(xValue, top); + context.lineTo(xValue, bottom); + context.stroke(); + } + }, + id: 'verticalHoverLine' + }; +} + +export function transformTickToAbbreviation(value: number) { + if (value === 0) { + return '0'; + } else if (value >= -999 && value <= 999) { + return value.toFixed(2); + } else if (value >= -999999 && value <= 999999) { + return `${value / 1000}K`; + } else { + return `${value / 1000000}M`; + } +} diff --git a/libs/common/src/lib/class-transformer.ts b/libs/common/src/lib/class-transformer.ts new file mode 100644 index 000000000..60e0eab60 --- /dev/null +++ b/libs/common/src/lib/class-transformer.ts @@ -0,0 +1,25 @@ +import { Big } from 'big.js'; + +export function transformToMapOfBig({ + value +}: { + value: { [key: string]: string }; +}): { + [key: string]: Big; +} { + const mapOfBig: { [key: string]: Big } = {}; + + for (const key in value) { + mapOfBig[key] = new Big(value[key]); + } + + return mapOfBig; +} + +export function transformToBig({ value }: { value: string }): Big | null { + if (value === null) { + return null; + } + + return new Big(value); +} diff --git a/libs/common/src/lib/config.ts b/libs/common/src/lib/config.ts new file mode 100644 index 000000000..5da0e0122 --- /dev/null +++ b/libs/common/src/lib/config.ts @@ -0,0 +1,268 @@ +import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; +import { JobOptions, JobStatus } from 'bull'; +import ms from 'ms'; + +export const ghostfolioPrefix = 'GF'; +export const ghostfolioScraperApiSymbolPrefix = `_${ghostfolioPrefix}_`; +export const ghostfolioFearAndGreedIndexDataSourceCryptocurrencies = + DataSource.MANUAL; +export const ghostfolioFearAndGreedIndexDataSourceStocks = DataSource.RAPID_API; +export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`; +export const ghostfolioFearAndGreedIndexSymbolCryptocurrencies = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_CRYPTOCURRENCIES`; +export const ghostfolioFearAndGreedIndexSymbolStocks = `${ghostfolioPrefix}_FEAR_AND_GREED_INDEX_STOCKS`; + +export const locale = 'en-US'; + +export const primaryColorHex = '#36cfcc'; +export const primaryColorRgb = { + r: 54, + g: 207, + b: 204 +}; + +export const secondaryColorHex = '#3686cf'; +export const secondaryColorRgb = { + r: 54, + g: 134, + b: 207 +}; + +export const warnColorHex = '#dc3545'; +export const warnColorRgb = { + r: 220, + g: 53, + b: 69 +}; + +export const ASSET_CLASS_MAPPING = new Map([ + [AssetClass.ALTERNATIVE_INVESTMENT, [AssetSubClass.COLLECTIBLE]], + [AssetClass.COMMODITY, [AssetSubClass.PRECIOUS_METAL]], + [ + AssetClass.EQUITY, + [ + AssetSubClass.ETF, + AssetSubClass.MUTUALFUND, + AssetSubClass.PRIVATE_EQUITY, + AssetSubClass.STOCK + ] + ], + [AssetClass.FIXED_INCOME, [AssetSubClass.BOND]], + [AssetClass.LIQUIDITY, [AssetSubClass.CRYPTOCURRENCY]], + [AssetClass.REAL_ESTATE, []] +]); + +export const CACHE_TTL_NO_CACHE = 1; +export const CACHE_TTL_INFINITE = 0; + +export const DATA_GATHERING_QUEUE = 'DATA_GATHERING_QUEUE'; +export const DATA_GATHERING_QUEUE_PRIORITY_HIGH = 1; +export const DATA_GATHERING_QUEUE_PRIORITY_LOW = Number.MAX_SAFE_INTEGER; +export const DATA_GATHERING_QUEUE_PRIORITY_MEDIUM = Math.round( + DATA_GATHERING_QUEUE_PRIORITY_LOW / 2 +); + +export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE = + 'PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE'; +export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_HIGH = 1; +export const PORTFOLIO_SNAPSHOT_COMPUTATION_QUEUE_PRIORITY_LOW = + Number.MAX_SAFE_INTEGER; + +export const DEFAULT_CURRENCY = 'USD'; +export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; +export const DEFAULT_HOST = '0.0.0.0'; +export const DEFAULT_LANGUAGE_CODE = 'en'; +export const DEFAULT_PAGE_SIZE = 50; +export const DEFAULT_PORT = 3333; +export const DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY = 1; +export const DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY = 1; +export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY = 1; +export const DEFAULT_PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_TIMEOUT = 30000; + +export const DEFAULT_REDACTED_PATHS = [ + 'accounts[*].balance', + 'accounts[*].balanceInBaseCurrency', + 'accounts[*].comment', + 'accounts[*].dividendInBaseCurrency', + 'accounts[*].interestInBaseCurrency', + 'accounts[*].value', + 'accounts[*].valueInBaseCurrency', + 'activities[*].account.balance', + 'activities[*].account.comment', + 'activities[*].comment', + 'activities[*].fee', + 'activities[*].feeInAssetProfileCurrency', + 'activities[*].feeInBaseCurrency', + 'activities[*].quantity', + 'activities[*].SymbolProfile.symbolMapping', + 'activities[*].SymbolProfile.watchedByCount', + 'activities[*].value', + 'activities[*].valueInBaseCurrency', + 'balance', + 'balanceInBaseCurrency', + 'balances[*].account.balance', + 'balances[*].account.comment', + 'balances[*].value', + 'balances[*].valueInBaseCurrency', + 'comment', + 'dividendInBaseCurrency', + 'feeInBaseCurrency', + 'grossPerformance', + 'grossPerformanceWithCurrencyEffect', + 'historicalData[*].quantity', + 'holdings[*].dividend', + 'holdings[*].grossPerformance', + 'holdings[*].grossPerformanceWithCurrencyEffect', + 'holdings[*].holdings[*].valueInBaseCurrency', + 'holdings[*].investment', + 'holdings[*].netPerformance', + 'holdings[*].netPerformanceWithCurrencyEffect', + 'holdings[*].quantity', + 'holdings[*].valueInBaseCurrency', + 'interestInBaseCurrency', + 'investmentInBaseCurrencyWithCurrencyEffect', + 'netPerformance', + 'netPerformanceWithCurrencyEffect', + 'platforms[*].balance', + 'platforms[*].valueInBaseCurrency', + 'quantity', + 'SymbolProfile.symbolMapping', + 'SymbolProfile.watchedByCount', + 'totalBalanceInBaseCurrency', + 'totalDividendInBaseCurrency', + 'totalInterestInBaseCurrency', + 'totalValueInBaseCurrency', + 'value', + 'valueInBaseCurrency' +]; + +// USX is handled separately +export const DERIVED_CURRENCIES = [ + { + currency: 'GBp', + factor: 100, + rootCurrency: 'GBP' + }, + { + currency: 'ILA', + factor: 100, + rootCurrency: 'ILS' + }, + { + currency: 'ZAc', + factor: 100, + rootCurrency: 'ZAR' + } +]; + +export const GATHER_ASSET_PROFILE_PROCESS_JOB_NAME = 'GATHER_ASSET_PROFILE'; +export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = { + attempts: 12, + backoff: { + delay: ms('1 minute'), + type: 'exponential' + }, + removeOnComplete: true +}; + +export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME = + 'GATHER_HISTORICAL_MARKET_DATA'; +export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = { + attempts: 12, + backoff: { + delay: ms('1 minute'), + type: 'exponential' + }, + removeOnComplete: true +}; + +export const INVESTMENT_ACTIVITY_TYPES = [ + Type.BUY, + Type.DIVIDEND, + Type.SELL +] as Type[]; + +export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME = 'PORTFOLIO'; +export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS: JobOptions = { + removeOnComplete: true +}; + +export const HEADER_KEY_IMPERSONATION = 'Impersonation-Id'; +export const HEADER_KEY_TIMEZONE = 'Timezone'; +export const HEADER_KEY_TOKEN = 'Authorization'; +export const HEADER_KEY_SKIP_INTERCEPTOR = 'X-Skip-Interceptor'; + +export const MAX_TOP_HOLDINGS = 50; + +export const NUMERICAL_PRECISION_THRESHOLD_3_FIGURES = 100; +export const NUMERICAL_PRECISION_THRESHOLD_5_FIGURES = 10000; +export const NUMERICAL_PRECISION_THRESHOLD_6_FIGURES = 100000; + +export const PROPERTY_API_KEY_GHOSTFOLIO = 'API_KEY_GHOSTFOLIO'; +export const PROPERTY_API_KEY_OPENROUTER = 'API_KEY_OPENROUTER'; +export const PROPERTY_BENCHMARKS = 'BENCHMARKS'; +export const PROPERTY_BETTER_UPTIME_MONITOR_ID = 'BETTER_UPTIME_MONITOR_ID'; +export const PROPERTY_COUNTRIES_OF_SUBSCRIBERS = 'COUNTRIES_OF_SUBSCRIBERS'; +export const PROPERTY_COUPONS = 'COUPONS'; +export const PROPERTY_CURRENCIES = 'CURRENCIES'; +export const PROPERTY_CUSTOM_CRYPTOCURRENCIES = 'CUSTOM_CRYPTOCURRENCIES'; +export const PROPERTY_DATA_SOURCE_MAPPING = 'DATA_SOURCE_MAPPING'; +export const PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS = + 'DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS'; +export const PROPERTY_DEMO_ACCOUNT_ID = 'DEMO_ACCOUNT_ID'; +export const PROPERTY_DEMO_USER_ID = 'DEMO_USER_ID'; +export const PROPERTY_IS_DATA_GATHERING_ENABLED = 'IS_DATA_GATHERING_ENABLED'; +export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE'; +export const PROPERTY_IS_USER_SIGNUP_ENABLED = 'IS_USER_SIGNUP_ENABLED'; +export const PROPERTY_OPENROUTER_MODEL = 'OPENROUTER_MODEL'; +export const PROPERTY_SLACK_COMMUNITY_USERS = 'SLACK_COMMUNITY_USERS'; +export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG'; +export const PROPERTY_SYSTEM_MESSAGE = 'SYSTEM_MESSAGE'; + +export const QUEUE_JOB_STATUS_LIST = [ + 'active', + 'completed', + 'delayed', + 'failed', + 'paused', + 'waiting' +] as JobStatus[]; + +export const REPLACE_NAME_PARTS = [ + 'Amundi Index Solutions -', + 'iShares ETF (CH) -', + 'iShares III Public Limited Company -', + 'iShares V PLC -', + 'iShares VI Public Limited Company -', + 'iShares VII PLC -', + 'Multi Units Luxembourg -', + 'VanEck ETFs N.V. -', + 'Vaneck Vectors Ucits Etfs Plc -', + 'Vanguard Funds Public Limited Company -', + 'Vanguard Index Funds -', + 'Xtrackers (IE) Plc -' +]; + +export const STORYBOOK_PATH = '/development/storybook'; + +export const SUPPORTED_LANGUAGE_CODES = [ + 'ca', + 'de', + 'en', + 'es', + 'fr', + 'it', + 'ko', + 'nl', + 'pl', + 'pt', + 'tr', + 'uk', + 'zh' +]; + +export const TAG_ID_EMERGENCY_FUND = '4452656d-9fa4-4bd0-ba38-70492e31d180'; +export const TAG_ID_EXCLUDE_FROM_ANALYSIS = + 'f2e868af-8333-459f-b161-cbc6544c24bd'; +export const TAG_ID_DEMO = 'efa08cb3-9b9d-4974-ac68-db13a19c4874'; + +export const UNKNOWN_KEY = 'UNKNOWN'; diff --git a/libs/common/src/lib/dtos/auth-device.dto.ts b/libs/common/src/lib/dtos/auth-device.dto.ts new file mode 100644 index 000000000..3be7f4cac --- /dev/null +++ b/libs/common/src/lib/dtos/auth-device.dto.ts @@ -0,0 +1,4 @@ +export interface AuthDeviceDto { + createdAt: string; + id: string; +} diff --git a/libs/common/src/lib/dtos/create-access.dto.ts b/libs/common/src/lib/dtos/create-access.dto.ts new file mode 100644 index 000000000..087df7183 --- /dev/null +++ b/libs/common/src/lib/dtos/create-access.dto.ts @@ -0,0 +1,16 @@ +import { AccessPermission } from '@prisma/client'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class CreateAccessDto { + @IsOptional() + @IsString() + alias?: string; + + @IsOptional() + @IsUUID() + granteeUserId?: string; + + @IsEnum(AccessPermission, { each: true }) + @IsOptional() + permissions?: AccessPermission[]; +} diff --git a/libs/common/src/lib/dtos/create-account-balance.dto.ts b/libs/common/src/lib/dtos/create-account-balance.dto.ts new file mode 100644 index 000000000..28e939b82 --- /dev/null +++ b/libs/common/src/lib/dtos/create-account-balance.dto.ts @@ -0,0 +1,12 @@ +import { IsISO8601, IsNumber, IsUUID } from 'class-validator'; + +export class CreateAccountBalanceDto { + @IsUUID() + accountId: string; + + @IsNumber() + balance: number; + + @IsISO8601() + date: string; +} diff --git a/libs/common/src/lib/dtos/create-account-with-balances.dto.ts b/libs/common/src/lib/dtos/create-account-with-balances.dto.ts new file mode 100644 index 000000000..2d1d3ed2a --- /dev/null +++ b/libs/common/src/lib/dtos/create-account-with-balances.dto.ts @@ -0,0 +1,11 @@ +import { AccountBalance } from '@ghostfolio/common/interfaces'; + +import { IsArray, IsOptional } from 'class-validator'; + +import { CreateAccountDto } from './create-account.dto'; + +export class CreateAccountWithBalancesDto extends CreateAccountDto { + @IsArray() + @IsOptional() + balances?: AccountBalance[]; +} diff --git a/libs/common/src/lib/dtos/create-account.dto.ts b/libs/common/src/lib/dtos/create-account.dto.ts new file mode 100644 index 000000000..fa88580f1 --- /dev/null +++ b/libs/common/src/lib/dtos/create-account.dto.ts @@ -0,0 +1,41 @@ +import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; + +import { Transform, TransformFnParams } from 'class-transformer'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + ValidateIf +} from 'class-validator'; +import { isString } from 'lodash'; + +export class CreateAccountDto { + @IsNumber() + balance: number; + + @IsOptional() + @IsString() + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) + comment?: string; + + @IsCurrencyCode() + currency: string; + + @IsOptional() + @IsString() + id?: string; + + @IsBoolean() + @IsOptional() + isExcluded?: boolean; + + @IsString() + name: string; + + @IsString() + @ValidateIf((_object, value) => value !== null) + platformId: string | null; +} diff --git a/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts b/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts new file mode 100644 index 000000000..04611371d --- /dev/null +++ b/libs/common/src/lib/dtos/create-asset-profile-with-market-data.dto.ts @@ -0,0 +1,17 @@ +import { MarketData } from '@ghostfolio/common/interfaces'; + +import { DataSource } from '@prisma/client'; +import { IsArray, IsEnum, IsOptional } from 'class-validator'; + +import { CreateAssetProfileDto } from './create-asset-profile.dto'; + +export class CreateAssetProfileWithMarketDataDto extends CreateAssetProfileDto { + @IsEnum([DataSource.MANUAL], { + message: `dataSource must be '${DataSource.MANUAL}'` + }) + dataSource: DataSource; + + @IsArray() + @IsOptional() + marketData?: MarketData[]; +} diff --git a/libs/common/src/lib/dtos/create-asset-profile.dto.ts b/libs/common/src/lib/dtos/create-asset-profile.dto.ts new file mode 100644 index 000000000..80d45ba42 --- /dev/null +++ b/libs/common/src/lib/dtos/create-asset-profile.dto.ts @@ -0,0 +1,92 @@ +import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; + +import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; +import { + IsArray, + IsBoolean, + IsEnum, + IsObject, + IsOptional, + IsString, + IsUrl +} from 'class-validator'; + +export class CreateAssetProfileDto { + @IsEnum(AssetClass, { each: true }) + @IsOptional() + assetClass?: AssetClass; + + @IsEnum(AssetSubClass, { each: true }) + @IsOptional() + assetSubClass?: AssetSubClass; + + @IsOptional() + @IsString() + comment?: string; + + @IsArray() + @IsOptional() + countries?: Prisma.InputJsonArray; + + @IsCurrencyCode() + currency: string; + + @IsOptional() + @IsString() + cusip?: string; + + @IsEnum(DataSource) + dataSource: DataSource; + + @IsOptional() + @IsString() + figi?: string; + + @IsOptional() + @IsString() + figiComposite?: string; + + @IsOptional() + @IsString() + figiShareClass?: string; + + @IsArray() + @IsOptional() + holdings?: Prisma.InputJsonArray; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsOptional() + @IsString() + isin?: string; + + @IsOptional() + @IsString() + name?: string; + + @IsObject() + @IsOptional() + scraperConfiguration?: Prisma.InputJsonObject; + + @IsArray() + @IsOptional() + sectors?: Prisma.InputJsonArray; + + @IsString() + symbol: string; + + @IsObject() + @IsOptional() + symbolMapping?: { + [dataProvider: string]: string; + }; + + @IsOptional() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url?: string; +} diff --git a/libs/common/src/lib/dtos/create-order.dto.ts b/libs/common/src/lib/dtos/create-order.dto.ts new file mode 100644 index 000000000..dfd0d8aa5 --- /dev/null +++ b/libs/common/src/lib/dtos/create-order.dto.ts @@ -0,0 +1,79 @@ +import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; +import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; + +import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Min, + Validate +} from 'class-validator'; +import { isString } from 'lodash'; + +export class CreateOrderDto { + @IsOptional() + @IsString() + accountId?: string; + + @IsEnum(AssetClass, { each: true }) + @IsOptional() + assetClass?: AssetClass; + + @IsEnum(AssetSubClass, { each: true }) + @IsOptional() + assetSubClass?: AssetSubClass; + + @IsOptional() + @IsString() + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) + comment?: string; + + @IsCurrencyCode() + currency: string; + + @IsCurrencyCode() + @IsOptional() + customCurrency?: string; + + @IsEnum(DataSource) + @IsOptional() // Optional for type FEE, INTEREST, and LIABILITY (default data source is resolved in the backend) + dataSource?: DataSource; + + @IsISO8601() + @Validate(IsAfter1970Constraint) + date: string; + + @IsNumber() + @Min(0) + fee: number; + + @IsNumber() + @Min(0) + quantity: number; + + @IsString() + symbol: string; + + @IsArray() + @IsOptional() + tags?: string[]; + + @IsEnum(Type, { each: true }) + type: Type; + + @IsNumber() + @Min(0) + unitPrice: number; + + @IsBoolean() + @IsOptional() + updateAccountBalance?: boolean; +} diff --git a/libs/common/src/lib/dtos/create-platform.dto.ts b/libs/common/src/lib/dtos/create-platform.dto.ts new file mode 100644 index 000000000..941354c11 --- /dev/null +++ b/libs/common/src/lib/dtos/create-platform.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsUrl } from 'class-validator'; + +export class CreatePlatformDto { + @IsString() + name: string; + + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url: string; +} diff --git a/libs/common/src/lib/dtos/create-tag.dto.ts b/libs/common/src/lib/dtos/create-tag.dto.ts new file mode 100644 index 000000000..b41c37a50 --- /dev/null +++ b/libs/common/src/lib/dtos/create-tag.dto.ts @@ -0,0 +1,14 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class CreateTagDto { + @IsOptional() + @IsString() + id?: string; + + @IsString() + name: string; + + @IsOptional() + @IsString() + userId?: string; +} diff --git a/libs/common/src/lib/dtos/create-watchlist-item.dto.ts b/libs/common/src/lib/dtos/create-watchlist-item.dto.ts new file mode 100644 index 000000000..663965ef1 --- /dev/null +++ b/libs/common/src/lib/dtos/create-watchlist-item.dto.ts @@ -0,0 +1,10 @@ +import { DataSource } from '@prisma/client'; +import { IsEnum, IsString } from 'class-validator'; + +export class CreateWatchlistItemDto { + @IsEnum(DataSource) + dataSource: DataSource; + + @IsString() + symbol: string; +} diff --git a/libs/common/src/lib/dtos/delete-own-user.dto.ts b/libs/common/src/lib/dtos/delete-own-user.dto.ts new file mode 100644 index 000000000..1e3f940cb --- /dev/null +++ b/libs/common/src/lib/dtos/delete-own-user.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class DeleteOwnUserDto { + @IsString() + accessToken: string; +} diff --git a/libs/common/src/lib/dtos/index.ts b/libs/common/src/lib/dtos/index.ts new file mode 100644 index 000000000..3631d6eae --- /dev/null +++ b/libs/common/src/lib/dtos/index.ts @@ -0,0 +1,51 @@ +import { AuthDeviceDto } from './auth-device.dto'; +import { CreateAccessDto } from './create-access.dto'; +import { CreateAccountBalanceDto } from './create-account-balance.dto'; +import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto'; +import { CreateAccountDto } from './create-account.dto'; +import { CreateAssetProfileWithMarketDataDto } from './create-asset-profile-with-market-data.dto'; +import { CreateAssetProfileDto } from './create-asset-profile.dto'; +import { CreateOrderDto } from './create-order.dto'; +import { CreatePlatformDto } from './create-platform.dto'; +import { CreateTagDto } from './create-tag.dto'; +import { CreateWatchlistItemDto } from './create-watchlist-item.dto'; +import { DeleteOwnUserDto } from './delete-own-user.dto'; +import { TransferBalanceDto } from './transfer-balance.dto'; +import { UpdateAccessDto } from './update-access.dto'; +import { UpdateAccountDto } from './update-account.dto'; +import { UpdateAssetProfileDto } from './update-asset-profile.dto'; +import { UpdateBulkMarketDataDto } from './update-bulk-market-data.dto'; +import { UpdateMarketDataDto } from './update-market-data.dto'; +import { UpdateOrderDto } from './update-order.dto'; +import { UpdateOwnAccessTokenDto } from './update-own-access-token.dto'; +import { UpdatePlatformDto } from './update-platform.dto'; +import { UpdatePropertyDto } from './update-property.dto'; +import { UpdateTagDto } from './update-tag.dto'; +import { UpdateUserSettingDto } from './update-user-setting.dto'; + +export { + AuthDeviceDto, + CreateAccessDto, + CreateAccountBalanceDto, + CreateAccountDto, + CreateAccountWithBalancesDto, + CreateAssetProfileDto, + CreateAssetProfileWithMarketDataDto, + CreateOrderDto, + CreatePlatformDto, + CreateTagDto, + CreateWatchlistItemDto, + DeleteOwnUserDto, + TransferBalanceDto, + UpdateAccessDto, + UpdateAccountDto, + UpdateAssetProfileDto, + UpdateBulkMarketDataDto, + UpdateMarketDataDto, + UpdateOrderDto, + UpdateOwnAccessTokenDto, + UpdatePlatformDto, + UpdatePropertyDto, + UpdateTagDto, + UpdateUserSettingDto +}; diff --git a/libs/common/src/lib/dtos/transfer-balance.dto.ts b/libs/common/src/lib/dtos/transfer-balance.dto.ts new file mode 100644 index 000000000..93a25d7cc --- /dev/null +++ b/libs/common/src/lib/dtos/transfer-balance.dto.ts @@ -0,0 +1,13 @@ +import { IsNumber, IsPositive, IsString } from 'class-validator'; + +export class TransferBalanceDto { + @IsString() + accountIdFrom: string; + + @IsString() + accountIdTo: string; + + @IsNumber() + @IsPositive() + balance: number; +} diff --git a/libs/common/src/lib/dtos/update-access.dto.ts b/libs/common/src/lib/dtos/update-access.dto.ts new file mode 100644 index 000000000..2850186f9 --- /dev/null +++ b/libs/common/src/lib/dtos/update-access.dto.ts @@ -0,0 +1,19 @@ +import { AccessPermission } from '@prisma/client'; +import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator'; + +export class UpdateAccessDto { + @IsOptional() + @IsString() + alias?: string; + + @IsOptional() + @IsUUID() + granteeUserId?: string; + + @IsString() + id: string; + + @IsEnum(AccessPermission, { each: true }) + @IsOptional() + permissions?: AccessPermission[]; +} diff --git a/libs/common/src/lib/dtos/update-account.dto.ts b/libs/common/src/lib/dtos/update-account.dto.ts new file mode 100644 index 000000000..066bacbfd --- /dev/null +++ b/libs/common/src/lib/dtos/update-account.dto.ts @@ -0,0 +1,40 @@ +import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; + +import { Transform, TransformFnParams } from 'class-transformer'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + ValidateIf +} from 'class-validator'; +import { isString } from 'lodash'; + +export class UpdateAccountDto { + @IsNumber() + balance: number; + + @IsOptional() + @IsString() + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) + comment?: string; + + @IsCurrencyCode() + currency: string; + + @IsString() + id: string; + + @IsBoolean() + @IsOptional() + isExcluded?: boolean; + + @IsString() + name: string; + + @IsString() + @ValidateIf((_object, value) => value !== null) + platformId: string | null; +} diff --git a/libs/common/src/lib/dtos/update-asset-profile.dto.ts b/libs/common/src/lib/dtos/update-asset-profile.dto.ts new file mode 100644 index 000000000..43f5aa617 --- /dev/null +++ b/libs/common/src/lib/dtos/update-asset-profile.dto.ts @@ -0,0 +1,71 @@ +import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; + +import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client'; +import { + IsArray, + IsBoolean, + IsEnum, + IsObject, + IsOptional, + IsString, + IsUrl +} from 'class-validator'; + +export class UpdateAssetProfileDto { + @IsEnum(AssetClass, { each: true }) + @IsOptional() + assetClass?: AssetClass; + + @IsEnum(AssetSubClass, { each: true }) + @IsOptional() + assetSubClass?: AssetSubClass; + + @IsOptional() + @IsString() + comment?: string; + + @IsArray() + @IsOptional() + countries?: Prisma.InputJsonArray; + + @IsCurrencyCode() + @IsOptional() + currency?: string; + + @IsEnum(DataSource) + @IsOptional() + dataSource?: DataSource; + + @IsBoolean() + @IsOptional() + isActive?: boolean; + + @IsOptional() + @IsString() + name?: string; + + @IsObject() + @IsOptional() + scraperConfiguration?: Prisma.InputJsonObject; + + @IsArray() + @IsOptional() + sectors?: Prisma.InputJsonArray; + + @IsOptional() + @IsString() + symbol?: string; + + @IsObject() + @IsOptional() + symbolMapping?: { + [dataProvider: string]: string; + }; + + @IsOptional() + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url?: string; +} diff --git a/libs/common/src/lib/dtos/update-bulk-market-data.dto.ts b/libs/common/src/lib/dtos/update-bulk-market-data.dto.ts new file mode 100644 index 000000000..f92112f24 --- /dev/null +++ b/libs/common/src/lib/dtos/update-bulk-market-data.dto.ts @@ -0,0 +1,11 @@ +import { UpdateMarketDataDto } from '@ghostfolio/common/dtos'; + +import { Type } from 'class-transformer'; +import { ArrayNotEmpty, IsArray } from 'class-validator'; + +export class UpdateBulkMarketDataDto { + @ArrayNotEmpty() + @IsArray() + @Type(() => UpdateMarketDataDto) + marketData: UpdateMarketDataDto[]; +} diff --git a/libs/common/src/lib/dtos/update-market-data.dto.ts b/libs/common/src/lib/dtos/update-market-data.dto.ts new file mode 100644 index 000000000..c2a6de11e --- /dev/null +++ b/libs/common/src/lib/dtos/update-market-data.dto.ts @@ -0,0 +1,10 @@ +import { IsISO8601, IsNumber, IsOptional } from 'class-validator'; + +export class UpdateMarketDataDto { + @IsISO8601() + @IsOptional() + date?: string; + + @IsNumber() + marketPrice: number; +} diff --git a/libs/common/src/lib/dtos/update-order.dto.ts b/libs/common/src/lib/dtos/update-order.dto.ts new file mode 100644 index 000000000..3656a8043 --- /dev/null +++ b/libs/common/src/lib/dtos/update-order.dto.ts @@ -0,0 +1,76 @@ +import { IsAfter1970Constraint } from '@ghostfolio/common/validator-constraints/is-after-1970'; +import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; + +import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client'; +import { Transform, TransformFnParams } from 'class-transformer'; +import { + IsArray, + IsEnum, + IsISO8601, + IsNumber, + IsOptional, + IsString, + Min, + Validate +} from 'class-validator'; +import { isString } from 'lodash'; + +export class UpdateOrderDto { + @IsOptional() + @IsString() + accountId?: string; + + @IsEnum(AssetClass, { each: true }) + @IsOptional() + assetClass?: AssetClass; + + @IsEnum(AssetSubClass, { each: true }) + @IsOptional() + assetSubClass?: AssetSubClass; + + @IsOptional() + @IsString() + @Transform(({ value }: TransformFnParams) => + isString(value) ? value.trim() : value + ) + comment?: string; + + @IsCurrencyCode() + currency: string; + + @IsCurrencyCode() + @IsOptional() + customCurrency?: string; + + @IsString() + dataSource: DataSource; + + @IsISO8601() + @Validate(IsAfter1970Constraint) + date: string; + + @IsNumber() + @Min(0) + fee: number; + + @IsString() + id: string; + + @IsNumber() + @Min(0) + quantity: number; + + @IsString() + symbol: string; + + @IsArray() + @IsOptional() + tags?: string[]; + + @IsString() + type: Type; + + @IsNumber() + @Min(0) + unitPrice: number; +} diff --git a/libs/common/src/lib/dtos/update-own-access-token.dto.ts b/libs/common/src/lib/dtos/update-own-access-token.dto.ts new file mode 100644 index 000000000..42f6f7289 --- /dev/null +++ b/libs/common/src/lib/dtos/update-own-access-token.dto.ts @@ -0,0 +1,6 @@ +import { IsString } from 'class-validator'; + +export class UpdateOwnAccessTokenDto { + @IsString() + accessToken: string; +} diff --git a/libs/common/src/lib/dtos/update-platform.dto.ts b/libs/common/src/lib/dtos/update-platform.dto.ts new file mode 100644 index 000000000..4c4f907af --- /dev/null +++ b/libs/common/src/lib/dtos/update-platform.dto.ts @@ -0,0 +1,15 @@ +import { IsString, IsUrl } from 'class-validator'; + +export class UpdatePlatformDto { + @IsString() + id: string; + + @IsString() + name: string; + + @IsUrl({ + protocols: ['https'], + require_protocol: true + }) + url: string; +} diff --git a/libs/common/src/lib/dtos/update-property.dto.ts b/libs/common/src/lib/dtos/update-property.dto.ts new file mode 100644 index 000000000..77115759a --- /dev/null +++ b/libs/common/src/lib/dtos/update-property.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdatePropertyDto { + @IsOptional() + @IsString() + value: string; +} diff --git a/libs/common/src/lib/dtos/update-tag.dto.ts b/libs/common/src/lib/dtos/update-tag.dto.ts new file mode 100644 index 000000000..5ae42dcc6 --- /dev/null +++ b/libs/common/src/lib/dtos/update-tag.dto.ts @@ -0,0 +1,13 @@ +import { IsOptional, IsString } from 'class-validator'; + +export class UpdateTagDto { + @IsString() + id: string; + + @IsString() + name: string; + + @IsOptional() + @IsString() + userId?: string; +} diff --git a/libs/common/src/lib/dtos/update-user-setting.dto.ts b/libs/common/src/lib/dtos/update-user-setting.dto.ts new file mode 100644 index 000000000..cf7dff7e8 --- /dev/null +++ b/libs/common/src/lib/dtos/update-user-setting.dto.ts @@ -0,0 +1,121 @@ +import { XRayRulesSettings } from '@ghostfolio/common/interfaces'; +import type { + ColorScheme, + DateRange, + HoldingsViewMode, + ViewMode +} from '@ghostfolio/common/types'; +import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code'; + +import { + IsArray, + IsBoolean, + IsISO8601, + IsIn, + IsNumber, + IsOptional, + IsString +} from 'class-validator'; +import { eachYearOfInterval, format } from 'date-fns'; + +export class UpdateUserSettingDto { + @IsNumber() + @IsOptional() + annualInterestRate?: number; + + @IsCurrencyCode() + @IsOptional() + baseCurrency?: string; + + @IsString() + @IsOptional() + benchmark?: string; + + @IsIn(['DARK', 'LIGHT'] as ColorScheme[]) + @IsOptional() + colorScheme?: ColorScheme; + + @IsIn([ + '1d', + '1y', + '5y', + 'max', + 'mtd', + 'wtd', + 'ytd', + ...eachYearOfInterval({ end: new Date(), start: new Date(0) }).map( + (date) => { + return format(date, 'yyyy'); + } + ) + ] as DateRange[]) + @IsOptional() + dateRange?: DateRange; + + @IsNumber() + @IsOptional() + emergencyFund?: number; + + @IsArray() + @IsOptional() + 'filters.accounts'?: string[]; + + @IsArray() + @IsOptional() + 'filters.assetClasses'?: string[]; + + @IsString() + @IsOptional() + 'filters.dataSource'?: string; + + @IsString() + @IsOptional() + 'filters.symbol'?: string; + + @IsArray() + @IsOptional() + 'filters.tags'?: string[]; + + @IsIn(['CHART', 'TABLE'] as HoldingsViewMode[]) + @IsOptional() + holdingsViewMode?: HoldingsViewMode; + + @IsBoolean() + @IsOptional() + isExperimentalFeatures?: boolean; + + @IsBoolean() + @IsOptional() + isRestrictedView?: boolean; + + @IsString() + @IsOptional() + language?: string; + + @IsString() + @IsOptional() + locale?: string; + + @IsNumber() + @IsOptional() + projectedTotalAmount?: number; + + @IsISO8601() + @IsOptional() + retirementDate?: string; + + @IsNumber() + @IsOptional() + safeWithdrawalRate?: number; + + @IsNumber() + @IsOptional() + savingsRate?: number; + + @IsIn(['DEFAULT', 'ZEN'] as ViewMode[]) + @IsOptional() + viewMode?: ViewMode; + + @IsOptional() + xRayRules?: XRayRulesSettings; +} diff --git a/libs/common/src/lib/enums/confirmation-dialog.type.ts b/libs/common/src/lib/enums/confirmation-dialog.type.ts new file mode 100644 index 000000000..1fe1fc7c9 --- /dev/null +++ b/libs/common/src/lib/enums/confirmation-dialog.type.ts @@ -0,0 +1,5 @@ +export enum ConfirmationDialogType { + Accent = 'accent', + Primary = 'primary', + Warn = 'warn' +} diff --git a/libs/common/src/lib/enums/index.ts b/libs/common/src/lib/enums/index.ts new file mode 100644 index 000000000..7384741de --- /dev/null +++ b/libs/common/src/lib/enums/index.ts @@ -0,0 +1,4 @@ +import { ConfirmationDialogType } from './confirmation-dialog.type'; +import { SubscriptionType } from './subscription-type.type'; + +export { ConfirmationDialogType, SubscriptionType }; diff --git a/libs/common/src/lib/enums/subscription-type.type.ts b/libs/common/src/lib/enums/subscription-type.type.ts new file mode 100644 index 000000000..0f4cd2dc2 --- /dev/null +++ b/libs/common/src/lib/enums/subscription-type.type.ts @@ -0,0 +1,4 @@ +export enum SubscriptionType { + Basic = 'Basic', + Premium = 'Premium' +} diff --git a/libs/common/src/lib/helper.spec.ts b/libs/common/src/lib/helper.spec.ts new file mode 100644 index 000000000..25779cf39 --- /dev/null +++ b/libs/common/src/lib/helper.spec.ts @@ -0,0 +1,119 @@ +import { + extractNumberFromString, + getNumberFormatGroup +} from '@ghostfolio/common/helper'; + +describe('Helper', () => { + describe('Extract number from string', () => { + it('Get decimal number', () => { + expect(extractNumberFromString({ value: '999.99' })).toEqual(999.99); + }); + + it('Get decimal number (with spaces)', () => { + expect(extractNumberFromString({ value: ' 999.99 ' })).toEqual(999.99); + }); + + it('Get decimal number (with currency)', () => { + expect(extractNumberFromString({ value: '999.99 CHF' })).toEqual(999.99); + }); + + it('Get decimal number (comma notation)', () => { + expect( + extractNumberFromString({ locale: 'de-DE', value: '999,99' }) + ).toEqual(999.99); + }); + + it('Get decimal number with group (dot notation)', () => { + expect( + extractNumberFromString({ locale: 'de-CH', value: '99’999.99' }) + ).toEqual(99999.99); + }); + + it('Get decimal number with group (comma notation)', () => { + expect( + extractNumberFromString({ locale: 'de-DE', value: '99.999,99' }) + ).toEqual(99999.99); + }); + + it('Get decimal number (comma notation) for locale where currency is not grouped by default', () => { + expect( + extractNumberFromString({ locale: 'es-ES', value: '999,99' }) + ).toEqual(999.99); + }); + + it('Not a number', () => { + expect(extractNumberFromString({ value: 'X' })).toEqual(NaN); + }); + }); + + describe('Get number format group', () => { + let languageGetter: jest.SpyInstance; + + beforeEach(() => { + languageGetter = jest.spyOn(window.navigator, 'language', 'get'); + }); + + it('Get de-CH number format group', () => { + expect(getNumberFormatGroup('de-CH')).toEqual('’'); + }); + + it('Get de-CH number format group when it is default', () => { + languageGetter.mockReturnValue('de-CH'); + expect(getNumberFormatGroup()).toEqual('’'); + }); + + it('Get de-DE number format group', () => { + expect(getNumberFormatGroup('de-DE')).toEqual('.'); + }); + + it('Get de-DE number format group when it is default', () => { + languageGetter.mockReturnValue('de-DE'); + expect(getNumberFormatGroup()).toEqual('.'); + }); + + it('Get en-GB number format group', () => { + expect(getNumberFormatGroup('en-GB')).toEqual(','); + }); + + it('Get en-GB number format group when it is default', () => { + languageGetter.mockReturnValue('en-GB'); + expect(getNumberFormatGroup()).toEqual(','); + }); + + it('Get en-US number format group', () => { + expect(getNumberFormatGroup('en-US')).toEqual(','); + }); + + it('Get en-US number format group when it is default', () => { + languageGetter.mockReturnValue('en-US'); + expect(getNumberFormatGroup()).toEqual(','); + }); + + it('Get es-ES number format group', () => { + expect(getNumberFormatGroup('es-ES')).toEqual('.'); + }); + + it('Get es-ES number format group when it is default', () => { + languageGetter.mockReturnValue('es-ES'); + expect(getNumberFormatGroup()).toEqual('.'); + }); + + it('Get ru-RU number format group', () => { + expect(getNumberFormatGroup('ru-RU')).toEqual(' '); + }); + + it('Get ru-RU number format group when it is default', () => { + languageGetter.mockReturnValue('ru-RU'); + expect(getNumberFormatGroup()).toEqual(' '); + }); + + it('Get zh-CN number format group', () => { + expect(getNumberFormatGroup('zh-CN')).toEqual(','); + }); + + it('Get zh-CN number format group when it is default', () => { + languageGetter.mockReturnValue('zh-CN'); + expect(getNumberFormatGroup()).toEqual(','); + }); + }); +}); diff --git a/libs/common/src/lib/helper.ts b/libs/common/src/lib/helper.ts new file mode 100644 index 000000000..4db1fcf2d --- /dev/null +++ b/libs/common/src/lib/helper.ts @@ -0,0 +1,476 @@ +import { NumberParser } from '@internationalized/number'; +import { Type as ActivityType, DataSource, MarketData } from '@prisma/client'; +import { Big } from 'big.js'; +import { isISO4217CurrencyCode } from 'class-validator'; +import { + getDate, + getMonth, + getYear, + isMatch, + parse, + parseISO, + subDays +} from 'date-fns'; +import { + ca, + de, + es, + fr, + it, + ko, + nl, + pl, + pt, + tr, + uk, + zhCN +} from 'date-fns/locale'; +import { get, isNil, isString } from 'lodash'; + +import { + DEFAULT_CURRENCY, + DERIVED_CURRENCIES, + ghostfolioScraperApiSymbolPrefix, + locale +} from './config'; +import { AssetProfileIdentifier, Benchmark } from './interfaces'; +import { BenchmarkTrend, ColorScheme } from './types'; + +export const DATE_FORMAT = 'yyyy-MM-dd'; +export const DATE_FORMAT_MONTHLY = 'MMMM yyyy'; +export const DATE_FORMAT_YEARLY = 'yyyy'; + +export function calculateBenchmarkTrend({ + days, + historicalData +}: { + days: number; + historicalData: MarketData[]; +}): BenchmarkTrend { + const hasEnoughData = historicalData.length >= 2 * days; + + if (!hasEnoughData) { + return 'UNKNOWN'; + } + + const recentPeriodAverage = calculateMovingAverage({ + days, + prices: historicalData.slice(0, days).map(({ marketPrice }) => { + return new Big(marketPrice); + }) + }); + + const pastPeriodAverage = calculateMovingAverage({ + days, + prices: historicalData.slice(days, 2 * days).map(({ marketPrice }) => { + return new Big(marketPrice); + }) + }); + + if (recentPeriodAverage > pastPeriodAverage) { + return 'UP'; + } + + if (recentPeriodAverage < pastPeriodAverage) { + return 'DOWN'; + } + + return 'NEUTRAL'; +} + +export function calculateMovingAverage({ + days, + prices +}: { + days: number; + prices: Big[]; +}) { + return prices + .reduce((previous, current) => { + return previous.add(current); + }, new Big(0)) + .div(days) + .toNumber(); +} + +export function capitalize(aString: string) { + return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); +} + +export function decodeDataSource(encodedDataSource: string) { + if (encodedDataSource) { + return Buffer.from(encodedDataSource, 'hex').toString(); + } + + return undefined; +} + +export function downloadAsFile({ + content, + contentType = 'text/plain', + fileName, + format +}: { + content: unknown; + contentType?: string; + fileName: string; + format: 'json' | 'string'; +}) { + const a = document.createElement('a'); + + if (format === 'json') { + content = JSON.stringify(content, undefined, ' '); + } + + const file = new Blob([content as string], { + type: contentType + }); + a.href = URL.createObjectURL(file); + a.download = fileName; + a.click(); +} + +export function encodeDataSource(aDataSource: DataSource) { + if (aDataSource) { + return Buffer.from(aDataSource, 'utf-8').toString('hex'); + } + + return undefined; +} + +export function extractNumberFromString({ + locale = 'en-US', + value +}: { + locale?: string; + value: string; +}): number | undefined { + try { + // Remove non-numeric characters (excluding international formatting characters) + const numericValue = value.replace(/[^\d.,'’\s]/g, ''); + + const parser = new NumberParser(locale); + + return parser.parse(numericValue); + } catch { + return undefined; + } +} + +export function getAllActivityTypes(): ActivityType[] { + return Object.values(ActivityType); +} + +export function getAssetProfileIdentifier({ + dataSource, + symbol +}: AssetProfileIdentifier) { + return `${dataSource}-${symbol}`; +} + +export function getBackgroundColor(aColorScheme: ColorScheme) { + return getCssVariable( + aColorScheme === 'DARK' || + window.matchMedia('(prefers-color-scheme: dark)').matches + ? '--dark-background' + : '--light-background' + ); +} + +export function getCssVariable(aCssVariable: string) { + return getComputedStyle(document.documentElement).getPropertyValue( + aCssVariable + ); +} + +export function getCurrencyFromSymbol(aSymbol = '') { + return aSymbol.replace(DEFAULT_CURRENCY, ''); +} + +export function getDateFnsLocale(aLanguageCode: string) { + if (aLanguageCode === 'ca') { + return ca; + } else if (aLanguageCode === 'de') { + return de; + } else if (aLanguageCode === 'es') { + return es; + } else if (aLanguageCode === 'fr') { + return fr; + } else if (aLanguageCode === 'it') { + return it; + } else if (aLanguageCode === 'ko') { + return ko; + } else if (aLanguageCode === 'nl') { + return nl; + } else if (aLanguageCode === 'pl') { + return pl; + } else if (aLanguageCode === 'pt') { + return pt; + } else if (aLanguageCode === 'tr') { + return tr; + } else if (aLanguageCode === 'uk') { + return uk; + } else if (aLanguageCode === 'zh') { + return zhCN; + } + + return undefined; +} + +export function getDateFormatString(aLocale?: string) { + const formatObject = new Intl.DateTimeFormat(aLocale).formatToParts( + new Date() + ); + + return formatObject + .map(({ type, value }) => { + switch (type) { + case 'day': + return 'dd'; + case 'month': + return 'MM'; + case 'year': + return 'yyyy'; + default: + return value; + } + }) + .join(''); +} + +export function getDateWithTimeFormatString(aLocale?: string) { + return `${getDateFormatString(aLocale)}, HH:mm:ss`; +} + +export function getEmojiFlag(aCountryCode: string) { + if (!aCountryCode) { + return aCountryCode; + } + + return aCountryCode + .toUpperCase() + .replace(/./g, (character) => + String.fromCodePoint(127397 + character.charCodeAt(0)) + ); +} + +export function getLocale() { + return navigator.language ?? locale; +} + +export function getLowercase(object: object, path: string) { + const value = get(object, path); + + if (isNil(value)) { + return ''; + } + + return isString(value) ? value.toLocaleLowerCase() : value; +} + +export function getNumberFormatDecimal(aLocale?: string) { + const formatObject = new Intl.NumberFormat(aLocale).formatToParts(9999.99); + + return formatObject.find(({ type }) => { + return type === 'decimal'; + })?.value; +} + +export function getNumberFormatGroup(aLocale = getLocale()) { + const formatObject = new Intl.NumberFormat(aLocale, { + useGrouping: true + }).formatToParts(9999.99); + + return formatObject.find(({ type }) => { + return type === 'group'; + })?.value; +} + +export function getStartOfUtcDate(aDate: Date) { + const date = new Date(aDate); + date.setUTCHours(0, 0, 0, 0); + + return date; +} + +export function getSum(aArray: Big[]) { + if (aArray?.length > 0) { + return aArray.reduce((a, b) => a.plus(b), new Big(0)); + } + + return new Big(0); +} + +export function getTextColor(aColorScheme: ColorScheme) { + const cssVariable = getCssVariable( + aColorScheme === 'DARK' || + window.matchMedia('(prefers-color-scheme: dark)').matches + ? '--light-primary-text' + : '--dark-primary-text' + ); + + const [r, g, b] = cssVariable.split(','); + + return `${r}, ${g}, ${b}`; +} + +export function getToday() { + const year = getYear(new Date()); + const month = getMonth(new Date()); + const day = getDate(new Date()); + + return new Date(Date.UTC(year, month, day)); +} + +export function getUtc(aDateString: string) { + const [yearString, monthString, dayString] = aDateString.split('-'); + + return new Date( + Date.UTC( + parseInt(yearString, 10), + parseInt(monthString, 10) - 1, + parseInt(dayString, 10) + ) + ); +} + +export function getYesterday() { + const year = getYear(new Date()); + const month = getMonth(new Date()); + const day = getDate(new Date()); + + return subDays(new Date(Date.UTC(year, month, day)), 1); +} + +export function groupBy( + key: K, + arr: T[] +): Map { + const map = new Map(); + arr.forEach((t) => { + if (!map.has(t[key])) { + map.set(t[key], []); + } + map.get(t[key])!.push(t); + }); + return map; +} + +export function interpolate(template: string, context: any) { + return template?.replace(/[$]{([^}]+)}/g, (_, objectPath) => { + const properties = objectPath.split('.'); + return properties.reduce( + (previous, current) => previous?.[current], + context + ); + }); +} + +export function isCurrency(aCurrency: string) { + if (!aCurrency) { + return false; + } + + return isISO4217CurrencyCode(aCurrency) || isDerivedCurrency(aCurrency); +} + +export function isDerivedCurrency(aCurrency: string) { + if (aCurrency === 'USX') { + return true; + } + + return DERIVED_CURRENCIES.some(({ currency }) => { + return currency === aCurrency; + }); +} + +export function isRootCurrency(aCurrency: string) { + if (aCurrency === 'USD') { + return true; + } + + return DERIVED_CURRENCIES.find(({ rootCurrency }) => { + return rootCurrency === aCurrency; + }); +} + +export function parseDate(date: string): Date | undefined { + if (!date) { + return undefined; + } + + // Transform 'yyyyMMdd' format to supported format by parse function + if (date?.length === 8) { + const match = /^(\d{4})(\d{2})(\d{2})$/.exec(date); + + if (match) { + const [, year, month, day] = match; + date = `${year}-${month}-${day}`; + } + } + + const dateFormat = [ + 'dd-MM-yyyy', + 'dd/MM/yyyy', + 'dd.MM.yyyy', + 'yyyy-MM-dd', + 'yyyy/MM/dd', + 'yyyy.MM.dd', + 'yyyyMMdd' + ].find((format) => { + return isMatch(date, format) && format.length === date.length; + }); + + if (dateFormat) { + return parse(date, dateFormat, new Date()); + } + + return parseISO(date); +} + +export function parseSymbol({ dataSource, symbol }: AssetProfileIdentifier) { + const [ticker, exchange] = symbol.split('.'); + + return { + ticker, + exchange: exchange ?? (dataSource === 'YAHOO' ? 'US' : undefined) + }; +} + +export function prettifySymbol(aSymbol: string): string { + return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, ''); +} + +export function resetHours(aDate: Date) { + const year = getYear(aDate); + const month = getMonth(aDate); + const day = getDate(aDate); + + return new Date(Date.UTC(year, month, day)); +} + +export function resolveFearAndGreedIndex(aValue: number) { + if (aValue <= 25) { + return { emoji: '🥵', key: 'EXTREME_FEAR', text: 'Extreme Fear' }; + } else if (aValue <= 45) { + return { emoji: '😨', key: 'FEAR', text: 'Fear' }; + } else if (aValue <= 55) { + return { emoji: '😐', key: 'NEUTRAL', text: 'Neutral' }; + } else if (aValue < 75) { + return { emoji: '😜', key: 'GREED', text: 'Greed' }; + } else { + return { emoji: '🤪', key: 'EXTREME_GREED', text: 'Extreme Greed' }; + } +} + +export function resolveMarketCondition( + aMarketCondition: Benchmark['marketCondition'] +) { + if (aMarketCondition === 'ALL_TIME_HIGH') { + return { emoji: '🎉' }; + } else if (aMarketCondition === 'BEAR_MARKET') { + return { emoji: '🐻' }; + } else { + return { emoji: undefined }; + } +} diff --git a/libs/common/src/lib/interfaces/access.interface.ts b/libs/common/src/lib/interfaces/access.interface.ts new file mode 100644 index 000000000..7736a71ab --- /dev/null +++ b/libs/common/src/lib/interfaces/access.interface.ts @@ -0,0 +1,11 @@ +import { AccessType } from '@ghostfolio/common/types'; + +import { AccessPermission } from '@prisma/client'; + +export interface Access { + alias?: string; + grantee?: string; + id: string; + permissions: AccessPermission[]; + type: AccessType; +} diff --git a/libs/common/src/lib/interfaces/account-balance.interface.ts b/libs/common/src/lib/interfaces/account-balance.interface.ts new file mode 100644 index 000000000..00fcf1e53 --- /dev/null +++ b/libs/common/src/lib/interfaces/account-balance.interface.ts @@ -0,0 +1,4 @@ +export interface AccountBalance { + date: string; + value: number; +} diff --git a/libs/common/src/lib/interfaces/activities.interface.ts b/libs/common/src/lib/interfaces/activities.interface.ts new file mode 100644 index 000000000..b9e64984b --- /dev/null +++ b/libs/common/src/lib/interfaces/activities.interface.ts @@ -0,0 +1,23 @@ +import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; + +import { Order, Tag } from '@prisma/client'; + +export interface Activity extends Order { + account?: AccountWithPlatform; + error?: ActivityError; + feeInAssetProfileCurrency: number; + feeInBaseCurrency: number; + SymbolProfile: EnhancedSymbolProfile; + tagIds?: string[]; + tags?: Tag[]; + unitPriceInAssetProfileCurrency: number; + updateAccountBalance?: boolean; + value: number; + valueInBaseCurrency: number; +} + +export interface ActivityError { + code: 'IS_DUPLICATE'; + message?: string; +} diff --git a/libs/common/src/lib/interfaces/admin-data.interface.ts b/libs/common/src/lib/interfaces/admin-data.interface.ts new file mode 100644 index 000000000..dd25b516d --- /dev/null +++ b/libs/common/src/lib/interfaces/admin-data.interface.ts @@ -0,0 +1,12 @@ +import { DataProviderInfo } from './data-provider-info.interface'; + +export interface AdminData { + activitiesCount: number; + dataProviders: (DataProviderInfo & { + assetProfileCount: number; + useForExchangeRates: boolean; + })[]; + settings: { [key: string]: boolean | object | string | string[] }; + userCount: number; + version: string; +} diff --git a/libs/common/src/lib/interfaces/admin-jobs.interface.ts b/libs/common/src/lib/interfaces/admin-jobs.interface.ts new file mode 100644 index 000000000..b4c91ebc0 --- /dev/null +++ b/libs/common/src/lib/interfaces/admin-jobs.interface.ts @@ -0,0 +1,17 @@ +import { Job, JobStatus } from 'bull'; + +export interface AdminJobs { + jobs: (Pick< + Job, + | 'attemptsMade' + | 'data' + | 'finishedOn' + | 'id' + | 'name' + | 'opts' + | 'stacktrace' + | 'timestamp' + > & { + state: JobStatus | 'stuck'; + })[]; +} diff --git a/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts b/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts new file mode 100644 index 000000000..441643f81 --- /dev/null +++ b/libs/common/src/lib/interfaces/admin-market-data-details.interface.ts @@ -0,0 +1,8 @@ +import { MarketData } from '@prisma/client'; + +import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; + +export interface AdminMarketDataDetails { + assetProfile: Partial; + marketData: MarketData[]; +} diff --git a/libs/common/src/lib/interfaces/admin-market-data.interface.ts b/libs/common/src/lib/interfaces/admin-market-data.interface.ts new file mode 100644 index 000000000..953f94e26 --- /dev/null +++ b/libs/common/src/lib/interfaces/admin-market-data.interface.ts @@ -0,0 +1,26 @@ +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; + +export interface AdminMarketData { + count: number; + marketData: AdminMarketDataItem[]; +} + +export interface AdminMarketDataItem { + activitiesCount: number; + assetClass?: AssetClass; + assetSubClass?: AssetSubClass; + countriesCount: number; + currency: string; + dataSource: DataSource; + date: Date; + id: string; + isActive: boolean; + isBenchmark?: boolean; + isUsedByUsersWithSubscription?: boolean; + lastMarketPrice: number; + marketDataItemCount: number; + name: string; + sectorsCount: number; + symbol: string; + watchedByCount: number; +} diff --git a/libs/common/src/lib/interfaces/admin-user.interface.ts b/libs/common/src/lib/interfaces/admin-user.interface.ts new file mode 100644 index 000000000..4cb02b16e --- /dev/null +++ b/libs/common/src/lib/interfaces/admin-user.interface.ts @@ -0,0 +1,15 @@ +import { Provider, Role, Subscription } from '@prisma/client'; + +export interface AdminUser { + accountCount: number; + activityCount: number; + country: string; + createdAt: Date; + dailyApiRequests: number; + engagement: number; + id: string; + lastActivity: Date; + provider: Provider; + role: Role; + subscription?: Subscription; +} diff --git a/libs/common/src/lib/interfaces/asset-class-selector-option.interface.ts b/libs/common/src/lib/interfaces/asset-class-selector-option.interface.ts new file mode 100644 index 000000000..b4b1060e4 --- /dev/null +++ b/libs/common/src/lib/interfaces/asset-class-selector-option.interface.ts @@ -0,0 +1,6 @@ +import { AssetClass, AssetSubClass } from '@prisma/client'; + +export interface AssetClassSelectorOption { + id: AssetClass | AssetSubClass; + label: string; +} diff --git a/libs/common/src/lib/interfaces/asset-profile-identifier.interface.ts b/libs/common/src/lib/interfaces/asset-profile-identifier.interface.ts new file mode 100644 index 000000000..48fc18c2f --- /dev/null +++ b/libs/common/src/lib/interfaces/asset-profile-identifier.interface.ts @@ -0,0 +1,6 @@ +import { DataSource } from '@prisma/client'; + +export interface AssetProfileIdentifier { + dataSource: DataSource; + symbol: string; +} diff --git a/libs/common/src/lib/interfaces/benchmark-property.interface.ts b/libs/common/src/lib/interfaces/benchmark-property.interface.ts new file mode 100644 index 000000000..a6c4958ed --- /dev/null +++ b/libs/common/src/lib/interfaces/benchmark-property.interface.ts @@ -0,0 +1,4 @@ +export interface BenchmarkProperty { + enableSharing?: boolean; + symbolProfileId: string; +} diff --git a/libs/common/src/lib/interfaces/benchmark.interface.ts b/libs/common/src/lib/interfaces/benchmark.interface.ts new file mode 100644 index 000000000..bf85cd752 --- /dev/null +++ b/libs/common/src/lib/interfaces/benchmark.interface.ts @@ -0,0 +1,18 @@ +import { BenchmarkTrend } from '@ghostfolio/common/types/'; + +import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; + +export interface Benchmark { + dataSource: EnhancedSymbolProfile['dataSource']; + marketCondition: 'ALL_TIME_HIGH' | 'BEAR_MARKET' | 'NEUTRAL_MARKET'; + name: EnhancedSymbolProfile['name']; + performances: { + allTimeHigh: { + date: Date; + performancePercent: number; + }; + }; + symbol: EnhancedSymbolProfile['symbol']; + trend50d: BenchmarkTrend; + trend200d: BenchmarkTrend; +} diff --git a/libs/common/src/lib/interfaces/country.interface.ts b/libs/common/src/lib/interfaces/country.interface.ts new file mode 100644 index 000000000..4119d91ea --- /dev/null +++ b/libs/common/src/lib/interfaces/country.interface.ts @@ -0,0 +1,6 @@ +export interface Country { + code: string; + continent: string; + name: string; + weight: number; +} diff --git a/libs/common/src/lib/interfaces/coupon.interface.ts b/libs/common/src/lib/interfaces/coupon.interface.ts new file mode 100644 index 000000000..cbf8525a2 --- /dev/null +++ b/libs/common/src/lib/interfaces/coupon.interface.ts @@ -0,0 +1,6 @@ +import { StringValue } from 'ms'; + +export interface Coupon { + code: string; + duration?: StringValue; +} diff --git a/libs/common/src/lib/interfaces/data-provider-info.interface.ts b/libs/common/src/lib/interfaces/data-provider-info.interface.ts new file mode 100644 index 000000000..9fba0e62d --- /dev/null +++ b/libs/common/src/lib/interfaces/data-provider-info.interface.ts @@ -0,0 +1,8 @@ +import { DataSource } from '@prisma/client'; + +export interface DataProviderInfo { + dataSource?: DataSource; + isPremium: boolean; + name?: string; + url?: string; +} diff --git a/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts new file mode 100644 index 000000000..8426916c9 --- /dev/null +++ b/libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts @@ -0,0 +1,37 @@ +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; + +import { Country } from './country.interface'; +import { DataProviderInfo } from './data-provider-info.interface'; +import { Holding } from './holding.interface'; +import { ScraperConfiguration } from './scraper-configuration.interface'; +import { Sector } from './sector.interface'; + +export interface EnhancedSymbolProfile { + activitiesCount: number; + assetClass: AssetClass; + assetSubClass: AssetSubClass; + comment?: string; + countries: Country[]; + createdAt: Date; + currency?: string; + cusip?: string; + dataProviderInfo?: DataProviderInfo; + dataSource: DataSource; + dateOfFirstActivity?: Date; + figi?: string; + figiComposite?: string; + figiShareClass?: string; + holdings: Holding[]; + id: string; + isActive: boolean; + isin?: string; + name?: string; + scraperConfiguration?: ScraperConfiguration; + sectors: Sector[]; + symbol: string; + symbolMapping?: { [key: string]: string }; + updatedAt: Date; + url?: string; + userId?: string; + watchedByCount?: number; +} diff --git a/libs/common/src/lib/interfaces/filter-group.interface.ts b/libs/common/src/lib/interfaces/filter-group.interface.ts new file mode 100644 index 000000000..7087b99fa --- /dev/null +++ b/libs/common/src/lib/interfaces/filter-group.interface.ts @@ -0,0 +1,6 @@ +import { Filter } from './filter.interface'; + +export interface FilterGroup { + filters: Filter[]; + name: Filter['type']; +} diff --git a/libs/common/src/lib/interfaces/filter.interface.ts b/libs/common/src/lib/interfaces/filter.interface.ts new file mode 100644 index 000000000..43634f876 --- /dev/null +++ b/libs/common/src/lib/interfaces/filter.interface.ts @@ -0,0 +1,14 @@ +export interface Filter { + id: string; + label?: string; + type: + | 'ACCOUNT' + | 'ASSET_CLASS' + | 'ASSET_SUB_CLASS' + | 'DATA_SOURCE' + | 'HOLDING_TYPE' + | 'PRESET_ID' + | 'SEARCH_QUERY' + | 'SYMBOL' + | 'TAG'; +} diff --git a/libs/common/src/lib/interfaces/fire-calculation-complete-event.interface.ts b/libs/common/src/lib/interfaces/fire-calculation-complete-event.interface.ts new file mode 100644 index 000000000..1238b0729 --- /dev/null +++ b/libs/common/src/lib/interfaces/fire-calculation-complete-event.interface.ts @@ -0,0 +1,4 @@ +export interface FireCalculationCompleteEvent { + projectedTotalAmount: number; + retirementDate: Date; +} diff --git a/libs/common/src/lib/interfaces/fire-wealth.interface.ts b/libs/common/src/lib/interfaces/fire-wealth.interface.ts new file mode 100644 index 000000000..42fbeabd4 --- /dev/null +++ b/libs/common/src/lib/interfaces/fire-wealth.interface.ts @@ -0,0 +1,3 @@ +export interface FireWealth { + today: { valueInBaseCurrency: number }; +} diff --git a/libs/common/src/lib/interfaces/historical-data-item.interface.ts b/libs/common/src/lib/interfaces/historical-data-item.interface.ts new file mode 100644 index 000000000..0b45cf0b7 --- /dev/null +++ b/libs/common/src/lib/interfaces/historical-data-item.interface.ts @@ -0,0 +1,20 @@ +export interface HistoricalDataItem { + averagePrice?: number; + date: string; + grossPerformancePercent?: number; + investmentValueWithCurrencyEffect?: number; + marketPrice?: number; + netPerformance?: number; + netPerformanceInPercentage?: number; + netPerformanceInPercentageWithCurrencyEffect?: number; + netPerformanceWithCurrencyEffect?: number; + netWorth?: number; + netWorthInPercentage?: number; + quantity?: number; + totalAccountBalance?: number; + totalInvestment?: number; + totalInvestmentValueWithCurrencyEffect?: number; + value?: number; + valueInPercentage?: number; + valueWithCurrencyEffect?: number; +} diff --git a/libs/common/src/lib/interfaces/holding-with-parents.interface.ts b/libs/common/src/lib/interfaces/holding-with-parents.interface.ts new file mode 100644 index 000000000..df3f32967 --- /dev/null +++ b/libs/common/src/lib/interfaces/holding-with-parents.interface.ts @@ -0,0 +1,5 @@ +import { Holding } from './holding.interface'; + +export interface HoldingWithParents extends Holding { + parents?: Holding[]; +} diff --git a/libs/common/src/lib/interfaces/holding.interface.ts b/libs/common/src/lib/interfaces/holding.interface.ts new file mode 100644 index 000000000..e963bc5a7 --- /dev/null +++ b/libs/common/src/lib/interfaces/holding.interface.ts @@ -0,0 +1,5 @@ +export interface Holding { + allocationInPercentage: number; + name: string; + valueInBaseCurrency: number; +} diff --git a/libs/common/src/lib/interfaces/index.ts b/libs/common/src/lib/interfaces/index.ts new file mode 100644 index 000000000..ad747d94e --- /dev/null +++ b/libs/common/src/lib/interfaces/index.ts @@ -0,0 +1,194 @@ +import type { Access } from './access.interface'; +import type { AccountBalance } from './account-balance.interface'; +import type { Activity, ActivityError } from './activities.interface'; +import type { AdminData } from './admin-data.interface'; +import type { AdminJobs } from './admin-jobs.interface'; +import type { AdminMarketDataDetails } from './admin-market-data-details.interface'; +import type { + AdminMarketData, + AdminMarketDataItem +} from './admin-market-data.interface'; +import type { AdminUser } from './admin-user.interface'; +import type { AssetClassSelectorOption } from './asset-class-selector-option.interface'; +import type { AssetProfileIdentifier } from './asset-profile-identifier.interface'; +import type { BenchmarkProperty } from './benchmark-property.interface'; +import type { Benchmark } from './benchmark.interface'; +import type { Coupon } from './coupon.interface'; +import type { DataProviderInfo } from './data-provider-info.interface'; +import type { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface'; +import type { FilterGroup } from './filter-group.interface'; +import type { Filter } from './filter.interface'; +import type { FireCalculationCompleteEvent } from './fire-calculation-complete-event.interface'; +import type { FireWealth } from './fire-wealth.interface'; +import type { HistoricalDataItem } from './historical-data-item.interface'; +import type { HoldingWithParents } from './holding-with-parents.interface'; +import type { Holding } from './holding.interface'; +import type { InfoItem } from './info-item.interface'; +import type { InvestmentItem } from './investment-item.interface'; +import type { LineChartItem } from './line-chart-item.interface'; +import type { LookupItem } from './lookup-item.interface'; +import type { MarketData } from './market-data.interface'; +import type { PortfolioChart } from './portfolio-chart.interface'; +import type { PortfolioDetails } from './portfolio-details.interface'; +import type { PortfolioPerformance } from './portfolio-performance.interface'; +import type { PortfolioPosition } from './portfolio-position.interface'; +import type { PortfolioReportRule } from './portfolio-report-rule.interface'; +import type { PortfolioSummary } from './portfolio-summary.interface'; +import type { Product } from './product'; +import type { AccessTokenResponse } from './responses/access-token-response.interface'; +import type { AccountBalancesResponse } from './responses/account-balances-response.interface'; +import type { AccountResponse } from './responses/account-response.interface'; +import type { AccountsResponse } from './responses/accounts-response.interface'; +import type { ActivitiesResponse } from './responses/activities-response.interface'; +import type { ActivityResponse } from './responses/activity-response.interface'; +import type { AdminUserResponse } from './responses/admin-user-response.interface'; +import type { AdminUsersResponse } from './responses/admin-users-response.interface'; +import type { AiPromptResponse } from './responses/ai-prompt-response.interface'; +import type { ApiKeyResponse } from './responses/api-key-response.interface'; +import type { AssetResponse } from './responses/asset-response.interface'; +import type { BenchmarkMarketDataDetailsResponse } from './responses/benchmark-market-data-details-response.interface'; +import type { BenchmarkResponse } from './responses/benchmark-response.interface'; +import type { CreateStripeCheckoutSessionResponse } from './responses/create-stripe-checkout-session-response.interface'; +import type { DataEnhancerHealthResponse } from './responses/data-enhancer-health-response.interface'; +import type { DataProviderGhostfolioAssetProfileResponse } from './responses/data-provider-ghostfolio-asset-profile-response.interface'; +import type { DataProviderGhostfolioStatusResponse } from './responses/data-provider-ghostfolio-status-response.interface'; +import type { DataProviderHealthResponse } from './responses/data-provider-health-response.interface'; +import type { + DataProviderResponse, + DataProviderHistoricalResponse +} from './responses/data-provider-response.interface'; +import type { DividendsResponse } from './responses/dividends-response.interface'; +import type { ResponseError } from './responses/errors.interface'; +import type { ExportResponse } from './responses/export-response.interface'; +import type { HistoricalResponse } from './responses/historical-response.interface'; +import type { ImportResponse } from './responses/import-response.interface'; +import type { InfoResponse } from './responses/info-response.interface'; +import type { LookupResponse } from './responses/lookup-response.interface'; +import type { MarketDataDetailsResponse } from './responses/market-data-details-response.interface'; +import type { MarketDataOfMarketsResponse } from './responses/market-data-of-markets-response.interface'; +import type { OAuthResponse } from './responses/oauth-response.interface'; +import type { PlatformsResponse } from './responses/platforms-response.interface'; +import type { PortfolioDividendsResponse } from './responses/portfolio-dividends-response.interface'; +import type { PortfolioHoldingResponse } from './responses/portfolio-holding-response.interface'; +import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface'; +import type { PortfolioInvestmentsResponse } from './responses/portfolio-investments.interface'; +import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface'; +import type { PortfolioReportResponse } from './responses/portfolio-report.interface'; +import type { PublicPortfolioResponse } from './responses/public-portfolio-response.interface'; +import type { QuotesResponse } from './responses/quotes-response.interface'; +import type { WatchlistResponse } from './responses/watchlist-response.interface'; +import type { RuleSettings } from './rule-settings.interface'; +import type { ScraperConfiguration } from './scraper-configuration.interface'; +import type { + AssertionCredentialJSON, + AttestationCredentialJSON, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON +} from './simplewebauthn.interface'; +import type { Statistics } from './statistics.interface'; +import type { SubscriptionOffer } from './subscription-offer.interface'; +import type { SymbolItem } from './symbol-item.interface'; +import type { SymbolMetrics } from './symbol-metrics.interface'; +import type { SystemMessage } from './system-message.interface'; +import type { TabConfiguration } from './tab-configuration.interface'; +import type { ToggleOption } from './toggle-option.interface'; +import type { UserItem } from './user-item.interface'; +import type { UserSettings } from './user-settings.interface'; +import type { User } from './user.interface'; +import type { XRayRulesSettings } from './x-ray-rules-settings.interface'; + +export { + Access, + AccessTokenResponse, + AccountBalance, + AccountBalancesResponse, + AccountResponse, + AccountsResponse, + ActivitiesResponse, + Activity, + ActivityError, + ActivityResponse, + AdminData, + AdminJobs, + AdminMarketData, + AdminMarketDataDetails, + AdminMarketDataItem, + AdminUser, + AdminUserResponse, + AdminUsersResponse, + AiPromptResponse, + ApiKeyResponse, + AssertionCredentialJSON, + AssetClassSelectorOption, + AssetProfileIdentifier, + AssetResponse, + AttestationCredentialJSON, + Benchmark, + BenchmarkMarketDataDetailsResponse, + BenchmarkProperty, + BenchmarkResponse, + Coupon, + CreateStripeCheckoutSessionResponse, + DataEnhancerHealthResponse, + DataProviderGhostfolioAssetProfileResponse, + DataProviderGhostfolioStatusResponse, + DataProviderHealthResponse, + DataProviderHistoricalResponse, + DataProviderInfo, + DataProviderResponse, + DividendsResponse, + EnhancedSymbolProfile, + ExportResponse, + Filter, + FilterGroup, + FireCalculationCompleteEvent, + FireWealth, + HistoricalDataItem, + HistoricalResponse, + Holding, + HoldingWithParents, + ImportResponse, + InfoItem, + InfoResponse, + InvestmentItem, + LineChartItem, + LookupItem, + LookupResponse, + MarketData, + MarketDataDetailsResponse, + MarketDataOfMarketsResponse, + OAuthResponse, + PlatformsResponse, + PortfolioChart, + PortfolioDetails, + PortfolioDividendsResponse, + PortfolioHoldingResponse, + PortfolioHoldingsResponse, + PortfolioInvestmentsResponse, + PortfolioPerformance, + PortfolioPerformanceResponse, + PortfolioPosition, + PortfolioReportResponse, + PortfolioReportRule, + PortfolioSummary, + Product, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + PublicPortfolioResponse, + QuotesResponse, + ResponseError, + RuleSettings, + ScraperConfiguration, + Statistics, + SubscriptionOffer, + SymbolItem, + SymbolMetrics, + SystemMessage, + TabConfiguration, + ToggleOption, + User, + UserItem, + UserSettings, + WatchlistResponse, + XRayRulesSettings +}; diff --git a/libs/common/src/lib/interfaces/info-item.interface.ts b/libs/common/src/lib/interfaces/info-item.interface.ts new file mode 100644 index 000000000..01897c066 --- /dev/null +++ b/libs/common/src/lib/interfaces/info-item.interface.ts @@ -0,0 +1,18 @@ +import { SymbolProfile } from '@prisma/client'; + +import { Statistics } from './statistics.interface'; +import { SubscriptionOffer } from './subscription-offer.interface'; + +export interface InfoItem { + baseCurrency: string; + benchmarks: Partial[]; + countriesOfSubscribers?: string[]; + currencies: string[]; + demoAuthToken: string; + fearAndGreedDataSource?: string; + globalPermissions: string[]; + isDataGatheringEnabled?: string; + isReadOnlyMode?: boolean; + statistics: Statistics; + subscriptionOffer?: SubscriptionOffer; +} diff --git a/libs/common/src/lib/interfaces/investment-item.interface.ts b/libs/common/src/lib/interfaces/investment-item.interface.ts new file mode 100644 index 000000000..effa18c1b --- /dev/null +++ b/libs/common/src/lib/interfaces/investment-item.interface.ts @@ -0,0 +1,4 @@ +export interface InvestmentItem { + date: string; + investment: number; +} diff --git a/libs/common/src/lib/interfaces/line-chart-item.interface.ts b/libs/common/src/lib/interfaces/line-chart-item.interface.ts new file mode 100644 index 000000000..e010ddfe6 --- /dev/null +++ b/libs/common/src/lib/interfaces/line-chart-item.interface.ts @@ -0,0 +1,4 @@ +export interface LineChartItem { + date: string; + value: number; +} diff --git a/libs/common/src/lib/interfaces/lookup-item.interface.ts b/libs/common/src/lib/interfaces/lookup-item.interface.ts new file mode 100644 index 000000000..fa91ed690 --- /dev/null +++ b/libs/common/src/lib/interfaces/lookup-item.interface.ts @@ -0,0 +1,13 @@ +import { AssetClass, AssetSubClass, DataSource } from '@prisma/client'; + +import { DataProviderInfo } from './data-provider-info.interface'; + +export interface LookupItem { + assetClass: AssetClass; + assetSubClass: AssetSubClass; + currency: string; + dataProviderInfo: DataProviderInfo; + dataSource: DataSource; + name: string; + symbol: string; +} diff --git a/libs/common/src/lib/interfaces/market-data.interface.ts b/libs/common/src/lib/interfaces/market-data.interface.ts new file mode 100644 index 000000000..b7a410cba --- /dev/null +++ b/libs/common/src/lib/interfaces/market-data.interface.ts @@ -0,0 +1,4 @@ +export interface MarketData { + date: string; + marketPrice: number; +} diff --git a/libs/common/src/lib/interfaces/portfolio-chart.interface.ts b/libs/common/src/lib/interfaces/portfolio-chart.interface.ts new file mode 100644 index 000000000..0ed4a8bb9 --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-chart.interface.ts @@ -0,0 +1,8 @@ +import { HistoricalDataItem } from './historical-data-item.interface'; + +export interface PortfolioChart { + hasError: boolean; + isAllTimeHigh: boolean; + isAllTimeLow: boolean; + chart: HistoricalDataItem[]; +} diff --git a/libs/common/src/lib/interfaces/portfolio-details.interface.ts b/libs/common/src/lib/interfaces/portfolio-details.interface.ts new file mode 100644 index 000000000..746736f6b --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-details.interface.ts @@ -0,0 +1,43 @@ +import { + PortfolioPosition, + PortfolioSummary +} from '@ghostfolio/common/interfaces'; +import { Market, MarketAdvanced } from '@ghostfolio/common/types'; + +export interface PortfolioDetails { + accounts: { + [id: string]: { + balance: number; + currency: string; + name: string; + valueInBaseCurrency: number; + valueInPercentage?: number; + }; + }; + createdAt: Date; + holdings: { [symbol: string]: PortfolioPosition }; + markets?: { + [key in Market]: { + id: Market; + valueInBaseCurrency?: number; + valueInPercentage: number; + }; + }; + marketsAdvanced?: { + [key in MarketAdvanced]: { + id: MarketAdvanced; + valueInBaseCurrency?: number; + valueInPercentage: number; + }; + }; + platforms: { + [id: string]: { + balance: number; + currency: string; + name: string; + valueInBaseCurrency: number; + valueInPercentage?: number; + }; + }; + summary?: PortfolioSummary; +} diff --git a/libs/common/src/lib/interfaces/portfolio-performance.interface.ts b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts new file mode 100644 index 000000000..0698004d5 --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-performance.interface.ts @@ -0,0 +1,11 @@ +export interface PortfolioPerformance { + annualizedPerformancePercent?: number; + currentNetWorth?: number; + currentValueInBaseCurrency: number; + netPerformance: number; + netPerformancePercentage: number; + netPerformancePercentageWithCurrencyEffect: number; + netPerformanceWithCurrencyEffect: number; + totalInvestment: number; + totalInvestmentValueWithCurrencyEffect: number; +} diff --git a/libs/common/src/lib/interfaces/portfolio-position.interface.ts b/libs/common/src/lib/interfaces/portfolio-position.interface.ts new file mode 100644 index 000000000..620cc00e9 --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-position.interface.ts @@ -0,0 +1,46 @@ +import { Market, MarketAdvanced } from '@ghostfolio/common/types'; + +import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client'; + +import { Country } from './country.interface'; +import { Holding } from './holding.interface'; +import { Sector } from './sector.interface'; + +export interface PortfolioPosition { + activitiesCount: number; + allocationInPercentage: number; + assetClass?: AssetClass; + assetClassLabel?: string; + assetSubClass?: AssetSubClass; + assetSubClassLabel?: string; + countries: Country[]; + currency: string; + dataSource: DataSource; + dateOfFirstActivity: Date; + dividend: number; + exchange?: string; + grossPerformance: number; + grossPerformancePercent: number; + grossPerformancePercentWithCurrencyEffect: number; + grossPerformanceWithCurrencyEffect: number; + holdings: Holding[]; + investment: number; + marketChange?: number; + marketChangePercent?: number; + marketPrice: number; + markets?: { [key in Market]: number }; + marketsAdvanced?: { [key in MarketAdvanced]: number }; + name: string; + netPerformance: number; + netPerformancePercent: number; + netPerformancePercentWithCurrencyEffect: number; + netPerformanceWithCurrencyEffect: number; + quantity: number; + sectors: Sector[]; + symbol: string; + tags?: Tag[]; + type?: string; + url?: string; + valueInBaseCurrency?: number; + valueInPercentage?: number; +} diff --git a/libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts b/libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts new file mode 100644 index 000000000..0296606b8 --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-report-rule.interface.ts @@ -0,0 +1,17 @@ +export interface PortfolioReportRule { + configuration?: { + threshold?: { + max: number; + min: number; + step: number; + unit?: string; + }; + thresholdMax?: boolean; + thresholdMin?: boolean; + }; + evaluation?: string; + isActive: boolean; + key: string; + name: string; + value?: boolean; +} diff --git a/libs/common/src/lib/interfaces/portfolio-summary.interface.ts b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts new file mode 100644 index 000000000..79fbf8707 --- /dev/null +++ b/libs/common/src/lib/interfaces/portfolio-summary.interface.ts @@ -0,0 +1,32 @@ +import { FireWealth } from './fire-wealth.interface'; +import { PortfolioPerformance } from './portfolio-performance.interface'; + +export interface PortfolioSummary extends PortfolioPerformance { + activityCount: number; + annualizedPerformancePercent: number; + annualizedPerformancePercentWithCurrencyEffect: number; + cash: number; + + /** @deprecated use totalInvestmentValueWithCurrencyEffect instead */ + committedFunds: number; + + dateOfFirstActivity: Date; + dividendInBaseCurrency: number; + emergencyFund: { + assets: number; + cash: number; + total: number; + }; + excludedAccountsAndActivities: number; + fees: number; + filteredValueInBaseCurrency?: number; + filteredValueInPercentage?: number; + fireWealth: FireWealth; + grossPerformance: number; + grossPerformanceWithCurrencyEffect: number; + interestInBaseCurrency: number; + liabilitiesInBaseCurrency: number; + totalBuy: number; + totalSell: number; + totalValueInBaseCurrency?: number; +} diff --git a/libs/common/src/lib/interfaces/product.ts b/libs/common/src/lib/interfaces/product.ts new file mode 100644 index 000000000..5ef023ff8 --- /dev/null +++ b/libs/common/src/lib/interfaces/product.ts @@ -0,0 +1,17 @@ +export interface Product { + alias?: string; + founded?: number; + hasFreePlan?: boolean; + hasSelfHostingAbility?: boolean; + isArchived?: boolean; + isOpenSource?: boolean; + key: string; + languages?: string[]; + name: string; + note?: string; + origin?: string; + pricingPerYear?: string; + regions?: string[]; + slogan?: string; + useAnonymously?: boolean; +} diff --git a/libs/common/src/lib/interfaces/responses/access-token-response.interface.ts b/libs/common/src/lib/interfaces/responses/access-token-response.interface.ts new file mode 100644 index 000000000..7aebaae27 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/access-token-response.interface.ts @@ -0,0 +1,3 @@ +export interface AccessTokenResponse { + accessToken: string; +} diff --git a/libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts b/libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts new file mode 100644 index 000000000..a623baaff --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/account-balances-response.interface.ts @@ -0,0 +1,7 @@ +import { AccountBalance } from '@prisma/client'; + +export interface AccountBalancesResponse { + balances: (Pick & { + valueInBaseCurrency: number; + })[]; +} diff --git a/libs/common/src/lib/interfaces/responses/account-response.interface.ts b/libs/common/src/lib/interfaces/responses/account-response.interface.ts new file mode 100644 index 000000000..3e954dc72 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/account-response.interface.ts @@ -0,0 +1,3 @@ +import { AccountWithValue } from '@ghostfolio/common/types'; + +export interface AccountResponse extends AccountWithValue {} diff --git a/libs/common/src/lib/interfaces/responses/accounts-response.interface.ts b/libs/common/src/lib/interfaces/responses/accounts-response.interface.ts new file mode 100644 index 000000000..90f1303e0 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/accounts-response.interface.ts @@ -0,0 +1,10 @@ +import { AccountWithValue } from '@ghostfolio/common/types'; + +export interface AccountsResponse { + accounts: AccountWithValue[]; + activitiesCount: number; + totalBalanceInBaseCurrency: number; + totalDividendInBaseCurrency: number; + totalInterestInBaseCurrency: number; + totalValueInBaseCurrency: number; +} diff --git a/libs/common/src/lib/interfaces/responses/activities-response.interface.ts b/libs/common/src/lib/interfaces/responses/activities-response.interface.ts new file mode 100644 index 000000000..863ae4665 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/activities-response.interface.ts @@ -0,0 +1,6 @@ +import { Activity } from '@ghostfolio/common/interfaces'; + +export interface ActivitiesResponse { + activities: Activity[]; + count: number; +} diff --git a/libs/common/src/lib/interfaces/responses/activity-response.interface.ts b/libs/common/src/lib/interfaces/responses/activity-response.interface.ts new file mode 100644 index 000000000..d26f13a5a --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/activity-response.interface.ts @@ -0,0 +1,3 @@ +import { Activity } from '@ghostfolio/common/interfaces'; + +export interface ActivityResponse extends Activity {} diff --git a/libs/common/src/lib/interfaces/responses/admin-user-response.interface.ts b/libs/common/src/lib/interfaces/responses/admin-user-response.interface.ts new file mode 100644 index 000000000..8e93fc097 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/admin-user-response.interface.ts @@ -0,0 +1,3 @@ +import { AdminUser } from '../admin-user.interface'; + +export interface AdminUserResponse extends AdminUser {} diff --git a/libs/common/src/lib/interfaces/responses/admin-users-response.interface.ts b/libs/common/src/lib/interfaces/responses/admin-users-response.interface.ts new file mode 100644 index 000000000..8dd058030 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/admin-users-response.interface.ts @@ -0,0 +1,6 @@ +import { AdminUser } from '../admin-user.interface'; + +export interface AdminUsersResponse { + count: number; + users: AdminUser[]; +} diff --git a/libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts b/libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts new file mode 100644 index 000000000..4b95bc871 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/ai-prompt-response.interface.ts @@ -0,0 +1,3 @@ +export interface AiPromptResponse { + prompt: string; +} diff --git a/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts new file mode 100644 index 000000000..dace14a02 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/api-key-response.interface.ts @@ -0,0 +1,3 @@ +export interface ApiKeyResponse { + apiKey: string; +} diff --git a/libs/common/src/lib/interfaces/responses/asset-response.interface.ts b/libs/common/src/lib/interfaces/responses/asset-response.interface.ts new file mode 100644 index 000000000..452ec0d3d --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/asset-response.interface.ts @@ -0,0 +1,3 @@ +import type { AdminMarketDataDetails } from '../admin-market-data-details.interface'; + +export interface AssetResponse extends AdminMarketDataDetails {} diff --git a/libs/common/src/lib/interfaces/responses/benchmark-market-data-details-response.interface.ts b/libs/common/src/lib/interfaces/responses/benchmark-market-data-details-response.interface.ts new file mode 100644 index 000000000..cdd63ff79 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/benchmark-market-data-details-response.interface.ts @@ -0,0 +1,5 @@ +import { LineChartItem } from '@ghostfolio/common/interfaces'; + +export interface BenchmarkMarketDataDetailsResponse { + marketData: LineChartItem[]; +} diff --git a/libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts b/libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts new file mode 100644 index 000000000..d47cf1864 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/benchmark-response.interface.ts @@ -0,0 +1,5 @@ +import { Benchmark } from '@ghostfolio/common/interfaces'; + +export interface BenchmarkResponse { + benchmarks: Benchmark[]; +} diff --git a/libs/common/src/lib/interfaces/responses/create-stripe-checkout-session-response.interface.ts b/libs/common/src/lib/interfaces/responses/create-stripe-checkout-session-response.interface.ts new file mode 100644 index 000000000..8ac1a8279 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/create-stripe-checkout-session-response.interface.ts @@ -0,0 +1,3 @@ +export interface CreateStripeCheckoutSessionResponse { + sessionUrl: string; +} diff --git a/libs/common/src/lib/interfaces/responses/data-enhancer-health-response.interface.ts b/libs/common/src/lib/interfaces/responses/data-enhancer-health-response.interface.ts new file mode 100644 index 000000000..025f8e8be --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/data-enhancer-health-response.interface.ts @@ -0,0 +1,3 @@ +export interface DataEnhancerHealthResponse { + status: string; +} diff --git a/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-asset-profile-response.interface.ts b/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-asset-profile-response.interface.ts new file mode 100644 index 000000000..3ea635c6d --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-asset-profile-response.interface.ts @@ -0,0 +1,3 @@ +import { SymbolProfile } from '@prisma/client'; + +export interface DataProviderGhostfolioAssetProfileResponse extends Partial {} diff --git a/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts b/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts new file mode 100644 index 000000000..9330adaa7 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/data-provider-ghostfolio-status-response.interface.ts @@ -0,0 +1,7 @@ +import { UserWithSettings } from '@ghostfolio/common/types'; + +export interface DataProviderGhostfolioStatusResponse { + dailyRequests: number; + dailyRequestsMax: number; + subscription: UserWithSettings['subscription']; +} diff --git a/libs/common/src/lib/interfaces/responses/data-provider-health-response.interface.ts b/libs/common/src/lib/interfaces/responses/data-provider-health-response.interface.ts new file mode 100644 index 000000000..a32d9b3c3 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/data-provider-health-response.interface.ts @@ -0,0 +1,3 @@ +export interface DataProviderHealthResponse { + status: string; +} diff --git a/libs/common/src/lib/interfaces/responses/data-provider-response.interface.ts b/libs/common/src/lib/interfaces/responses/data-provider-response.interface.ts new file mode 100644 index 000000000..ff152b1b2 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/data-provider-response.interface.ts @@ -0,0 +1,16 @@ +import { DataProviderInfo } from '@ghostfolio/common/interfaces'; +import { MarketState } from '@ghostfolio/common/types'; + +import { DataSource } from '@prisma/client'; + +export interface DataProviderHistoricalResponse { + marketPrice: number; +} + +export interface DataProviderResponse { + currency: string; + dataProviderInfo?: DataProviderInfo; + dataSource: DataSource; + marketPrice: number; + marketState: MarketState; +} diff --git a/libs/common/src/lib/interfaces/responses/dividends-response.interface.ts b/libs/common/src/lib/interfaces/responses/dividends-response.interface.ts new file mode 100644 index 000000000..8bbd8b755 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/dividends-response.interface.ts @@ -0,0 +1,7 @@ +import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces'; + +export interface DividendsResponse { + dividends: { + [date: string]: DataProviderHistoricalResponse; + }; +} diff --git a/libs/common/src/lib/interfaces/responses/errors.interface.ts b/libs/common/src/lib/interfaces/responses/errors.interface.ts new file mode 100644 index 000000000..b830a9e30 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/errors.interface.ts @@ -0,0 +1,6 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +export interface ResponseError { + errors?: AssetProfileIdentifier[]; + hasErrors: boolean; +} diff --git a/libs/common/src/lib/interfaces/responses/export-response.interface.ts b/libs/common/src/lib/interfaces/responses/export-response.interface.ts new file mode 100644 index 000000000..8b1697ca4 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/export-response.interface.ts @@ -0,0 +1,46 @@ +import { + Account, + DataSource, + Order, + Platform, + SymbolProfile, + Tag +} from '@prisma/client'; + +import { AccountBalance } from '../account-balance.interface'; +import { MarketData } from '../market-data.interface'; +import { UserSettings } from '../user-settings.interface'; + +export interface ExportResponse { + accounts: (Omit & { + balances: AccountBalance[]; + })[]; + activities: (Omit< + Order, + | 'accountUserId' + | 'createdAt' + | 'date' + | 'isDraft' + | 'symbolProfileId' + | 'updatedAt' + | 'userId' + > & { dataSource: DataSource; date: string; symbol: string })[]; + assetProfiles: (Omit< + SymbolProfile, + 'createdAt' | 'id' | 'updatedAt' | 'userId' + > & { + marketData: MarketData[]; + })[]; + meta: { + date: string; + version: string; + }; + platforms: Platform[]; + tags: Omit[]; + user: { + settings: { + currency: UserSettings['baseCurrency']; + performanceCalculationType: UserSettings['performanceCalculationType']; + }; + }; +} diff --git a/libs/common/src/lib/interfaces/responses/historical-response.interface.ts b/libs/common/src/lib/interfaces/responses/historical-response.interface.ts new file mode 100644 index 000000000..211b19b4d --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/historical-response.interface.ts @@ -0,0 +1,7 @@ +import { DataProviderHistoricalResponse } from '@ghostfolio/common/interfaces'; + +export interface HistoricalResponse { + historicalData: { + [date: string]: DataProviderHistoricalResponse; + }; +} diff --git a/libs/common/src/lib/interfaces/responses/import-response.interface.ts b/libs/common/src/lib/interfaces/responses/import-response.interface.ts new file mode 100644 index 000000000..24b0e4f4b --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/import-response.interface.ts @@ -0,0 +1,5 @@ +import { Activity } from '@ghostfolio/common/interfaces'; + +export interface ImportResponse { + activities: Activity[]; +} diff --git a/libs/common/src/lib/interfaces/responses/info-response.interface.ts b/libs/common/src/lib/interfaces/responses/info-response.interface.ts new file mode 100644 index 000000000..45e62db73 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/info-response.interface.ts @@ -0,0 +1,3 @@ +import { InfoItem } from '../index'; + +export interface InfoResponse extends InfoItem {} diff --git a/libs/common/src/lib/interfaces/responses/lookup-response.interface.ts b/libs/common/src/lib/interfaces/responses/lookup-response.interface.ts new file mode 100644 index 000000000..579be9d01 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/lookup-response.interface.ts @@ -0,0 +1,5 @@ +import { LookupItem } from '../lookup-item.interface'; + +export interface LookupResponse { + items: LookupItem[]; +} diff --git a/libs/common/src/lib/interfaces/responses/market-data-details-response.interface.ts b/libs/common/src/lib/interfaces/responses/market-data-details-response.interface.ts new file mode 100644 index 000000000..bbf947301 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/market-data-details-response.interface.ts @@ -0,0 +1,8 @@ +import { MarketData } from '@prisma/client'; + +import { EnhancedSymbolProfile } from '../enhanced-symbol-profile.interface'; + +export interface MarketDataDetailsResponse { + assetProfile: Partial; + marketData: MarketData[]; +} diff --git a/libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts b/libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts new file mode 100644 index 000000000..997a42737 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/market-data-of-markets-response.interface.ts @@ -0,0 +1,8 @@ +import { SymbolItem } from '@ghostfolio/common/interfaces'; + +export interface MarketDataOfMarketsResponse { + fearAndGreedIndex: { + CRYPTOCURRENCIES: SymbolItem; + STOCKS: SymbolItem; + }; +} diff --git a/libs/common/src/lib/interfaces/responses/oauth-response.interface.ts b/libs/common/src/lib/interfaces/responses/oauth-response.interface.ts new file mode 100644 index 000000000..5b33ad651 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/oauth-response.interface.ts @@ -0,0 +1,3 @@ +export interface OAuthResponse { + authToken: string; +} diff --git a/libs/common/src/lib/interfaces/responses/platforms-response.interface.ts b/libs/common/src/lib/interfaces/responses/platforms-response.interface.ts new file mode 100644 index 000000000..552912d9d --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/platforms-response.interface.ts @@ -0,0 +1,5 @@ +import { Platform } from '@prisma/client'; + +export interface PlatformsResponse { + platforms: Platform[]; +} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-dividends-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-dividends-response.interface.ts new file mode 100644 index 000000000..bd33dbccb --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/portfolio-dividends-response.interface.ts @@ -0,0 +1,5 @@ +import { InvestmentItem } from '../investment-item.interface'; + +export interface PortfolioDividendsResponse { + dividends: InvestmentItem[]; +} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts new file mode 100644 index 000000000..76bc7dc02 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts @@ -0,0 +1,37 @@ +import { + Benchmark, + DataProviderInfo, + EnhancedSymbolProfile, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; + +import { Tag } from '@prisma/client'; + +export interface PortfolioHoldingResponse { + activitiesCount: number; + averagePrice: number; + dataProviderInfo: DataProviderInfo; + dateOfFirstActivity: string; + dividendInBaseCurrency: number; + dividendYieldPercent: number; + dividendYieldPercentWithCurrencyEffect: number; + feeInBaseCurrency: number; + grossPerformance: number; + grossPerformancePercent: number; + grossPerformancePercentWithCurrencyEffect: number; + grossPerformanceWithCurrencyEffect: number; + historicalData: HistoricalDataItem[]; + investmentInBaseCurrencyWithCurrencyEffect: number; + marketPrice: number; + marketPriceMax: number; + marketPriceMin: number; + netPerformance: number; + netPerformancePercent: number; + netPerformancePercentWithCurrencyEffect: number; + netPerformanceWithCurrencyEffect: number; + performances: Benchmark['performances']; + quantity: number; + SymbolProfile: EnhancedSymbolProfile; + tags: Tag[]; + value: number; +} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts new file mode 100644 index 000000000..d2cf38f55 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts @@ -0,0 +1,5 @@ +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; + +export interface PortfolioHoldingsResponse { + holdings: PortfolioPosition[]; +} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts new file mode 100644 index 000000000..6d0d60002 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/portfolio-investments.interface.ts @@ -0,0 +1,6 @@ +import { InvestmentItem } from '../investment-item.interface'; + +export interface PortfolioInvestmentsResponse { + investments: InvestmentItem[]; + streaks: { currentStreak: number; longestStreak: number }; +} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts new file mode 100644 index 000000000..b0c453514 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/portfolio-performance-response.interface.ts @@ -0,0 +1,9 @@ +import { HistoricalDataItem } from '../historical-data-item.interface'; +import { PortfolioPerformance } from '../portfolio-performance.interface'; +import { ResponseError } from './errors.interface'; + +export interface PortfolioPerformanceResponse extends ResponseError { + chart?: HistoricalDataItem[]; + firstOrderDate: Date; + performance: PortfolioPerformance; +} diff --git a/libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts b/libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts new file mode 100644 index 000000000..8146b6086 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/portfolio-report.interface.ts @@ -0,0 +1,15 @@ +import { PortfolioReportRule } from '../portfolio-report-rule.interface'; + +export interface PortfolioReportResponse { + xRay: { + categories: { + key: string; + name: string; + rules: PortfolioReportRule[]; + }[]; + statistics: { + rulesActiveCount: number; + rulesFulfilledCount: number; + }; + }; +} diff --git a/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts new file mode 100644 index 000000000..4a087ad16 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/public-portfolio-response.interface.ts @@ -0,0 +1,61 @@ +import { + EnhancedSymbolProfile, + PortfolioDetails, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { Market } from '@ghostfolio/common/types'; + +import { Order } from '@prisma/client'; + +export interface PublicPortfolioResponse extends PublicPortfolioResponseV1 { + alias?: string; + hasDetails: boolean; + holdings: { + [symbol: string]: Pick< + PortfolioPosition, + | 'allocationInPercentage' + | 'assetClass' + | 'countries' + | 'currency' + | 'dataSource' + | 'dateOfFirstActivity' + | 'markets' + | 'name' + | 'netPerformancePercentWithCurrencyEffect' + | 'sectors' + | 'symbol' + | 'url' + | 'valueInBaseCurrency' + | 'valueInPercentage' + >; + }; + latestActivities: (Pick< + Order, + 'currency' | 'date' | 'fee' | 'quantity' | 'type' | 'unitPrice' + > & { + SymbolProfile?: EnhancedSymbolProfile; + value: number; + valueInBaseCurrency: number; + })[]; + markets: { + [key in Market]: Pick< + NonNullable[key], + 'id' | 'valueInPercentage' + >; + }; +} + +interface PublicPortfolioResponseV1 { + createdAt: Date; + performance: { + '1d': { + relativeChange: number; + }; + max: { + relativeChange: number; + }; + ytd: { + relativeChange: number; + }; + }; +} diff --git a/libs/common/src/lib/interfaces/responses/quotes-response.interface.ts b/libs/common/src/lib/interfaces/responses/quotes-response.interface.ts new file mode 100644 index 000000000..933220ed7 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/quotes-response.interface.ts @@ -0,0 +1,5 @@ +import { DataProviderResponse } from '@ghostfolio/common/interfaces'; + +export interface QuotesResponse { + quotes: { [symbol: string]: DataProviderResponse }; +} diff --git a/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts new file mode 100644 index 000000000..21570a459 --- /dev/null +++ b/libs/common/src/lib/interfaces/responses/watchlist-response.interface.ts @@ -0,0 +1,14 @@ +import { + AssetProfileIdentifier, + Benchmark +} from '@ghostfolio/common/interfaces'; + +export interface WatchlistResponse { + watchlist: (AssetProfileIdentifier & { + marketCondition: Benchmark['marketCondition']; + name: string; + performances: Benchmark['performances']; + trend50d: Benchmark['trend50d']; + trend200d: Benchmark['trend200d']; + })[]; +} diff --git a/libs/common/src/lib/interfaces/rule-settings.interface.ts b/libs/common/src/lib/interfaces/rule-settings.interface.ts new file mode 100644 index 000000000..ff22650ca --- /dev/null +++ b/libs/common/src/lib/interfaces/rule-settings.interface.ts @@ -0,0 +1,4 @@ +export interface RuleSettings { + isActive: boolean; + locale: string; +} diff --git a/libs/common/src/lib/interfaces/scraper-configuration.interface.ts b/libs/common/src/lib/interfaces/scraper-configuration.interface.ts new file mode 100644 index 000000000..70fcd939d --- /dev/null +++ b/libs/common/src/lib/interfaces/scraper-configuration.interface.ts @@ -0,0 +1,8 @@ +export interface ScraperConfiguration { + defaultMarketPrice?: number; + headers?: { [key: string]: string }; + locale?: string; + mode?: 'instant' | 'lazy'; + selector: string; + url: string; +} diff --git a/libs/common/src/lib/interfaces/sector.interface.ts b/libs/common/src/lib/interfaces/sector.interface.ts new file mode 100644 index 000000000..a17fb9869 --- /dev/null +++ b/libs/common/src/lib/interfaces/sector.interface.ts @@ -0,0 +1,4 @@ +export interface Sector { + name: string; + weight: number; +} diff --git a/libs/common/src/lib/interfaces/simplewebauthn.interface.ts b/libs/common/src/lib/interfaces/simplewebauthn.interface.ts new file mode 100644 index 000000000..69464b961 --- /dev/null +++ b/libs/common/src/lib/interfaces/simplewebauthn.interface.ts @@ -0,0 +1,221 @@ +export interface AuthenticatorAssertionResponse extends AuthenticatorResponse { + readonly authenticatorData: ArrayBuffer; + readonly signature: ArrayBuffer; + readonly userHandle: ArrayBuffer | null; +} +export interface AuthenticatorAttestationResponse extends AuthenticatorResponse { + readonly attestationObject: ArrayBuffer; +} +export interface AuthenticationExtensionsClientInputs { + appid?: string; + appidExclude?: string; + credProps?: boolean; + uvm?: boolean; +} +export interface AuthenticationExtensionsClientOutputs { + appid?: boolean; + credProps?: CredentialPropertiesOutput; + uvm?: UvmEntries; +} +export interface AuthenticatorSelectionCriteria { + authenticatorAttachment?: AuthenticatorAttachment; + requireResidentKey?: boolean; + residentKey?: ResidentKeyRequirement; + userVerification?: UserVerificationRequirement; +} +export interface PublicKeyCredential extends Credential { + readonly rawId: ArrayBuffer; + readonly response: AuthenticatorResponse; + getClientExtensionResults(): AuthenticationExtensionsClientOutputs; +} +export interface PublicKeyCredentialCreationOptions { + attestation?: AttestationConveyancePreference; + authenticatorSelection?: AuthenticatorSelectionCriteria; + challenge: BufferSource; + excludeCredentials?: PublicKeyCredentialDescriptor[]; + extensions?: AuthenticationExtensionsClientInputs; + pubKeyCredParams: PublicKeyCredentialParameters[]; + rp: PublicKeyCredentialRpEntity; + timeout?: number; + user: PublicKeyCredentialUserEntity; +} +export interface PublicKeyCredentialDescriptor { + id: BufferSource; + transports?: AuthenticatorTransport[]; + type: PublicKeyCredentialType; +} +export interface PublicKeyCredentialParameters { + alg: COSEAlgorithmIdentifier; + type: PublicKeyCredentialType; +} +export interface PublicKeyCredentialRequestOptions { + allowCredentials?: PublicKeyCredentialDescriptor[]; + challenge: BufferSource; + extensions?: AuthenticationExtensionsClientInputs; + rpId?: string; + timeout?: number; + userVerification?: UserVerificationRequirement; +} +export interface PublicKeyCredentialUserEntity extends PublicKeyCredentialEntity { + displayName: string; + id: BufferSource; +} +export interface AuthenticatorResponse { + readonly clientDataJSON: ArrayBuffer; +} +export interface CredentialPropertiesOutput { + rk?: boolean; +} +export interface Credential { + readonly id: string; + readonly type: string; +} +export interface PublicKeyCredentialRpEntity extends PublicKeyCredentialEntity { + id?: string; +} +export interface PublicKeyCredentialEntity { + name: string; +} +export declare type AttestationConveyancePreference = + | 'direct' + | 'enterprise' + | 'indirect' + | 'none'; +export declare type AuthenticatorTransport = 'ble' | 'internal' | 'nfc' | 'usb'; +export declare type COSEAlgorithmIdentifier = number; +export declare type UserVerificationRequirement = + | 'discouraged' + | 'preferred' + | 'required'; +export declare type UvmEntries = UvmEntry[]; +export declare type AuthenticatorAttachment = 'cross-platform' | 'platform'; +export declare type ResidentKeyRequirement = + | 'discouraged' + | 'preferred' + | 'required'; +export declare type BufferSource = ArrayBufferView | ArrayBuffer; +export declare type PublicKeyCredentialType = 'public-key'; +export declare type UvmEntry = number[]; + +export interface PublicKeyCredentialCreationOptionsJSON extends Omit< + PublicKeyCredentialCreationOptions, + 'challenge' | 'user' | 'excludeCredentials' +> { + user: PublicKeyCredentialUserEntityJSON; + challenge: Base64URLString; + excludeCredentials: PublicKeyCredentialDescriptorJSON[]; + extensions?: AuthenticationExtensionsClientInputs; +} +/** + * A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to + * (eventually) get passed into navigator.credentials.get(...) in the browser. + */ +export interface PublicKeyCredentialRequestOptionsJSON extends Omit< + PublicKeyCredentialRequestOptions, + 'challenge' | 'allowCredentials' +> { + challenge: Base64URLString; + allowCredentials?: PublicKeyCredentialDescriptorJSON[]; + extensions?: AuthenticationExtensionsClientInputs; +} +export interface PublicKeyCredentialDescriptorJSON extends Omit< + PublicKeyCredentialDescriptor, + 'id' +> { + id: Base64URLString; +} +export interface PublicKeyCredentialUserEntityJSON extends Omit< + PublicKeyCredentialUserEntity, + 'id' +> { + id: string; +} +/** + * The value returned from navigator.credentials.create() + */ +export interface AttestationCredential extends PublicKeyCredential { + response: AuthenticatorAttestationResponseFuture; +} +/** + * A slightly-modified AttestationCredential to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AttestationCredentialJSON extends Omit< + AttestationCredential, + 'response' | 'rawId' | 'getClientExtensionResults' +> { + rawId: Base64URLString; + response: AuthenticatorAttestationResponseJSON; + clientExtensionResults: AuthenticationExtensionsClientOutputs; + transports?: AuthenticatorTransport[]; +} +/** + * The value returned from navigator.credentials.get() + */ +export interface AssertionCredential extends PublicKeyCredential { + response: AuthenticatorAssertionResponse; +} +/** + * A slightly-modified AssertionCredential to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AssertionCredentialJSON extends Omit< + AssertionCredential, + 'response' | 'rawId' | 'getClientExtensionResults' +> { + rawId: Base64URLString; + response: AuthenticatorAssertionResponseJSON; + clientExtensionResults: AuthenticationExtensionsClientOutputs; +} +/** + * A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AuthenticatorAttestationResponseJSON extends Omit< + AuthenticatorAttestationResponseFuture, + 'clientDataJSON' | 'attestationObject' +> { + clientDataJSON: Base64URLString; + attestationObject: Base64URLString; +} +/** + * A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + */ +export interface AuthenticatorAssertionResponseJSON extends Omit< + AuthenticatorAssertionResponse, + 'authenticatorData' | 'clientDataJSON' | 'signature' | 'userHandle' +> { + authenticatorData: Base64URLString; + clientDataJSON: Base64URLString; + signature: Base64URLString; + userHandle?: string; +} +/** + * A WebAuthn-compatible device and the information needed to verify assertions by it + */ +export declare interface AuthenticatorDevice { + credentialPublicKey: Buffer; + credentialID: Buffer; + counter: number; + transports?: AuthenticatorTransport[]; +} +/** + * An attempt to communicate that this isn't just any string, but a Base64URL-encoded string + */ +export declare type Base64URLString = string; +/** + * AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7). + * Maintain an augmented version here so we can implement additional properties as the WebAuthn + * spec evolves. + * + * See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse + * + * Properties marked optional are not supported in all browsers. + */ +export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse { + getTransports?: () => AuthenticatorTransport[]; + getAuthenticatorData?: () => ArrayBuffer; + getPublicKey?: () => ArrayBuffer; + getPublicKeyAlgorithm?: () => COSEAlgorithmIdentifier[]; +} diff --git a/libs/common/src/lib/interfaces/statistics.interface.ts b/libs/common/src/lib/interfaces/statistics.interface.ts new file mode 100644 index 000000000..2852d34ab --- /dev/null +++ b/libs/common/src/lib/interfaces/statistics.interface.ts @@ -0,0 +1,10 @@ +export interface Statistics { + activeUsers1d: number; + activeUsers30d: number; + dockerHubPulls: number; + gitHubContributors: number; + gitHubStargazers: number; + newUsers30d: number; + slackCommunityUsers: string; + uptime: number; +} diff --git a/libs/common/src/lib/interfaces/subscription-offer.interface.ts b/libs/common/src/lib/interfaces/subscription-offer.interface.ts new file mode 100644 index 000000000..a9ebb54f4 --- /dev/null +++ b/libs/common/src/lib/interfaces/subscription-offer.interface.ts @@ -0,0 +1,11 @@ +import { StringValue } from 'ms'; + +export interface SubscriptionOffer { + coupon?: number; + couponId?: string; + durationExtension?: StringValue; + isRenewal?: boolean; + label?: string; + price: number; + priceId: string; +} diff --git a/libs/common/src/lib/interfaces/symbol-item.interface.ts b/libs/common/src/lib/interfaces/symbol-item.interface.ts new file mode 100644 index 000000000..710a84144 --- /dev/null +++ b/libs/common/src/lib/interfaces/symbol-item.interface.ts @@ -0,0 +1,10 @@ +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; + +export interface SymbolItem extends AssetProfileIdentifier { + currency: string; + historicalData: HistoricalDataItem[]; + marketPrice: number; +} diff --git a/libs/common/src/lib/interfaces/symbol-metrics.interface.ts b/libs/common/src/lib/interfaces/symbol-metrics.interface.ts new file mode 100644 index 000000000..c2b70a4bc --- /dev/null +++ b/libs/common/src/lib/interfaces/symbol-metrics.interface.ts @@ -0,0 +1,54 @@ +import { DateRange } from '@ghostfolio/common/types'; + +import { Big } from 'big.js'; + +export interface SymbolMetrics { + currentValues: { + [date: string]: Big; + }; + currentValuesWithCurrencyEffect: { + [date: string]: Big; + }; + feesWithCurrencyEffect: Big; + grossPerformance: Big; + grossPerformancePercentage: Big; + grossPerformancePercentageWithCurrencyEffect: Big; + grossPerformanceWithCurrencyEffect: Big; + hasErrors: boolean; + initialValue: Big; + initialValueWithCurrencyEffect: Big; + investmentValuesAccumulated: { + [date: string]: Big; + }; + investmentValuesAccumulatedWithCurrencyEffect: { + [date: string]: Big; + }; + investmentValuesWithCurrencyEffect: { + [date: string]: Big; + }; + netPerformance: Big; + netPerformancePercentage: Big; + netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big }; + netPerformanceValues: { + [date: string]: Big; + }; + netPerformanceValuesWithCurrencyEffect: { [date: string]: Big }; + netPerformanceWithCurrencyEffectMap: { [key: DateRange]: Big }; + timeWeightedInvestment: Big; + timeWeightedInvestmentValues: { + [date: string]: Big; + }; + timeWeightedInvestmentValuesWithCurrencyEffect: { + [date: string]: Big; + }; + timeWeightedInvestmentWithCurrencyEffect: Big; + totalAccountBalanceInBaseCurrency: Big; + totalDividend: Big; + totalDividendInBaseCurrency: Big; + totalInterest: Big; + totalInterestInBaseCurrency: Big; + totalInvestment: Big; + totalInvestmentWithCurrencyEffect: Big; + totalLiabilities: Big; + totalLiabilitiesInBaseCurrency: Big; +} diff --git a/libs/common/src/lib/interfaces/system-message.interface.ts b/libs/common/src/lib/interfaces/system-message.interface.ts new file mode 100644 index 000000000..617d40ea2 --- /dev/null +++ b/libs/common/src/lib/interfaces/system-message.interface.ts @@ -0,0 +1,7 @@ +import { SubscriptionType } from '@ghostfolio/common/enums'; + +export interface SystemMessage { + message: string; + routerLink?: string[]; + targetGroups: SubscriptionType[]; +} diff --git a/libs/common/src/lib/interfaces/tab-configuration.interface.ts b/libs/common/src/lib/interfaces/tab-configuration.interface.ts new file mode 100644 index 000000000..7b18b26ec --- /dev/null +++ b/libs/common/src/lib/interfaces/tab-configuration.interface.ts @@ -0,0 +1,6 @@ +export interface TabConfiguration { + iconName: string; + label: string; + routerLink: string[]; + showCondition?: boolean; +} diff --git a/libs/common/src/lib/interfaces/toggle-option.interface.ts b/libs/common/src/lib/interfaces/toggle-option.interface.ts new file mode 100644 index 000000000..ca7b16bb2 --- /dev/null +++ b/libs/common/src/lib/interfaces/toggle-option.interface.ts @@ -0,0 +1,4 @@ +export interface ToggleOption { + label: string; + value: string; +} diff --git a/libs/common/src/lib/interfaces/user-item.interface.ts b/libs/common/src/lib/interfaces/user-item.interface.ts new file mode 100644 index 000000000..32230b69e --- /dev/null +++ b/libs/common/src/lib/interfaces/user-item.interface.ts @@ -0,0 +1,7 @@ +import { Role } from '@prisma/client'; + +export interface UserItem { + accessToken?: string; + authToken: string; + role: Role; +} diff --git a/libs/common/src/lib/interfaces/user-settings.interface.ts b/libs/common/src/lib/interfaces/user-settings.interface.ts new file mode 100644 index 000000000..65325a42f --- /dev/null +++ b/libs/common/src/lib/interfaces/user-settings.interface.ts @@ -0,0 +1,36 @@ +import { XRayRulesSettings } from '@ghostfolio/common/interfaces/x-ray-rules-settings.interface'; +import { + ColorScheme, + DateRange, + HoldingsViewMode, + ViewMode +} from '@ghostfolio/common/types'; +import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; + +import { AssetClass } from '@prisma/client'; + +export interface UserSettings { + annualInterestRate?: number; + baseCurrency?: string; + benchmark?: string; + colorScheme?: ColorScheme; + dateRange?: DateRange; + emergencyFund?: number; + 'filters.accounts'?: string[]; + 'filters.assetClasses'?: AssetClass[]; + 'filters.dataSource'?: string; + 'filters.symbol'?: string; + 'filters.tags'?: string[]; + holdingsViewMode?: HoldingsViewMode; + isExperimentalFeatures?: boolean; + isRestrictedView?: boolean; + language?: string; + locale?: string; + performanceCalculationType?: PerformanceCalculationType; + projectedTotalAmount?: number; + retirementDate?: string; + safeWithdrawalRate?: number; + savingsRate?: number; + viewMode?: ViewMode; + xRayRules?: XRayRulesSettings; +} diff --git a/libs/common/src/lib/interfaces/user.interface.ts b/libs/common/src/lib/interfaces/user.interface.ts new file mode 100644 index 000000000..e60f01915 --- /dev/null +++ b/libs/common/src/lib/interfaces/user.interface.ts @@ -0,0 +1,26 @@ +import { SubscriptionType } from '@ghostfolio/common/enums'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; + +import { Access, Tag } from '@prisma/client'; + +import { SubscriptionOffer } from './subscription-offer.interface'; +import { SystemMessage } from './system-message.interface'; +import { UserSettings } from './user-settings.interface'; + +// TODO: Compare with UserWithSettings +export interface User { + access: Pick[]; + accounts: AccountWithPlatform[]; + activitiesCount: number; + dateOfFirstActivity: Date; + id: string; + permissions: string[]; + settings: UserSettings; + systemMessage?: SystemMessage; + subscription: { + expiresAt?: Date; + offer: SubscriptionOffer; + type: SubscriptionType; + }; + tags: (Tag & { isUsed: boolean })[]; +} diff --git a/libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts b/libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts new file mode 100644 index 000000000..688d4f2a0 --- /dev/null +++ b/libs/common/src/lib/interfaces/x-ray-rules-settings.interface.ts @@ -0,0 +1,25 @@ +export interface XRayRulesSettings { + AccountClusterRiskCurrentInvestment?: RuleSettings; + AccountClusterRiskSingleAccount?: RuleSettings; + AssetClassClusterRiskEquity?: RuleSettings; + AssetClassClusterRiskFixedIncome?: RuleSettings; + BuyingPower?: RuleSettings; + CurrencyClusterRiskBaseCurrencyCurrentInvestment?: RuleSettings; + CurrencyClusterRiskCurrentInvestment?: RuleSettings; + EconomicMarketClusterRiskDevelopedMarkets?: RuleSettings; + EconomicMarketClusterRiskEmergingMarkets?: RuleSettings; + EmergencyFundSetup?: RuleSettings; + FeeRatioInitialInvestment?: RuleSettings; + FeeRatioTotalInvestmentVolume?: RuleSettings; + RegionalMarketClusterRiskAsiaPacific?: RuleSettings; + RegionalMarketClusterRiskEmergingMarkets?: RuleSettings; + RegionalMarketClusterRiskEurope?: RuleSettings; + RegionalMarketClusterRiskJapan?: RuleSettings; + RegionalMarketClusterRiskNorthAmerica?: RuleSettings; +} + +interface RuleSettings { + isActive: boolean; + thresholdMax?: number; + thresholdMin?: number; +} diff --git a/libs/common/src/lib/models/index.ts b/libs/common/src/lib/models/index.ts new file mode 100644 index 000000000..0dd601a0e --- /dev/null +++ b/libs/common/src/lib/models/index.ts @@ -0,0 +1,4 @@ +import { PortfolioSnapshot } from './portfolio-snapshot'; +import { TimelinePosition } from './timeline-position'; + +export { PortfolioSnapshot, TimelinePosition }; diff --git a/libs/common/src/lib/models/portfolio-snapshot.ts b/libs/common/src/lib/models/portfolio-snapshot.ts new file mode 100644 index 000000000..6b13ca048 --- /dev/null +++ b/libs/common/src/lib/models/portfolio-snapshot.ts @@ -0,0 +1,48 @@ +import { transformToBig } from '@ghostfolio/common/class-transformer'; +import { + AssetProfileIdentifier, + HistoricalDataItem +} from '@ghostfolio/common/interfaces'; +import { TimelinePosition } from '@ghostfolio/common/models'; + +import { Big } from 'big.js'; +import { Transform, Type } from 'class-transformer'; + +export class PortfolioSnapshot { + activitiesCount: number; + + createdAt: Date; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + currentValueInBaseCurrency: Big; + + errors: AssetProfileIdentifier[]; + + hasErrors: boolean; + + historicalData: HistoricalDataItem[]; + + @Type(() => TimelinePosition) + positions: TimelinePosition[]; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + totalFeesWithCurrencyEffect: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + totalInterestWithCurrencyEffect: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + totalInvestment: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + totalInvestmentWithCurrencyEffect: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + totalLiabilitiesWithCurrencyEffect: Big; +} diff --git a/libs/common/src/lib/models/timeline-position.ts b/libs/common/src/lib/models/timeline-position.ts new file mode 100644 index 000000000..13f9001d5 --- /dev/null +++ b/libs/common/src/lib/models/timeline-position.ts @@ -0,0 +1,99 @@ +import { + transformToBig, + transformToMapOfBig +} from '@ghostfolio/common/class-transformer'; +import { DateRange } from '@ghostfolio/common/types'; + +import { DataSource, Tag } from '@prisma/client'; +import { Big } from 'big.js'; +import { Transform, Type } from 'class-transformer'; + +export class TimelinePosition { + activitiesCount: number; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + averagePrice: Big; + + currency: string; + dataSource: DataSource; + dateOfFirstActivity: string; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + dividend: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + dividendInBaseCurrency: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + fee: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + feeInBaseCurrency: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + grossPerformance: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + grossPerformancePercentage: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + grossPerformancePercentageWithCurrencyEffect: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + grossPerformanceWithCurrencyEffect: Big; + + includeInTotalAssetValue?: boolean; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + investment: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + investmentWithCurrencyEffect: Big; + + marketPrice: number; + marketPriceInBaseCurrency: number; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + netPerformance: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + netPerformancePercentage: Big; + + @Transform(transformToMapOfBig, { toClassOnly: true }) + netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big }; + + @Transform(transformToMapOfBig, { toClassOnly: true }) + netPerformanceWithCurrencyEffectMap: { [key: DateRange]: Big }; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + quantity: Big; + + symbol: string; + tags?: Tag[]; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + timeWeightedInvestment: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + timeWeightedInvestmentWithCurrencyEffect: Big; + + @Transform(transformToBig, { toClassOnly: true }) + @Type(() => Big) + valueInBaseCurrency: Big; +} diff --git a/libs/common/src/lib/permissions.ts b/libs/common/src/lib/permissions.ts new file mode 100644 index 000000000..cb4eb175b --- /dev/null +++ b/libs/common/src/lib/permissions.ts @@ -0,0 +1,214 @@ +import { UserWithSettings } from '@ghostfolio/common/types'; + +import { Role } from '@prisma/client'; + +export const permissions = { + accessAdminControl: 'accessAdminControl', + accessAssistant: 'accessAssistant', + accessHoldingsChart: 'accessHoldingsChart', + createAccess: 'createAccess', + createAccount: 'createAccount', + createAccountBalance: 'createAccountBalance', + createApiKey: 'createApiKey', + createMarketData: 'createMarketData', + createMarketDataOfOwnAssetProfile: 'createMarketDataOfOwnAssetProfile', + createOrder: 'createOrder', + createOwnTag: 'createOwnTag', + createPlatform: 'createPlatform', + createTag: 'createTag', + createUserAccount: 'createUserAccount', + createWatchlistItem: 'createWatchlistItem', + deleteAccess: 'deleteAccess', + deleteAccount: 'deleteAccount', + deleteAccountBalance: 'deleteAccountBalance', + deleteAuthDevice: 'deleteAuthDevice', + deleteOrder: 'deleteOrder', + deleteOwnUser: 'deleteOwnUser', + deletePlatform: 'deletePlatform', + deleteTag: 'deleteTag', + deleteUser: 'deleteUser', + deleteWatchlistItem: 'deleteWatchlistItem', + enableAuthGoogle: 'enableAuthGoogle', + enableAuthOidc: 'enableAuthOidc', + enableAuthToken: 'enableAuthToken', + enableDataProviderGhostfolio: 'enableDataProviderGhostfolio', + enableFearAndGreedIndex: 'enableFearAndGreedIndex', + enableImport: 'enableImport', + enableBlog: 'enableBlog', + enableStatistics: 'enableStatistics', + enableSubscription: 'enableSubscription', + enableSubscriptionInterstitial: 'enableSubscriptionInterstitial', + enableSystemMessage: 'enableSystemMessage', + impersonateAllUsers: 'impersonateAllUsers', + readAiPrompt: 'readAiPrompt', + readMarketData: 'readMarketData', + readMarketDataOfMarkets: 'readMarketDataOfMarkets', + readMarketDataOfOwnAssetProfile: 'readMarketDataOfOwnAssetProfile', + readPlatforms: 'readPlatforms', + readPlatformsWithAccountCount: 'readPlatformsWithAccountCount', + readTags: 'readTags', + readWatchlist: 'readWatchlist', + reportDataGlitch: 'reportDataGlitch', + syncDemoUserAccount: 'syncDemoUserAccount', + toggleReadOnlyMode: 'toggleReadOnlyMode', + updateAccount: 'updateAccount', + updateAccess: 'updateAccess', + updateAuthDevice: 'updateAuthDevice', + updateMarketData: 'updateMarketData', + updateMarketDataOfOwnAssetProfile: 'updateMarketDataOfOwnAssetProfile', + updateOrder: 'updateOrder', + updateOwnAccessToken: 'updateOwnAccessToken', + updatePlatform: 'updatePlatform', + updateTag: 'updateTag', + updateUserSettings: 'updateUserSettings', + updateViewMode: 'updateViewMode' +} as const; + +export function getPermissions(aRole: Role): string[] { + switch (aRole) { + case 'ADMIN': + return [ + permissions.accessAdminControl, + permissions.accessAssistant, + permissions.accessHoldingsChart, + permissions.createAccess, + permissions.createAccount, + permissions.createAccountBalance, + permissions.createWatchlistItem, + permissions.deleteAccountBalance, + permissions.deleteWatchlistItem, + permissions.createMarketData, + permissions.createMarketDataOfOwnAssetProfile, + permissions.createOrder, + permissions.createOwnTag, + permissions.createPlatform, + permissions.createTag, + permissions.deleteAccess, + permissions.deleteAccount, + permissions.deleteAuthDevice, + permissions.deleteOrder, + permissions.deletePlatform, + permissions.deleteTag, + permissions.deleteUser, + permissions.readAiPrompt, + permissions.readMarketData, + permissions.readMarketDataOfOwnAssetProfile, + permissions.readPlatforms, + permissions.readPlatformsWithAccountCount, + permissions.readTags, + permissions.readWatchlist, + permissions.updateAccount, + permissions.updateAccess, + permissions.updateAuthDevice, + permissions.updateMarketData, + permissions.updateMarketDataOfOwnAssetProfile, + permissions.updateOrder, + permissions.updatePlatform, + permissions.updateTag, + permissions.updateUserSettings, + permissions.updateViewMode + ]; + + case 'DEMO': + return [ + permissions.accessAssistant, + permissions.accessHoldingsChart, + permissions.createUserAccount, + permissions.readAiPrompt, + permissions.readWatchlist + ]; + + case 'USER': + return [ + permissions.accessAssistant, + permissions.accessHoldingsChart, + permissions.createAccess, + permissions.createAccount, + permissions.createAccountBalance, + permissions.createMarketDataOfOwnAssetProfile, + permissions.createOrder, + permissions.createOwnTag, + permissions.createWatchlistItem, + permissions.deleteAccess, + permissions.deleteAccount, + permissions.deleteAccountBalance, + permissions.deleteAuthDevice, + permissions.deleteOrder, + permissions.deleteWatchlistItem, + permissions.readAiPrompt, + permissions.readMarketDataOfOwnAssetProfile, + permissions.readPlatforms, + permissions.readWatchlist, + permissions.updateAccount, + permissions.updateAccess, + permissions.updateAuthDevice, + permissions.updateMarketDataOfOwnAssetProfile, + permissions.updateOrder, + permissions.updateUserSettings, + permissions.updateViewMode + ]; + + default: + return []; + } +} + +export function filterGlobalPermissions( + aGlobalPermissions: string[], + aUtmSource: 'ios' | 'trusted-web-activity' +) { + const globalPermissions = aGlobalPermissions; + + if (aUtmSource === 'ios') { + return globalPermissions.filter((permission) => { + return ( + permission !== permissions.enableAuthGoogle && + permission !== permissions.enableAuthOidc && + permission !== permissions.enableSubscription + ); + }); + } else if (aUtmSource === 'trusted-web-activity') { + return globalPermissions.filter((permission) => { + return permission !== permissions.enableSubscription; + }); + } + + return globalPermissions; +} + +export function hasPermission( + aPermissions: string[] = [], + aPermission: string +) { + return aPermissions.includes(aPermission); +} + +export function hasReadRestrictedAccessPermission({ + impersonationId, + user +}: { + impersonationId: string; + user: UserWithSettings; +}) { + if (!impersonationId) { + return false; + } + + const access = user.accessesGet?.find(({ id }) => { + return id === impersonationId; + }); + + return access?.permissions?.includes('READ_RESTRICTED') ?? true; +} + +export function hasRole(aUser: UserWithSettings, aRole: Role) { + return aUser?.role === aRole; +} + +export function isRestrictedView(aUser: UserWithSettings) { + if (!aUser) { + return true; + } + + return aUser?.settings?.settings?.isRestrictedView ?? false; +} diff --git a/libs/common/src/lib/personal-finance-tools.ts b/libs/common/src/lib/personal-finance-tools.ts new file mode 100644 index 000000000..6d0a85fb2 --- /dev/null +++ b/libs/common/src/lib/personal-finance-tools.ts @@ -0,0 +1,1145 @@ +import { Product } from '@ghostfolio/common/interfaces'; + +export const personalFinanceTools: Product[] = [ + { + founded: 2023, + hasSelfHostingAbility: false, + key: 'allinvestview', + languages: ['English'], + name: 'AllInvestView', + slogan: 'All your Investments in One View' + }, + { + founded: 2019, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'allvue-systems', + name: 'Allvue Systems', + origin: 'United States', + slogan: 'Investment Software Suite' + }, + { + founded: 2016, + key: 'alphatrackr', + languages: ['English'], + name: 'AlphaTrackr', + slogan: 'Investment Portfolio Tracking Tool' + }, + { + founded: 2017, + hasSelfHostingAbility: false, + key: 'altoo', + name: 'Altoo Wealth Platform', + origin: 'Switzerland', + slogan: 'Simplicity for Complex Wealth' + }, + { + founded: 2023, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'amsflow', + name: 'Amsflow Portfolio', + origin: 'Singapore', + pricingPerYear: '$228', + slogan: 'Portfolio Visualizer' + }, + { + founded: 2018, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'anlage.app', + languages: ['English'], + name: 'Anlage.App', + origin: 'Austria', + pricingPerYear: '$120', + slogan: 'Analyze and track your portfolio.' + }, + { + founded: 2022, + hasFreePlan: false, + key: 'asseta', + languages: ['English'], + name: 'Asseta', + origin: 'United States', + slogan: 'The Intelligent Family Office Suite' + }, + { + founded: 2016, + key: 'atominvest', + name: 'Atominvest', + origin: 'United Kingdom', + slogan: 'Portfolio Management' + }, + { + founded: 2020, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'balance-pro', + name: 'Balance Pro', + origin: 'United States', + pricingPerYear: '$47.99', + slogan: 'The Smarter Way to Track Your Finances' + }, + { + hasFreePlan: false, + hasSelfHostingAbility: true, + key: 'banktivity', + name: 'Banktivity', + origin: 'United States', + pricingPerYear: '$59.99', + slogan: 'Proactive money management app for macOS & iOS' + }, + { + founded: 2022, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'basil-finance', + name: 'Basil Finance', + slogan: 'The ultimate solution for tracking and managing your investments' + }, + { + founded: 2020, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'beanvest', + name: 'Beanvest', + origin: 'France', + pricingPerYear: '$100', + slogan: 'Stock Portfolio Tracker for Smart Investors' + }, + { + founded: 2024, + hasSelfHostingAbility: false, + key: 'bluebudget', + languages: ['Deutsch', 'English', 'Français', 'Italiano'], + name: 'BlueBudget', + origin: 'Switzerland', + slogan: 'Schweizer Budget App für einfache & smarte Budgetplanung' + }, + { + key: 'budgetpulse', + name: 'BudgetPulse', + origin: 'United States', + slogan: 'Giving life to your finance!' + }, + { + founded: 2007, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'buxfer', + name: 'Buxfer', + origin: 'United States', + pricingPerYear: '$48', + regions: ['Global'], + slogan: 'Take control of your financial future' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'capitally', + name: 'Capitally', + origin: 'Poland', + pricingPerYear: '€80', + slogan: 'Optimize your investments performance' + }, + { + founded: 2022, + isArchived: true, + key: 'capmon', + name: 'CapMon.org', + origin: 'Germany', + note: 'CapMon.org was discontinued in 2023', + slogan: 'Next Generation Assets Tracking' + }, + { + founded: 2024, + hasFreePlan: true, + isOpenSource: true, + key: 'cleverbilling', + languages: ['Español'], + name: 'CleverBilling', + slogan: 'Toma el control total de tus finanzas.' + }, + { + founded: 2011, + key: 'cobalt', + name: 'Cobalt', + origin: 'United States', + slogan: 'Next-Level Portfolio Monitoring' + }, + { + founded: 2017, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'coinstats', + name: 'CoinStats', + origin: 'Armenia', + pricingPerYear: '$168', + slogan: 'Manage All Your Wallets & Exchanges From One Place' + }, + { + founded: 2013, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'cointracking', + languages: ['Deutsch', 'English'], + name: 'CoinTracking', + origin: 'Germany', + pricingPerYear: '$120', + slogan: 'The leading Crypto Portfolio Tracker & Tax Calculator' + }, + { + founded: 2019, + key: 'compound-planning', + name: 'Compound Planning', + origin: 'United States', + slogan: 'Modern Wealth & Investment Management' + }, + { + founded: 2019, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'copilot-money', + name: 'Copilot Money', + origin: 'United States', + pricingPerYear: '$95', + slogan: 'Do money better with Copilot' + }, + { + founded: 2014, + hasFreePlan: false, + key: 'countabout', + name: 'CountAbout', + origin: 'United States', + pricingPerYear: '$9.99', + slogan: 'Customizable and Secure Personal Finance App' + }, + { + founded: 2023, + hasFreePlan: false, + key: 'danti', + name: 'Danti', + origin: 'United Kingdom', + slogan: 'Digitising Generational Wealth' + }, + { + founded: 2020, + key: 'de.fi', + languages: ['English'], + name: 'De.Fi', + slogan: 'DeFi Portfolio Tracker' + }, + { + founded: 2016, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'defi-portfolio-tracker-by-zerion', + languages: ['English'], + name: 'DeFi Portfolio Tracker by Zerion', + origin: 'United States', + pricingPerYear: '$99', + slogan: 'DeFi Portfolio Tracker for All Chains' + }, + { + founded: 2022, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'degiro-portfolio-tracker-by-capitalyse', + languages: ['English'], + name: 'DEGIRO Portfolio Tracker by Capitalyse', + origin: 'Netherlands', + pricingPerYear: '€24', + slogan: 'Democratizing Data Analytics' + }, + { + founded: 2017, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'delta', + name: 'Delta Investment Tracker', + note: 'Acquired by eToro', + origin: 'Belgium', + pricingPerYear: '$150', + slogan: 'The app to track all your investments. Make smart moves only.' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'digrin', + languages: ['English'], + name: 'Digrin', + pricingPerYear: '$49.90', + slogan: 'Dividend Portfolio Tracker' + }, + { + founded: 2019, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'divvydiary', + languages: ['Deutsch', 'English'], + name: 'DivvyDiary', + origin: 'Germany', + pricingPerYear: '€65', + slogan: 'Your personal Dividend Calendar' + }, + { + founded: 2009, + hasSelfHostingAbility: false, + key: 'empower', + name: 'Empower', + note: 'Originally named as Personal Capital', + origin: 'United States', + slogan: 'Get answers to your money questions' + }, + { + alias: '8figures', + founded: 2022, + key: 'eightfigures', + name: '8FIGURES', + origin: 'United States', + slogan: 'Portfolio Tracker Designed by Professional Investors' + }, + { + founded: 2010, + hasFreePlan: false, + key: 'etops', + name: 'etops', + origin: 'Switzerland', + slogan: 'Your financial superpower' + }, + { + founded: 2020, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'exirio', + name: 'Exirio', + origin: 'United States', + pricingPerYear: '$100', + slogan: 'All your wealth, in one place.' + }, + { + founded: 2018, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'fey', + name: 'Fey', + origin: 'Canada', + pricingPerYear: '$300', + slogan: 'Make better investments.' + }, + { + founded: 2023, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'fina', + languages: ['English'], + name: 'Fina', + origin: 'United States', + pricingPerYear: '$115', + slogan: 'Flexible Financial Management' + }, + { + founded: 2023, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'finanzfluss-copilot', + name: 'Finanzfluss Copilot', + origin: 'Germany', + pricingPerYear: '€69.99', + slogan: 'Portfolio Tracker für dein Vermögen' + }, + { + founded: 2020, + key: 'finary', + languages: ['Deutsch', 'English', 'Français'], + name: 'Finary', + origin: 'United States', + slogan: 'Real-Time Portfolio Tracker & Stock Tracker' + }, + { + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'finateka', + languages: ['English'], + name: 'FINATEKA', + origin: 'United States', + slogan: + 'The most convenient mobile application for personal finance accounting' + }, + { + founded: 2022, + key: 'fincake', + name: 'Fincake', + origin: 'British Virgin Islands', + slogan: 'Easy-to-use Portfolio Tracker' + }, + { + founded: 2021, + hasSelfHostingAbility: false, + key: 'finvest', + name: 'Finvest', + origin: 'United States', + slogan: 'Grow your wealth in a stress-free way' + }, + { + founded: 2023, + hasFreePlan: true, + key: 'finwise', + name: 'FinWise', + origin: 'South Africa', + pricingPerYear: '€69.99', + slogan: 'Personal finances, simplified' + }, + { + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'firekit', + languages: ['English', 'українська мова'], + name: 'FIREkit', + origin: 'Ukraine', + pricingPerYear: '$40', + slogan: 'A simple solution to track your wealth online' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'folishare', + languages: ['Deutsch', 'English'], + name: 'folishare', + origin: 'Austria', + pricingPerYear: '$65', + slogan: 'Take control over your investments' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'gasti', + languages: [ + 'Deutsch', + 'English', + 'Español', + 'Français', + 'Italiano', + 'Português' + ], + name: 'Gasti', + origin: 'Argentina', + pricingPerYear: '$60', + regions: ['Global'], + slogan: 'Take control of your finances from WhatsApp' + }, + { + founded: 2020, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'getquin', + languages: ['Deutsch', 'English'], + name: 'getquin', + origin: 'Germany', + pricingPerYear: '€48', + slogan: 'Portfolio Tracker, Analysis & Community' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + isArchived: true, + key: 'gospatz', + name: 'goSPATZ', + note: 'Renamed to Money Peak', + origin: 'Germany', + slogan: 'Volle Kontrolle über deine Investitionen' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'holistic-capital', + languages: ['Deutsch'], + name: 'Holistic', + origin: 'Germany', + slogan: 'Die All-in-One Lösung für dein Vermögen.', + useAnonymously: true + }, + { + founded: 2017, + hasSelfHostingAbility: false, + key: 'honeydue', + name: 'Honeydue', + origin: 'United States', + slogan: 'Finance App for Couples' + }, + { + founded: 2022, + key: 'income-reign', + languages: ['English'], + name: 'Income Reign', + note: 'Income Reign was discontinued in 2025', + origin: 'United States', + pricingPerYear: '$120' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + isArchived: true, + key: 'intuit-mint', + name: 'Intuit Mint', + note: 'Intuit Mint was discontinued in 2023', + origin: 'United States', + pricingPerYear: '$60', + slogan: 'Managing money, made simple' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'investify', + name: 'Investify', + origin: 'Pakistan', + slogan: 'Advanced portfolio tracking and stock market information' + }, + { + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: true, + key: 'invmon', + name: 'InvMon', + note: 'Originally named as A2PB', + origin: 'Switzerland', + pricingPerYear: '$156', + slogan: 'Track all your assets, investments and portfolios in one place', + useAnonymously: true + }, + { + founded: 2011, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'justetf', + name: 'justETF', + origin: 'Germany', + pricingPerYear: '€119', + slogan: 'ETF portfolios made simple' + }, + { + founded: 2018, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'koinly', + name: 'Koinly', + origin: 'Singapore', + slogan: 'Track all your crypto wallets in one place' + }, + { + founded: 2016, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'koyfin', + name: 'Koyfin', + origin: 'United States', + pricingPerYear: '$468', + slogan: 'Comprehensive financial data analysis' + }, + { + founded: 2019, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'kubera', + name: 'Kubera®', + origin: 'United States', + pricingPerYear: '$249', + slogan: 'The Time Machine for your Net Worth' + }, + { + founded: 2021, + hasFreePlan: false, + key: 'leafs', + languages: ['Deutsch', 'English'], + name: 'Leafs', + origin: 'Switzerland', + slogan: 'Sustainability insights for wealth managers' + }, + { + founded: 2018, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'magnifi', + name: 'Magnifi', + origin: 'United States', + pricingPerYear: '$132', + slogan: 'AI Investing Assistant' + }, + { + founded: 2022, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'markets.sh', + languages: ['English'], + name: 'markets.sh', + origin: 'Germany', + pricingPerYear: '€168', + regions: ['Global'], + slogan: 'Track your investments' + }, + { + founded: 2010, + key: 'masttro', + name: 'Masttro', + origin: 'United States', + slogan: 'Your platform for wealth in full view' + }, + { + founded: 2021, + hasFreePlan: false, + hasSelfHostingAbility: true, + isArchived: true, + isOpenSource: true, + key: 'maybe-finance', + languages: ['English'], + name: 'Maybe Finance', + note: 'Maybe Finance was discontinued in 2023, relaunched in 2024, and discontinued again in 2025', + origin: 'United States', + pricingPerYear: '$145', + regions: ['United States'], + slogan: 'Your financial future, in your control' + }, + { + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'merlincrypto', + languages: ['English'], + name: 'Merlin', + origin: 'United States', + pricingPerYear: '$204', + regions: ['Canada', 'United States'], + slogan: 'The smartest way to track your crypto' + }, + { + founded: 1991, + hasSelfHostingAbility: true, + isArchived: true, + key: 'microsoft-money', + name: 'Microsoft Money', + note: 'Microsoft Money was discontinued in 2010', + origin: 'United States' + }, + { + founded: 2019, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'monarch-money', + name: 'Monarch Money', + origin: 'United States', + pricingPerYear: '$99.99', + slogan: 'The modern way to manage your money' + }, + { + founded: 1999, + hasFreePlan: false, + hasSelfHostingAbility: true, + key: 'moneydance', + name: 'Moneydance', + origin: 'Scotland', + pricingPerYear: '$100', + slogan: 'Personal Finance Manager for Mac, Windows, and Linux' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'moneypeak', + name: 'Money Peak', + note: 'Originally named as goSPATZ', + origin: 'Germany', + slogan: 'Dein smarter Finance Assistant' + }, + { + founded: 2007, + key: 'moneyspire', + name: 'Moneyspire', + note: 'License is a perpetual license', + origin: 'United States', + pricingPerYear: '$59.99', + slogan: 'Have total control of your financial life' + }, + { + key: 'moneywiz', + name: 'MoneyWiz', + origin: 'United States', + pricingPerYear: '$29.99', + slogan: 'Get money management superpowers' + }, + { + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'monse', + name: 'Monse', + pricingPerYear: '$60', + slogan: 'Gain financial control and keep your data private.' + }, + { + founded: 2025, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'monsy', + languages: ['English'], + name: 'Monsy', + origin: 'Indonesia', + pricingPerYear: '$20', + slogan: 'Smart, simple, stress-free money tracking.' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'morningstar-portfolio-manager', + languages: ['English'], + name: 'Morningstar® Portfolio Manager', + origin: 'United States', + slogan: + 'Track your equity, fund, investment trust, ETF and pension investments in one place.' + }, + { + founded: 2020, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'nansen', + name: 'Crypto Portfolio Tracker by Nansen', + origin: 'Singapore', + pricingPerYear: '$1188', + slogan: 'Your Complete Crypto Portfolio, Reimagined' + }, + { + founded: 2017, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'navexa', + name: 'Navexa', + origin: 'Australia', + pricingPerYear: '$90', + slogan: 'The Intelligent Portfolio Tracker' + }, + { + founded: 2020, + hasSelfHostingAbility: false, + hasFreePlan: true, + key: 'parqet', + name: 'Parqet', + note: 'Originally named as Tresor One', + origin: 'Germany', + pricingPerYear: '€99.99', + regions: ['Austria', 'Germany', 'Switzerland'], + slogan: 'Dein Vermögen immer im Blick' + }, + { + hasSelfHostingAbility: false, + key: 'peek', + name: 'Peek', + origin: 'Singapore', + slogan: 'Feel in control of your money without spreadsheets or shame' + }, + { + key: 'pennies', + name: 'Pennies', + origin: 'United States', + pricingPerYear: '$39.99', + slogan: 'Your money. Made simple.' + }, + { + founded: 2022, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'pinklion', + name: 'PinkLion', + origin: 'Germany', + pricingPerYear: '€50', + slogan: 'Invest smarter, not harder' + }, + { + founded: 2023, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'plainzer', + languages: ['English'], + name: 'Plainzer', + origin: 'Poland', + pricingPerYear: '$74', + slogan: 'Free dividend tracker for your portfolio' + }, + { + founded: 2023, + hasSelfHostingAbility: false, + key: 'plannix', + name: 'Plannix', + origin: 'Italy', + slogan: 'Your Personal Finance Hub' + }, + { + founded: 2015, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'pocketguard', + name: 'PocketGuard', + origin: 'United States', + pricingPerYear: '$74.99', + slogan: 'Budgeting App & Finance Planner' + }, + { + founded: 2008, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'pocketsmith', + languages: ['English'], + name: 'PocketSmith', + origin: 'New Zealand', + pricingPerYear: '$120', + regions: ['Global'], + slogan: 'Know where your money is going' + }, + { + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'portfolio-dividend-tracker', + languages: ['English', 'Nederlands'], + name: 'Portfolio Dividend Tracker', + origin: 'Netherlands', + pricingPerYear: '€60', + slogan: 'Manage all your portfolios' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'portfolio-visualizer', + languages: ['English'], + name: 'Portfolio Visualizer', + pricingPerYear: '$360', + slogan: 'Tools for Better Investors' + }, + { + hasFreePlan: true, + isArchived: true, + key: 'portfoloo', + name: 'Portfoloo', + note: 'Portfoloo was discontinued', + slogan: + 'Free Stock Portfolio Tracker with unlimited portfolio and stocks for DIY investors' + }, + { + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'portseido', + languages: ['Deutsch', 'English', 'Français', 'Nederlands'], + name: 'Portseido', + origin: 'Thailand', + pricingPerYear: '$96', + slogan: 'Portfolio Performance and Dividend Tracker' + }, + { + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: true, + key: 'projectionlab', + name: 'ProjectionLab', + origin: 'United States', + pricingPerYear: '$108', + slogan: 'Build Financial Plans You Love.' + }, + { + founded: 2015, + hasSelfHostingAbility: false, + key: 'rocket-money', + name: 'Rocket Money', + origin: 'United States', + slogan: 'Track your net worth' + }, + { + founded: 2019, + hasSelfHostingAbility: false, + isArchived: true, + key: 'sarmaaya.pk', + name: 'Sarmaaya.pk Portfolio Tracking', + note: 'Sarmaaya.pk Portfolio Tracking was discontinued in 2024', + origin: 'Pakistan', + slogan: 'Unified platform for financial research and portfolio tracking' + }, + { + founded: 2004, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'seeking-alpha', + name: 'Seeking Alpha', + origin: 'United States', + pricingPerYear: '$239', + slogan: 'Stock Market Analysis & Tools for Investors' + }, + { + founded: 2022, + key: 'segmio', + name: 'Segmio', + origin: 'Romania', + slogan: 'Wealth Management and Net Worth Tracking' + }, + { + founded: 2007, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'sharesight', + name: 'Sharesight', + origin: 'New Zealand', + pricingPerYear: '$135', + regions: ['Global'], + slogan: 'Stock Portfolio Tracker' + }, + { + hasFreePlan: true, + isArchived: true, + key: 'sharesmaster', + name: 'SharesMaster', + note: 'SharesMaster was discontinued', + slogan: 'Free Stock Portfolio Tracker' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'simple-portfolio', + name: 'Simple Portfolio', + origin: 'Czech Republic', + pricingPerYear: '€80', + slogan: 'Stock Portfolio Tracker' + }, + { + founded: 2014, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'simply-wallstreet', + name: 'Stock Portfolio Tracker & Visualizer by Simply Wall St', + origin: 'Australia', + pricingPerYear: '$120', + slogan: 'Smart portfolio tracker for informed investors' + }, + { + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'snowball-analytics', + name: 'Snowball Analytics', + origin: 'France', + pricingPerYear: '$80', + slogan: 'Simple and powerful portfolio tracker' + }, + { + key: 'splashmoney', + name: 'SplashMoney', + origin: 'United States', + slogan: 'Manage your money anytime, anywhere.' + }, + { + founded: 2019, + hasSelfHostingAbility: false, + key: 'stock-events', + name: 'Stock Events', + origin: 'Germany', + slogan: 'Track all your Investments' + }, + { + key: 'stockle', + name: 'Stockle', + origin: 'Finland', + slogan: 'Supercharge your investments tracking experience' + }, + { + founded: 2008, + isArchived: true, + key: 'stockmarketeye', + name: 'StockMarketEye', + origin: 'France', + note: 'StockMarketEye was discontinued in 2023', + slogan: 'A Powerful Portfolio & Investment Tracking App' + }, + { + founded: 2011, + hasFreePlan: true, + key: 'stock-rover', + languages: ['English'], + name: 'Stock Rover', + origin: 'United States', + pricingPerYear: '$79.99', + slogan: 'Investment Research and Portfolio Management' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'stonksfolio', + languages: ['English'], + name: 'Stonksfolio', + origin: 'Bulgaria', + pricingPerYear: '€49.90', + slogan: 'Visualize all of your portfolios' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'sumio', + name: 'Sumio', + origin: 'Czech Republic', + pricingPerYear: '$20', + slogan: 'Sum up and build your wealth.' + }, + { + founded: 2016, + hasFreePlan: false, + key: 'tiller', + name: 'Tiller', + origin: 'United States', + pricingPerYear: '$79', + slogan: + 'Your financial life in a spreadsheet, automatically updated each day' + }, + { + founded: 2011, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'tradervue', + name: 'Tradervue', + origin: 'United States', + pricingPerYear: '$360', + slogan: 'The Trading Journal to Improve Your Trading Performance' + }, + { + founded: 2020, + hasSelfHostingAbility: false, + hasFreePlan: true, + isArchived: true, + key: 'tresor-one', + name: 'Tresor One', + note: 'Renamed to Parqet', + origin: 'Germany', + regions: ['Austria', 'Germany', 'Switzerland'], + slogan: 'Dein Vermögen immer im Blick' + }, + { + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'utluna', + languages: ['Deutsch', 'English', 'Français'], + name: 'Utluna', + origin: 'Switzerland', + pricingPerYear: '$300', + slogan: 'Your Portfolio. Revealed.', + useAnonymously: true + }, + { + founded: 2020, + hasFreePlan: true, + key: 'vyzer', + name: 'Vyzer', + origin: 'United States', + pricingPerYear: '$348', + slogan: 'Virtual Family Office for Smart Wealth Management' + }, + { + founded: 2020, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'walletguide', + name: 'Walletguide', + origin: 'Germany', + pricingPerYear: '€90', + slogan: 'Personal finance reimagined with AI' + }, + { + hasSelfHostingAbility: false, + isArchived: true, + key: 'wallmine', + languages: ['English'], + name: 'wallmine', + note: 'wallmine was discontinued in 2024', + origin: 'Czech Republic', + pricingPerYear: '$600', + slogan: 'Make Smarter Investments' + }, + { + founded: 2019, + hasFreePlan: false, + key: 'wealthbrain', + languages: ['English'], + name: 'Wealthbrain', + origin: 'United Arab Emirates', + slogan: 'Portfolio Management System' + }, + { + founded: 2024, + hasSelfHostingAbility: true, + isArchived: true, + isOpenSource: true, + key: 'wealthfolio', + languages: ['English'], + name: 'Wealthfolio', + origin: 'Canada', + slogan: 'Desktop Investment Tracker' + }, + { + founded: 2015, + hasFreePlan: true, + hasSelfHostingAbility: false, + key: 'wealthica', + languages: ['English', 'Français'], + name: 'Wealthica', + origin: 'Canada', + pricingPerYear: '$50', + slogan: 'See all your investments in one place' + }, + { + founded: 2018, + hasFreePlan: true, + key: 'wealthposition', + name: 'WealthPosition', + pricingPerYear: '$60', + slogan: 'Personal Finance & Budgeting App' + }, + { + founded: 2018, + hasSelfHostingAbility: false, + key: 'wealthy-tracker', + languages: ['English'], + name: 'Wealthy Tracker', + origin: 'India', + slogan: 'One app to manage all your investments' + }, + { + key: 'whal', + name: 'Whal', + origin: 'United States', + slogan: 'Manage your investments in one place' + }, + { + founded: 2021, + hasFreePlan: true, + hasSelfHostingAbility: false, + isArchived: true, + key: 'yeekatee', + languages: ['Deutsch', 'English', 'Español', 'Français', 'Italiano'], + name: 'yeekatee', + note: 'yeekatee was discontinued in 2024', + origin: 'Switzerland', + regions: ['Global'], + slogan: 'Connect. Share. Invest.' + }, + { + founded: 2004, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'ynab', + name: 'YNAB (You Need a Budget)', + origin: 'United States', + pricingPerYear: '$109', + slogan: 'Change Your Relationship With Money' + }, + { + founded: 2019, + hasFreePlan: false, + hasSelfHostingAbility: false, + key: 'ziggma', + name: 'Ziggma', + origin: 'United States', + pricingPerYear: '$84', + slogan: 'Your solution for investing success' + } +]; diff --git a/libs/common/src/lib/pipes/index.ts b/libs/common/src/lib/pipes/index.ts new file mode 100644 index 000000000..7b5ca4bac --- /dev/null +++ b/libs/common/src/lib/pipes/index.ts @@ -0,0 +1,3 @@ +import { GfSymbolPipe } from './symbol.pipe'; + +export { GfSymbolPipe }; diff --git a/libs/common/src/lib/pipes/symbol.pipe.ts b/libs/common/src/lib/pipes/symbol.pipe.ts new file mode 100644 index 000000000..6f4981699 --- /dev/null +++ b/libs/common/src/lib/pipes/symbol.pipe.ts @@ -0,0 +1,12 @@ +import { prettifySymbol } from '@ghostfolio/common/helper'; + +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'gfSymbol' +}) +export class GfSymbolPipe implements PipeTransform { + public transform(aSymbol: string) { + return prettifySymbol(aSymbol); + } +} diff --git a/libs/common/src/lib/routes/interfaces/internal-route.interface.ts b/libs/common/src/lib/routes/interfaces/internal-route.interface.ts new file mode 100644 index 000000000..f08cf8b5c --- /dev/null +++ b/libs/common/src/lib/routes/interfaces/internal-route.interface.ts @@ -0,0 +1,9 @@ +import { User } from '@ghostfolio/common/interfaces'; + +export interface InternalRoute { + excludeFromAssistant?: boolean | ((aUser: User) => boolean); + path?: string; + routerLink: string[]; + subRoutes?: Record; + title: string; +} diff --git a/libs/common/src/lib/routes/interfaces/public-route.interface.ts b/libs/common/src/lib/routes/interfaces/public-route.interface.ts new file mode 100644 index 000000000..ad133200d --- /dev/null +++ b/libs/common/src/lib/routes/interfaces/public-route.interface.ts @@ -0,0 +1,7 @@ +export interface PublicRoute { + excludeFromSitemap?: boolean; + path: string; + routerLink: string[]; + subRoutes?: Record; + title?: string; +} diff --git a/libs/common/src/lib/routes/routes.ts b/libs/common/src/lib/routes/routes.ts new file mode 100644 index 000000000..53ecd104e --- /dev/null +++ b/libs/common/src/lib/routes/routes.ts @@ -0,0 +1,339 @@ +import { User } from '@ghostfolio/common/interfaces'; +import { hasPermission, permissions } from '@ghostfolio/common/permissions'; + +import { InternalRoute } from './interfaces/internal-route.interface'; +import { PublicRoute } from './interfaces/public-route.interface'; + +if (typeof window !== 'undefined') { + import('@angular/localize'); +} else { + (global as any).$localize = ( + messageParts: TemplateStringsArray, + ...expressions: any[] + ) => { + return String.raw({ raw: messageParts }, ...expressions); + }; +} + +export const internalRoutes: Record = { + account: { + path: 'account', + routerLink: ['/account'], + subRoutes: { + access: { + path: 'access', + routerLink: ['/account', 'access'], + title: $localize`Access` + }, + membership: { + path: 'membership', + routerLink: ['/account', 'membership'], + title: $localize`Membership` + } + }, + title: $localize`Settings` + }, + adminControl: { + excludeFromAssistant: (aUser: User) => { + return hasPermission(aUser?.permissions, permissions.accessAdminControl); + }, + path: 'admin', + routerLink: ['/admin'], + subRoutes: { + jobs: { + path: 'jobs', + routerLink: ['/admin', 'jobs'], + title: $localize`Job Queue` + }, + marketData: { + path: 'market-data', + routerLink: ['/admin', 'market-data'], + title: $localize`Market Data` + }, + settings: { + path: 'settings', + routerLink: ['/admin', 'settings'], + title: $localize`Settings` + }, + users: { + path: 'users', + routerLink: ['/admin', 'users'], + title: $localize`Users` + } + }, + title: $localize`Admin Control` + }, + accounts: { + path: 'accounts', + routerLink: ['/accounts'], + title: $localize`Accounts` + }, + api: { + excludeFromAssistant: true, + path: 'api', + routerLink: ['/api'], + title: 'Ghostfolio API' + }, + auth: { + excludeFromAssistant: true, + path: 'auth', + routerLink: ['/auth'], + title: $localize`Sign in` + }, + home: { + path: 'home', + routerLink: ['/home'], + subRoutes: { + holdings: { + path: 'holdings', + routerLink: ['/home', 'holdings'], + title: $localize`Holdings` + }, + markets: { + path: 'markets', + routerLink: ['/home', 'markets'], + title: $localize`Markets` + }, + marketsPremium: { + path: 'markets-premium', + routerLink: ['/home', 'markets-premium'], + title: $localize`Markets` + }, + summary: { + path: 'summary', + routerLink: ['/home', 'summary'], + title: $localize`Summary` + }, + watchlist: { + path: 'watchlist', + routerLink: ['/home', 'watchlist'], + title: $localize`Watchlist` + } + }, + title: $localize`Overview` + }, + i18n: { + excludeFromAssistant: true, + path: 'i18n', + routerLink: ['/i18n'], + title: $localize`Internationalization` + }, + portfolio: { + path: 'portfolio', + routerLink: ['/portfolio'], + subRoutes: { + activities: { + path: 'activities', + routerLink: ['/portfolio', 'activities'], + title: $localize`Activities` + }, + allocations: { + path: 'allocations', + routerLink: ['/portfolio', 'allocations'], + title: $localize`Allocations` + }, + analysis: { + path: undefined, // Default sub route + routerLink: ['/portfolio'], + title: $localize`Analysis` + }, + fire: { + path: 'fire', + routerLink: ['/portfolio', 'fire'], + title: 'FIRE' + }, + xRay: { + path: 'x-ray', + routerLink: ['/portfolio', 'x-ray'], + title: 'X-ray' + } + }, + title: $localize`Portfolio` + }, + webauthn: { + excludeFromAssistant: true, + path: 'webauthn', + routerLink: ['/webauthn'], + title: $localize`Sign in` + }, + zen: { + excludeFromAssistant: true, + path: 'zen', + routerLink: ['/zen'], + subRoutes: { + holdings: { + path: 'holdings', + routerLink: ['/zen', 'holdings'], + title: $localize`Holdings` + } + }, + title: $localize`Overview` + } +}; + +export const publicRoutes: Record = { + about: { + path: $localize`:kebab-case@@routes.about:about`, + routerLink: ['/' + $localize`:kebab-case@@routes.about:about`], + subRoutes: { + changelog: { + path: $localize`:kebab-case@@routes.about.changelog:changelog`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.about:about`, + $localize`:kebab-case@@routes.about.changelog:changelog` + ], + title: $localize`Changelog` + }, + license: { + path: $localize`:kebab-case@@routes.about.license:license`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.about:about`, + $localize`:kebab-case@@routes.about.license:license` + ], + title: $localize`License` + }, + ossFriends: { + path: 'oss-friends', + routerLink: [ + '/' + $localize`:kebab-case@@routes.about:about`, + 'oss-friends' + ], + title: 'OSS Friends' + }, + privacyPolicy: { + path: $localize`:kebab-case@@routes.about.privacyPolicy:privacy-policy`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.about:about`, + $localize`:kebab-case@@routes.about.privacyPolicy:privacy-policy` + ], + title: $localize`Privacy Policy` + }, + termsOfService: { + path: $localize`:kebab-case@@routes.about.termsOfService:terms-of-service`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.about:about`, + $localize`:kebab-case@@routes.about.termsOfService:terms-of-service` + ], + title: $localize`Terms of Service` + } + }, + title: $localize`About` + }, + blog: { + path: 'blog', + routerLink: ['/blog'], + title: $localize`Blog` + }, + demo: { + excludeFromSitemap: true, + path: 'demo', + routerLink: ['/demo'], + title: $localize`Live Demo` + }, + faq: { + path: $localize`:kebab-case@@routes.faq:faq`, + routerLink: ['/' + $localize`:kebab-case@@routes.faq:faq`], + subRoutes: { + saas: { + path: 'saas', + routerLink: ['/' + $localize`:kebab-case@@routes.faq:faq`, 'saas'], + title: $localize`Cloud` + ' (SaaS)' + }, + selfHosting: { + path: $localize`:kebab-case@@routes.faq.selfHosting:self-hosting`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.faq:faq`, + $localize`:kebab-case@@routes.faq.selfHosting:self-hosting` + ], + title: $localize`Self-Hosting` + } + }, + title: $localize`Frequently Asked Questions (FAQ)` + }, + features: { + path: $localize`:kebab-case@@routes.features:features`, + routerLink: ['/' + $localize`:kebab-case@@routes.features:features`], + title: $localize`Features` + }, + markets: { + path: $localize`:kebab-case@@routes.markets:markets`, + routerLink: ['/' + $localize`:kebab-case@@routes.markets:markets`], + title: $localize`Markets` + }, + openStartup: { + path: 'open', + routerLink: ['/open'], + title: 'Open Startup' + }, + pricing: { + path: $localize`:kebab-case@@routes.pricing:pricing`, + routerLink: ['/' + $localize`:kebab-case@@routes.pricing:pricing`], + title: $localize`Pricing` + }, + public: { + excludeFromSitemap: true, + path: 'p', + routerLink: ['/p'] + }, + register: { + path: $localize`:kebab-case@@routes.register:register`, + routerLink: ['/' + $localize`:kebab-case@@routes.register:register`], + title: $localize`Registration` + }, + resources: { + path: $localize`:kebab-case@@routes.resources:resources`, + routerLink: ['/' + $localize`:kebab-case@@routes.resources:resources`], + subRoutes: { + glossary: { + path: $localize`:kebab-case@@routes.resources.glossary:glossary`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.resources:resources`, + $localize`:kebab-case@@routes.resources.glossary:glossary` + ], + title: $localize`Glossary` + }, + guides: { + path: $localize`:kebab-case@@routes.resources.guides:guides`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.resources:resources`, + $localize`:kebab-case@@routes.resources.guides:guides` + ], + title: $localize`Guides` + }, + markets: { + path: $localize`:kebab-case@@routes.resources.markets:markets`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.resources:resources`, + $localize`:kebab-case@@routes.resources.markets:markets` + ], + title: $localize`Markets` + }, + personalFinanceTools: { + path: $localize`:kebab-case@@routes.resources.personalFinanceTools:personal-finance-tools`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.resources:resources`, + $localize`:kebab-case@@routes.resources.personalFinanceTools:personal-finance-tools` + ], + subRoutes: { + product: { + excludeFromSitemap: true, + path: $localize`:kebab-case@@routes.resources.personalFinanceTools.openSourceAlternativeTo:open-source-alternative-to`, + routerLink: [ + '/' + $localize`:kebab-case@@routes.resources:resources`, + $localize`:kebab-case@@routes.resources.personalFinanceTools:personal-finance-tools`, + $localize`:kebab-case@@routes.resources.personalFinanceTools.openSourceAlternativeTo:open-source-alternative-to` + ], + title: $localize`Open Source Alternative to` + } + }, + title: $localize`Personal Finance Tools` + } + }, + title: $localize`Resources` + }, + start: { + excludeFromSitemap: true, + path: $localize`:kebab-case@@routes.start:start`, + routerLink: ['/' + $localize`:kebab-case@@routes.start:start`] + } +}; diff --git a/libs/common/src/lib/types/access-type.type.ts b/libs/common/src/lib/types/access-type.type.ts new file mode 100644 index 000000000..fa8e966aa --- /dev/null +++ b/libs/common/src/lib/types/access-type.type.ts @@ -0,0 +1 @@ +export type AccessType = 'PRIVATE' | 'PUBLIC'; diff --git a/libs/common/src/lib/types/access-with-grantee-user.type.ts b/libs/common/src/lib/types/access-with-grantee-user.type.ts new file mode 100644 index 000000000..98551e0fd --- /dev/null +++ b/libs/common/src/lib/types/access-with-grantee-user.type.ts @@ -0,0 +1,3 @@ +import { Access, User } from '@prisma/client'; + +export type AccessWithGranteeUser = Access & { granteeUser?: User }; diff --git a/libs/common/src/lib/types/account-with-platform.type.ts b/libs/common/src/lib/types/account-with-platform.type.ts new file mode 100644 index 000000000..fbaa47393 --- /dev/null +++ b/libs/common/src/lib/types/account-with-platform.type.ts @@ -0,0 +1,3 @@ +import { Account, Platform } from '@prisma/client'; + +export type AccountWithPlatform = Account & { platform?: Platform }; diff --git a/libs/common/src/lib/types/account-with-value.type.ts b/libs/common/src/lib/types/account-with-value.type.ts new file mode 100644 index 000000000..23cb14749 --- /dev/null +++ b/libs/common/src/lib/types/account-with-value.type.ts @@ -0,0 +1,12 @@ +import { Account as AccountModel, Platform } from '@prisma/client'; + +export type AccountWithValue = AccountModel & { + activitiesCount: number; + allocationInPercentage: number; + balanceInBaseCurrency: number; + dividendInBaseCurrency: number; + interestInBaseCurrency: number; + platform?: Platform; + value: number; + valueInBaseCurrency: number; +}; diff --git a/libs/common/src/lib/types/ai-prompt-mode.type.ts b/libs/common/src/lib/types/ai-prompt-mode.type.ts new file mode 100644 index 000000000..00a031df0 --- /dev/null +++ b/libs/common/src/lib/types/ai-prompt-mode.type.ts @@ -0,0 +1 @@ +export type AiPromptMode = 'analysis' | 'portfolio'; diff --git a/libs/common/src/lib/types/benchmark-trend.type.ts b/libs/common/src/lib/types/benchmark-trend.type.ts new file mode 100644 index 000000000..b437d388a --- /dev/null +++ b/libs/common/src/lib/types/benchmark-trend.type.ts @@ -0,0 +1 @@ +export type BenchmarkTrend = 'DOWN' | 'NEUTRAL' | 'UNKNOWN' | 'UP'; diff --git a/libs/common/src/lib/types/color-scheme.type.ts b/libs/common/src/lib/types/color-scheme.type.ts new file mode 100644 index 000000000..1f3efce53 --- /dev/null +++ b/libs/common/src/lib/types/color-scheme.type.ts @@ -0,0 +1 @@ +export type ColorScheme = 'DARK' | 'LIGHT'; diff --git a/libs/common/src/lib/types/date-range.type.ts b/libs/common/src/lib/types/date-range.type.ts new file mode 100644 index 000000000..09fa3c15b --- /dev/null +++ b/libs/common/src/lib/types/date-range.type.ts @@ -0,0 +1,9 @@ +export type DateRange = + | '1d' + | '1y' + | '5y' + | 'max' + | 'mtd' + | 'wtd' + | 'ytd' + | string; // '2024', '2023', '2022', etc. diff --git a/libs/common/src/lib/types/fear-and-greed-index.type.ts b/libs/common/src/lib/types/fear-and-greed-index.type.ts new file mode 100644 index 000000000..0dc6655a8 --- /dev/null +++ b/libs/common/src/lib/types/fear-and-greed-index.type.ts @@ -0,0 +1 @@ +export type FearAndGreedIndexMode = 'CRYPTOCURRENCIES' | 'STOCKS'; diff --git a/libs/common/src/lib/types/granularity.type.ts b/libs/common/src/lib/types/granularity.type.ts new file mode 100644 index 000000000..01c778a0c --- /dev/null +++ b/libs/common/src/lib/types/granularity.type.ts @@ -0,0 +1 @@ +export type Granularity = 'day' | 'month'; diff --git a/libs/common/src/lib/types/group-by.type.ts b/libs/common/src/lib/types/group-by.type.ts new file mode 100644 index 000000000..01fd67a20 --- /dev/null +++ b/libs/common/src/lib/types/group-by.type.ts @@ -0,0 +1 @@ +export type GroupBy = 'month' | 'year'; diff --git a/libs/common/src/lib/types/holding-type.type.ts b/libs/common/src/lib/types/holding-type.type.ts new file mode 100644 index 000000000..985214a5e --- /dev/null +++ b/libs/common/src/lib/types/holding-type.type.ts @@ -0,0 +1 @@ +export type HoldingType = 'ACTIVE' | 'CLOSED'; diff --git a/libs/common/src/lib/types/holdings-view-mode.type.ts b/libs/common/src/lib/types/holdings-view-mode.type.ts new file mode 100644 index 000000000..7b5d0a09c --- /dev/null +++ b/libs/common/src/lib/types/holdings-view-mode.type.ts @@ -0,0 +1 @@ +export type HoldingsViewMode = 'CHART' | 'TABLE'; diff --git a/libs/common/src/lib/types/index.ts b/libs/common/src/lib/types/index.ts new file mode 100644 index 000000000..781e50c55 --- /dev/null +++ b/libs/common/src/lib/types/index.ts @@ -0,0 +1,47 @@ +import type { AccessType } from './access-type.type'; +import type { AccessWithGranteeUser } from './access-with-grantee-user.type'; +import type { AccountWithPlatform } from './account-with-platform.type'; +import type { AccountWithValue } from './account-with-value.type'; +import type { AiPromptMode } from './ai-prompt-mode.type'; +import type { BenchmarkTrend } from './benchmark-trend.type'; +import type { ColorScheme } from './color-scheme.type'; +import type { DateRange } from './date-range.type'; +import type { FearAndGreedIndexMode } from './fear-and-greed-index.type'; +import type { Granularity } from './granularity.type'; +import type { GroupBy } from './group-by.type'; +import type { HoldingType } from './holding-type.type'; +import type { HoldingsViewMode } from './holdings-view-mode.type'; +import type { MarketAdvanced } from './market-advanced.type'; +import type { MarketDataPreset } from './market-data-preset.type'; +import type { MarketState } from './market-state.type'; +import type { Market } from './market.type'; +import type { OrderWithAccount } from './order-with-account.type'; +import type { RequestWithUser } from './request-with-user.type'; +import type { SubscriptionOfferKey } from './subscription-offer-key.type'; +import type { UserWithSettings } from './user-with-settings.type'; +import type { ViewMode } from './view-mode.type'; + +export type { + AccessType, + AccessWithGranteeUser, + AccountWithPlatform, + AccountWithValue, + AiPromptMode, + BenchmarkTrend, + ColorScheme, + DateRange, + FearAndGreedIndexMode, + Granularity, + GroupBy, + HoldingType, + HoldingsViewMode, + Market, + MarketAdvanced, + MarketDataPreset, + MarketState, + OrderWithAccount, + RequestWithUser, + SubscriptionOfferKey, + UserWithSettings, + ViewMode +}; diff --git a/libs/common/src/lib/types/market-advanced.type.ts b/libs/common/src/lib/types/market-advanced.type.ts new file mode 100644 index 000000000..dc2dc641e --- /dev/null +++ b/libs/common/src/lib/types/market-advanced.type.ts @@ -0,0 +1,8 @@ +export type MarketAdvanced = + | 'asiaPacific' + | 'emergingMarkets' + | 'europe' + | 'japan' + | 'northAmerica' + | 'otherMarkets' + | 'UNKNOWN'; diff --git a/libs/common/src/lib/types/market-data-preset.type.ts b/libs/common/src/lib/types/market-data-preset.type.ts new file mode 100644 index 000000000..2622feac3 --- /dev/null +++ b/libs/common/src/lib/types/market-data-preset.type.ts @@ -0,0 +1,6 @@ +export type MarketDataPreset = + | 'BENCHMARKS' + | 'CURRENCIES' + | 'ETF_WITHOUT_COUNTRIES' + | 'ETF_WITHOUT_SECTORS' + | 'NO_ACTIVITIES'; diff --git a/libs/common/src/lib/types/market-state.type.ts b/libs/common/src/lib/types/market-state.type.ts new file mode 100644 index 000000000..3311507d7 --- /dev/null +++ b/libs/common/src/lib/types/market-state.type.ts @@ -0,0 +1 @@ +export type MarketState = 'closed' | 'delayed' | 'open'; diff --git a/libs/common/src/lib/types/market.type.ts b/libs/common/src/lib/types/market.type.ts new file mode 100644 index 000000000..e6769e3c9 --- /dev/null +++ b/libs/common/src/lib/types/market.type.ts @@ -0,0 +1,5 @@ +export type Market = + | 'developedMarkets' + | 'emergingMarkets' + | 'otherMarkets' + | 'UNKNOWN'; diff --git a/libs/common/src/lib/types/order-with-account.type.ts b/libs/common/src/lib/types/order-with-account.type.ts new file mode 100644 index 000000000..b3ae4a990 --- /dev/null +++ b/libs/common/src/lib/types/order-with-account.type.ts @@ -0,0 +1,9 @@ +import { Order, SymbolProfile, Tag } from '@prisma/client'; + +import { AccountWithPlatform } from './account-with-platform.type'; + +export type OrderWithAccount = Order & { + account?: AccountWithPlatform; + SymbolProfile?: SymbolProfile; + tags?: Tag[]; +}; diff --git a/libs/common/src/lib/types/performance-calculation-type.type.ts b/libs/common/src/lib/types/performance-calculation-type.type.ts new file mode 100644 index 000000000..a970636b6 --- /dev/null +++ b/libs/common/src/lib/types/performance-calculation-type.type.ts @@ -0,0 +1,6 @@ +export enum PerformanceCalculationType { + MWR = 'MWR', // Money-Weighted Rate of Return + ROAI = 'ROAI', // Return on Average Investment + ROI = 'ROI', // Return on Investment + TWR = 'TWR' // Time-Weighted Rate of Return +} diff --git a/libs/common/src/lib/types/request-with-user.type.ts b/libs/common/src/lib/types/request-with-user.type.ts new file mode 100644 index 000000000..a6bea37b5 --- /dev/null +++ b/libs/common/src/lib/types/request-with-user.type.ts @@ -0,0 +1,3 @@ +import { UserWithSettings } from '@ghostfolio/common/types'; + +export type RequestWithUser = Request & { user: UserWithSettings }; diff --git a/libs/common/src/lib/types/subscription-offer-key.type.ts b/libs/common/src/lib/types/subscription-offer-key.type.ts new file mode 100644 index 000000000..89322a400 --- /dev/null +++ b/libs/common/src/lib/types/subscription-offer-key.type.ts @@ -0,0 +1,6 @@ +export type SubscriptionOfferKey = + | 'default' + | 'renewal' + | 'renewal-early-bird-2023' + | 'renewal-early-bird-2024' + | 'renewal-early-bird-2025'; diff --git a/libs/common/src/lib/types/user-with-settings.type.ts b/libs/common/src/lib/types/user-with-settings.type.ts new file mode 100644 index 000000000..3c6adfec0 --- /dev/null +++ b/libs/common/src/lib/types/user-with-settings.type.ts @@ -0,0 +1,19 @@ +import { SubscriptionType } from '@ghostfolio/common/enums'; +import { SubscriptionOffer, UserSettings } from '@ghostfolio/common/interfaces'; + +import { Access, Account, Settings, User } from '@prisma/client'; + +// TODO: Compare with User interface +export type UserWithSettings = User & { + accessesGet: Access[]; + accounts: Account[]; + activityCount: number; + dataProviderGhostfolioDailyRequests: number; + permissions?: string[]; + settings: Settings & { settings: UserSettings }; + subscription?: { + expiresAt?: Date; + offer: SubscriptionOffer; + type: SubscriptionType; + }; +}; diff --git a/libs/common/src/lib/types/view-mode.type.ts b/libs/common/src/lib/types/view-mode.type.ts new file mode 100644 index 000000000..ad38adb0a --- /dev/null +++ b/libs/common/src/lib/types/view-mode.type.ts @@ -0,0 +1 @@ +export type ViewMode = 'DEFAULT' | 'ZEN'; diff --git a/libs/common/src/lib/utils/form.util.ts b/libs/common/src/lib/utils/form.util.ts new file mode 100644 index 000000000..b510e6215 --- /dev/null +++ b/libs/common/src/lib/utils/form.util.ts @@ -0,0 +1,46 @@ +import { FormGroup } from '@angular/forms'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; + +export async function validateObjectForForm({ + classDto, + form, + ignoreFields = [], + object +}: { + classDto: new () => T; + form: FormGroup; + ignoreFields?: string[]; + object: T; +}): Promise { + const objectInstance = plainToInstance(classDto, object); + const errors = await validate(objectInstance as object); + + const nonIgnoredErrors = errors.filter(({ property }) => { + return !ignoreFields.includes(property); + }); + + if (nonIgnoredErrors.length === 0) { + return Promise.resolve(); + } + + for (const { constraints, property } of nonIgnoredErrors) { + const formControl = form.get(property); + + if (formControl) { + formControl.setErrors({ + validationError: Object.values(constraints ?? {})[0] + }); + } + + const formControlInCustomCurrency = form.get(`${property}InCustomCurrency`); + + if (formControlInCustomCurrency) { + formControlInCustomCurrency.setErrors({ + validationError: Object.values(constraints ?? {})[0] + }); + } + } + + return Promise.reject(nonIgnoredErrors); +} diff --git a/libs/common/src/lib/utils/index.ts b/libs/common/src/lib/utils/index.ts new file mode 100644 index 000000000..2bdd03fdc --- /dev/null +++ b/libs/common/src/lib/utils/index.ts @@ -0,0 +1,3 @@ +import { validateObjectForForm } from './form.util'; + +export { validateObjectForForm }; diff --git a/libs/common/src/lib/validator-constraints/is-after-1970.ts b/libs/common/src/lib/validator-constraints/is-after-1970.ts new file mode 100644 index 000000000..9dc0b04c0 --- /dev/null +++ b/libs/common/src/lib/validator-constraints/is-after-1970.ts @@ -0,0 +1,16 @@ +import { + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; +import { format, isAfter } from 'date-fns'; + +@ValidatorConstraint({ name: 'isAfter1970' }) +export class IsAfter1970Constraint implements ValidatorConstraintInterface { + public defaultMessage() { + return `date must be after ${format(new Date(0), 'yyyy')}`; + } + + public validate(aDate: Date) { + return isAfter(aDate, new Date(0)); + } +} diff --git a/libs/common/src/lib/validators/is-currency-code.ts b/libs/common/src/lib/validators/is-currency-code.ts new file mode 100644 index 000000000..52d99816b --- /dev/null +++ b/libs/common/src/lib/validators/is-currency-code.ts @@ -0,0 +1,40 @@ +import { isDerivedCurrency } from '@ghostfolio/common/helper'; + +import { + registerDecorator, + ValidationOptions, + ValidatorConstraint, + ValidatorConstraintInterface +} from 'class-validator'; +import { isISO4217CurrencyCode } from 'class-validator'; + +export function IsCurrencyCode(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + propertyName, + constraints: [], + options: validationOptions, + target: object.constructor, + validator: IsExtendedCurrencyConstraint + }); + }; +} + +@ValidatorConstraint({ async: false }) +export class IsExtendedCurrencyConstraint implements ValidatorConstraintInterface { + public defaultMessage() { + return '$property must be a valid ISO4217 currency code'; + } + + public validate(currency: string) { + // Return true if currency is a derived currency or a standard ISO 4217 code + return ( + isDerivedCurrency(currency) || + (this.isUpperCase(currency) && isISO4217CurrencyCode(currency)) + ); + } + + private isUpperCase(aString: string) { + return aString === aString?.toUpperCase(); + } +} diff --git a/libs/common/tsconfig.json b/libs/common/tsconfig.json new file mode 100644 index 000000000..2b4603b71 --- /dev/null +++ b/libs/common/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ], + "compilerOptions": { + "module": "preserve", + "lib": ["dom", "es2022"], + "strictNullChecks": true + } +} diff --git a/libs/common/tsconfig.lib.json b/libs/common/tsconfig.lib.json new file mode 100644 index 000000000..95cc0c01c --- /dev/null +++ b/libs/common/tsconfig.lib.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [] + }, + "include": ["**/*.ts"], + "exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"] +} diff --git a/libs/common/tsconfig.spec.json b/libs/common/tsconfig.spec.json new file mode 100644 index 000000000..864945e11 --- /dev/null +++ b/libs/common/tsconfig.spec.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "preserve", + "isolatedModules": true, + "types": ["jest", "node"] + }, + "include": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx", + "**/*.d.ts", + "jest.config.ts" + ] +} diff --git a/libs/ui/.storybook/main.mjs b/libs/ui/.storybook/main.mjs new file mode 100644 index 000000000..e7d1378c7 --- /dev/null +++ b/libs/ui/.storybook/main.mjs @@ -0,0 +1,30 @@ +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; + +const require = createRequire(import.meta.url); + +/** @type {import('@storybook/angular').StorybookConfig} */ +const config = { + addons: [getAbsolutePath('@storybook/addon-docs')], + framework: { + name: getAbsolutePath('@storybook/angular'), + options: {} + }, + staticDirs: [ + { + from: '../../../apps/client/src/assets', + to: '/assets' + } + ], + stories: ['../**/*.stories.@(js|jsx|ts|tsx|mdx)'] +}; + +export default config; + +// To customize your webpack configuration you can use the webpackFinal field. +// Check https://storybook.js.org/docs/react/builders/webpack#extending-storybooks-webpack-config +// and https://nx.dev/packages/storybook/documents/custom-builder-configs + +function getAbsolutePath(value) { + return dirname(require.resolve(join(value, 'package.json'))); +} diff --git a/libs/ui/.storybook/preview.js b/libs/ui/.storybook/preview.js new file mode 100644 index 000000000..e69de29bb diff --git a/libs/ui/.storybook/tsconfig.json b/libs/ui/.storybook/tsconfig.json new file mode 100644 index 000000000..13fafb239 --- /dev/null +++ b/libs/ui/.storybook/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "emitDecoratorMetadata": true + }, + + "exclude": ["../**/*.spec.ts"], + "include": [ + "../src/**/*.stories.mdx", + "../src/**/*.stories.js", + "../src/**/*.stories.jsx", + "../src/**/*.stories.ts", + "../src/**/*.stories.tsx", + "*.js" + ] +} diff --git a/libs/ui/README.md b/libs/ui/README.md new file mode 100644 index 000000000..139ffca13 --- /dev/null +++ b/libs/ui/README.md @@ -0,0 +1,7 @@ +# ui + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test ui` to execute the unit tests. diff --git a/libs/ui/eslint.config.cjs b/libs/ui/eslint.config.cjs new file mode 100644 index 000000000..e21452b5f --- /dev/null +++ b/libs/ui/eslint.config.cjs @@ -0,0 +1,66 @@ +const { FlatCompat } = require('@eslint/eslintrc'); +const js = require('@eslint/js'); +const baseConfig = require('../../eslint.config.cjs'); + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: js.configs.recommended +}); + +module.exports = [ + { + ignores: ['**/dist'] + }, + ...baseConfig, + ...compat + .config({ + extends: [ + 'plugin:@nx/angular', + 'plugin:@angular-eslint/template/process-inline-templates' + ] + }) + .map((config) => ({ + ...config, + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], + rules: { + ...config.rules, + '@angular-eslint/directive-selector': [ + 'error', + { + type: 'attribute', + prefix: 'gf', + style: 'camelCase' + } + ], + '@angular-eslint/component-selector': [ + 'error', + { + type: 'element', + prefix: 'gf', + style: 'kebab-case' + } + ], + '@angular-eslint/prefer-inject': 'off', + '@angular-eslint/prefer-standalone': 'off' + }, + languageOptions: { + parserOptions: { + project: ['libs/ui/tsconfig.*?.json'] + } + } + })), + ...compat + .config({ + extends: ['plugin:@nx/angular-template'] + }) + .map((config) => ({ + ...config, + files: ['**/*.html'], + rules: { + ...config.rules + } + })), + { + ignores: ['**/*.stories.ts'] + } +]; diff --git a/libs/ui/jest.config.ts b/libs/ui/jest.config.ts new file mode 100644 index 000000000..116fe6d54 --- /dev/null +++ b/libs/ui/jest.config.ts @@ -0,0 +1,24 @@ +/* eslint-disable */ +export default { + displayName: 'ui', + + setupFilesAfterEnv: ['/src/test-setup.ts'], + globals: {}, + coverageDirectory: '../../coverage/libs/ui', + transform: { + '^.+.(ts|mjs|js|html)$': [ + 'jest-preset-angular', + { + tsconfig: '/tsconfig.spec.json', + stringifyContentPathRegex: '\\.(html|svg)$' + } + ] + }, + transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'], + snapshotSerializers: [ + 'jest-preset-angular/build/serializers/no-ng-attributes', + 'jest-preset-angular/build/serializers/ng-snapshot', + 'jest-preset-angular/build/serializers/html-comment' + ], + preset: '../../jest.preset.js' +}; diff --git a/libs/ui/project.json b/libs/ui/project.json new file mode 100644 index 000000000..a07afd8cf --- /dev/null +++ b/libs/ui/project.json @@ -0,0 +1,75 @@ +{ + "name": "ui", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "projectType": "library", + "sourceRoot": "libs/ui/src", + "prefix": "gf", + "tags": [], + "generators": { + "@schematics/angular:component": { + "style": "scss" + } + }, + "targets": { + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/libs/ui"], + "options": { + "jestConfig": "libs/ui/jest.config.ts", + "tsConfig": "libs/ui/tsconfig.spec.json" + } + }, + "lint": { + "executor": "@nx/eslint:lint", + "options": { + "lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"] + } + }, + "storybook": { + "executor": "@storybook/angular:start-storybook", + "options": { + "browserTarget": "ui:build-storybook", + "compodoc": false, + "configDir": "libs/ui/.storybook", + "port": 4400 + }, + "configurations": { + "ci": { + "quiet": true + } + } + }, + "build-storybook": { + "executor": "@storybook/angular:build-storybook", + "outputs": ["{options.outputDir}"], + "options": { + "browserTarget": "ui:build-storybook", + "compodoc": false, + "configDir": "libs/ui/.storybook", + "outputDir": "dist/apps/client/development/storybook", + "styles": [ + "apps/client/src/assets/fonts/inter.css", + "apps/client/src/styles/theme.scss", + "apps/client/src/styles.scss" + ] + }, + "configurations": { + "ci": { + "quiet": true + } + } + }, + "static-storybook": { + "executor": "@nx/web:file-server", + "options": { + "buildTarget": "ui:build-storybook", + "staticFilePath": "dist/storybook" + }, + "configurations": { + "ci": { + "buildTarget": "ui:build-storybook:ci" + } + } + } + } +} diff --git a/libs/ui/src/lib/account-balances/account-balances.component.html b/libs/ui/src/lib/account-balances/account-balances.component.html new file mode 100644 index 000000000..29037a985 --- /dev/null +++ b/libs/ui/src/lib/account-balances/account-balances.component.html @@ -0,0 +1,106 @@ +
+ + + + + + + + + + + + + + + + + + + + + + +
+ Date + + + + + + + + + + + + Value + +
+ +
+
+
+ + +
+ {{ accountCurrency() }} +
+
+
+
+ @if (showActions()) { + + } + + + + + +
+
diff --git a/libs/ui/src/lib/account-balances/account-balances.component.scss b/libs/ui/src/lib/account-balances/account-balances.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/account-balances/account-balances.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/account-balances/account-balances.component.ts b/libs/ui/src/lib/account-balances/account-balances.component.ts new file mode 100644 index 000000000..7b26263b0 --- /dev/null +++ b/libs/ui/src/lib/account-balances/account-balances.component.ts @@ -0,0 +1,143 @@ +import { CreateAccountBalanceDto } from '@ghostfolio/common/dtos'; +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +import { DATE_FORMAT, getLocale } from '@ghostfolio/common/helper'; +import { AccountBalancesResponse } from '@ghostfolio/common/interfaces'; +import { validateObjectForForm } from '@ghostfolio/common/utils'; +import { NotificationService } from '@ghostfolio/ui/notifications'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + EventEmitter, + OnChanges, + OnInit, + Output, + inject, + input, + viewChild +} from '@angular/core'; +import { + FormGroup, + FormControl, + Validators, + ReactiveFormsModule +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { DateAdapter } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { IonIcon } from '@ionic/angular/standalone'; +import { format } from 'date-fns'; +import { addIcons } from 'ionicons'; +import { + calendarClearOutline, + ellipsisHorizontal, + trashOutline +} from 'ionicons/icons'; +import { get, isNil } from 'lodash'; + +import { GfValueComponent } from '../value'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + GfValueComponent, + IonIcon, + MatButtonModule, + MatDatepickerModule, + MatFormFieldModule, + MatInputModule, + MatMenuModule, + MatSortModule, + MatTableModule, + ReactiveFormsModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-account-balances', + styleUrls: ['./account-balances.component.scss'], + templateUrl: './account-balances.component.html' +}) +export class GfAccountBalancesComponent implements OnChanges, OnInit { + @Output() accountBalanceCreated = new EventEmitter(); + @Output() accountBalanceDeleted = new EventEmitter(); + + public readonly accountBalances = + input.required(); + public readonly accountCurrency = input.required(); + public readonly accountId = input.required(); + public readonly displayedColumns: string[] = ['date', 'value', 'actions']; + public readonly locale = input(getLocale()); + public readonly showActions = input(true); + public readonly sort = viewChild(MatSort); + + public accountBalanceForm = new FormGroup({ + balance: new FormControl(0, (control) => Validators.required(control)), + date: new FormControl(new Date(), (control) => Validators.required(control)) + }); + + public dataSource = new MatTableDataSource< + AccountBalancesResponse['balances'][0] + >(); + + private dateAdapter = inject>(DateAdapter); + private notificationService = inject(NotificationService); + + public constructor() { + addIcons({ calendarClearOutline, ellipsisHorizontal, trashOutline }); + } + + public ngOnInit() { + this.dateAdapter.setLocale(this.locale()); + } + + public ngOnChanges() { + if (this.accountBalances()) { + this.dataSource = new MatTableDataSource(this.accountBalances()); + + this.dataSource.sort = this.sort(); + this.dataSource.sortingDataAccessor = get; + } + } + + public onDeleteAccountBalance(aId: string) { + this.notificationService.confirm({ + confirmFn: () => { + this.accountBalanceDeleted.emit(aId); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete this account balance?` + }); + } + + public async onSubmitAccountBalance() { + const { balance, date } = this.accountBalanceForm.value; + + if (isNil(balance) || !date) { + return; + } + + const accountBalance: CreateAccountBalanceDto = { + balance, + accountId: this.accountId(), + date: format(date, DATE_FORMAT) + }; + + try { + await validateObjectForForm({ + classDto: CreateAccountBalanceDto, + form: this.accountBalanceForm, + object: accountBalance + }); + } catch (error) { + console.error(error); + return; + } + + this.accountBalanceCreated.emit(accountBalance); + } +} diff --git a/libs/ui/src/lib/account-balances/index.ts b/libs/ui/src/lib/account-balances/index.ts new file mode 100644 index 000000000..b32ba9a0f --- /dev/null +++ b/libs/ui/src/lib/account-balances/index.ts @@ -0,0 +1 @@ +export * from './account-balances.component'; diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.html b/libs/ui/src/lib/accounts-table/accounts-table.component.html new file mode 100644 index 000000000..15f5bb21f --- /dev/null +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.html @@ -0,0 +1,365 @@ +@if (showActions()) { +
+ +
+} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ @if (element.isExcluded) { + + } +
+
+ Name + + @if (element.platform?.url) { + + } + {{ element.name }} + Total + Currency + + {{ element.currency }} + + {{ baseCurrency() }} + + Platform + +
+ @if (element.platform?.url) { + + } + {{ element.platform?.name }} +
+
+ # + Activities + + {{ element.activitiesCount }} + + {{ activitiesCount() }} + + Cash Balance + + + + + + Value + + + + + + Value + + + + + + Allocation + + + + @if (element.comment) { + + } + + + + + +
+ +
+
+
+ +@if (isLoading()) { + +} diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.scss b/libs/ui/src/lib/accounts-table/accounts-table.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts b/libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts new file mode 100644 index 000000000..62a01164f --- /dev/null +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.stories.ts @@ -0,0 +1,162 @@ +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfEntityLogoComponent } from '../entity-logo'; +import { NotificationService } from '../notifications'; +import { GfValueComponent } from '../value'; +import { GfAccountsTableComponent } from './accounts-table.component'; + +const accounts = [ + { + activitiesCount: 0, + allocationInPercentage: null, + balance: 278, + balanceInBaseCurrency: 278, + comment: null, + createdAt: new Date('2025-06-01T06:52:49.063Z'), + currency: 'USD', + id: '460d7401-ca43-4ed4-b08e-349f1822e9db', + isExcluded: false, + name: 'Coinbase Account', + platform: { + id: '8dc24b88-bb92-4152-af25-fe6a31643e26', + name: 'Coinbase', + url: 'https://www.coinbase.com' + }, + platformId: '8dc24b88-bb92-4152-af25-fe6a31643e26', + updatedAt: new Date('2025-06-01T06:52:49.063Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + value: 278, + valueInBaseCurrency: 278 + }, + { + activitiesCount: 0, + allocationInPercentage: null, + balance: 12000, + balanceInBaseCurrency: 12000, + comment: null, + createdAt: new Date('2025-06-01T06:48:53.055Z'), + currency: 'USD', + id: '6d773e31-0583-4c85-a247-e69870b4f1ee', + isExcluded: false, + name: 'Private Banking Account', + platform: { + id: '43e8fcd1-5b79-4100-b678-d2229bd1660d', + name: 'J.P. Morgan', + url: 'https://www.jpmorgan.com' + }, + platformId: '43e8fcd1-5b79-4100-b678-d2229bd1660d', + updatedAt: new Date('2025-06-01T06:48:53.055Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + value: 12000, + valueInBaseCurrency: 12000 + }, + { + activitiesCount: 12, + allocationInPercentage: null, + balance: 150.2, + balanceInBaseCurrency: 150.2, + comment: null, + createdAt: new Date('2025-05-31T13:00:13.940Z'), + currency: 'USD', + id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + isExcluded: false, + name: 'Trading Account', + platform: { + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + }, + platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', + valueInBaseCurrency: 95693.70321466809, + updatedAt: new Date('2025-06-01T06:53:10.569Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + value: 95693.70321466809 + } +]; + +export default { + title: 'Accounts Table', + component: GfAccountsTableComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + GfEntityLogoComponent, + GfValueComponent, + IonIcon, + MatButtonModule, + MatMenuModule, + MatSortModule, + MatTableModule, + NgxSkeletonLoaderModule, + RouterModule.forChild([]) + ], + providers: [NotificationService] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { + accounts: undefined, + baseCurrency: 'USD', + hasPermissionToOpenDetails: false, + locale: 'en-US', + showActions: false, + showActivitiesCount: true, + showAllocationInPercentage: false, + showBalance: true, + showFooter: true, + showValue: true, + showValueInBaseCurrency: true + } +}; + +export const Default: Story = { + args: { + accounts, + activitiesCount: 12, + baseCurrency: 'USD', + hasPermissionToOpenDetails: false, + locale: 'en-US', + showActions: false, + showActivitiesCount: true, + showAllocationInPercentage: false, + showBalance: true, + showFooter: true, + showValue: true, + showValueInBaseCurrency: true, + totalBalanceInBaseCurrency: 12428.2, + totalValueInBaseCurrency: 107971.70321466809 + } +}; + +export const WithoutFooter: Story = { + args: { + accounts, + activitiesCount: 12, + baseCurrency: 'USD', + hasPermissionToOpenDetails: false, + locale: 'en-US', + showActions: false, + showActivitiesCount: true, + showAllocationInPercentage: false, + showBalance: true, + showFooter: false, + showValue: true, + showValueInBaseCurrency: true, + totalBalanceInBaseCurrency: 12428.2, + totalValueInBaseCurrency: 107971.70321466809 + } +}; diff --git a/libs/ui/src/lib/accounts-table/accounts-table.component.ts b/libs/ui/src/lib/accounts-table/accounts-table.component.ts new file mode 100644 index 000000000..99e68c679 --- /dev/null +++ b/libs/ui/src/lib/accounts-table/accounts-table.component.ts @@ -0,0 +1,173 @@ +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +import { getLocale, getLowercase } from '@ghostfolio/common/helper'; +import { GfEntityLogoComponent } from '@ghostfolio/ui/entity-logo'; +import { NotificationService } from '@ghostfolio/ui/notifications'; +import { GfValueComponent } from '@ghostfolio/ui/value'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + output, + viewChild +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { Router, RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { Account } from '@prisma/client'; +import { addIcons } from 'ionicons'; +import { + arrowRedoOutline, + createOutline, + documentTextOutline, + ellipsisHorizontal, + eyeOffOutline, + trashOutline, + walletOutline +} from 'ionicons/icons'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfEntityLogoComponent, + GfValueComponent, + IonIcon, + MatButtonModule, + MatMenuModule, + MatSortModule, + MatTableModule, + NgxSkeletonLoaderModule, + RouterModule + ], + selector: 'gf-accounts-table', + styleUrls: ['./accounts-table.component.scss'], + templateUrl: './accounts-table.component.html' +}) +export class GfAccountsTableComponent { + public readonly accounts = input.required(); + public readonly activitiesCount = input(); + public readonly baseCurrency = input(); + public readonly hasPermissionToOpenDetails = input(true); + public readonly locale = input(getLocale()); + public readonly showActions = input(); + public readonly showActivitiesCount = input(true); + public readonly showAllocationInPercentage = input(); + public readonly showBalance = input(true); + public readonly showFooter = input(true); + public readonly showValue = input(true); + public readonly showValueInBaseCurrency = input(false); + public readonly totalBalanceInBaseCurrency = input(); + public readonly totalValueInBaseCurrency = input(); + + public readonly accountDeleted = output(); + public readonly accountToUpdate = output(); + public readonly transferBalance = output(); + + public readonly sort = viewChild.required(MatSort); + + protected readonly dataSource = new MatTableDataSource([]); + + protected readonly displayedColumns = computed(() => { + const columns = ['status', 'account', 'platform']; + + if (this.showActivitiesCount()) { + columns.push('activitiesCount'); + } + + if (this.showBalance()) { + columns.push('balance'); + } + + if (this.showValue()) { + columns.push('value'); + } + + columns.push('currency'); + + if (this.showValueInBaseCurrency()) { + columns.push('valueInBaseCurrency'); + } + + if (this.showAllocationInPercentage()) { + columns.push('allocation'); + } + + columns.push('comment'); + + if (this.showActions()) { + columns.push('actions'); + } + + return columns; + }); + + protected readonly isLoading = computed(() => !this.accounts()); + + private readonly notificationService = inject(NotificationService); + private readonly router = inject(Router); + + public constructor() { + addIcons({ + arrowRedoOutline, + createOutline, + documentTextOutline, + ellipsisHorizontal, + eyeOffOutline, + trashOutline, + walletOutline + }); + + this.dataSource.sortingDataAccessor = getLowercase; + + // Reactive data update + effect(() => { + this.dataSource.data = this.accounts(); + }); + + // Reactive view connection + effect(() => { + this.dataSource.sort = this.sort(); + }); + } + + protected onDeleteAccount(aId: string) { + this.notificationService.confirm({ + confirmFn: () => { + this.accountDeleted.emit(aId); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete this account?` + }); + } + + protected onOpenAccountDetailDialog(accountId: string) { + if (this.hasPermissionToOpenDetails()) { + this.router.navigate([], { + queryParams: { accountId, accountDetailDialog: true } + }); + } + } + + protected onOpenComment(aComment: string) { + this.notificationService.alert({ + title: aComment + }); + } + + protected onTransferBalance() { + this.transferBalance.emit(); + } + + protected onUpdateAccount(aAccount: Account) { + this.accountToUpdate.emit(aAccount); + } +} diff --git a/libs/ui/src/lib/accounts-table/index.ts b/libs/ui/src/lib/accounts-table/index.ts new file mode 100644 index 000000000..6b1909639 --- /dev/null +++ b/libs/ui/src/lib/accounts-table/index.ts @@ -0,0 +1 @@ +export * from './accounts-table.component'; diff --git a/libs/ui/src/lib/activities-filter/activities-filter.component.html b/libs/ui/src/lib/activities-filter/activities-filter.component.html new file mode 100644 index 000000000..d87ce16ce --- /dev/null +++ b/libs/ui/src/lib/activities-filter/activities-filter.component.html @@ -0,0 +1,53 @@ + + + + @for (filter of selectedFilters; track filter) { + + {{ filter.label ?? '' | gfSymbol }} + + + } + + + + @for (filterGroup of filterGroups$ | async; track filterGroup) { + + @for (filter of filterGroup.filters; track filter) { + + {{ filter.label ?? '' | gfSymbol }} + + } + + } + + + + diff --git a/libs/ui/src/lib/activities-filter/activities-filter.component.scss b/libs/ui/src/lib/activities-filter/activities-filter.component.scss new file mode 100644 index 000000000..f8b7f88dc --- /dev/null +++ b/libs/ui/src/lib/activities-filter/activities-filter.component.scss @@ -0,0 +1,30 @@ +:host { + display: block; + + ::ng-deep { + .mat-mdc-progress-spinner { + circle { + stroke: rgba(var(--dark-dividers)); + } + } + } + + .mat-mdc-chip { + cursor: pointer; + min-height: 1.5rem !important; + } +} + +:host-context(.theme-dark) { + .mat-mdc-form-field { + color: rgba(var(--light-primary-text)); + } + + ::ng-deep { + .mat-mdc-progress-spinner { + circle { + stroke: rgba(var(--light-dividers)); + } + } + } +} diff --git a/libs/ui/src/lib/activities-filter/activities-filter.component.ts b/libs/ui/src/lib/activities-filter/activities-filter.component.ts new file mode 100644 index 000000000..25fad683d --- /dev/null +++ b/libs/ui/src/lib/activities-filter/activities-filter.component.ts @@ -0,0 +1,182 @@ +import { Filter, FilterGroup } from '@ghostfolio/common/interfaces'; +import { GfSymbolPipe } from '@ghostfolio/common/pipes'; + +import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + ElementRef, + Input, + OnChanges, + SimpleChanges, + ViewChild, + input, + output +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { + MatAutocomplete, + MatAutocompleteModule, + MatAutocompleteSelectedEvent +} from '@angular/material/autocomplete'; +import { MatButtonModule } from '@angular/material/button'; +import { MatChipInputEvent, MatChipsModule } from '@angular/material/chips'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { closeOutline, searchOutline } from 'ionicons/icons'; +import { groupBy } from 'lodash'; +import { BehaviorSubject } from 'rxjs'; + +import { translate } from '../i18n'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfSymbolPipe, + IonIcon, + MatAutocompleteModule, + MatButtonModule, + MatChipsModule, + MatInputModule, + MatProgressSpinnerModule, + ReactiveFormsModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-activities-filter', + styleUrls: ['./activities-filter.component.scss'], + templateUrl: './activities-filter.component.html' +}) +export class GfActivitiesFilterComponent implements OnChanges { + @Input() allFilters: Filter[]; + + @ViewChild('autocomplete') protected matAutocomplete: MatAutocomplete; + @ViewChild('searchInput') protected searchInput: ElementRef; + + public readonly isLoading = input.required(); + public readonly placeholder = input.required(); + public readonly valueChanged = output(); + + protected readonly filterGroups$ = new BehaviorSubject([]); + protected readonly searchControl = new FormControl( + null + ); + protected selectedFilters: Filter[] = []; + protected readonly separatorKeysCodes: number[] = [ENTER, COMMA]; + + public constructor() { + this.searchControl.valueChanges + .pipe(takeUntilDestroyed()) + .subscribe((filterOrSearchTerm) => { + if (filterOrSearchTerm) { + const searchTerm = + typeof filterOrSearchTerm === 'string' + ? filterOrSearchTerm + : filterOrSearchTerm?.label; + + this.filterGroups$.next(this.getGroupedFilters(searchTerm)); + } else { + this.filterGroups$.next(this.getGroupedFilters()); + } + }); + + addIcons({ closeOutline, searchOutline }); + } + + public ngOnChanges(changes: SimpleChanges) { + if (changes.allFilters?.currentValue) { + this.updateFilters(); + } + } + + public onAddFilter({ chipInput, value }: MatChipInputEvent) { + if (value?.trim()) { + this.updateFilters(); + } + + // Reset the input value + if (chipInput.inputElement) { + chipInput.inputElement.value = ''; + } + + this.searchControl.setValue(null); + } + + public onRemoveFilter(aFilter: Filter) { + this.selectedFilters = this.selectedFilters.filter(({ id }) => { + return id !== aFilter.id; + }); + + this.updateFilters(); + } + + public onSelectFilter(event: MatAutocompleteSelectedEvent) { + const filter = this.allFilters.find(({ id }) => { + return id === event.option.value; + }); + + if (filter) { + this.selectedFilters.push(filter); + } + + this.updateFilters(); + this.searchInput.nativeElement.value = ''; + this.searchControl.setValue(null); + } + + private getGroupedFilters(searchTerm?: string) { + const filterGroupsMap = groupBy( + this.allFilters + .filter((filter) => { + // Filter selected filters + return !this.selectedFilters.some(({ id }) => { + return id === filter.id; + }); + }) + .filter((filter) => { + if (searchTerm) { + // Filter by search term + return filter.label + ?.toLowerCase() + .includes(searchTerm.toLowerCase()); + } + + return filter; + }) + .sort((a, b) => (a.label ?? '').localeCompare(b.label ?? '')), + ({ type }) => { + return type; + } + ); + + const filterGroups: FilterGroup[] = []; + + for (const type of Object.keys(filterGroupsMap)) { + filterGroups.push({ + name: translate(type) as Filter['type'], + filters: filterGroupsMap[type] + }); + } + + return filterGroups + .sort((a, b) => a.name?.localeCompare(b.name)) + .map((filterGroup) => { + return { + ...filterGroup, + filters: filterGroup.filters + }; + }); + } + + private updateFilters() { + this.filterGroups$.next(this.getGroupedFilters()); + + // Emit an array with a new reference + this.valueChanged.emit([...this.selectedFilters]); + } +} diff --git a/libs/ui/src/lib/activities-filter/index.ts b/libs/ui/src/lib/activities-filter/index.ts new file mode 100644 index 000000000..ef776e130 --- /dev/null +++ b/libs/ui/src/lib/activities-filter/index.ts @@ -0,0 +1 @@ +export * from './activities-filter.component'; diff --git a/libs/ui/src/lib/activities-table/activities-table.component.html b/libs/ui/src/lib/activities-table/activities-table.component.html new file mode 100644 index 000000000..bdb1e6373 --- /dev/null +++ b/libs/ui/src/lib/activities-table/activities-table.component.html @@ -0,0 +1,534 @@ +@if (hasPermissionToCreateActivity) { +
+ + @if (hasPermissionToExportActivities) { + + } + + + @if (hasPermissionToExportActivities) { + + } + @if (hasPermissionToExportActivities) { + + } +
+ +
+
+} + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + @if (element.error) { +
+ +
+ } +
+ + + Name + +
+
+ {{ element.SymbolProfile?.name }} + @if (element.isDraft) { + Draft + } +
+
+ @if ( + element.SymbolProfile?.dataSource !== 'MANUAL' && + !isUUID(element.SymbolProfile?.symbol) + ) { +
+ {{ + element.SymbolProfile?.symbol | gfSymbol + }} +
+ } +
+ Type + + + + Date + +
+ +
+
+ Quantity + +
+ +
+
+ Unit Price + +
+ +
+
+ Fee + +
+ +
+
+ Value + +
+ +
+
+ Currency + + {{ element.currency ?? element.SymbolProfile?.currency }} + + Value + +
+ +
+
+ Account + +
+ @if (element.account?.platform?.url) { + + } + {{ element.account?.name }} +
+
+ @if (element.comment) { + + } + + @if ( + !hasPermissionToCreateActivity && hasPermissionToExportActivities + ) { + + } + + @if (hasPermissionToCreateActivity) { + + } + @if (hasPermissionToCreateActivity) { + + } + @if (hasPermissionToExportActivities) { + + } + @if (hasPermissionToExportActivities) { + + } + + + @if (showActions) { + + } + + @if (canClickActivity(element)) { + + } + + + +
+ +
+
+
+ +@if (isLoading()) { + +} + + + +@if ( + !hasActivities && + dataSource()?.data.length === 0 && + hasPermissionToCreateActivity && + !isLoading() +) { +
+ +
+} diff --git a/libs/ui/src/lib/activities-table/activities-table.component.scss b/libs/ui/src/lib/activities-table/activities-table.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/activities-table/activities-table.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/activities-table/activities-table.component.stories.ts b/libs/ui/src/lib/activities-table/activities-table.component.stories.ts new file mode 100644 index 000000000..25463e576 --- /dev/null +++ b/libs/ui/src/lib/activities-table/activities-table.component.stories.ts @@ -0,0 +1,471 @@ +import { Activity } from '@ghostfolio/common/interfaces'; +import { GfSymbolPipe } from '@ghostfolio/common/pipes'; + +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfActivityTypeComponent } from '../activity-type/activity-type.component'; +import { GfEntityLogoComponent } from '../entity-logo'; +import { GfNoTransactionsInfoComponent } from '../no-transactions-info/no-transactions-info.component'; +import { NotificationService } from '../notifications'; +import { GfValueComponent } from '../value'; +import { GfActivitiesTableComponent } from './activities-table.component'; + +const activities: Activity[] = [ + { + accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e', + comment: null, + createdAt: new Date('2025-04-09T13:47:33.133Z'), + currency: 'USD', + date: new Date('2025-04-09T13:45:45.504Z'), + fee: 1, + id: 'a76968ff-80a4-4453-81ed-c3627dea3919', + isDraft: false, + quantity: 115, + symbolProfileId: '21746431-d612-4298-911c-3099b2a43003', + type: 'BUY', + unitPrice: 103.543, + updatedAt: new Date('2025-05-31T18:43:01.840Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + account: { + balance: 150.2, + comment: null, + createdAt: new Date('2025-05-31T13:00:13.940Z'), + currency: 'USD', + id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + isExcluded: false, + name: 'Trading Account', + platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', + updatedAt: new Date('2025-06-01T06:53:10.569Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + platform: { + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + } + }, + SymbolProfile: { + assetClass: 'EQUITY', + assetSubClass: 'ETF', + comment: undefined, + countries: [], + createdAt: new Date('2021-06-06T16:12:20.982Z'), + currency: 'USD', + cusip: '922042742', + dataSource: 'YAHOO', + figi: 'BBG000GM5FZ6', + figiComposite: 'BBG000GM5FZ6', + figiShareClass: 'BBG001T2YZG9', + holdings: [], + id: '21746431-d612-4298-911c-3099b2a43003', + isActive: true, + isin: 'US9220427424', + name: 'Vanguard Total World Stock Index Fund ETF Shares', + updatedAt: new Date('2025-10-01T20:09:39.500Z'), + scraperConfiguration: undefined, + sectors: [], + symbol: 'VT', + symbolMapping: {}, + url: 'https://www.vanguard.com', + userId: undefined, + activitiesCount: 267, + dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z') + }, + tags: [], + feeInAssetProfileCurrency: 1, + feeInBaseCurrency: 1, + unitPriceInAssetProfileCurrency: 103.543, + value: 11907.445, + valueInBaseCurrency: 11907.445 + }, + { + accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e', + comment: null, + createdAt: new Date('2024-08-07T13:40:39.103Z'), + currency: 'USD', + date: new Date('2024-08-07T13:38:06.289Z'), + fee: 2.97, + id: '0c2f4fbf-6edc-4adc-8f83-abf8148500ec', + isDraft: false, + quantity: 105, + symbolProfileId: '21746431-d612-4298-911c-3099b2a43003', + type: 'BUY', + unitPrice: 110.24, + updatedAt: new Date('2025-05-31T18:46:14.175Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + account: { + balance: 150.2, + comment: null, + createdAt: new Date('2025-05-31T13:00:13.940Z'), + currency: 'USD', + id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + isExcluded: false, + name: 'Trading Account', + platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', + updatedAt: new Date('2025-06-01T06:53:10.569Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + platform: { + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + } + }, + SymbolProfile: { + assetClass: 'EQUITY', + assetSubClass: 'ETF', + comment: undefined, + countries: [], + createdAt: new Date('2021-06-06T16:12:20.982Z'), + currency: 'USD', + cusip: '922042742', + dataSource: 'YAHOO', + figi: 'BBG000GM5FZ6', + figiComposite: 'BBG000GM5FZ6', + figiShareClass: 'BBG001T2YZG9', + holdings: [], + id: '21746431-d612-4298-911c-3099b2a43003', + isActive: true, + isin: 'US9220427424', + name: 'Vanguard Total World Stock Index Fund ETF Shares', + updatedAt: new Date('2025-10-01T20:09:39.500Z'), + scraperConfiguration: undefined, + sectors: [], + symbol: 'VT', + symbolMapping: {}, + url: 'https://www.vanguard.com', + userId: undefined, + activitiesCount: 267, + dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z') + }, + tags: [], + feeInAssetProfileCurrency: 2.97, + feeInBaseCurrency: 2.97, + unitPriceInAssetProfileCurrency: 110.24, + value: 11575.2, + valueInBaseCurrency: 11575.2 + }, + { + accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e', + comment: null, + createdAt: new Date('2024-03-12T15:15:21.217Z'), + currency: 'USD', + date: new Date('2024-03-12T15:14:38.597Z'), + fee: 45.29, + id: 'bfc92677-faf4-4d4f-9762-e0ec056525c2', + isDraft: false, + quantity: 167, + symbolProfileId: '888d4123-db9a-42f3-9775-01b1ae6f9092', + type: 'BUY', + unitPrice: 41.0596, + updatedAt: new Date('2025-05-31T18:49:54.064Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + account: { + balance: 150.2, + comment: null, + createdAt: new Date('2025-05-31T13:00:13.940Z'), + currency: 'USD', + id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + isExcluded: false, + name: 'Trading Account', + platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', + updatedAt: new Date('2025-06-01T06:53:10.569Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + platform: { + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + } + }, + SymbolProfile: { + assetClass: 'LIQUIDITY', + assetSubClass: 'CRYPTOCURRENCY', + comment: undefined, + countries: [], + createdAt: new Date('2024-03-12T15:15:21.217Z'), + currency: 'USD', + cusip: '463918102', + dataSource: 'YAHOO', + figi: 'BBG01KYQ6PV3', + figiComposite: 'BBG01KYQ6PV3', + figiShareClass: 'BBG01KYQ6QS5', + holdings: [], + id: '888d4123-db9a-42f3-9775-01b1ae6f9092', + isActive: true, + isin: 'CA4639181029', + name: 'iShares Bitcoin Trust', + updatedAt: new Date('2025-09-29T03:14:07.742Z'), + scraperConfiguration: undefined, + sectors: [], + symbol: 'IBIT', + symbolMapping: {}, + url: 'https://www.ishares.com', + userId: undefined, + activitiesCount: 6, + dateOfFirstActivity: new Date('2024-01-01T08:00:00.000Z') + }, + tags: [], + feeInAssetProfileCurrency: 45.29, + feeInBaseCurrency: 45.29, + unitPriceInAssetProfileCurrency: 41.0596, + value: 6856.9532, + valueInBaseCurrency: 6856.9532 + }, + { + accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e', + comment: null, + createdAt: new Date('2024-02-23T15:53:46.907Z'), + currency: 'USD', + date: new Date('2024-02-23T15:53:15.745Z'), + fee: 3, + id: '7c9ceb54-acb1-4850-bfb1-adb41c29fd6a', + isDraft: false, + quantity: 81, + symbolProfileId: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9', + type: 'BUY', + unitPrice: 67.995, + updatedAt: new Date('2025-05-31T18:48:48.209Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + account: { + balance: 150.2, + comment: null, + createdAt: new Date('2025-05-31T13:00:13.940Z'), + currency: 'USD', + id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + isExcluded: false, + name: 'Trading Account', + platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', + updatedAt: new Date('2025-06-01T06:53:10.569Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + platform: { + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + } + }, + SymbolProfile: { + assetClass: 'FIXED_INCOME', + assetSubClass: 'BOND', + comment: 'No data', + countries: [], + createdAt: new Date('2022-04-13T20:05:47.301Z'), + currency: 'USD', + cusip: '92206C565', + dataSource: 'YAHOO', + figi: 'BBG00LWSF7T3', + figiComposite: 'BBG00LWSF7T3', + figiShareClass: 'BBG00LWSF8K0', + holdings: [], + id: '36effe43-7cb4-4e8b-b7ac-03ff65702cb9', + isActive: true, + isin: 'US92206C5655', + name: 'Vanguard Total World Bond ETF', + updatedAt: new Date('2025-10-02T06:02:56.314Z'), + + sectors: [], + symbol: 'BNDW', + symbolMapping: {}, + url: 'https://vanguard.com', + userId: undefined, + activitiesCount: 38, + dateOfFirstActivity: new Date('2022-04-13T20:05:48.742Z') + }, + tags: [], + feeInAssetProfileCurrency: 3, + feeInBaseCurrency: 3, + unitPriceInAssetProfileCurrency: 67.995, + value: 5507.595, + valueInBaseCurrency: 5507.595 + }, + { + accountId: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + accountUserId: '081aa387-487d-4438-83a4-3060eb2a016e', + comment: null, + createdAt: new Date('2023-01-11T14:35:22.325Z'), + currency: 'USD', + date: new Date('2023-01-11T14:34:55.174Z'), + fee: 7.38, + id: '3fe87b3f-78de-407a-bc02-4189b221051f', + isDraft: false, + quantity: 55, + symbolProfileId: '21746431-d612-4298-911c-3099b2a43003', + type: 'BUY', + unitPrice: 89.48, + updatedAt: new Date('2025-05-31T18:46:44.616Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + account: { + balance: 150.2, + comment: null, + createdAt: new Date('2025-05-31T13:00:13.940Z'), + currency: 'USD', + id: '776bd1e9-b2f6-4f7e-933d-18756c2f0625', + isExcluded: false, + name: 'Trading Account', + platformId: '9da3a8a7-4795-43e3-a6db-ccb914189737', + updatedAt: new Date('2025-06-01T06:53:10.569Z'), + userId: '081aa387-487d-4438-83a4-3060eb2a016e', + platform: { + id: '9da3a8a7-4795-43e3-a6db-ccb914189737', + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + } + }, + SymbolProfile: { + assetClass: 'EQUITY', + assetSubClass: 'ETF', + comment: undefined, + countries: [], + createdAt: new Date('2021-06-06T16:12:20.982Z'), + currency: 'USD', + cusip: '922042742', + dataSource: 'YAHOO', + figi: 'BBG000GM5FZ6', + figiComposite: 'BBG000GM5FZ6', + figiShareClass: 'BBG001T2YZG9', + holdings: [], + id: '21746431-d612-4298-911c-3099b2a43003', + isActive: true, + isin: 'US9220427424', + name: 'Vanguard Total World Stock Index Fund ETF Shares', + updatedAt: new Date('2025-10-01T20:09:39.500Z'), + scraperConfiguration: undefined, + sectors: [], + symbol: 'VT', + symbolMapping: {}, + url: 'https://www.vanguard.com', + userId: undefined, + activitiesCount: 267, + dateOfFirstActivity: new Date('2018-05-31T16:00:00.000Z') + }, + tags: [], + feeInAssetProfileCurrency: 7.38, + feeInBaseCurrency: 7.38, + unitPriceInAssetProfileCurrency: 89.48, + value: 4921.4, + valueInBaseCurrency: 4921.4 + } +]; + +const dataSource = new MatTableDataSource(activities); + +export default { + title: 'Activities Table', + component: GfActivitiesTableComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + GfActivityTypeComponent, + GfEntityLogoComponent, + GfNoTransactionsInfoComponent, + GfSymbolPipe, + GfValueComponent, + IonIcon, + MatButtonModule, + MatCheckboxModule, + MatMenuModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + MatTooltipModule, + NgxSkeletonLoaderModule, + RouterModule.forChild([]) + ], + providers: [NotificationService] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { + baseCurrency: 'USD', + dataSource: undefined, + deviceType: 'desktop', + hasActivities: true, + hasPermissionToCreateActivity: false, + hasPermissionToDeleteActivity: false, + hasPermissionToExportActivities: false, + hasPermissionToOpenDetails: false, + locale: 'en-US', + pageIndex: 0, + pageSize: 10, + showAccountColumn: true, + showActions: false, + showCheckbox: false, + showNameColumn: true, + sortColumn: 'date', + sortDirection: 'desc', + sortDisabled: false, + totalItems: 0 + } +}; + +export const Default: Story = { + args: { + baseCurrency: 'USD', + dataSource, + deviceType: 'desktop', + hasActivities: true, + hasPermissionToCreateActivity: false, + hasPermissionToDeleteActivity: false, + hasPermissionToExportActivities: false, + hasPermissionToOpenDetails: false, + locale: 'en-US', + pageIndex: 0, + pageSize: 10, + showAccountColumn: true, + showActions: false, + showCheckbox: false, + showNameColumn: true, + sortColumn: 'date', + sortDirection: 'desc', + sortDisabled: false, + totalItems: activities.length + } +}; + +export const Pagination: Story = { + args: { + baseCurrency: 'USD', + dataSource: new MatTableDataSource( + Array.from({ length: 50 }).map((_, i) => ({ + ...(activities[i % activities.length] as Activity), + date: new Date(2025, 5, (i % 28) + 1), + id: `${i}` + })) + ), + deviceType: 'desktop', + hasActivities: true, + hasPermissionToCreateActivity: false, + hasPermissionToDeleteActivity: false, + hasPermissionToExportActivities: false, + hasPermissionToOpenDetails: false, + locale: 'en-US', + pageIndex: 0, + pageSize: 10, + showAccountColumn: true, + showActions: false, + showCheckbox: false, + showNameColumn: true, + sortColumn: 'date', + sortDirection: 'desc', + sortDisabled: false, + totalItems: 50 + } +}; diff --git a/libs/ui/src/lib/activities-table/activities-table.component.ts b/libs/ui/src/lib/activities-table/activities-table.component.ts new file mode 100644 index 000000000..62f5c81d0 --- /dev/null +++ b/libs/ui/src/lib/activities-table/activities-table.component.ts @@ -0,0 +1,350 @@ +import { + DEFAULT_PAGE_SIZE, + TAG_ID_EXCLUDE_FROM_ANALYSIS +} from '@ghostfolio/common/config'; +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +import { getLocale } from '@ghostfolio/common/helper'; +import { + Activity, + AssetProfileIdentifier +} from '@ghostfolio/common/interfaces'; +import { GfSymbolPipe } from '@ghostfolio/common/pipes'; +import { OrderWithAccount } from '@ghostfolio/common/types'; +import { NotificationService } from '@ghostfolio/ui/notifications'; + +import { SelectionModel } from '@angular/cdk/collections'; +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnDestroy, + OnInit, + Output, + ViewChild, + computed, + inject, + input +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatMenuModule } from '@angular/material/menu'; +import { + MatPaginator, + MatPaginatorModule, + PageEvent +} from '@angular/material/paginator'; +import { + MatSort, + MatSortModule, + Sort, + SortDirection +} from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { IonIcon } from '@ionic/angular/standalone'; +import { isUUID } from 'class-validator'; +import { addIcons } from 'ionicons'; +import { + alertCircleOutline, + calendarClearOutline, + cloudDownloadOutline, + cloudUploadOutline, + colorWandOutline, + copyOutline, + createOutline, + documentTextOutline, + ellipsisHorizontal, + ellipsisVertical, + tabletLandscapeOutline, + trashOutline +} from 'ionicons/icons'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { Subject, takeUntil } from 'rxjs'; + +import { GfActivityTypeComponent } from '../activity-type/activity-type.component'; +import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; +import { GfNoTransactionsInfoComponent } from '../no-transactions-info/no-transactions-info.component'; +import { GfValueComponent } from '../value/value.component'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfActivityTypeComponent, + GfEntityLogoComponent, + GfNoTransactionsInfoComponent, + GfSymbolPipe, + GfValueComponent, + IonIcon, + MatButtonModule, + MatCheckboxModule, + MatMenuModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + MatTooltipModule, + NgxSkeletonLoaderModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-activities-table', + styleUrls: ['./activities-table.component.scss'], + templateUrl: './activities-table.component.html' +}) +export class GfActivitiesTableComponent + implements AfterViewInit, OnDestroy, OnInit +{ + @Input() baseCurrency: string; + @Input() deviceType: string; + @Input() hasActivities: boolean; + @Input() hasPermissionToCreateActivity: boolean; + @Input() hasPermissionToDeleteActivity: boolean; + @Input() hasPermissionToExportActivities: boolean; + @Input() hasPermissionToOpenDetails = true; + @Input() locale = getLocale(); + @Input() pageIndex: number; + @Input() pageSize = DEFAULT_PAGE_SIZE; + @Input() showActions = true; + @Input() sortColumn: string; + @Input() sortDirection: SortDirection; + @Input() sortDisabled = false; + @Input() totalItems = Number.MAX_SAFE_INTEGER; + + @Output() activitiesDeleted = new EventEmitter(); + @Output() activityClicked = new EventEmitter(); + @Output() activityDeleted = new EventEmitter(); + @Output() activityToClone = new EventEmitter(); + @Output() activityToUpdate = new EventEmitter(); + @Output() export = new EventEmitter(); + @Output() exportDrafts = new EventEmitter(); + @Output() import = new EventEmitter(); + @Output() importDividends = new EventEmitter(); + @Output() pageChanged = new EventEmitter(); + @Output() selectedActivities = new EventEmitter(); + @Output() sortChanged = new EventEmitter(); + + @ViewChild(MatPaginator) paginator: MatPaginator; + @ViewChild(MatSort) sort: MatSort; + + public hasDrafts = false; + public hasErrors = false; + public isUUID = isUUID; + public selectedRows = new SelectionModel(true, []); + + public readonly dataSource = input.required< + MatTableDataSource | undefined + >(); + public readonly showAccountColumn = input(true); + public readonly showCheckbox = input(false); + public readonly showNameColumn = input(true); + + protected readonly displayedColumns = computed(() => { + let columns = [ + 'select', + 'importStatus', + 'icon', + 'nameWithSymbol', + 'type', + 'date', + 'quantity', + 'unitPrice', + 'fee', + 'value', + 'currency', + 'valueInBaseCurrency', + 'account', + 'comment', + 'actions' + ]; + + if (!this.showAccountColumn()) { + columns = columns.filter((column) => { + return column !== 'account'; + }); + } + + if (!this.showCheckbox()) { + columns = columns.filter((column) => { + return column !== 'importStatus' && column !== 'select'; + }); + } + + if (!this.showNameColumn()) { + columns = columns.filter((column) => { + return column !== 'nameWithSymbol'; + }); + } + + return columns; + }); + + protected readonly isLoading = computed(() => { + return !this.dataSource(); + }); + + private readonly notificationService = inject(NotificationService); + private readonly unsubscribeSubject = new Subject(); + + public constructor() { + addIcons({ + alertCircleOutline, + calendarClearOutline, + cloudDownloadOutline, + cloudUploadOutline, + colorWandOutline, + copyOutline, + createOutline, + documentTextOutline, + ellipsisHorizontal, + ellipsisVertical, + tabletLandscapeOutline, + trashOutline + }); + } + + public ngOnInit() { + if (this.showCheckbox()) { + this.toggleAllRows(); + this.selectedRows.changed + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((selectedRows) => { + this.selectedActivities.emit(selectedRows.source.selected); + }); + } + } + + public ngAfterViewInit() { + const dataSource = this.dataSource(); + + if (dataSource) { + dataSource.paginator = this.paginator; + } + + this.sort.sortChange.subscribe((value: Sort) => { + this.sortChanged.emit(value); + }); + } + + public areAllRowsSelected() { + const numSelectedRows = this.selectedRows.selected.length; + const numTotalRows = this.dataSource()?.data.length; + return numSelectedRows === numTotalRows; + } + + public canClickActivity(activity: Activity) { + return ( + this.hasPermissionToOpenDetails && + this.isExcludedFromAnalysis(activity) === false && + activity.isDraft === false && + ['BUY', 'DIVIDEND', 'SELL'].includes(activity.type) + ); + } + + public isExcludedFromAnalysis(activity: Activity) { + return ( + activity.account?.isExcluded ?? + activity.tags?.some(({ id }) => { + return id === TAG_ID_EXCLUDE_FROM_ANALYSIS; + }) + ); + } + + public onChangePage(page: PageEvent) { + this.pageChanged.emit(page); + } + + public onClickActivity(activity: Activity) { + if (this.showCheckbox()) { + if (!activity.error) { + this.selectedRows.toggle(activity); + } + } else if (this.canClickActivity(activity)) { + this.activityClicked.emit({ + dataSource: activity.SymbolProfile.dataSource, + symbol: activity.SymbolProfile.symbol + }); + } + } + + public onCloneActivity(aActivity: OrderWithAccount) { + this.activityToClone.emit(aActivity); + } + + public onDeleteActivities() { + this.notificationService.confirm({ + confirmFn: () => { + this.activitiesDeleted.emit(); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete these activities?` + }); + } + + public onDeleteActivity(aId: string) { + this.notificationService.confirm({ + confirmFn: () => { + this.activityDeleted.emit(aId); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete this activity?` + }); + } + + public onExport() { + this.export.emit(); + } + + public onExportDraft(aActivityId: string) { + this.exportDrafts.emit([aActivityId]); + } + + public onExportDrafts() { + this.exportDrafts.emit( + this.dataSource() + ?.filteredData.filter((activity) => { + return activity.isDraft; + }) + .map((activity) => { + return activity.id; + }) + ); + } + + public onImport() { + this.import.emit(); + } + + public onImportDividends() { + this.importDividends.emit(); + } + + public onOpenComment(aComment: string) { + this.notificationService.alert({ + title: aComment + }); + } + + public onUpdateActivity(aActivity: OrderWithAccount) { + this.activityToUpdate.emit(aActivity); + } + + public toggleAllRows() { + if (this.areAllRowsSelected()) { + this.selectedRows.clear(); + } else { + this.dataSource()?.data.forEach((row) => { + this.selectedRows.select(row); + }); + } + + this.selectedActivities.emit(this.selectedRows.selected); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/libs/ui/src/lib/activities-table/index.ts b/libs/ui/src/lib/activities-table/index.ts new file mode 100644 index 000000000..82eee49b1 --- /dev/null +++ b/libs/ui/src/lib/activities-table/index.ts @@ -0,0 +1 @@ +export * from './activities-table.component'; diff --git a/libs/ui/src/lib/activity-type/activity-type.component.html b/libs/ui/src/lib/activity-type/activity-type.component.html new file mode 100644 index 000000000..fe5ecfa01 --- /dev/null +++ b/libs/ui/src/lib/activity-type/activity-type.component.html @@ -0,0 +1,24 @@ +
+ @if (activityType === 'BUY') { + + } @else if (activityType === 'DIVIDEND' || activityType === 'INTEREST') { + + } @else if (activityType === 'FEE') { + + } @else if (activityType === 'LIABILITY') { + + } @else if (activityType === 'SELL') { + + } + {{ activityTypeLabel }} +
diff --git a/libs/ui/src/lib/activity-type/activity-type.component.scss b/libs/ui/src/lib/activity-type/activity-type.component.scss new file mode 100644 index 000000000..34b951805 --- /dev/null +++ b/libs/ui/src/lib/activity-type/activity-type.component.scss @@ -0,0 +1,43 @@ +:host { + display: block; + + .activity-type-badge { + background-color: rgba(var(--palette-foreground-text), 0.05); + border-radius: 1rem; + line-height: 1em; + + ion-icon { + font-size: 1rem; + } + + &.buy { + color: var(--green); + } + + &.dividend { + color: var(--blue); + } + + &.fee { + color: var(--gray); + } + + &.interest { + color: var(--cyan); + } + + &.liability { + color: var(--red); + } + + &.sell { + color: var(--orange); + } + } +} + +:host-context(.theme-dark) { + .activity-type-badge { + background-color: rgba(var(--palette-foreground-text-dark), 0.1) !important; + } +} diff --git a/libs/ui/src/lib/activity-type/activity-type.component.stories.ts b/libs/ui/src/lib/activity-type/activity-type.component.stories.ts new file mode 100644 index 000000000..349cf6a7b --- /dev/null +++ b/libs/ui/src/lib/activity-type/activity-type.component.stories.ts @@ -0,0 +1,30 @@ +import { CommonModule } from '@angular/common'; +import { IonIcon } from '@ionic/angular/standalone'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfActivityTypeComponent } from './activity-type.component'; + +export default { + title: 'Activity Type', + component: GfActivityTypeComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, IonIcon] + }) + ], + argTypes: { + activityType: { + control: 'select', + options: ['BUY', 'DIVIDEND', 'FEE', 'INTEREST', 'LIABILITY', 'SELL'] + } + } +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + activityType: 'BUY' + } +}; diff --git a/libs/ui/src/lib/activity-type/activity-type.component.ts b/libs/ui/src/lib/activity-type/activity-type.component.ts new file mode 100644 index 000000000..07e8c7ef4 --- /dev/null +++ b/libs/ui/src/lib/activity-type/activity-type.component.ts @@ -0,0 +1,50 @@ +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Input, + OnChanges +} from '@angular/core'; +import { IonIcon } from '@ionic/angular/standalone'; +import { Type as ActivityType } from '@prisma/client'; +import { addIcons } from 'ionicons'; +import { + addCircleOutline, + arrowDownCircleOutline, + arrowUpCircleOutline, + cubeOutline, + flameOutline, + hammerOutline +} from 'ionicons/icons'; + +import { translate } from '../i18n'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, IonIcon], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-activity-type', + styleUrls: ['./activity-type.component.scss'], + templateUrl: './activity-type.component.html' +}) +export class GfActivityTypeComponent implements OnChanges { + @Input() activityType: ActivityType; + + public activityTypeLabel: string; + + public constructor() { + addIcons({ + addCircleOutline, + arrowDownCircleOutline, + arrowUpCircleOutline, + cubeOutline, + flameOutline, + hammerOutline + }); + } + + public ngOnChanges() { + this.activityTypeLabel = translate(this.activityType); + } +} diff --git a/libs/ui/src/lib/activity-type/index.ts b/libs/ui/src/lib/activity-type/index.ts new file mode 100644 index 000000000..9fcf60eeb --- /dev/null +++ b/libs/ui/src/lib/activity-type/index.ts @@ -0,0 +1 @@ +export * from './activity-type.component'; diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts new file mode 100644 index 000000000..c2ad2462e --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.component.ts @@ -0,0 +1,107 @@ +import { GfSymbolPipe } from '@ghostfolio/common/pipes'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; + +import { FocusableOption } from '@angular/cdk/a11y'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostBinding, + Input, + OnChanges, + Output, + ViewChild +} from '@angular/core'; +import { Params, RouterModule } from '@angular/router'; + +import { SearchMode } from '../enums/search-mode'; +import { + AssetSearchResultItem, + SearchResultItem +} from '../interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [GfSymbolPipe, RouterModule], + selector: 'gf-assistant-list-item', + styleUrls: ['./assistant-list-item.scss'], + templateUrl: './assistant-list-item.html' +}) +export class GfAssistantListItemComponent + implements FocusableOption, OnChanges +{ + @HostBinding('attr.tabindex') tabindex = -1; + @HostBinding('class.has-focus') get getHasFocus() { + return this.hasFocus; + } + + @Input() item: SearchResultItem; + + @Output() clicked = new EventEmitter(); + + @ViewChild('link') public linkElement: ElementRef; + + public hasFocus = false; + public queryParams: Params; + public routerLink: string[]; + + public constructor(private changeDetectorRef: ChangeDetectorRef) {} + + public ngOnChanges() { + if (this.item?.mode === SearchMode.ACCOUNT) { + this.queryParams = { + accountDetailDialog: true, + accountId: this.item.id + }; + + this.routerLink = internalRoutes.accounts.routerLink; + } else if (this.item?.mode === SearchMode.ASSET_PROFILE) { + this.queryParams = { + assetProfileDialog: true, + dataSource: this.item.dataSource, + symbol: this.item.symbol + }; + + this.routerLink = + internalRoutes.adminControl.subRoutes.marketData.routerLink; + } else if (this.item?.mode === SearchMode.HOLDING) { + this.queryParams = { + dataSource: this.item.dataSource, + holdingDetailDialog: true, + symbol: this.item.symbol + }; + + this.routerLink = []; + } else if (this.item?.mode === SearchMode.QUICK_LINK) { + this.queryParams = {}; + this.routerLink = this.item.routerLink; + } + } + + public focus() { + this.hasFocus = true; + + this.changeDetectorRef.markForCheck(); + } + + public isAsset(item: SearchResultItem): item is AssetSearchResultItem { + return ( + (item.mode === SearchMode.ASSET_PROFILE || + item.mode === SearchMode.HOLDING) && + !!item.dataSource && + !!item.symbol + ); + } + + public onClick() { + this.clicked.emit(); + } + + public removeFocus() { + this.hasFocus = false; + + this.changeDetectorRef.markForCheck(); + } +} diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html new file mode 100644 index 000000000..fd2c4011d --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.html @@ -0,0 +1,20 @@ +{{ item?.name }} + @if (item && isAsset(item)) { +
+ {{ item?.symbol | gfSymbol }} · {{ item?.currency }} + @if (item?.assetSubClassString) { + · {{ item.assetSubClassString }} + } + @if (item?.mode === 'assetProfile') { + · {{ item.dataSource }} + } + + } +
diff --git a/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.scss b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.scss new file mode 100644 index 000000000..7c33c0ff9 --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant-list-item/assistant-list-item.scss @@ -0,0 +1,28 @@ +:host { + display: block; + + &.has-focus { + background-color: rgba(var(--palette-primary-500), 1); + + a { + color: rgba(var(--light-primary-text)); + font-weight: bold; + + .text-muted { + color: rgba(var(--light-primary-text)) !important; + } + } + } +} + +:host-context(.theme-dark) { + &.has-focus { + a { + color: rgba(var(--dark-primary-text)); + + .text-muted { + color: rgba(var(--dark-primary-text)) !important; + } + } + } +} diff --git a/libs/ui/src/lib/assistant/assistant.component.ts b/libs/ui/src/lib/assistant/assistant.component.ts new file mode 100644 index 000000000..2b0216613 --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant.component.ts @@ -0,0 +1,772 @@ +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { Filter, PortfolioPosition, User } from '@ghostfolio/common/interfaces'; +import { InternalRoute } from '@ghostfolio/common/routes/interfaces/internal-route.interface'; +import { internalRoutes } from '@ghostfolio/common/routes/routes'; +import { AccountWithPlatform, DateRange } from '@ghostfolio/common/types'; +import { AdminService, DataService } from '@ghostfolio/ui/services'; + +import { FocusKeyManager } from '@angular/cdk/a11y'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + HostListener, + Input, + OnChanges, + OnDestroy, + OnInit, + Output, + QueryList, + ViewChild, + ViewChildren +} from '@angular/core'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatMenuTrigger } from '@angular/material/menu'; +import { MatSelectModule } from '@angular/material/select'; +import { RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { AssetClass, DataSource } from '@prisma/client'; +import { differenceInYears, eachYearOfInterval, format } from 'date-fns'; +import Fuse from 'fuse.js'; +import { addIcons } from 'ionicons'; +import { + closeCircleOutline, + closeOutline, + searchOutline +} from 'ionicons/icons'; +import { isFunction } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { EMPTY, Observable, Subject, merge, of } from 'rxjs'; +import { + catchError, + debounceTime, + distinctUntilChanged, + map, + scan, + switchMap, + takeUntil, + tap +} from 'rxjs/operators'; + +import { translate } from '../i18n'; +import { + GfPortfolioFilterFormComponent, + PortfolioFilterFormValue +} from '../portfolio-filter-form'; +import { GfAssistantListItemComponent } from './assistant-list-item/assistant-list-item.component'; +import { SearchMode } from './enums/search-mode'; +import { + DateRangeOption, + SearchResultItem, + SearchResults +} from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + GfAssistantListItemComponent, + GfPortfolioFilterFormComponent, + IonIcon, + MatButtonModule, + MatFormFieldModule, + MatSelectModule, + NgxSkeletonLoaderModule, + ReactiveFormsModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-assistant', + styleUrls: ['./assistant.scss'], + templateUrl: './assistant.html' +}) +export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit { + @HostListener('document:keydown', ['$event']) onKeydown( + event: KeyboardEvent + ) { + if (!this.isOpen) { + return; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + for (const item of this.assistantListItems) { + item.removeFocus(); + } + + this.keyManager.onKeydown(event); + + const currentAssistantListItem = this.getCurrentAssistantListItem(); + + if (currentAssistantListItem?.linkElement) { + currentAssistantListItem.linkElement.nativeElement?.scrollIntoView({ + behavior: 'smooth', + block: 'center' + }); + } + } else if (event.key === 'Enter') { + const currentAssistantListItem = this.getCurrentAssistantListItem(); + + if (currentAssistantListItem?.linkElement) { + currentAssistantListItem.linkElement.nativeElement?.click(); + event.stopPropagation(); + } + } + } + + @Input() deviceType: string; + @Input() hasPermissionToAccessAdminControl: boolean; + @Input() hasPermissionToChangeDateRange: boolean; + @Input() hasPermissionToChangeFilters: boolean; + @Input() user: User; + + @Output() closed = new EventEmitter(); + @Output() dateRangeChanged = new EventEmitter(); + @Output() filtersChanged = new EventEmitter(); + + @ViewChild('menuTrigger') menuTriggerElement: MatMenuTrigger; + @ViewChild('search', { static: true }) searchElement: ElementRef; + + @ViewChildren(GfAssistantListItemComponent) + assistantListItems: QueryList; + + public static readonly SEARCH_RESULTS_DEFAULT_LIMIT = 5; + + public accounts: AccountWithPlatform[] = []; + public assetClasses: Filter[] = []; + public dateRangeFormControl = new FormControl(undefined); + public dateRangeOptions: DateRangeOption[] = []; + public holdings: PortfolioPosition[] = []; + public isLoading = { + accounts: false, + assetProfiles: false, + holdings: false, + quickLinks: false + }; + public isOpen = false; + public placeholder = $localize`Find account, holding or page...`; + public portfolioFilterFormControl = new FormControl( + { + account: null, + assetClass: null, + holding: null, + tag: null + } + ); + public searchFormControl = new FormControl(''); + public searchResults: SearchResults = { + accounts: [], + assetProfiles: [], + holdings: [], + quickLinks: [] + }; + public tags: Filter[] = []; + + private readonly PRESELECTION_DELAY = 100; + + private filterTypes: Filter['type'][] = [ + 'ACCOUNT', + 'ASSET_CLASS', + 'DATA_SOURCE', + 'SYMBOL', + 'TAG' + ]; + + private keyManager: FocusKeyManager; + private preselectionTimeout: ReturnType; + private unsubscribeSubject = new Subject(); + + public constructor( + private adminService: AdminService, + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService + ) { + addIcons({ closeCircleOutline, closeOutline, searchOutline }); + } + + public ngOnInit() { + this.assetClasses = Object.keys(AssetClass).map((assetClass) => { + return { + id: assetClass, + label: translate(assetClass), + type: 'ASSET_CLASS' + }; + }); + + this.searchFormControl.valueChanges + .pipe( + map((searchTerm) => { + this.isLoading = { + accounts: true, + assetProfiles: true, + holdings: true, + quickLinks: true + }; + this.searchResults = { + accounts: [], + assetProfiles: [], + holdings: [], + quickLinks: [] + }; + + this.changeDetectorRef.markForCheck(); + + return searchTerm?.trim(); + }), + debounceTime(300), + distinctUntilChanged(), + switchMap((searchTerm) => { + const results = { + accounts: [], + assetProfiles: [], + holdings: [], + quickLinks: [] + } as SearchResults; + + if (!searchTerm) { + return of(results).pipe( + tap(() => { + this.isLoading = { + accounts: false, + assetProfiles: false, + holdings: false, + quickLinks: false + }; + }) + ); + } + + const accounts$: Observable> = + this.searchAccounts(searchTerm).pipe( + map((accounts) => ({ + accounts: accounts.slice( + 0, + GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + ) + })), + catchError((error) => { + console.error('Error fetching accounts for assistant:', error); + return of({ accounts: [] as SearchResultItem[] }); + }), + tap(() => { + this.isLoading.accounts = false; + this.changeDetectorRef.markForCheck(); + }) + ); + + const assetProfiles$: Observable> = this + .hasPermissionToAccessAdminControl + ? this.searchAssetProfiles(searchTerm).pipe( + map((assetProfiles) => ({ + assetProfiles: assetProfiles.slice( + 0, + GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + ) + })), + catchError((error) => { + console.error( + 'Error fetching asset profiles for assistant:', + error + ); + return of({ assetProfiles: [] as SearchResultItem[] }); + }), + tap(() => { + this.isLoading.assetProfiles = false; + this.changeDetectorRef.markForCheck(); + }) + ) + : of({ assetProfiles: [] as SearchResultItem[] }).pipe( + tap(() => { + this.isLoading.assetProfiles = false; + this.changeDetectorRef.markForCheck(); + }) + ); + + const holdings$: Observable> = + this.searchHoldings(searchTerm).pipe( + map((holdings) => ({ + holdings: holdings.slice( + 0, + GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + ) + })), + catchError((error) => { + console.error('Error fetching holdings for assistant:', error); + return of({ holdings: [] as SearchResultItem[] }); + }), + tap(() => { + this.isLoading.holdings = false; + this.changeDetectorRef.markForCheck(); + }) + ); + + const quickLinks$: Observable> = of( + this.searchQuickLinks(searchTerm) + ).pipe( + map((quickLinks) => ({ + quickLinks: quickLinks.slice( + 0, + GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + ) + })), + tap(() => { + this.isLoading.quickLinks = false; + this.changeDetectorRef.markForCheck(); + }) + ); + + return merge(accounts$, assetProfiles$, holdings$, quickLinks$).pipe( + scan( + (acc: SearchResults, curr: Partial) => ({ + ...acc, + ...curr + }), + { + accounts: [], + assetProfiles: [], + holdings: [], + quickLinks: [] + } as SearchResults + ) + ); + }), + takeUntil(this.unsubscribeSubject) + ) + .subscribe({ + next: (searchResults) => { + this.searchResults = searchResults; + + this.preselectFirstItem(); + + this.changeDetectorRef.markForCheck(); + }, + error: (error) => { + console.error('Assistant search stream error:', error); + this.searchResults = { + accounts: [], + assetProfiles: [], + holdings: [], + quickLinks: [] + }; + this.changeDetectorRef.markForCheck(); + } + }); + } + + public ngOnChanges() { + this.dateRangeOptions = [ + { + label: $localize`Today`, + value: '1d' + }, + { + label: $localize`Week to date` + ' (' + $localize`WTD` + ')', + value: 'wtd' + }, + { + label: $localize`Month to date` + ' (' + $localize`MTD` + ')', + value: 'mtd' + }, + { + label: $localize`Year to date` + ' (' + $localize`YTD` + ')', + value: 'ytd' + } + ]; + + if ( + this.user?.dateOfFirstActivity && + differenceInYears(new Date(), this.user.dateOfFirstActivity) >= 1 + ) { + this.dateRangeOptions.push({ + label: '1 ' + $localize`year` + ' (' + $localize`1Y` + ')', + value: '1y' + }); + } + + if (this.user?.settings?.isExperimentalFeatures) { + this.dateRangeOptions = this.dateRangeOptions.concat( + eachYearOfInterval({ + end: new Date(), + start: this.user?.dateOfFirstActivity ?? new Date() + }) + .map((date) => { + return { label: format(date, 'yyyy'), value: format(date, 'yyyy') }; + }) + .slice(0, -1) + .reverse() + ); + } + + if ( + this.user?.dateOfFirstActivity && + differenceInYears(new Date(), this.user.dateOfFirstActivity) >= 5 + ) { + this.dateRangeOptions.push({ + label: '5 ' + $localize`years` + ' (' + $localize`5Y` + ')', + value: '5y' + }); + } + + this.dateRangeOptions.push({ + label: $localize`Max`, + value: 'max' + }); + + this.dateRangeFormControl.disable({ emitEvent: false }); + + if (this.hasPermissionToChangeDateRange) { + this.dateRangeFormControl.enable({ emitEvent: false }); + } + + this.dateRangeFormControl.setValue(this.user?.settings?.dateRange ?? null); + + if (this.hasPermissionToChangeFilters) { + this.portfolioFilterFormControl.enable({ emitEvent: false }); + } else { + this.portfolioFilterFormControl.disable({ emitEvent: false }); + } + + this.tags = + this.user?.tags + ?.filter(({ isUsed }) => { + return isUsed; + }) + .map(({ id, name }) => { + return { + id, + label: translate(name), + type: 'TAG' + }; + }) ?? []; + } + + public initialize() { + this.isLoading = { + accounts: true, + assetProfiles: true, + holdings: true, + quickLinks: true + }; + this.keyManager = new FocusKeyManager(this.assistantListItems).withWrap(); + this.searchResults = { + accounts: [], + assetProfiles: [], + holdings: [], + quickLinks: [] + }; + + for (const item of this.assistantListItems) { + item.removeFocus(); + } + + this.searchFormControl.setValue(''); + setTimeout(() => { + this.searchElement?.nativeElement?.focus(); + }); + + this.isLoading = { + accounts: false, + assetProfiles: false, + holdings: false, + quickLinks: false + }; + this.setIsOpen(true); + + this.dataService + .fetchPortfolioHoldings() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ holdings }) => { + this.holdings = holdings + .filter(({ assetSubClass }) => { + return !['CASH'].includes(assetSubClass); + }) + .sort((a, b) => { + return a.name?.localeCompare(b.name); + }); + + this.setPortfolioFilterFormValues(); + + this.changeDetectorRef.markForCheck(); + }); + } + + public onApplyFilters() { + const filterValue = this.portfolioFilterFormControl.value; + + this.filtersChanged.emit([ + { + id: filterValue?.account, + type: 'ACCOUNT' + }, + { + id: filterValue?.assetClass, + type: 'ASSET_CLASS' + }, + { + id: filterValue?.holding?.dataSource, + type: 'DATA_SOURCE' + }, + { + id: filterValue?.holding?.symbol, + type: 'SYMBOL' + }, + { + id: filterValue?.tag, + type: 'TAG' + } + ]); + + this.onCloseAssistant(); + } + + public onChangeDateRange(dateRangeString: string) { + this.dateRangeChanged.emit(dateRangeString); + } + + public onCloseAssistant() { + this.portfolioFilterFormControl.reset(); + this.setIsOpen(false); + + this.closed.emit(); + } + + public onResetFilters() { + this.portfolioFilterFormControl.reset(); + + this.filtersChanged.emit( + this.filterTypes.map((type) => { + return { + type, + id: null + }; + }) + ); + + this.onCloseAssistant(); + } + + public setIsOpen(aIsOpen: boolean) { + this.isOpen = aIsOpen; + } + + public ngOnDestroy() { + if (this.preselectionTimeout) { + clearTimeout(this.preselectionTimeout); + } + + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private getCurrentAssistantListItem() { + return this.assistantListItems.find(({ getHasFocus }) => { + return getHasFocus; + }); + } + + private getFirstSearchResultItem() { + if (this.searchResults.quickLinks?.length > 0) { + return this.searchResults.quickLinks[0]; + } + + if (this.searchResults.accounts?.length > 0) { + return this.searchResults.accounts[0]; + } + + if (this.searchResults.holdings?.length > 0) { + return this.searchResults.holdings[0]; + } + + if (this.searchResults.assetProfiles?.length > 0) { + return this.searchResults.assetProfiles[0]; + } + + return null; + } + + private preselectFirstItem() { + if (this.preselectionTimeout) { + clearTimeout(this.preselectionTimeout); + } + + this.preselectionTimeout = setTimeout(() => { + if (!this.isOpen || !this.searchFormControl.value) { + return; + } + + const firstItem = this.getFirstSearchResultItem(); + + if (!firstItem) { + return; + } + + for (const item of this.assistantListItems) { + item.removeFocus(); + } + + this.keyManager.setFirstItemActive(); + + const currentFocusedItem = this.getCurrentAssistantListItem(); + + if (currentFocusedItem) { + currentFocusedItem.focus(); + } + + this.changeDetectorRef.markForCheck(); + }, this.PRESELECTION_DELAY); + } + + private searchAccounts(aSearchTerm: string): Observable { + return this.dataService + .fetchAccounts({ + filters: [ + { + id: aSearchTerm, + type: 'SEARCH_QUERY' + } + ] + }) + .pipe( + catchError(() => { + return EMPTY; + }), + map(({ accounts }) => { + return accounts.map(({ id, name }) => { + return { + id, + name, + routerLink: internalRoutes.accounts.routerLink, + mode: SearchMode.ACCOUNT as const + }; + }); + }), + takeUntil(this.unsubscribeSubject) + ); + } + + private searchAssetProfiles( + aSearchTerm: string + ): Observable { + return this.adminService + .fetchAdminMarketData({ + filters: [ + { + id: aSearchTerm, + type: 'SEARCH_QUERY' + } + ], + take: GfAssistantComponent.SEARCH_RESULTS_DEFAULT_LIMIT + }) + .pipe( + catchError(() => { + return EMPTY; + }), + map(({ marketData }) => { + return marketData.map( + ({ assetSubClass, currency, dataSource, name, symbol }) => { + return { + currency, + dataSource, + name, + symbol, + assetSubClassString: translate(assetSubClass), + mode: SearchMode.ASSET_PROFILE as const + }; + } + ); + }), + takeUntil(this.unsubscribeSubject) + ); + } + + private searchHoldings(aSearchTerm: string): Observable { + return this.dataService + .fetchPortfolioHoldings({ + filters: [ + { + id: aSearchTerm, + type: 'SEARCH_QUERY' + } + ] + }) + .pipe( + catchError(() => { + return EMPTY; + }), + map(({ holdings }) => { + return holdings.map( + ({ assetSubClass, currency, dataSource, name, symbol }) => { + return { + currency, + dataSource, + name, + symbol, + assetSubClassString: translate(assetSubClass), + mode: SearchMode.HOLDING as const + }; + } + ); + }), + takeUntil(this.unsubscribeSubject) + ); + } + + private searchQuickLinks(aSearchTerm: string): SearchResultItem[] { + const searchTerm = aSearchTerm.toLowerCase(); + + const allRoutes = Object.values(internalRoutes) + .filter(({ excludeFromAssistant }) => { + if (isFunction(excludeFromAssistant)) { + return excludeFromAssistant(this.user); + } + + return !excludeFromAssistant; + }) + .reduce((acc, route) => { + acc.push(route); + if (route.subRoutes) { + acc.push(...Object.values(route.subRoutes)); + } + return acc; + }, [] as InternalRoute[]); + + const fuse = new Fuse(allRoutes, { + keys: ['title'], + threshold: 0.3 + }); + + return fuse.search(searchTerm).map(({ item: { routerLink, title } }) => { + return { + routerLink, + mode: SearchMode.QUICK_LINK as const, + name: title + }; + }); + } + + private setPortfolioFilterFormValues() { + const dataSource = this.user?.settings?.[ + 'filters.dataSource' + ] as DataSource; + const symbol = this.user?.settings?.['filters.symbol']; + const selectedHolding = this.holdings.find((holding) => { + return ( + getAssetProfileIdentifier({ + dataSource: holding.dataSource, + symbol: holding.symbol + }) === getAssetProfileIdentifier({ dataSource, symbol }) + ); + }); + + this.portfolioFilterFormControl.setValue({ + account: this.user?.settings?.['filters.accounts']?.[0] ?? null, + assetClass: this.user?.settings?.['filters.assetClasses']?.[0] ?? null, + holding: selectedHolding ?? null, + tag: this.user?.settings?.['filters.tags']?.[0] ?? null + }); + } +} diff --git a/libs/ui/src/lib/assistant/assistant.html b/libs/ui/src/lib/assistant/assistant.html new file mode 100644 index 000000000..307269262 --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant.html @@ -0,0 +1,222 @@ +
+
+
+ + + @if (deviceType !== 'mobile' && !searchFormControl.value) { +
/
+ } + @if (searchFormControl.value) { + + } @else { + + } +
+ @if (searchFormControl.value) { +
+ @if ( + !isLoading.accounts && + !isLoading.assetProfiles && + !isLoading.holdings && + !isLoading.quickLinks && + searchResults.accounts?.length === 0 && + searchResults.assetProfiles?.length === 0 && + searchResults.holdings?.length === 0 && + searchResults.quickLinks?.length === 0 + ) { +
No results found...
+ } @else { + @if ( + isLoading.quickLinks || searchResults?.quickLinks?.length !== 0 + ) { +
+
+ Quick Links +
+ @for ( + searchResultItem of searchResults.quickLinks; + track searchResultItem + ) { + + } + @if (isLoading.quickLinks) { + + } +
+ } + @if (isLoading.accounts || searchResults?.accounts?.length !== 0) { +
+
+ Accounts +
+ @for ( + searchResultItem of searchResults.accounts; + track searchResultItem + ) { + + } + @if (isLoading.accounts) { + + } +
+ } + @if (isLoading.holdings || searchResults?.holdings?.length !== 0) { +
+
+ Holdings +
+ @for ( + searchResultItem of searchResults.holdings; + track searchResultItem + ) { + + } + @if (isLoading.holdings) { + + } +
+ } + @if ( + hasPermissionToAccessAdminControl && + (isLoading.assetProfiles || + searchResults?.assetProfiles?.length !== 0) + ) { +
+
+ Asset Profiles +
+ @for ( + searchResultItem of searchResults.assetProfiles; + track searchResultItem + ) { + + } + @if (isLoading.assetProfiles) { + + } +
+ } + } +
+ } +
+ @if (!searchFormControl.value) { +
+ + Date Range + + @for ( + dateRangeOption of dateRangeOptions; + track dateRangeOption.value + ) { + {{ + dateRangeOption.label + }} + } + + +
+
+ +
+ + + +
+
+ } +
diff --git a/libs/ui/src/lib/assistant/assistant.scss b/libs/ui/src/lib/assistant/assistant.scss new file mode 100644 index 000000000..3630978c1 --- /dev/null +++ b/libs/ui/src/lib/assistant/assistant.scss @@ -0,0 +1,71 @@ +:host { + display: block; + + .date-range-selector-container { + border-bottom: 1px solid rgba(var(--dark-dividers)); + } + + .result-container { + max-height: 15rem; + + .title { + align-items: center; + display: flex; + font-size: 0.75rem; + display: flex; + margin: 0; + position: relative; + white-space: nowrap; + + &::after { + content: ''; + flex-grow: 1; + height: 1px; + background: rgba(var(--dark-dividers)); + margin-left: 0.25rem; + } + } + } + + .search-container { + border-bottom: 1px solid rgba(var(--dark-dividers)); + height: 2.5rem; + + input { + background: transparent; + outline: 0; + } + + .hot-key-hint { + border: 1px solid rgba(var(--dark-dividers)); + border-radius: 0.25rem; + cursor: default; + } + } +} + +:host-context(.theme-dark) { + .date-range-selector-container { + border-color: rgba(var(--light-dividers)); + } + + .result-container { + .title { + &::after { + background: rgba(var(--light-dividers)); + } + } + } + + .search-container { + border-color: rgba(var(--light-dividers)); + + input { + color: rgba(var(--light-primary-text)); + } + + .hot-key-hint { + border-color: rgba(var(--light-dividers)); + } + } +} diff --git a/libs/ui/src/lib/assistant/enums/search-mode.ts b/libs/ui/src/lib/assistant/enums/search-mode.ts new file mode 100644 index 000000000..af9c4dd45 --- /dev/null +++ b/libs/ui/src/lib/assistant/enums/search-mode.ts @@ -0,0 +1,6 @@ +export enum SearchMode { + ACCOUNT = 'account', + ASSET_PROFILE = 'assetProfile', + HOLDING = 'holding', + QUICK_LINK = 'quickLink' +} diff --git a/libs/ui/src/lib/assistant/index.ts b/libs/ui/src/lib/assistant/index.ts new file mode 100644 index 000000000..aded19f26 --- /dev/null +++ b/libs/ui/src/lib/assistant/index.ts @@ -0,0 +1 @@ +export * from './assistant.component'; diff --git a/libs/ui/src/lib/assistant/interfaces/interfaces.ts b/libs/ui/src/lib/assistant/interfaces/interfaces.ts new file mode 100644 index 000000000..c00a2b832 --- /dev/null +++ b/libs/ui/src/lib/assistant/interfaces/interfaces.ts @@ -0,0 +1,42 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; +import { AccountWithValue, DateRange } from '@ghostfolio/common/types'; + +import { SearchMode } from '../enums/search-mode'; + +export interface AccountSearchResultItem extends Pick< + AccountWithValue, + 'id' | 'name' +> { + mode: SearchMode.ACCOUNT; + routerLink: string[]; +} + +export interface AssetSearchResultItem extends AssetProfileIdentifier { + assetSubClassString: string; + currency: string; + mode: SearchMode.ASSET_PROFILE | SearchMode.HOLDING; + name: string; +} + +export interface DateRangeOption { + label: string; + value: DateRange; +} + +export interface QuickLinkSearchResultItem { + mode: SearchMode.QUICK_LINK; + name: string; + routerLink: string[]; +} + +export type SearchResultItem = + | AccountSearchResultItem + | AssetSearchResultItem + | QuickLinkSearchResultItem; + +export interface SearchResults { + accounts: SearchResultItem[]; + assetProfiles: SearchResultItem[]; + holdings: SearchResultItem[]; + quickLinks: SearchResultItem[]; +} diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.scss b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.scss new file mode 100644 index 000000000..02f5d58a1 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.scss @@ -0,0 +1,12 @@ +:host { + display: block; + + .mat-mdc-dialog-content { + max-height: unset; + + gf-line-chart { + aspect-ratio: 16 / 9; + margin: 0 -0.5rem; + } + } +} diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts new file mode 100644 index 000000000..2f4c18288 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.component.ts @@ -0,0 +1,96 @@ +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + AdminMarketDataDetails, + LineChartItem +} from '@ghostfolio/common/interfaces'; +import { GfDialogFooterComponent } from '@ghostfolio/ui/dialog-footer'; +import { GfDialogHeaderComponent } from '@ghostfolio/ui/dialog-header'; +import { DataService } from '@ghostfolio/ui/services'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Inject, + OnDestroy, + OnInit +} from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef +} from '@angular/material/dialog'; +import { format } from 'date-fns'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +import { GfLineChartComponent } from '../../line-chart/line-chart.component'; +import { GfValueComponent } from '../../value/value.component'; +import { BenchmarkDetailDialogParams } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'd-flex flex-column h-100' }, + imports: [ + GfDialogFooterComponent, + GfDialogHeaderComponent, + GfLineChartComponent, + GfValueComponent, + MatDialogModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-benchmark-detail-dialog', + styleUrls: ['./benchmark-detail-dialog.component.scss'], + templateUrl: 'benchmark-detail-dialog.html' +}) +export class GfBenchmarkDetailDialogComponent implements OnDestroy, OnInit { + public assetProfile: AdminMarketDataDetails['assetProfile']; + public historicalDataItems: LineChartItem[]; + public value: number; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: BenchmarkDetailDialogParams + ) {} + + public ngOnInit() { + this.dataService + .fetchAsset({ + dataSource: this.data.dataSource, + symbol: this.data.symbol + }) + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(({ assetProfile, marketData }) => { + this.assetProfile = assetProfile; + + this.historicalDataItems = marketData.map( + ({ date, marketPrice }, index) => { + if (marketData.length - 1 === index) { + this.value = marketPrice; + } + + return { + date: format(date, DATE_FORMAT), + value: marketPrice + }; + } + ); + + this.changeDetectorRef.markForCheck(); + }); + } + + public onClose() { + this.dialogRef.close(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html new file mode 100644 index 000000000..a8cf3730c --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/benchmark-detail-dialog.html @@ -0,0 +1,39 @@ + + +
+
+
+
+ +
+
+ + +
+
+ + diff --git a/libs/ui/src/lib/benchmark/benchmark-detail-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..291f4c973 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark-detail-dialog/interfaces/interfaces.ts @@ -0,0 +1,11 @@ +import { ColorScheme } from '@ghostfolio/common/types'; + +import { DataSource } from '@prisma/client'; + +export interface BenchmarkDetailDialogParams { + colorScheme: ColorScheme; + dataSource: DataSource; + deviceType: string; + locale: string; + symbol: string; +} diff --git a/libs/ui/src/lib/benchmark/benchmark.component.html b/libs/ui/src/lib/benchmark/benchmark.component.html new file mode 100644 index 000000000..ab6db8887 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.html @@ -0,0 +1,211 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + +
+ {{ element?.name }} +
+ @if (showSymbol) { +
+ {{ element?.symbol }} +
+ } +
+ 50-Day Trend + +
+ @if (element?.trend50d !== 'UNKNOWN') { + + } +
+
+ 200-Day Trend + +
+ @if (element?.trend200d !== 'UNKNOWN') { + + } +
+
+ Last All Time High + +
+ @if (element?.performances?.allTimeHigh?.date) { + + } +
+
+ Change from All Time High + from ATH + + @if (isNumber(element?.performances?.allTimeHigh?.performancePercent)) { + + } + + @if (element?.marketCondition) { +
+ {{ resolveMarketCondition(element.marketCondition).emoji }} +
+ } +
+ @if (hasPermissionToDeleteItem) { + + } + + + +
+
+ +@if (isLoading) { + +} @else if (benchmarks?.length === 0) { +
+ No data available +
+} diff --git a/libs/ui/src/lib/benchmark/benchmark.component.scss b/libs/ui/src/lib/benchmark/benchmark.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/benchmark/benchmark.component.ts b/libs/ui/src/lib/benchmark/benchmark.component.ts new file mode 100644 index 000000000..adef1f41b --- /dev/null +++ b/libs/ui/src/lib/benchmark/benchmark.component.ts @@ -0,0 +1,185 @@ +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +import { + getLocale, + getLowercase, + resolveMarketCondition +} from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + Benchmark, + User +} from '@ghostfolio/common/interfaces'; +import { NotificationService } from '@ghostfolio/ui/notifications'; + +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + ViewChild +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { ellipsisHorizontal, trashOutline } from 'ionicons/icons'; +import { isNumber } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { Subject, takeUntil } from 'rxjs'; + +import { translate } from '../i18n'; +import { GfTrendIndicatorComponent } from '../trend-indicator/trend-indicator.component'; +import { GfValueComponent } from '../value/value.component'; +import { GfBenchmarkDetailDialogComponent } from './benchmark-detail-dialog/benchmark-detail-dialog.component'; +import { BenchmarkDetailDialogParams } from './benchmark-detail-dialog/interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfTrendIndicatorComponent, + GfValueComponent, + IonIcon, + MatButtonModule, + MatMenuModule, + MatSortModule, + MatTableModule, + NgxSkeletonLoaderModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-benchmark', + styleUrls: ['./benchmark.component.scss'], + templateUrl: './benchmark.component.html' +}) +export class GfBenchmarkComponent implements OnChanges, OnDestroy { + @Input() benchmarks: Benchmark[]; + @Input() deviceType: string; + @Input() hasPermissionToDeleteItem: boolean; + @Input() locale = getLocale(); + @Input() showSymbol = true; + @Input() user: User; + + @Output() itemDeleted = new EventEmitter(); + + @ViewChild(MatSort) sort: MatSort; + + public dataSource = new MatTableDataSource([]); + public displayedColumns = [ + 'name', + 'date', + 'change', + 'marketCondition', + 'actions' + ]; + public isLoading = true; + public isNumber = isNumber; + public resolveMarketCondition = resolveMarketCondition; + public translate = translate; + + private unsubscribeSubject = new Subject(); + + public constructor( + private dialog: MatDialog, + private notificationService: NotificationService, + private route: ActivatedRoute, + private router: Router + ) { + this.route.queryParams + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((params) => { + if ( + params['benchmarkDetailDialog'] && + params['dataSource'] && + params['symbol'] + ) { + this.openBenchmarkDetailDialog({ + dataSource: params['dataSource'], + symbol: params['symbol'] + }); + } + }); + + addIcons({ ellipsisHorizontal, trashOutline }); + } + + public ngOnChanges() { + if (this.benchmarks) { + this.dataSource.data = this.benchmarks; + this.dataSource.sortingDataAccessor = getLowercase; + + this.dataSource.sort = this.sort; + + this.isLoading = false; + } + + if (this.user?.settings?.isExperimentalFeatures) { + this.displayedColumns = [ + 'name', + 'trend50d', + 'trend200d', + 'date', + 'change', + 'marketCondition', + 'actions' + ]; + } + } + + public onDeleteItem({ dataSource, symbol }: AssetProfileIdentifier) { + this.notificationService.confirm({ + confirmFn: () => { + this.itemDeleted.emit({ dataSource, symbol }); + }, + confirmType: ConfirmationDialogType.Warn, + title: $localize`Do you really want to delete this item?` + }); + } + + public onOpenBenchmarkDialog({ dataSource, symbol }: AssetProfileIdentifier) { + this.router.navigate([], { + queryParams: { dataSource, symbol, benchmarkDetailDialog: true } + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private openBenchmarkDetailDialog({ + dataSource, + symbol + }: AssetProfileIdentifier) { + const dialogRef = this.dialog.open< + GfBenchmarkDetailDialogComponent, + BenchmarkDetailDialogParams + >(GfBenchmarkDetailDialogComponent, { + data: { + dataSource, + symbol, + colorScheme: this.user?.settings?.colorScheme, + deviceType: this.deviceType, + locale: this.locale + }, + height: this.deviceType === 'mobile' ? '98vh' : undefined, + width: this.deviceType === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.router.navigate(['.'], { relativeTo: this.route }); + }); + } +} diff --git a/libs/ui/src/lib/benchmark/index.ts b/libs/ui/src/lib/benchmark/index.ts new file mode 100644 index 000000000..87fdc713c --- /dev/null +++ b/libs/ui/src/lib/benchmark/index.ts @@ -0,0 +1 @@ +export * from './benchmark.component'; diff --git a/libs/ui/src/lib/carousel/carousel-item.directive.ts b/libs/ui/src/lib/carousel/carousel-item.directive.ts new file mode 100644 index 000000000..38c3ab212 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel-item.directive.ts @@ -0,0 +1,8 @@ +import { Directive, ElementRef } from '@angular/core'; + +@Directive({ + selector: '[gfCarouselItem]' +}) +export class CarouselItemDirective { + public constructor(readonly element: ElementRef) {} +} diff --git a/libs/ui/src/lib/carousel/carousel.component.html b/libs/ui/src/lib/carousel/carousel.component.html new file mode 100644 index 000000000..3c0945483 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.html @@ -0,0 +1,31 @@ +@if (this.showPrevArrow) { + +} + +
+ +
+ +@if (this.showNextArrow) { + +} diff --git a/libs/ui/src/lib/carousel/carousel.component.scss b/libs/ui/src/lib/carousel/carousel.component.scss new file mode 100644 index 000000000..05ab9ff93 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.scss @@ -0,0 +1,35 @@ +:host { + display: block; + position: relative; + + ::ng-deep { + [gfCarouselItem] { + flex-shrink: 0; + width: 100%; + } + } + + button { + top: 50%; + transform: translateY(-50%); + z-index: 1; + + &.carousel-nav-prev { + left: -0.5rem; + } + + &.carousel-nav-next { + right: -0.5rem; + } + } + + .carousel-content { + flex-direction: row; + outline: none; + transition: transform 0.5s ease-in-out; + + .animations-disabled & { + transition: none; + } + } +} diff --git a/libs/ui/src/lib/carousel/carousel.component.ts b/libs/ui/src/lib/carousel/carousel.component.ts new file mode 100644 index 000000000..4ecd12c79 --- /dev/null +++ b/libs/ui/src/lib/carousel/carousel.component.ts @@ -0,0 +1,108 @@ +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + contentChildren, + ElementRef, + HostBinding, + Inject, + Input, + Optional, + ViewChild +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { ANIMATION_MODULE_TYPE } from '@angular/platform-browser/animations'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { chevronBackOutline, chevronForwardOutline } from 'ionicons/icons'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IonIcon, MatButtonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-carousel', + styleUrls: ['./carousel.component.scss'], + templateUrl: './carousel.component.html' +}) +export class GfCarouselComponent { + @HostBinding('class.animations-disabled') + public readonly animationsDisabled: boolean; + + @Input('aria-label') public ariaLabel: string | undefined; + + @ViewChild('list') public list!: ElementRef; + + public items = contentChildren('carouselItem', { read: ElementRef }); + public showPrevArrow = false; + public showNextArrow = true; + + private index = 0; + private position = 0; + + public constructor( + @Optional() @Inject(ANIMATION_MODULE_TYPE) animationsModule?: string + ) { + this.animationsDisabled = animationsModule === 'NoopAnimations'; + + addIcons({ chevronBackOutline, chevronForwardOutline }); + } + + public next() { + for (let i = this.index; i < this.items().length; i++) { + if (this.isOutOfView(i)) { + this.index = i; + this.scrollToActiveItem(); + break; + } + } + } + + public previous() { + for (let i = this.index; i > -1; i--) { + if (this.isOutOfView(i)) { + this.index = i; + this.scrollToActiveItem(); + break; + } + } + } + + private isOutOfView(index: number, side?: 'start' | 'end') { + const { offsetWidth, offsetLeft } = this.items()[index].nativeElement; + + if ((!side || side === 'start') && offsetLeft - this.position < 0) { + return true; + } + + return ( + (!side || side === 'end') && + offsetWidth + offsetLeft - this.position > + this.list.nativeElement.clientWidth + ); + } + + private scrollToActiveItem() { + if (!this.isOutOfView(this.index)) { + return; + } + + let targetItemIndex = this.index; + + if (this.index > 0 && !this.isOutOfView(this.index - 1)) { + targetItemIndex = + this.items().findIndex((_, i) => !this.isOutOfView(i)) + 1; + } + + this.position = this.items()[targetItemIndex].nativeElement.offsetLeft; + this.list.nativeElement.style.transform = `translateX(-${this.position}px)`; + this.showPrevArrow = this.index > 0; + this.showNextArrow = false; + + for (let i = this.items().length - 1; i > -1; i--) { + if (this.isOutOfView(i, 'end')) { + this.showNextArrow = true; + break; + } + } + } +} diff --git a/libs/ui/src/lib/carousel/index.ts b/libs/ui/src/lib/carousel/index.ts new file mode 100644 index 000000000..3cd42148e --- /dev/null +++ b/libs/ui/src/lib/carousel/index.ts @@ -0,0 +1 @@ +export * from './carousel.component'; diff --git a/libs/ui/src/lib/chart/chart.registry.ts b/libs/ui/src/lib/chart/chart.registry.ts new file mode 100644 index 000000000..465d6e716 --- /dev/null +++ b/libs/ui/src/lib/chart/chart.registry.ts @@ -0,0 +1,29 @@ +import { getTooltipPositionerMapTop } from '@ghostfolio/common/chart-helper'; + +import { Tooltip, TooltipPositionerFunction, ChartType } from 'chart.js'; + +interface VerticalHoverLinePluginOptions { + color?: string; + width?: number; +} + +declare module 'chart.js' { + interface PluginOptionsByType { + verticalHoverLine: TType extends 'line' | 'bar' + ? VerticalHoverLinePluginOptions + : never; + } + interface TooltipPositionerMap { + top: TooltipPositionerFunction; + } +} + +export function registerChartConfiguration() { + if (Tooltip.positioners['top']) { + return; + } + + Tooltip.positioners.top = function (_elements, eventPosition) { + return getTooltipPositionerMapTop(this.chart, eventPosition); + }; +} diff --git a/libs/ui/src/lib/chart/index.ts b/libs/ui/src/lib/chart/index.ts new file mode 100644 index 000000000..2a3d3b358 --- /dev/null +++ b/libs/ui/src/lib/chart/index.ts @@ -0,0 +1 @@ +export * from './chart.registry'; diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.html b/libs/ui/src/lib/currency-selector/currency-selector.component.html new file mode 100644 index 000000000..e07101f9a --- /dev/null +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.html @@ -0,0 +1,18 @@ + + + + @for (currency of filteredCurrencies; track currency) { + + {{ currency }} + + } + diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.scss b/libs/ui/src/lib/currency-selector/currency-selector.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/currency-selector/currency-selector.component.ts b/libs/ui/src/lib/currency-selector/currency-selector.component.ts new file mode 100644 index 000000000..35a911716 --- /dev/null +++ b/libs/ui/src/lib/currency-selector/currency-selector.component.ts @@ -0,0 +1,174 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + Input, + OnDestroy, + OnInit, + ViewChild +} from '@angular/core'; +import { + FormControl, + FormGroupDirective, + FormsModule, + NgControl, + ReactiveFormsModule +} from '@angular/forms'; +import { + MatAutocomplete, + MatAutocompleteModule, + MatAutocompleteSelectedEvent +} from '@angular/material/autocomplete'; +import { + MatFormFieldControl, + MatFormFieldModule +} from '@angular/material/form-field'; +import { MatInput, MatInputModule } from '@angular/material/input'; +import { Subject } from 'rxjs'; +import { map, startWith, takeUntil } from 'rxjs/operators'; + +import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[attr.aria-describedBy]': 'describedBy', + '[id]': 'id' + }, + imports: [ + FormsModule, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule + ], + providers: [ + { + provide: MatFormFieldControl, + useExisting: GfCurrencySelectorComponent + } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-currency-selector', + styleUrls: ['./currency-selector.component.scss'], + templateUrl: 'currency-selector.component.html' +}) +export class GfCurrencySelectorComponent + extends AbstractMatFormField + implements DoCheck, OnDestroy, OnInit +{ + @Input() private currencies: string[] = []; + @Input() private formControlName: string; + + @ViewChild(MatInput) private input: MatInput; + + @ViewChild('currencyAutocomplete') + public currencyAutocomplete: MatAutocomplete; + + public control = new FormControl(); + public filteredCurrencies: string[] = []; + + private unsubscribeSubject = new Subject(); + + public constructor( + public readonly _elementRef: ElementRef, + public readonly _focusMonitor: FocusMonitor, + public readonly changeDetectorRef: ChangeDetectorRef, + private readonly formGroupDirective: FormGroupDirective, + public readonly ngControl: NgControl + ) { + super(_elementRef, _focusMonitor, ngControl); + + this.controlType = 'currency-selector'; + } + + public ngOnInit() { + if (this.disabled) { + this.control.disable(); + } + + const formGroup = this.formGroupDirective.form; + + if (formGroup) { + const control = formGroup.get(this.formControlName); + + if (control) { + this.value = this.currencies.find((value) => { + return value === control.value; + }); + } + } + + this.control.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + if (super.value) { + super.value = null; + } + }); + + this.control.valueChanges + .pipe( + takeUntil(this.unsubscribeSubject), + startWith(''), + map((value) => { + return value ? this.filter(value) : this.currencies.slice(); + }) + ) + .subscribe((values) => { + this.filteredCurrencies = values; + }); + } + + public get empty() { + return this.input?.empty; + } + + public focus() { + this.input.focus(); + } + + public ngDoCheck() { + if (this.ngControl) { + this.validateRequired(); + this.errorState = this.ngControl.invalid && this.ngControl.touched; + this.stateChanges.next(); + } + } + + public onUpdateCurrency(event: MatAutocompleteSelectedEvent) { + super.value = event.option.value; + } + + public set value(value: string) { + this.control.setValue(value); + super.value = value; + } + + public ngOnDestroy() { + super.ngOnDestroy(); + + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private filter(value: string) { + const filterValue = value?.toLowerCase(); + + return this.currencies.filter((currency) => { + return currency.toLowerCase().startsWith(filterValue); + }); + } + + private validateRequired() { + const requiredCheck = super.required ? !super.value : false; + + if (requiredCheck) { + this.ngControl.control.setErrors({ invalidData: true }); + } + } +} diff --git a/libs/ui/src/lib/currency-selector/index.ts b/libs/ui/src/lib/currency-selector/index.ts new file mode 100644 index 000000000..2faa42b80 --- /dev/null +++ b/libs/ui/src/lib/currency-selector/index.ts @@ -0,0 +1 @@ +export * from './currency-selector.component'; diff --git a/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.html b/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.html new file mode 100644 index 000000000..921433620 --- /dev/null +++ b/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.html @@ -0,0 +1,16 @@ + + Market data provided by  + @for ( + dataProviderInfo of dataProviderInfos; + track dataProviderInfo; + let last = $last + ) { + {{ + dataProviderInfo.name + }} + @if (!last) { + ,  + } + } + . + diff --git a/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.scss b/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.scss new file mode 100644 index 000000000..d732e2f02 --- /dev/null +++ b/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.scss @@ -0,0 +1,7 @@ +:host { + display: block; + + a { + color: rgba(var(--palette-primary-500), 1); + } +} diff --git a/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.ts b/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.ts new file mode 100644 index 000000000..2d455c0d6 --- /dev/null +++ b/libs/ui/src/lib/data-provider-credits/data-provider-credits.component.ts @@ -0,0 +1,19 @@ +import { DataProviderInfo } from '@ghostfolio/common/interfaces'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Input +} from '@angular/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-data-provider-credits', + styleUrls: ['./data-provider-credits.component.scss'], + templateUrl: './data-provider-credits.component.html' +}) +export class GfDataProviderCreditsComponent { + @Input() dataProviderInfos: DataProviderInfo[]; +} diff --git a/libs/ui/src/lib/data-provider-credits/index.ts b/libs/ui/src/lib/data-provider-credits/index.ts new file mode 100644 index 000000000..44db13ea5 --- /dev/null +++ b/libs/ui/src/lib/data-provider-credits/index.ts @@ -0,0 +1 @@ +export * from './data-provider-credits.component'; diff --git a/libs/ui/src/lib/dialog-footer/dialog-footer.component.html b/libs/ui/src/lib/dialog-footer/dialog-footer.component.html new file mode 100644 index 000000000..463354cf1 --- /dev/null +++ b/libs/ui/src/lib/dialog-footer/dialog-footer.component.html @@ -0,0 +1,7 @@ +@if (deviceType === 'mobile') { +
+ +
+} diff --git a/libs/ui/src/lib/dialog-footer/dialog-footer.component.scss b/libs/ui/src/lib/dialog-footer/dialog-footer.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/dialog-footer/dialog-footer.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/dialog-footer/dialog-footer.component.ts b/libs/ui/src/lib/dialog-footer/dialog-footer.component.ts new file mode 100644 index 000000000..e230802a7 --- /dev/null +++ b/libs/ui/src/lib/dialog-footer/dialog-footer.component.ts @@ -0,0 +1,34 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { close } from 'ionicons/icons'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'justify-content-center' }, + imports: [IonIcon, MatButtonModule, MatDialogModule], + selector: 'gf-dialog-footer', + styleUrls: ['./dialog-footer.component.scss'], + templateUrl: './dialog-footer.component.html' +}) +export class GfDialogFooterComponent { + @Input() deviceType: string; + + @Output() closeButtonClicked = new EventEmitter(); + + public constructor() { + addIcons({ close }); + } + + public onClickCloseButton() { + this.closeButtonClicked.emit(); + } +} diff --git a/libs/ui/src/lib/dialog-footer/index.ts b/libs/ui/src/lib/dialog-footer/index.ts new file mode 100644 index 000000000..822be3e98 --- /dev/null +++ b/libs/ui/src/lib/dialog-footer/index.ts @@ -0,0 +1 @@ +export * from './dialog-footer.component'; diff --git a/libs/ui/src/lib/dialog-header/dialog-header.component.html b/libs/ui/src/lib/dialog-header/dialog-header.component.html new file mode 100644 index 000000000..019d85a52 --- /dev/null +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.html @@ -0,0 +1,12 @@ +
+ {{ title }} + @if (deviceType !== 'mobile') { + + } +
diff --git a/libs/ui/src/lib/dialog-header/dialog-header.component.scss b/libs/ui/src/lib/dialog-header/dialog-header.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/dialog-header/dialog-header.component.ts b/libs/ui/src/lib/dialog-header/dialog-header.component.ts new file mode 100644 index 000000000..ce3173d0e --- /dev/null +++ b/libs/ui/src/lib/dialog-header/dialog-header.component.ts @@ -0,0 +1,37 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { close } from 'ionicons/icons'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'justify-content-center' }, + imports: [CommonModule, IonIcon, MatButtonModule, MatDialogModule], + selector: 'gf-dialog-header', + styleUrls: ['./dialog-header.component.scss'], + templateUrl: './dialog-header.component.html' +}) +export class GfDialogHeaderComponent { + @Input() deviceType: string; + @Input() position: 'center' | 'left' = 'left'; + @Input() title: string; + + @Output() closeButtonClicked = new EventEmitter(); + + public constructor() { + addIcons({ close }); + } + + public onClickCloseButton() { + this.closeButtonClicked.emit(); + } +} diff --git a/libs/ui/src/lib/dialog-header/index.ts b/libs/ui/src/lib/dialog-header/index.ts new file mode 100644 index 000000000..9beb9d4ac --- /dev/null +++ b/libs/ui/src/lib/dialog-header/index.ts @@ -0,0 +1 @@ +export * from './dialog-header.component'; diff --git a/libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts b/libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts new file mode 100644 index 000000000..9cbea529b --- /dev/null +++ b/libs/ui/src/lib/entity-logo/entity-logo-image-source.service.ts @@ -0,0 +1,20 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +import { Injectable } from '@angular/core'; + +@Injectable({ + // Required to allow mocking in Storybook + providedIn: 'root' +}) +export class EntityLogoImageSourceService { + public getLogoUrlByAssetProfileIdentifier({ + dataSource, + symbol + }: AssetProfileIdentifier) { + return `../api/v1/logo/${dataSource}/${symbol}`; + } + + public getLogoUrlByUrl(url: string) { + return `../api/v1/logo?url=${url}`; + } +} diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.html b/libs/ui/src/lib/entity-logo/entity-logo.component.html new file mode 100644 index 000000000..942ea23e5 --- /dev/null +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.html @@ -0,0 +1,8 @@ +@if (src) { + +} diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.scss b/libs/ui/src/lib/entity-logo/entity-logo.component.scss new file mode 100644 index 000000000..23bc7a487 --- /dev/null +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.scss @@ -0,0 +1,15 @@ +:host { + align-items: center; + display: flex; + + img { + border-radius: 0.2rem; + height: 0.8rem; + width: 0.8rem; + + &.large { + height: 1.4rem; + width: 1.4rem; + } + } +} diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts b/libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts new file mode 100644 index 000000000..6c89718bd --- /dev/null +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.stories.ts @@ -0,0 +1,44 @@ +import { CommonModule } from '@angular/common'; +import { importProvidersFrom } from '@angular/core'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { applicationConfig, Meta, StoryObj } from '@storybook/angular'; + +import { EntityLogoImageSourceServiceMock } from '../mocks/entity-logo-image-source.service.mock'; +import { EntityLogoImageSourceService } from './entity-logo-image-source.service'; +import { GfEntityLogoComponent } from './entity-logo.component'; + +export default { + title: 'Entity Logo', + component: GfEntityLogoComponent, + decorators: [ + applicationConfig({ + providers: [ + provideNoopAnimations(), + importProvidersFrom(CommonModule), + { + provide: EntityLogoImageSourceService, + useValue: new EntityLogoImageSourceServiceMock() + } + ] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const LogoByAssetProfileIdentifier: Story = { + args: { + dataSource: 'YAHOO', + size: 'large', + symbol: 'AAPL', + tooltip: 'Apple Inc.' + } +}; + +export const LogoByUrl: Story = { + args: { + size: 'large', + tooltip: 'Ghostfolio', + url: 'https://ghostfol.io' + } +}; diff --git a/libs/ui/src/lib/entity-logo/entity-logo.component.ts b/libs/ui/src/lib/entity-logo/entity-logo.component.ts new file mode 100644 index 000000000..212e232be --- /dev/null +++ b/libs/ui/src/lib/entity-logo/entity-logo.component.ts @@ -0,0 +1,44 @@ +import { EntityLogoImageSourceService } from '@ghostfolio/ui/entity-logo/entity-logo-image-source.service'; + +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Input, + OnChanges +} from '@angular/core'; +import { DataSource } from '@prisma/client'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-entity-logo', + styleUrls: ['./entity-logo.component.scss'], + templateUrl: './entity-logo.component.html' +}) +export class GfEntityLogoComponent implements OnChanges { + @Input() dataSource: DataSource; + @Input() size: 'large'; + @Input() symbol: string; + @Input() tooltip: string; + @Input() url: string; + + public src: string; + + public constructor( + private readonly imageSourceService: EntityLogoImageSourceService + ) {} + + public ngOnChanges() { + if (this.dataSource && this.symbol) { + this.src = this.imageSourceService.getLogoUrlByAssetProfileIdentifier({ + dataSource: this.dataSource, + symbol: this.symbol + }); + } else if (this.url) { + this.src = this.imageSourceService.getLogoUrlByUrl(this.url); + } + } +} diff --git a/libs/ui/src/lib/entity-logo/index.ts b/libs/ui/src/lib/entity-logo/index.ts new file mode 100644 index 000000000..9c5885208 --- /dev/null +++ b/libs/ui/src/lib/entity-logo/index.ts @@ -0,0 +1 @@ +export * from './entity-logo.component'; diff --git a/libs/ui/src/lib/environment/environment.token.ts b/libs/ui/src/lib/environment/environment.token.ts new file mode 100644 index 000000000..277e9c5e2 --- /dev/null +++ b/libs/ui/src/lib/environment/environment.token.ts @@ -0,0 +1,7 @@ +import { InjectionToken } from '@angular/core'; + +import { GfEnvironment } from './environment.interface'; + +export const GF_ENVIRONMENT = new InjectionToken( + 'GF_ENVIRONMENT' +); diff --git a/libs/ui/src/lib/environment/index.ts b/libs/ui/src/lib/environment/index.ts new file mode 100644 index 000000000..828eea646 --- /dev/null +++ b/libs/ui/src/lib/environment/index.ts @@ -0,0 +1,2 @@ +export * from './environment.interface'; +export * from './environment.token'; diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.html b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html new file mode 100644 index 000000000..4f9ac456c --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.html @@ -0,0 +1,89 @@ +
+
+
+
+ + + + Savings Rate per Month + + {{ currency }} + + + + Annual Interest Rate + +
%
+
+ + + Retirement Date +
+ {{ + calculatorForm.get('retirementDate')?.value | date: 'MMMM yyyy' + }} +
+ + + +
+ + + Projected Total Amount + + {{ currency }} + +
+
+
+
+ @if (isLoading) { + + } + +
+
+
+
diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss b/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss new file mode 100644 index 000000000..5662415da --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.scss @@ -0,0 +1,38 @@ +:host { + display: block; + + .chart-container { + aspect-ratio: 16 / 9; + + ngx-skeleton-loader { + height: 100%; + } + } + + ::ng-deep { + .mdc-text-field--disabled { + .mdc-floating-label, + .mdc-text-field__input { + color: inherit !important; + } + + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: rgba(var(--dark-disabled-text)); + } + } + } +} + +:host-context(.theme-dark) { + ::ng-deep { + .mdc-text-field--disabled { + .mdc-notched-outline__leading, + .mdc-notched-outline__notch, + .mdc-notched-outline__trailing { + border-color: rgba(var(--light-disabled-text)); + } + } + } +} diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts new file mode 100644 index 000000000..0872c2aac --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.stories.ts @@ -0,0 +1,53 @@ +import { locale } from '@ghostfolio/common/config'; + +import { CommonModule } from '@angular/common'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import '@angular/localize/init'; +import { MatButtonModule } from '@angular/material/button'; +import { provideNativeDateAdapter } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfValueComponent } from '../value'; +import { GfFireCalculatorComponent } from './fire-calculator.component'; +import { FireCalculatorService } from './fire-calculator.service'; + +export default { + title: 'FIRE Calculator', + component: GfFireCalculatorComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + FormsModule, + GfFireCalculatorComponent, + GfValueComponent, + MatButtonModule, + MatDatepickerModule, + MatFormFieldModule, + MatInputModule, + NgxSkeletonLoaderModule, + NoopAnimationsModule, + ReactiveFormsModule + ], + providers: [FireCalculatorService, provideNativeDateAdapter()] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Simple: Story = { + args: { + annualInterestRate: 5, + currency: 'USD', + fireWealth: 50000, + locale: locale, + savingsRate: 1000 + } +}; diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts new file mode 100644 index 000000000..7461f6729 --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -0,0 +1,494 @@ +import { + getTooltipOptions, + transformTickToAbbreviation +} from '@ghostfolio/common/chart-helper'; +import { primaryColorRgb } from '@ghostfolio/common/config'; +import { getLocale } from '@ghostfolio/common/helper'; +import { FireCalculationCompleteEvent } from '@ghostfolio/common/interfaces'; +import { ColorScheme } from '@ghostfolio/common/types'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + ViewChild +} from '@angular/core'; +import { + FormBuilder, + FormControl, + FormsModule, + ReactiveFormsModule +} from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { + MatDatepicker, + MatDatepickerModule +} from '@angular/material/datepicker'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { + BarController, + BarElement, + CategoryScale, + Chart, + type ChartData, + type ChartDataset, + LinearScale, + Tooltip +} from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import Color from 'color'; +import { + add, + addDays, + addYears, + getMonth, + setMonth, + setYear, + startOfMonth, + sub +} from 'date-fns'; +import { isNumber } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { Subject, debounceTime, takeUntil } from 'rxjs'; + +import { FireCalculatorService } from './fire-calculator.service'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + FormsModule, + MatButtonModule, + MatDatepickerModule, + MatFormFieldModule, + MatInputModule, + NgxSkeletonLoaderModule, + ReactiveFormsModule + ], + providers: [FireCalculatorService], + selector: 'gf-fire-calculator', + styleUrls: ['./fire-calculator.component.scss'], + templateUrl: './fire-calculator.component.html' +}) +export class GfFireCalculatorComponent implements OnChanges, OnDestroy { + @Input() annualInterestRate = 0; + @Input() colorScheme: ColorScheme; + @Input() currency: string; + @Input() deviceType: string; + @Input() fireWealth = 0; + @Input() hasPermissionToUpdateUserSettings: boolean; + @Input() locale = getLocale(); + @Input() projectedTotalAmount = 0; + @Input() retirementDate: Date; + @Input() savingsRate = 0; + + @Output() annualInterestRateChanged = new EventEmitter(); + @Output() calculationCompleted = + new EventEmitter(); + @Output() projectedTotalAmountChanged = new EventEmitter(); + @Output() retirementDateChanged = new EventEmitter(); + @Output() savingsRateChanged = new EventEmitter(); + + @ViewChild('chartCanvas') chartCanvas: ElementRef; + + public calculatorForm = this.formBuilder.group({ + annualInterestRate: new FormControl(undefined), + paymentPerPeriod: new FormControl(undefined), + principalInvestmentAmount: new FormControl(undefined), + projectedTotalAmount: new FormControl(undefined), + retirementDate: new FormControl(undefined) + }); + public chart: Chart<'bar'>; + public isLoading = true; + public minDate = addDays(new Date(), 1); + public periodsToRetire = 0; + + private readonly CONTRIBUTION_PERIOD = 12; + private readonly DEFAULT_RETIREMENT_DATE = startOfMonth( + addYears(new Date(), 10) + ); + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private fireCalculatorService: FireCalculatorService, + private formBuilder: FormBuilder + ) { + Chart.register( + BarController, + BarElement, + CategoryScale, + LinearScale, + Tooltip + ); + + this.calculatorForm.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + this.initialize(); + }); + + this.calculatorForm.valueChanges + .pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + const { projectedTotalAmount, retirementDate } = + this.calculatorForm.getRawValue(); + + this.calculationCompleted.emit({ + projectedTotalAmount, + retirementDate + }); + }); + + this.calculatorForm + .get('annualInterestRate') + .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) + .subscribe((annualInterestRate) => { + this.annualInterestRateChanged.emit(annualInterestRate); + }); + this.calculatorForm + .get('paymentPerPeriod') + .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) + .subscribe((savingsRate) => { + this.savingsRateChanged.emit(savingsRate); + }); + this.calculatorForm + .get('projectedTotalAmount') + .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) + .subscribe((projectedTotalAmount) => { + this.projectedTotalAmountChanged.emit(projectedTotalAmount); + }); + this.calculatorForm + .get('retirementDate') + .valueChanges.pipe(debounceTime(500), takeUntil(this.unsubscribeSubject)) + .subscribe((retirementDate) => { + this.retirementDateChanged.emit(retirementDate); + }); + } + + public ngOnChanges() { + if (isNumber(this.fireWealth) && this.fireWealth >= 0) { + this.calculatorForm.setValue( + { + annualInterestRate: this.annualInterestRate, + paymentPerPeriod: this.savingsRate, + principalInvestmentAmount: this.fireWealth, + projectedTotalAmount: this.projectedTotalAmount, + retirementDate: this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE + }, + { + emitEvent: false + } + ); + + this.periodsToRetire = this.getPeriodsToRetire(); + + setTimeout(() => { + // Wait for the chartCanvas + this.calculatorForm.patchValue( + { + annualInterestRate: + this.calculatorForm.get('annualInterestRate').value, + paymentPerPeriod: this.getPMT(), + principalInvestmentAmount: this.calculatorForm.get( + 'principalInvestmentAmount' + ).value, + projectedTotalAmount: + Math.round(this.getProjectedTotalAmount()) || 0, + retirementDate: + this.getRetirementDate() ?? this.DEFAULT_RETIREMENT_DATE + }, + { + emitEvent: false + } + ); + this.calculatorForm.get('principalInvestmentAmount').disable(); + + this.changeDetectorRef.markForCheck(); + }); + } + + if (this.hasPermissionToUpdateUserSettings === true) { + this.calculatorForm + .get('annualInterestRate') + .enable({ emitEvent: false }); + this.calculatorForm.get('paymentPerPeriod').enable({ emitEvent: false }); + this.calculatorForm + .get('projectedTotalAmount') + .enable({ emitEvent: false }); + } else { + this.calculatorForm + .get('annualInterestRate') + .disable({ emitEvent: false }); + this.calculatorForm.get('paymentPerPeriod').disable({ emitEvent: false }); + this.calculatorForm + .get('projectedTotalAmount') + .disable({ emitEvent: false }); + } + + this.calculatorForm.get('retirementDate').disable({ emitEvent: false }); + } + + public setMonthAndYear( + normalizedMonthAndYear: Date, + datepicker: MatDatepicker + ) { + const retirementDate = this.calculatorForm.get('retirementDate').value; + const newRetirementDate = setMonth( + setYear(retirementDate, normalizedMonthAndYear.getFullYear()), + normalizedMonthAndYear.getMonth() + ); + this.calculatorForm.get('retirementDate').setValue(newRetirementDate); + datepicker.close(); + } + + public ngOnDestroy() { + this.chart?.destroy(); + + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private initialize() { + this.isLoading = true; + + const chartData = this.getChartData(); + + if (this.chartCanvas) { + if (this.chart) { + this.chart.data.labels = chartData.labels; + + for (let index = 0; index < this.chart.data.datasets.length; index++) { + this.chart.data.datasets[index].data = chartData.datasets[index].data; + } + + this.chart.update(); + } else { + this.chart = new Chart<'bar'>(this.chartCanvas.nativeElement, { + data: chartData, + options: { + plugins: { + tooltip: { + ...getTooltipOptions({ colorScheme: this.colorScheme }), + mode: 'index', + callbacks: { + footer: (items) => { + const totalAmount = items.reduce( + (a, b) => a + (b.parsed.y ?? 0), + 0 + ); + + return `Total: ${new Intl.NumberFormat(this.locale, { + currency: this.currency, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: Only supported from ES2020 or later + currencyDisplay: 'code', + style: 'currency' + }).format(totalAmount)}`; + }, + label: (context) => { + let label = context.dataset.label || ''; + + if (label) { + label += ': '; + } + + if (context.parsed.y !== null) { + label += new Intl.NumberFormat(this.locale, { + currency: this.currency, + currencyDisplay: 'code', + style: 'currency' + }).format(context.parsed.y); + } + + return label; + } + } + } + }, + responsive: true, + scales: { + x: { + grid: { + display: false + }, + stacked: true + }, + y: { + display: this.deviceType !== 'mobile', + grid: { + display: false + }, + position: 'right', + stacked: true, + ticks: { + callback: (value: number) => { + return transformTickToAbbreviation(value); + } + } + } + } + }, + type: 'bar' + }); + } + } + + this.isLoading = false; + } + + private getChartData(): ChartData<'bar'> { + const currentYear = new Date().getFullYear(); + const labels: number[] = []; + + // Principal investment amount + const P: number = this.getP(); + + // Payment per period + const PMT = this.getPMT(); + + // Annual interest rate + const r: number = this.getR(); + + // Calculate retirement date + // if we want to retire at month x, we need the projectedTotalAmount at month x-1 + const lastPeriodDate = sub(this.getRetirementDate(), { months: 1 }); + const yearsToRetire = lastPeriodDate.getFullYear() - currentYear; + + // Time + // +1 to take into account the current year + const t = yearsToRetire + 1; + + for (let year = currentYear; year < currentYear + t; year++) { + labels.push(year); + } + + const datasetDeposit: ChartDataset<'bar'> = { + backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + data: [], + label: $localize`Deposit` + }; + + const datasetInterest: ChartDataset<'bar'> = { + backgroundColor: Color( + `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` + ) + .lighten(0.5) + .hex(), + data: [], + label: $localize`Interest` + }; + + const datasetSavings: ChartDataset<'bar'> = { + backgroundColor: Color( + `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})` + ) + .lighten(0.25) + .hex(), + data: [], + label: $localize`Savings` + }; + + const monthsPassedInCurrentYear = getMonth(new Date()); + + for (let period = 1; period <= t; period++) { + const periodInMonths = + period * this.CONTRIBUTION_PERIOD - monthsPassedInCurrentYear; + const { interest, principal } = + this.fireCalculatorService.calculateCompoundInterest({ + P, + periodInMonths, + PMT, + r + }); + + datasetDeposit.data.push(this.fireWealth); + datasetInterest.data.push(interest.toNumber()); + datasetSavings.data.push(principal.minus(this.fireWealth).toNumber()); + } + + return { + labels, + datasets: [datasetDeposit, datasetSavings, datasetInterest] + }; + } + + private getP() { + return this.fireWealth || 0; + } + + private getPeriodsToRetire(): number { + if (this.calculatorForm.get('projectedTotalAmount').value) { + let periods = this.fireCalculatorService.calculatePeriodsToRetire({ + P: this.getP(), + PMT: this.getPMT(), + r: this.getR(), + totalAmount: this.calculatorForm.get('projectedTotalAmount').value + }); + + if (periods === Infinity) { + periods = Number.MAX_SAFE_INTEGER; + } + + return periods; + } else { + const today = new Date(); + const retirementDate = + this.retirementDate ?? this.DEFAULT_RETIREMENT_DATE; + + return ( + 12 * (retirementDate.getFullYear() - today.getFullYear()) + + retirementDate.getMonth() - + today.getMonth() + ); + } + } + + private getPMT() { + return this.calculatorForm.get('paymentPerPeriod').value; + } + + private getProjectedTotalAmount() { + if (this.calculatorForm.get('projectedTotalAmount').value) { + return this.calculatorForm.get('projectedTotalAmount').value; + } + + const { totalAmount } = + this.fireCalculatorService.calculateCompoundInterest({ + P: this.getP(), + periodInMonths: this.periodsToRetire, + PMT: this.getPMT(), + r: this.getR() + }); + + return totalAmount.toNumber(); + } + + private getR() { + return this.calculatorForm.get('annualInterestRate').value / 100; + } + + private getRetirementDate(): Date { + if (this.periodsToRetire === Number.MAX_SAFE_INTEGER) { + return undefined; + } + + const monthsToRetire = this.periodsToRetire % 12; + const yearsToRetire = Math.floor(this.periodsToRetire / 12); + + return startOfMonth( + add(new Date(), { + months: monthsToRetire, + years: yearsToRetire + }) + ); + } +} diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts new file mode 100644 index 000000000..fbdc859d2 --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.service.spec.ts @@ -0,0 +1,68 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { FireCalculatorService } from './fire-calculator.service'; + +describe('FireCalculatorService', () => { + let fireCalculatorService: FireCalculatorService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [FireCalculatorService] + }).compile(); + + fireCalculatorService = module.get( + FireCalculatorService + ); + }); + + describe('Test periods to retire', () => { + it('should return the correct amount of periods to retire with no interst rate', async () => { + const r = 0; + const P = 1000; + const totalAmount = 1900; + const PMT = 100; + + const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({ + P, + r, + PMT, + totalAmount + }); + + expect(periodsToRetire).toBe(9); + }); + + it('should return the 0 when total amount is 0', async () => { + const r = 0.05; + const P = 100000; + const totalAmount = 0; + const PMT = 10000; + + const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({ + P, + r, + PMT, + totalAmount + }); + + expect(periodsToRetire).toBe(0); + }); + + it('should return the correct amount of periods to retire with interst rate', async () => { + const r = 0.05; + const P = 598478.96; + const totalAmount = 812399.66; + const PMT = 6000; + const expectedPeriods = 24; + + const periodsToRetire = fireCalculatorService.calculatePeriodsToRetire({ + P, + r, + PMT, + totalAmount + }); + + expect(Math.round(periodsToRetire)).toBe(expectedPeriods); + }); + }); +}); diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts new file mode 100644 index 000000000..848a9efa4 --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.service.ts @@ -0,0 +1,73 @@ +import { Injectable } from '@angular/core'; +import { Big } from 'big.js'; + +@Injectable() +export class FireCalculatorService { + private readonly COMPOUND_PERIOD = 12; + + public calculateCompoundInterest({ + P, + periodInMonths, + PMT, + r + }: { + P: number; + periodInMonths: number; + PMT: number; + r: number; + }) { + let interest = new Big(0); + const principal = new Big(P).plus(new Big(PMT).mul(periodInMonths)); + let totalAmount = principal; + + if (r) { + const compoundInterestForPrincipal = new Big(1) + .plus(new Big(r).div(this.COMPOUND_PERIOD)) + .pow(periodInMonths); + const compoundInterest = new Big(P).mul(compoundInterestForPrincipal); + const contributionInterest = new Big( + new Big(PMT).mul(compoundInterestForPrincipal.minus(1)) + ).div(new Big(r).div(this.COMPOUND_PERIOD)); + interest = compoundInterest.plus(contributionInterest).minus(principal); + totalAmount = compoundInterest.plus(contributionInterest); + } + + return { + interest, + principal, + totalAmount + }; + } + + public calculatePeriodsToRetire({ + P, + PMT, + r, + totalAmount + }: { + P: number; + PMT: number; + r: number; + totalAmount: number; + }) { + if (r === 0) { + // No compound interest + return (totalAmount - P) / PMT; + } else if (totalAmount <= P) { + return 0; + } + + const periodInterest = new Big(r).div(this.COMPOUND_PERIOD); + const numerator1: number = Math.log10( + new Big(totalAmount).plus(new Big(PMT).div(periodInterest)).toNumber() + ); + const numerator2: number = Math.log10( + new Big(P).plus(new Big(PMT).div(periodInterest)).toNumber() + ); + const denominator: number = Math.log10( + new Big(1).plus(periodInterest).toNumber() + ); + + return (numerator1 - numerator2) / denominator; + } +} diff --git a/libs/ui/src/lib/fire-calculator/index.ts b/libs/ui/src/lib/fire-calculator/index.ts new file mode 100644 index 000000000..1636174fc --- /dev/null +++ b/libs/ui/src/lib/fire-calculator/index.ts @@ -0,0 +1 @@ +export * from './fire-calculator.component'; diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts new file mode 100644 index 000000000..3e5e3b2e9 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.component.ts @@ -0,0 +1,115 @@ +import { AdminService, DataService } from '@ghostfolio/ui/services'; + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + CUSTOM_ELEMENTS_SCHEMA, + DestroyRef, + OnInit, + inject, + signal +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core'; +import { MatDatepickerModule } from '@angular/material/datepicker'; +import { + MAT_DIALOG_DATA, + MatDialogModule, + MatDialogRef +} from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { calendarClearOutline, refreshOutline } from 'ionicons/icons'; + +import { HistoricalMarketDataEditorDialogParams } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'h-100' }, + imports: [ + FormsModule, + IonIcon, + MatButtonModule, + MatDatepickerModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule + ], + selector: 'gf-historical-market-data-editor-dialog', + schemas: [CUSTOM_ELEMENTS_SCHEMA], + styleUrls: ['./historical-market-data-editor-dialog.scss'], + templateUrl: 'historical-market-data-editor-dialog.html' +}) +export class GfHistoricalMarketDataEditorDialogComponent implements OnInit { + public readonly data = + inject(MAT_DIALOG_DATA); + + protected readonly marketPrice = signal(this.data.marketPrice); + + private readonly destroyRef = inject(DestroyRef); + private readonly locale = + this.data.user.settings.locale ?? inject(MAT_DATE_LOCALE); + + public constructor( + private adminService: AdminService, + private changeDetectorRef: ChangeDetectorRef, + private dataService: DataService, + private dateAdapter: DateAdapter, + public dialogRef: MatDialogRef + ) { + addIcons({ calendarClearOutline, refreshOutline }); + } + + public ngOnInit() { + this.dateAdapter.setLocale(this.locale); + } + + public onCancel() { + this.dialogRef.close({ withRefresh: false }); + } + + public onFetchSymbolForDate() { + this.adminService + .fetchSymbolForDate({ + dataSource: this.data.dataSource, + dateString: this.data.dateString, + symbol: this.data.symbol + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ marketPrice }) => { + this.marketPrice.set(marketPrice); + + this.changeDetectorRef.markForCheck(); + }); + } + + public onUpdate() { + if (this.marketPrice() === undefined) { + return; + } + + this.dataService + .postMarketData({ + dataSource: this.data.dataSource, + marketData: { + marketData: [ + { + date: this.data.dateString, + marketPrice: this.marketPrice() + } + ] + }, + symbol: this.data.symbol + }) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + this.dialogRef.close({ withRefresh: true }); + }); + } +} diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html new file mode 100644 index 000000000..7bb5827ef --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.html @@ -0,0 +1,52 @@ +
+

Details for {{ data.symbol }}

+
+
+ + Date + + + + + + +
+
+ + Market Price + + {{ data.currency }} + + +
+
+
+ + +
+
diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss new file mode 100644 index 000000000..b63df0134 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/historical-market-data-editor-dialog.scss @@ -0,0 +1,7 @@ +:host { + display: block; + + .mat-mdc-dialog-content { + max-height: unset; + } +} diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..edb9a852f --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor-dialog/interfaces/interfaces.ts @@ -0,0 +1,12 @@ +import { User } from '@ghostfolio/common/interfaces'; + +import { DataSource } from '@prisma/client'; + +export interface HistoricalMarketDataEditorDialogParams { + currency: string; + dataSource: DataSource; + dateString: string; + marketPrice?: number; + symbol: string; + user: User; +} diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html new file mode 100644 index 000000000..91e3dd8d7 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.html @@ -0,0 +1,71 @@ +
+ @for (itemByMonth of marketDataByMonth | keyvalue; track itemByMonth) { +
+
{{ itemByMonth.key }}
+
+ @for (day of days; track day) { +
+ } +
+
+ } +
+
+ + + Historical Data (CSV) + + + +
+ +
+ +
+
+
diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss new file mode 100644 index 000000000..cc835a90e --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.scss @@ -0,0 +1,31 @@ +:host { + display: block; + font-size: 0.9rem; + + .date { + font-feature-settings: 'tnum'; + font-variant-numeric: tabular-nums; + } + + .day { + height: 0.5rem; + margin-right: 0.25rem; + width: 0.5rem; + + &:hover { + opacity: 0.8; + } + + &.valid { + background-color: var(--danger); + } + + &.available { + background-color: var(--success); + } + + &.today { + background-color: rgba(var(--palette-accent-500), 1); + } + } +} diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts new file mode 100644 index 000000000..7cb9636f0 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.spec.ts @@ -0,0 +1,60 @@ +import { DataService } from '@ghostfolio/ui/services'; + +import { signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FormBuilder } from '@angular/forms'; +import { MatDialog } from '@angular/material/dialog'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { DeviceDetectorService } from 'ngx-device-detector'; + +import { GfHistoricalMarketDataEditorComponent } from './historical-market-data-editor.component'; + +jest.mock( + './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component', + () => ({ + GfHistoricalMarketDataEditorDialogComponent: class {} + }) +); + +describe('GfHistoricalMarketDataEditorComponent', () => { + let component: GfHistoricalMarketDataEditorComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GfHistoricalMarketDataEditorComponent], + providers: [ + FormBuilder, + { provide: DataService, useValue: {} }, + { + provide: DeviceDetectorService, + useValue: { + deviceInfo: signal({ deviceType: 'desktop' }) + } + }, + { provide: MatDialog, useValue: {} }, + { provide: MatSnackBar, useValue: {} } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(GfHistoricalMarketDataEditorComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('formatDay', () => { + it('should pad single digit days with zero', () => { + expect(component.formatDay(1)).toBe('01'); + expect(component.formatDay(9)).toBe('09'); + }); + + it('should not pad double digit days', () => { + expect(component.formatDay(10)).toBe('10'); + expect(component.formatDay(31)).toBe('31'); + }); + }); +}); diff --git a/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts new file mode 100644 index 000000000..cde180dd9 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/historical-market-data-editor.component.ts @@ -0,0 +1,301 @@ +import { UpdateMarketDataDto } from '@ghostfolio/common/dtos'; +import { + DATE_FORMAT, + getDateFormatString, + getLocale +} from '@ghostfolio/common/helper'; +import { LineChartItem, User } from '@ghostfolio/common/interfaces'; +import { DataService } from '@ghostfolio/ui/services'; + +import { CommonModule } from '@angular/common'; +import type { HttpErrorResponse } from '@angular/common/http'; +import { + ChangeDetectionStrategy, + Component, + computed, + DestroyRef, + EventEmitter, + inject, + input, + Input, + OnChanges, + OnInit, + Output +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatInputModule } from '@angular/material/input'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { DataSource, MarketData } from '@prisma/client'; +import { + addDays, + addMonths, + format, + isBefore, + isSameDay, + isToday, + isValid, + min, + parse, + parseISO +} from 'date-fns'; +import { first, last } from 'lodash'; +import ms from 'ms'; +import { DeviceDetectorService } from 'ngx-device-detector'; +import { parse as csvToJson } from 'papaparse'; +import { EMPTY } from 'rxjs'; +import { catchError } from 'rxjs/operators'; + +import { GfHistoricalMarketDataEditorDialogComponent } from './historical-market-data-editor-dialog/historical-market-data-editor-dialog.component'; +import { HistoricalMarketDataEditorDialogParams } from './historical-market-data-editor-dialog/interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, MatButtonModule, MatInputModule, ReactiveFormsModule], + selector: 'gf-historical-market-data-editor', + styleUrls: ['./historical-market-data-editor.component.scss'], + templateUrl: './historical-market-data-editor.component.html' +}) +export class GfHistoricalMarketDataEditorComponent + implements OnChanges, OnInit +{ + private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format( + new Date(), + DATE_FORMAT + )};123.45`; + + @Input() currency: string; + @Input() dataSource: DataSource; + @Input() dateOfFirstActivity: string; + @Input() symbol: string; + @Input() user: User; + + @Output() marketDataChanged = new EventEmitter(); + + public historicalDataForm = this.formBuilder.group({ + historicalData: this.formBuilder.group({ + csvString: '' + }) + }); + public marketDataByMonth: { + [yearMonth: string]: { + [day: string]: { + date: Date; + day: number; + marketPrice?: number; + }; + }; + } = {}; + + public readonly locale = input(getLocale()); + public readonly marketData = input.required(); + + protected readonly days = Array.from({ length: 31 }, (_, i) => i + 1); + protected readonly defaultDateFormat = computed(() => + getDateFormatString(this.locale()) + ); + + private readonly destroyRef = inject(DestroyRef); + private readonly deviceDetectorService = inject(DeviceDetectorService); + private readonly deviceType = computed( + () => this.deviceDetectorService.deviceInfo().deviceType + ); + private readonly historicalDataItems = computed(() => + this.marketData().map(({ date, marketPrice }) => { + return { + date: format(date, DATE_FORMAT), + value: marketPrice + }; + }) + ); + + public constructor( + private dataService: DataService, + private dialog: MatDialog, + private formBuilder: FormBuilder, + private snackBar: MatSnackBar + ) {} + + public ngOnInit() { + this.initializeHistoricalDataForm(); + } + + public ngOnChanges() { + if (this.dateOfFirstActivity) { + let date = parseISO(this.dateOfFirstActivity); + + const missingMarketData: { date: Date; marketPrice?: number }[] = []; + + if (this.historicalDataItems()?.[0]?.date) { + while ( + isBefore( + date, + parse(this.historicalDataItems()[0].date, DATE_FORMAT, new Date()) + ) + ) { + missingMarketData.push({ + date, + marketPrice: undefined + }); + + date = addDays(date, 1); + } + } + + const marketDataItems = [...missingMarketData, ...this.marketData()]; + + const lastDate = last(marketDataItems)?.date; + if (!lastDate || !isToday(lastDate)) { + marketDataItems.push({ date: new Date() }); + } + + this.marketDataByMonth = {}; + + for (const marketDataItem of marketDataItems) { + const currentDay = parseInt(format(marketDataItem.date, 'd'), 10); + const key = format(marketDataItem.date, 'yyyy-MM'); + + if (!this.marketDataByMonth[key]) { + this.marketDataByMonth[key] = {}; + } + + this.marketDataByMonth[key][ + currentDay < 10 ? `0${currentDay}` : currentDay + ] = { + date: marketDataItem.date, + day: currentDay, + marketPrice: marketDataItem.marketPrice + }; + } + + // Fill up missing months + const dates = Object.keys(this.marketDataByMonth).sort(); + const startDateString = first(dates); + const startDate = min([ + parseISO(this.dateOfFirstActivity), + ...(startDateString ? [parseISO(startDateString)] : []) + ]); + const endDateString = last(dates); + + if (endDateString) { + const endDate = parseISO(endDateString); + + let currentDate = startDate; + + while (isBefore(currentDate, endDate)) { + const key = format(currentDate, 'yyyy-MM'); + if (!this.marketDataByMonth[key]) { + this.marketDataByMonth[key] = {}; + } + + currentDate = addMonths(currentDate, 1); + } + } + } + } + + public formatDay(day: number): string { + return day < 10 ? `0${day}` : `${day}`; + } + + public isDateOfInterest(aDateString: string) { + // Date is valid and in the past + const date = parse(aDateString, DATE_FORMAT, new Date()); + return isValid(date) && isBefore(date, new Date()); + } + + public isToday(aDateString: string) { + const date = parse(aDateString, DATE_FORMAT, new Date()); + return isValid(date) && isSameDay(date, new Date()); + } + + public onOpenMarketDataDetail({ + day, + yearMonth + }: { + day: string; + yearMonth: string; + }) { + const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; + + const dialogRef = this.dialog.open< + GfHistoricalMarketDataEditorDialogComponent, + HistoricalMarketDataEditorDialogParams, + { withRefresh: boolean } + >(GfHistoricalMarketDataEditorDialogComponent, { + data: { + marketPrice, + currency: this.currency, + dataSource: this.dataSource, + dateString: `${yearMonth}-${day}`, + symbol: this.symbol, + user: this.user + }, + height: this.deviceType() === 'mobile' ? '98vh' : '80vh', + width: this.deviceType() === 'mobile' ? '100vw' : '50rem' + }); + + dialogRef + .afterClosed() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(({ withRefresh } = { withRefresh: false }) => { + this.marketDataChanged.emit(withRefresh); + }); + } + + public onImportHistoricalData() { + try { + const marketData = csvToJson( + this.historicalDataForm.controls.historicalData.controls.csvString + .value ?? '', + { + dynamicTyping: true, + header: true, + skipEmptyLines: true + } + ).data; + + this.dataService + .postMarketData({ + dataSource: this.dataSource, + marketData: { + marketData + }, + symbol: this.symbol + }) + .pipe( + catchError(({ error, message }: HttpErrorResponse) => { + this.snackBar.open(`${error}: ${message[0]}`, undefined, { + duration: ms('3 seconds') + }); + return EMPTY; + }), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + this.initializeHistoricalDataForm(); + + this.marketDataChanged.emit(true); + }); + } catch { + this.snackBar.open( + $localize`Oops! Could not parse historical data.`, + undefined, + { + duration: ms('3 seconds') + } + ); + } + } + + private initializeHistoricalDataForm() { + this.historicalDataForm.setValue({ + historicalData: { + csvString: + GfHistoricalMarketDataEditorComponent.HISTORICAL_DATA_TEMPLATE + } + }); + } +} diff --git a/libs/ui/src/lib/historical-market-data-editor/index.ts b/libs/ui/src/lib/historical-market-data-editor/index.ts new file mode 100644 index 000000000..6c7004ce9 --- /dev/null +++ b/libs/ui/src/lib/historical-market-data-editor/index.ts @@ -0,0 +1 @@ +export * from './historical-market-data-editor.component'; diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.html b/libs/ui/src/lib/holdings-table/holdings-table.component.html new file mode 100644 index 000000000..250eff578 --- /dev/null +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.html @@ -0,0 +1,215 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + Name + +
+ {{ element.name }} + @if (element.name === element.symbol) { + ({{ element.assetSubClassLabel }}) + } +
+
+ {{ element.symbol }} +
+
+ First Activity + +
+ +
+
+ Quantity + +
+ +
+
+ Value + +
+ +
+
+ Allocation + % + +
+ +
+
+ Change + +
+ +
+
+ Performance + ± + +
+ +
+
+
+ + + +@if (isLoading()) { + +} + +@if (dataSource.data.length > pageSize && !isLoading()) { +
+ +
+} diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.scss b/libs/ui/src/lib/holdings-table/holdings-table.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts new file mode 100644 index 000000000..8748a830f --- /dev/null +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.stories.ts @@ -0,0 +1,58 @@ +import { CommonModule } from '@angular/common'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatPaginatorModule } from '@angular/material/paginator'; +import { MatSortModule } from '@angular/material/sort'; +import { MatTableModule } from '@angular/material/table'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfEntityLogoComponent } from '../entity-logo'; +import { holdings } from '../mocks/holdings'; +import { GfValueComponent } from '../value'; +import { GfHoldingsTableComponent } from './holdings-table.component'; + +export default { + title: 'Holdings Table', + component: GfHoldingsTableComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + GfEntityLogoComponent, + GfValueComponent, + MatButtonModule, + MatDialogModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + NgxSkeletonLoaderModule + ] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { + holdings: undefined, + hasPermissionToOpenDetails: false, + hasPermissionToShowQuantities: true, + hasPermissionToShowValues: true, + locale: 'en-US', + pageSize: Number.MAX_SAFE_INTEGER + } +}; + +export const Default: Story = { + args: { + holdings, + hasPermissionToOpenDetails: false, + hasPermissionToShowQuantities: true, + hasPermissionToShowValues: true, + locale: 'en-US', + pageSize: Number.MAX_SAFE_INTEGER + } +}; diff --git a/libs/ui/src/lib/holdings-table/holdings-table.component.ts b/libs/ui/src/lib/holdings-table/holdings-table.component.ts new file mode 100644 index 000000000..bea555a0b --- /dev/null +++ b/libs/ui/src/lib/holdings-table/holdings-table.component.ts @@ -0,0 +1,127 @@ +import { getLocale, getLowercase } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; + +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + computed, + effect, + input, + viewChild +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule } from '@angular/material/dialog'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; +import { MatSort, MatSortModule } from '@angular/material/sort'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { AssetSubClass } from '@prisma/client'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; +import { GfValueComponent } from '../value/value.component'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfEntityLogoComponent, + GfValueComponent, + MatButtonModule, + MatDialogModule, + MatPaginatorModule, + MatSortModule, + MatTableModule, + NgxSkeletonLoaderModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-holdings-table', + styleUrls: ['./holdings-table.component.scss'], + templateUrl: './holdings-table.component.html' +}) +export class GfHoldingsTableComponent { + @Input() pageSize = Number.MAX_SAFE_INTEGER; + + @Output() holdingClicked = new EventEmitter(); + + public readonly hasPermissionToOpenDetails = input(true); + public readonly hasPermissionToShowQuantities = input(true); + public readonly hasPermissionToShowValues = input(true); + public readonly holdings = input.required(); + public readonly locale = input(getLocale()); + public readonly paginator = viewChild.required(MatPaginator); + public readonly sort = viewChild.required(MatSort); + + protected readonly dataSource = new MatTableDataSource([]); + + protected readonly displayedColumns = computed(() => { + const columns = ['icon', 'nameWithSymbol', 'dateOfFirstActivity']; + + if (this.hasPermissionToShowQuantities()) { + columns.push('quantity'); + } + + if (this.hasPermissionToShowValues()) { + columns.push('valueInBaseCurrency'); + } + + columns.push('allocationInPercentage'); + + if (this.hasPermissionToShowValues()) { + columns.push('performance'); + } + + columns.push('performanceInPercentage'); + return columns; + }); + + protected readonly ignoreAssetSubClasses: AssetSubClass[] = [ + AssetSubClass.CASH + ]; + + protected readonly isLoading = computed(() => !this.holdings()); + + public constructor() { + this.dataSource.sortingDataAccessor = getLowercase; + + // Reactive data update + effect(() => { + this.dataSource.data = this.holdings(); + }); + + // Reactive view connection + effect(() => { + this.dataSource.paginator = this.paginator(); + this.dataSource.sort = this.sort(); + }); + } + + protected canShowDetails(holding: PortfolioPosition): boolean { + return ( + this.hasPermissionToOpenDetails() && + !this.ignoreAssetSubClasses.includes(holding.assetSubClass) + ); + } + + protected onOpenHoldingDialog({ + dataSource, + symbol + }: AssetProfileIdentifier) { + this.holdingClicked.emit({ dataSource, symbol }); + } + + protected onShowAllHoldings() { + this.pageSize = Number.MAX_SAFE_INTEGER; + + setTimeout(() => { + this.dataSource.paginator = this.paginator(); + }); + } +} diff --git a/libs/ui/src/lib/holdings-table/index.ts b/libs/ui/src/lib/holdings-table/index.ts new file mode 100644 index 000000000..f2dd696ce --- /dev/null +++ b/libs/ui/src/lib/holdings-table/index.ts @@ -0,0 +1 @@ +export * from './holdings-table.component'; diff --git a/libs/ui/src/lib/i18n.ts b/libs/ui/src/lib/i18n.ts new file mode 100644 index 000000000..4d494a43a --- /dev/null +++ b/libs/ui/src/lib/i18n.ts @@ -0,0 +1,115 @@ +import '@angular/localize/init'; + +const locales = { + ACCOUNT: $localize`Account`, + 'Asia-Pacific': $localize`Asia-Pacific`, + ASSET_CLASS: $localize`Asset Class`, + ASSET_SUB_CLASS: $localize`Asset Sub Class`, + BUY_AND_SELL_ACTIVITIES_TOOLTIP: $localize`Buy and sell`, + CANCEL: $localize`Cancel`, + CORE: $localize`Core`, + CLOSE: $localize`Close`, + DATA_IMPORT_AND_EXPORT_TOOLTIP_BASIC: $localize`Switch to Ghostfolio Premium or Ghostfolio Open Source easily`, + DATA_IMPORT_AND_EXPORT_TOOLTIP_OSS: $localize`Switch to Ghostfolio Premium easily`, + DATA_IMPORT_AND_EXPORT_TOOLTIP_PREMIUM: $localize`Switch to Ghostfolio Open Source or Ghostfolio Basic easily`, + DATA_SOURCE: $localize`Data Source`, + EMERGENCY_FUND: $localize`Emergency Fund`, + EXCLUDE_FROM_ANALYSIS: $localize`Exclude from Analysis`, + Global: $localize`Global`, + GRANT: $localize`Grant`, + HIGHER_RISK: $localize`Higher Risk`, + IMPORT_ACTIVITY_ERROR_IS_DUPLICATE: $localize`This activity already exists.`, + LOWER_RISK: $localize`Lower Risk`, + MONTH: $localize`Month`, + MONTHS: $localize`Months`, + OTHER: $localize`Other`, + PROFESSIONAL_DATA_PROVIDER_TOOLTIP_PREMIUM: $localize`Get access to 80’000+ tickers from over 50 exchanges`, + PRESET_ID: $localize`Preset`, + RETIREMENT_PROVISION: $localize`Retirement Provision`, + SATELLITE: $localize`Satellite`, + SYMBOL: $localize`Symbol`, + TAG: $localize`Tag`, + YEAR: $localize`Year`, + YEARS: $localize`Years`, + YES: $localize`Yes`, + + // Activity types + BUY: $localize`Buy`, + DIVIDEND: $localize`Dividend`, + FEE: $localize`Fee`, + INTEREST: $localize`Interest`, + LIABILITY: $localize`Liability`, + SELL: $localize`Sell`, + VALUABLE: $localize`Valuable`, + + // AssetClass (enum) + ALTERNATIVE_INVESTMENT: $localize`Alternative Investment`, + COMMODITY: $localize`Commodity`, + EQUITY: $localize`Equity`, + FIXED_INCOME: $localize`Fixed Income`, + LIQUIDITY: $localize`Liquidity`, + REAL_ESTATE: $localize`Real Estate`, + + // AssetSubClass (enum) + BOND: $localize`Bond`, + CASH: $localize`Cash`, + COLLECTIBLE: $localize`Collectible`, + CRYPTOCURRENCY: $localize`Cryptocurrency`, + ETF: $localize`ETF`, + MUTUALFUND: $localize`Mutual Fund`, + PRECIOUS_METAL: $localize`Precious Metal`, + PRIVATE_EQUITY: $localize`Private Equity`, + STOCK: $localize`Stock`, + + // Benchmark + ALL_TIME_HIGH: 'All time high', + BEAR_MARKET: 'Bear market', + + // Continents + Africa: $localize`Africa`, + Asia: $localize`Asia`, + Europe: $localize`Europe`, + 'North America': $localize`North America`, + Oceania: $localize`Oceania`, + 'South America': $localize`South America`, + + // Countries + Armenia: $localize`Armenia`, + Argentina: $localize`Argentina`, + Australia: $localize`Australia`, + Austria: $localize`Austria`, + Belgium: $localize`Belgium`, + 'British Virgin Islands': $localize`British Virgin Islands`, + Bulgaria: $localize`Bulgaria`, + Canada: $localize`Canada`, + 'Czech Republic': $localize`Czech Republic`, + Finland: $localize`Finland`, + France: $localize`France`, + Germany: $localize`Germany`, + India: $localize`India`, + Indonesia: $localize`Indonesia`, + Italy: $localize`Italy`, + Japan: $localize`Japan`, + Netherlands: $localize`Netherlands`, + 'New Zealand': $localize`New Zealand`, + Poland: $localize`Poland`, + Romania: $localize`Romania`, + Singapore: $localize`Singapore`, + 'South Africa': $localize`South Africa`, + Switzerland: $localize`Switzerland`, + Thailand: $localize`Thailand`, + Ukraine: $localize`Ukraine`, + 'United Kingdom': $localize`United Kingdom`, + 'United States': $localize`United States`, + + // Fear and Greed Index + EXTREME_FEAR: $localize`Extreme Fear`, + EXTREME_GREED: $localize`Extreme Greed`, + FEAR: $localize`Fear`, + GREED: $localize`Greed`, + NEUTRAL: $localize`Neutral` +}; + +export function translate(aKey: string): string { + return locales[aKey] ?? aKey; +} diff --git a/libs/ui/src/lib/line-chart/index.ts b/libs/ui/src/lib/line-chart/index.ts new file mode 100644 index 000000000..fca368497 --- /dev/null +++ b/libs/ui/src/lib/line-chart/index.ts @@ -0,0 +1 @@ +export * from './line-chart.component'; diff --git a/libs/ui/src/lib/line-chart/line-chart.component.html b/libs/ui/src/lib/line-chart/line-chart.component.html new file mode 100644 index 000000000..e9a5bbbe0 --- /dev/null +++ b/libs/ui/src/lib/line-chart/line-chart.component.html @@ -0,0 +1,13 @@ +@if (isLoading && showLoader) { + +} + diff --git a/libs/ui/src/lib/line-chart/line-chart.component.scss b/libs/ui/src/lib/line-chart/line-chart.component.scss new file mode 100644 index 000000000..ddaebfe44 --- /dev/null +++ b/libs/ui/src/lib/line-chart/line-chart.component.scss @@ -0,0 +1,7 @@ +:host { + display: block; + + ngx-skeleton-loader { + height: 100%; + } +} diff --git a/libs/ui/src/lib/line-chart/line-chart.component.stories.ts b/libs/ui/src/lib/line-chart/line-chart.component.stories.ts new file mode 100644 index 000000000..ba8ee1298 --- /dev/null +++ b/libs/ui/src/lib/line-chart/line-chart.component.stories.ts @@ -0,0 +1,236 @@ +import { CommonModule } from '@angular/common'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfLineChartComponent } from './line-chart.component'; + +export default { + title: 'Line Chart', + component: GfLineChartComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, GfLineChartComponent, NgxSkeletonLoaderModule] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Simple: Story = { + args: { + historicalDataItems: [ + { + date: '2017-01-01', + value: 2561.60376 + }, + { + date: '2017-02-01', + value: 2261.60376 + }, + { + date: '2017-03-01', + value: 2268.68157074 + }, + { + date: '2017-04-01', + value: 2525.2942 + }, + { + date: '2017-05-01', + value: 2929.3595107399997 + }, + { + date: '2017-06-01', + value: 3088.5172438900004 + }, + { + date: '2017-07-01', + value: 3281.2490946300004 + }, + { + date: '2017-08-01', + value: 4714.57822537 + }, + { + date: '2017-09-01', + value: 5717.262455259565 + }, + { + date: '2017-10-01', + value: 5338.742482334544 + }, + { + date: '2017-11-01', + value: 6361.263771554509 + }, + { + date: '2017-12-01', + value: 8373.260491904595 + }, + { + date: '2018-01-01', + value: 9783.208968191562 + }, + { + date: '2018-02-01', + value: 7841.2667100173 + }, + { + date: '2018-03-01', + value: 8582.133039380678 + }, + { + date: '2018-04-01', + value: 5901.8362986766715 + }, + { + date: '2018-05-01', + value: 7367.392976151925 + }, + { + date: '2018-06-01', + value: 6490.164314049853 + }, + { + date: '2018-07-01', + value: 6365.351621654618 + }, + { + date: '2018-08-01', + value: 6614.532706931272 + }, + { + date: '2018-09-01', + value: 6402.052882414409 + }, + { + date: '2018-10-01', + value: 15270.327917651943 + }, + { + date: '2018-11-01', + value: 13929.833891940816 + }, + { + date: '2018-12-01', + value: 12995.832254431414 + }, + { + date: '2019-01-01', + value: 11792.447013828998 + }, + { + date: '2019-02-01', + value: 11988.224284346446 + }, + { + date: '2019-03-01', + value: 13536.39667099519 + }, + { + date: '2019-04-01', + value: 14301.83740090022 + }, + { + date: '2019-05-01', + value: 14902.994910520581 + }, + { + date: '2019-06-01', + value: 15373.418326284132 + }, + { + date: '2019-07-01', + value: 17545.70705465703 + }, + { + date: '2019-08-01', + value: 17206.28500223782 + }, + { + date: '2019-09-01', + value: 17782.445200108898 + }, + { + date: '2019-10-01', + value: 17050.25875252655 + }, + { + date: '2019-11-01', + value: 18517.053521416237 + }, + { + date: '2019-12-01', + value: 17850.649021651363 + }, + { + date: '2020-01-01', + value: 18817.269786559067 + }, + { + date: '2020-02-01', + value: 22769.842312027653 + }, + { + date: '2020-03-01', + value: 23065.56002316582 + }, + { + date: '2020-04-01', + value: 19738.122641884733 + }, + { + date: '2020-05-01', + value: 25112.281463810643 + }, + { + date: '2020-06-01', + value: 28753.054132147452 + }, + { + date: '2020-07-01', + value: 32207.62827031543 + }, + { + date: '2020-08-01', + value: 37837.88816828611 + }, + { + date: '2020-09-01', + value: 50018.740185519295 + }, + { + date: '2020-10-01', + value: 46593.322295801525 + }, + { + date: '2020-11-01', + value: 44440.18743231742 + }, + { + date: '2020-12-01', + value: 57582.23077536893 + }, + { + date: '2021-01-01', + value: 68823.02446077733 + }, + { + date: '2021-02-01', + value: 64516.42092139593 + }, + { + date: '2021-03-01', + value: 82465.97581106682 + }, + { + date: '2021-03-18', + value: 86666.03082624623 + } + ], + isAnimated: true, + label: 'Net Worth', + unit: 'USD' + } +}; diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts new file mode 100644 index 000000000..dd972bc5a --- /dev/null +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -0,0 +1,338 @@ +import { + getTooltipOptions, + getVerticalHoverLinePlugin +} from '@ghostfolio/common/chart-helper'; +import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config'; +import { + getBackgroundColor, + getDateFormatString, + getLocale, + getTextColor +} from '@ghostfolio/common/helper'; +import { LineChartItem } from '@ghostfolio/common/interfaces'; +import { ColorScheme } from '@ghostfolio/common/types'; + +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + type ElementRef, + Input, + OnChanges, + OnDestroy, + ViewChild +} from '@angular/core'; +import { + type AnimationsSpec, + Chart, + Filler, + LinearScale, + LineController, + LineElement, + PointElement, + TimeScale, + Tooltip, + type TooltipOptions +} from 'chart.js'; +import 'chartjs-adapter-date-fns'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { registerChartConfiguration } from '../chart'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, NgxSkeletonLoaderModule], + selector: 'gf-line-chart', + styleUrls: ['./line-chart.component.scss'], + templateUrl: './line-chart.component.html' +}) +export class GfLineChartComponent + implements AfterViewInit, OnChanges, OnDestroy +{ + @Input() benchmarkDataItems: LineChartItem[] = []; + @Input() benchmarkLabel = ''; + @Input() colorScheme: ColorScheme; + @Input() currency: string; + @Input() historicalDataItems: LineChartItem[]; + @Input() isAnimated = false; + @Input() label: string; + @Input() locale = getLocale(); + @Input() showGradient = false; + @Input() showLegend = false; + @Input() showLoader = true; + @Input() showXAxis = false; + @Input() showYAxis = false; + @Input() unit: string; + @Input() yMax: number; + @Input() yMaxLabel: string; + @Input() yMin: number; + @Input() yMinLabel: string; + + @ViewChild('chartCanvas') chartCanvas: ElementRef; + + public chart: Chart<'line'>; + public isLoading = true; + + private readonly ANIMATION_DURATION = 1200; + + public constructor(private changeDetectorRef: ChangeDetectorRef) { + Chart.register( + Filler, + LineController, + LineElement, + PointElement, + LinearScale, + TimeScale, + Tooltip + ); + + registerChartConfiguration(); + } + + public ngAfterViewInit() { + if (this.historicalDataItems) { + setTimeout(() => { + // Wait for the chartCanvas + this.initialize(); + + this.changeDetectorRef.markForCheck(); + }); + } + } + + public ngOnChanges() { + if (this.historicalDataItems || this.historicalDataItems === null) { + setTimeout(() => { + // Wait for the chartCanvas + this.initialize(); + + this.changeDetectorRef.markForCheck(); + }); + } + } + + public ngOnDestroy() { + this.chart?.destroy(); + } + + private initialize() { + this.isLoading = true; + const benchmarkPrices: number[] = []; + const labels: string[] = []; + const marketPrices: number[] = []; + + this.historicalDataItems?.forEach((historicalDataItem, index) => { + benchmarkPrices.push(this.benchmarkDataItems?.[index]?.value); + labels.push(historicalDataItem.date); + marketPrices.push(historicalDataItem.value); + }); + + const gradient = this.chartCanvas?.nativeElement + ?.getContext('2d') + ?.createLinearGradient( + 0, + 0, + 0, + ((this.chartCanvas.nativeElement.parentNode as HTMLElement) + .offsetHeight * + 4) / + 5 + ); + + if (gradient && this.showGradient) { + gradient.addColorStop( + 0, + `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)` + ); + gradient.addColorStop(1, getBackgroundColor(this.colorScheme)); + } + + const data = { + labels, + datasets: [ + { + borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`, + borderWidth: 1, + data: benchmarkPrices, + fill: false, + label: this.benchmarkLabel, + pointRadius: 0, + spanGaps: false + }, + { + backgroundColor: gradient, + borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`, + borderWidth: 2, + data: marketPrices, + fill: true, + label: this.label, + pointRadius: 0 + } + ] + }; + + if (this.chartCanvas) { + const animations = { + x: this.getAnimationConfigurationForAxis({ labels, axis: 'x' }), + y: this.getAnimationConfigurationForAxis({ labels, axis: 'y' }) + }; + + if (this.chart) { + this.chart.data = data; + this.chart.options.plugins ??= {}; + this.chart.options.plugins.tooltip = + this.getTooltipPluginConfiguration(); + this.chart.options.animations = this.isAnimated + ? animations + : undefined; + + this.chart.update(); + } else { + this.chart = new Chart(this.chartCanvas.nativeElement, { + data, + options: { + animations: this.isAnimated ? animations : undefined, + aspectRatio: 16 / 9, + elements: { + point: { + hoverBackgroundColor: getBackgroundColor(this.colorScheme), + hoverRadius: 2 + } + }, + interaction: { intersect: false, mode: 'index' }, + plugins: { + legend: { + align: 'start', + display: this.showLegend, + position: 'bottom' + }, + tooltip: this.getTooltipPluginConfiguration(), + verticalHoverLine: { + color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` + } + }, + scales: { + x: { + border: { + color: `rgba(${getTextColor(this.colorScheme)}, 0.1)` + }, + display: this.showXAxis, + grid: { + display: false + }, + time: { + tooltipFormat: getDateFormatString(this.locale), + unit: 'year' + }, + type: 'time' + }, + y: { + border: { + width: 0 + }, + display: this.showYAxis, + grid: { + color: ({ scale, tick }) => { + if ( + tick.value === 0 || + tick.value === scale.max || + tick.value === scale.min || + tick.value === this.yMax || + tick.value === this.yMin + ) { + return `rgba(${getTextColor(this.colorScheme)}, 0.1)`; + } + + return 'transparent'; + } + }, + max: this.yMax, + min: this.yMin, + position: 'right', + ticks: { + callback: (tickValue, index, ticks) => { + if (index === 0 || index === ticks.length - 1) { + // Only print last and first legend entry + + if (index === 0 && this.yMinLabel) { + return this.yMinLabel; + } + + if (index === ticks.length - 1 && this.yMaxLabel) { + return this.yMaxLabel; + } + + if (typeof tickValue === 'number') { + return tickValue.toLocaleString(this.locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + }); + } + + return tickValue; + } + + return ''; + }, + display: this.showYAxis, + mirror: true, + z: 1 + }, + type: 'linear' + } + }, + spanGaps: true + }, + plugins: [ + getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme) + ], + type: 'line' + }); + } + } + + this.isLoading = false; + } + + private getAnimationConfigurationForAxis({ + axis, + labels + }: { + axis: 'x' | 'y'; + labels: string[]; + }): Partial[string]> { + const delayBetweenPoints = this.ANIMATION_DURATION / labels.length; + + return { + delay(context) { + if (context.type !== 'data' || context[`${axis}Started`]) { + return 0; + } + + context[`${axis}Started`] = true; + return context.dataIndex * delayBetweenPoints; + }, + duration: delayBetweenPoints, + easing: 'linear', + from: NaN, + type: 'number' + }; + } + + private getTooltipPluginConfiguration(): Partial> { + return { + ...getTooltipOptions({ + colorScheme: this.colorScheme, + currency: this.currency, + locale: this.locale, + unit: this.unit + }), + mode: 'index', + position: 'top', + xAlign: 'center', + yAlign: 'bottom' + }; + } +} diff --git a/libs/ui/src/lib/logo-carousel/index.ts b/libs/ui/src/lib/logo-carousel/index.ts new file mode 100644 index 000000000..e69f6039c --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/index.ts @@ -0,0 +1 @@ +export * from './logo-carousel.component'; diff --git a/libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts b/libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts new file mode 100644 index 000000000..43dcfeefd --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/interfaces/interfaces.ts @@ -0,0 +1,7 @@ +export interface LogoItem { + className: string; + isMask?: boolean; + name: string; + title: string; + url: string; +} diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.html b/libs/ui/src/lib/logo-carousel/logo-carousel.component.html new file mode 100644 index 000000000..79b4ca11b --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.html @@ -0,0 +1,16 @@ + diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss b/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss new file mode 100644 index 000000000..352f52ee8 --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.scss @@ -0,0 +1,220 @@ +:host { + display: block; + overflow: hidden; + position: relative; + width: 100%; + + .logo-carousel-container { + &::before, + &::after { + content: ''; + height: 100%; + pointer-events: none; + position: absolute; + top: 0; + width: 100px; + z-index: 2; + } + + &::before { + background: linear-gradient( + to right, + var(--light-background) 0%, + rgba(var(--palette-background-background), 0) 100% + ); + left: 0; + } + + &::after { + background: linear-gradient( + to left, + var(--light-background) 0%, + rgba(var(--palette-background-background), 0) 100% + ); + right: 0; + } + + @media (max-width: 768px) { + &::before, + &::after { + width: 50px; + } + } + + @media (max-width: 576px) { + &::before, + &::after { + width: 30px; + } + } + + .logo-carousel-track { + animation: scroll 60s linear infinite; + width: fit-content; + + &:hover { + animation-play-state: paused; + } + + .logo-carousel-item { + flex-shrink: 0; + min-width: 200px; + padding: 0 2rem; + + @media (max-width: 768px) { + min-width: 150px; + padding: 0 1.5rem; + } + + @media (max-width: 576px) { + min-width: 120px; + padding: 0 1rem; + } + + .logo { + height: 3rem; + transition: + opacity 0.3s ease, + transform 0.3s ease; + width: 7.5rem; + + &:hover { + opacity: 0.8; + } + + &.mask { + background-color: rgba(var(--dark-secondary-text)); + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + } + + &.logo-alternative-to { + mask-image: url('/assets/images/logo-alternative-to.svg'); + } + + &.logo-awesome { + background-image: url('/assets/images/logo-awesome.png'); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + filter: grayscale(1); + } + + &.logo-dev-community { + mask-image: url('/assets/images/logo-dev-community.svg'); + } + + &.logo-hacker-news { + mask-image: url('/assets/images/logo-hacker-news.svg'); + } + + &.logo-openalternative { + mask-image: url('/assets/images/logo-openalternative.svg'); + } + + &.logo-oss-gallery { + mask-image: url('/assets/images/logo-oss-gallery.svg'); + } + + &.logo-privacy-tools { + mask-image: url('/assets/images/logo-privacy-tools.svg'); + } + + &.logo-product-hunt { + background-image: url('/assets/images/logo-product-hunt.png'); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + filter: grayscale(1); + } + + &.logo-reddit { + mask-image: url('/assets/images/logo-reddit.svg'); + max-height: 1rem; + } + + &.logo-sackgeld { + mask-image: url('/assets/images/logo-sackgeld.png'); + } + + &.logo-selfh-st { + mask-image: url('/assets/images/logo-selfh-st.svg'); + max-height: 1.25rem; + } + + &.logo-selfhostedhub { + background-image: url('/assets/images/logo-selfhostedhub.svg'); + background-position: center; + background-repeat: no-repeat; + background-size: contain; + filter: grayscale(1); + opacity: 0.5; + } + + &.logo-sourceforge { + mask-image: url('/assets/images/logo-sourceforge.svg'); + } + + &.logo-umbrel { + mask-image: url('/assets/images/logo-umbrel.svg'); + max-height: 1.5rem; + } + + &.logo-unraid { + mask-image: url('/assets/images/logo-unraid.svg'); + } + + @media (max-width: 768px) { + height: 2.5rem; + width: 6rem; + } + + @media (max-width: 576px) { + height: 2rem; + width: 5rem; + } + } + } + + @keyframes scroll { + 0% { + transform: translateX(0); + } + 100% { + transform: translateX(-50%); + } + } + } + } +} + +:host-context(.theme-dark) { + .logo-carousel-container { + &::before { + background: linear-gradient( + to right, + var(--dark-background) 0%, + rgba(var(--palette-background-background-dark), 0) 100% + ); + } + + &::after { + background: linear-gradient( + to left, + var(--dark-background) 0%, + rgba(var(--palette-background-background-dark), 0) 100% + ); + } + + .logo-carousel-track { + .logo-carousel-item { + .logo { + &.mask { + background-color: rgba(var(--light-secondary-text)); + } + } + } + } + } +} diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts b/libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts new file mode 100644 index 000000000..a4e88df98 --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.stories.ts @@ -0,0 +1,13 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfLogoCarouselComponent } from './logo-carousel.component'; + +const meta: Meta = { + title: 'Logo Carousel', + component: GfLogoCarouselComponent +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts b/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts new file mode 100644 index 000000000..cf50017e6 --- /dev/null +++ b/libs/ui/src/lib/logo-carousel/logo-carousel.component.ts @@ -0,0 +1,121 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +import { LogoItem } from './interfaces/interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'gf-logo-carousel', + styleUrls: ['./logo-carousel.component.scss'], + templateUrl: './logo-carousel.component.html' +}) +export class GfLogoCarouselComponent { + public readonly logos: LogoItem[] = [ + { + className: 'logo-alternative-to', + isMask: true, + name: 'AlternativeTo', + title: 'AlternativeTo - Crowdsourced software recommendations', + url: 'https://alternativeto.net' + }, + { + className: 'logo-awesome', + name: 'Awesome Selfhosted', + title: + 'Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers', + url: 'https://github.com/awesome-selfhosted/awesome-selfhosted' + }, + { + className: 'logo-dev-community', + isMask: true, + name: 'DEV Community', + title: + 'DEV Community - A constructive and inclusive social network for software developers', + url: 'https://dev.to' + }, + { + className: 'logo-hacker-news', + isMask: true, + name: 'Hacker News', + title: 'Hacker News', + url: 'https://news.ycombinator.com' + }, + { + className: 'logo-openalternative', + isMask: true, + name: 'OpenAlternative', + title: 'OpenAlternative: Open Source Alternatives to Popular Software', + url: 'https://openalternative.co' + }, + { + className: 'logo-oss-gallery', + isMask: true, + name: 'OSS Gallery', + title: 'OSS Gallery: Discover the best open-source projects', + url: 'https://oss.gallery' + }, + { + className: 'logo-privacy-tools', + isMask: true, + name: 'Privacy Tools', + title: 'Privacy Tools: Software Alternatives and Encryption', + url: 'https://www.privacytools.io' + }, + { + className: 'logo-product-hunt', + name: 'Product Hunt', + title: 'Product Hunt – The best new products in tech.', + url: 'https://www.producthunt.com' + }, + { + className: 'logo-reddit', + isMask: true, + name: 'Reddit', + title: 'Reddit - Dive into anything', + url: 'https://www.reddit.com' + }, + { + className: 'logo-sackgeld', + isMask: true, + name: 'Sackgeld', + title: 'Sackgeld.com – Apps für ein höheres Sackgeld', + url: 'https://www.sackgeld.com' + }, + { + className: 'logo-selfh-st', + isMask: true, + name: 'selfh.st', + title: 'selfh.st — Self-hosted content and software', + url: 'https://selfh.st' + }, + { + className: 'logo-selfhostedhub', + name: 'SelfhostedHub', + title: 'SelfhostedHub — Discover best self-hosted software', + url: 'https://selfhostedhub.com' + }, + { + className: 'logo-sourceforge', + isMask: true, + name: 'SourceForge', + title: + 'SourceForge: The Complete Open-Source and Business Software Platform', + url: 'https://sourceforge.net' + }, + { + className: 'logo-umbrel', + isMask: true, + name: 'Umbrel', + title: 'Umbrel — A personal server OS for self-hosting', + url: 'https://umbrel.com' + }, + { + className: 'logo-unraid', + isMask: true, + name: 'Unraid', + title: 'Unraid | Unleash Your Hardware', + url: 'https://unraid.net' + } + ]; + + public readonly logosRepeated = [...this.logos, ...this.logos]; +} diff --git a/libs/ui/src/lib/logo/index.ts b/libs/ui/src/lib/logo/index.ts new file mode 100644 index 000000000..9a94f8985 --- /dev/null +++ b/libs/ui/src/lib/logo/index.ts @@ -0,0 +1 @@ +export * from './logo.component'; diff --git a/libs/ui/src/lib/logo/logo.component.html b/libs/ui/src/lib/logo/logo.component.html new file mode 100644 index 000000000..6e31e0f9e --- /dev/null +++ b/libs/ui/src/lib/logo/logo.component.html @@ -0,0 +1,6 @@ + + @if (showLabel) { + {{ label ?? 'Ghostfolio' }} + } + diff --git a/libs/ui/src/lib/logo/logo.component.scss b/libs/ui/src/lib/logo/logo.component.scss new file mode 100644 index 000000000..f0cbd5f75 --- /dev/null +++ b/libs/ui/src/lib/logo/logo.component.scss @@ -0,0 +1,34 @@ +:host { + .label { + font-weight: 600; + letter-spacing: -0.03em; + } + + .logo { + background-color: currentColor; + margin-top: -2px; + mask: url('/assets/ghost.svg') no-repeat center; + } +} + +:host-context(.large) { + .label { + font-size: 3rem; + } + + .logo { + height: 2.5rem; + width: 2.5rem; + } +} + +:host-context(.medium) { + .label { + font-size: 1.5rem; + } + + .logo { + height: 1.5rem; + width: 1.5rem; + } +} diff --git a/libs/ui/src/lib/logo/logo.component.stories.ts b/libs/ui/src/lib/logo/logo.component.stories.ts new file mode 100644 index 000000000..50f047184 --- /dev/null +++ b/libs/ui/src/lib/logo/logo.component.stories.ts @@ -0,0 +1,32 @@ +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfLogoComponent } from './logo.component'; + +export default { + title: 'Logo', + component: GfLogoComponent, + decorators: [ + moduleMetadata({ + imports: [] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; + +export const Large: Story = { + args: { + size: 'large' + } +}; + +export const WithoutLabel: Story = { + args: { + showLabel: false + } +}; diff --git a/libs/ui/src/lib/logo/logo.component.ts b/libs/ui/src/lib/logo/logo.component.ts new file mode 100644 index 000000000..0b766429c --- /dev/null +++ b/libs/ui/src/lib/logo/logo.component.ts @@ -0,0 +1,22 @@ +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + HostBinding, + Input +} from '@angular/core'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-logo', + styleUrls: ['./logo.component.scss'], + templateUrl: './logo.component.html' +}) +export class GfLogoComponent { + @HostBinding('class') @Input() size: 'large' | 'medium' = 'medium'; + @Input() label: string; + @Input() showLabel = true; +} diff --git a/libs/ui/src/lib/membership-card/index.ts b/libs/ui/src/lib/membership-card/index.ts new file mode 100644 index 000000000..1a0b5cac1 --- /dev/null +++ b/libs/ui/src/lib/membership-card/index.ts @@ -0,0 +1 @@ +export * from './membership-card.component'; diff --git a/libs/ui/src/lib/membership-card/membership-card.component.html b/libs/ui/src/lib/membership-card/membership-card.component.html new file mode 100644 index 000000000..9faac0d3d --- /dev/null +++ b/libs/ui/src/lib/membership-card/membership-card.component.html @@ -0,0 +1,54 @@ + diff --git a/libs/ui/src/lib/membership-card/membership-card.component.scss b/libs/ui/src/lib/membership-card/membership-card.component.scss new file mode 100644 index 000000000..fcd923f12 --- /dev/null +++ b/libs/ui/src/lib/membership-card/membership-card.component.scss @@ -0,0 +1,232 @@ +:host { + --borderRadius: 1rem; + --borderWidth: 2px; + --hover3dSpotlightOpacity: 0.2; + + display: block; + max-width: 25rem; + padding-top: calc(1 * var(--borderWidth)); + width: 100%; + + .card-wrapper { + &.hover-3d { + perspective: 1000px; + } + + .card-container { + border-radius: var(--borderRadius); + box-shadow: 0 5px 15px rgba(0, 0, 0, 0.15); + + .card-item { + aspect-ratio: 1.586; + background-color: #1d2124; + border-radius: calc(var(--borderRadius) - var(--borderWidth)); + color: rgba(var(--light-primary-text)); + line-height: 1.2; + + button { + color: rgba(var(--light-primary-text)); + height: 1.5rem; + z-index: 3; + } + + .heading { + font-size: 13px; + } + + .value { + font-size: 18px; + } + } + + &:not(.premium) { + &::after { + opacity: 0; + } + + .card-item { + background-color: #ffffff; + color: rgba(var(--dark-primary-text)); + } + } + } + + &.hover-3d { + --hover3d-rotate-x: 0; + --hover3d-rotate-y: 0; + --hover3d-shine: 100% 100%; + + .card-container { + overflow: hidden; + position: relative; + scale: 1; + transform: rotate3d( + var(--hover3d-rotate-x), + var(--hover3d-rotate-y), + 0, + 10deg + ); + transform-style: preserve-3d; + transition: + box-shadow 400ms ease-out, + scale 500ms ease-out, + transform 500ms ease-out; + will-change: transform, scale; + + &::before { + background-image: radial-gradient( + circle at 50%, + rgba(255, 255, 255, var(--hover3dSpotlightOpacity)) 10%, + transparent 50% + ); + content: ''; + filter: blur(0.75rem); + height: 33.333%; + opacity: 0; + pointer-events: none; + position: absolute; + scale: 500%; + translate: var(--hover3d-shine); + transition: + opacity 400ms ease-out, + translate 400ms ease-out; + width: 33.333%; + z-index: 1; + } + + .card-item { + position: relative; + + .hover-zone { + height: 33.333%; + width: 33.333%; + z-index: 2; + + &:nth-child(1) { + left: 0; + top: 0; + } + + &:nth-child(2) { + left: 33.333%; + top: 0; + } + + &:nth-child(3) { + right: 0; + top: 0; + } + + &:nth-child(4) { + left: 0; + top: 33.333%; + } + + &:nth-child(5) { + left: 33.333%; + top: 33.333%; + } + + &:nth-child(6) { + right: 0; + top: 33.333%; + } + + &:nth-child(7) { + bottom: 0; + left: 0; + } + + &:nth-child(8) { + bottom: 0; + left: 33.333%; + } + + &:nth-child(9) { + bottom: 0; + right: 0; + } + } + } + } + + &:has(.hover-zone:hover) .card-container { + box-shadow: 0 18px 40px rgba(15, 23, 42, 0.3); + scale: 1.05; + + &::before { + opacity: 1; + } + } + + &:has(.hover-zone:nth-child(1):hover) { + --hover3d-rotate-x: 1; + --hover3d-rotate-y: -1; + --hover3d-shine: 0% 0%; + } + + &:has(.hover-zone:nth-child(2):hover) { + --hover3d-rotate-x: 1; + --hover3d-rotate-y: 0; + --hover3d-shine: 100% 0%; + } + + &:has(.hover-zone:nth-child(3):hover) { + --hover3d-rotate-x: 1; + --hover3d-rotate-y: 1; + --hover3d-shine: 200% 0%; + } + + &:has(.hover-zone:nth-child(4):hover) { + --hover3d-rotate-x: 0; + --hover3d-rotate-y: -1; + --hover3d-shine: 0% 100%; + } + + &:has(.hover-zone:nth-child(5):hover) { + --hover3d-rotate-x: 0; + --hover3d-rotate-y: 0; + --hover3d-shine: 100% 100%; + } + + &:has(.hover-zone:nth-child(6):hover) { + --hover3d-rotate-x: 0; + --hover3d-rotate-y: 1; + --hover3d-shine: 200% 100%; + } + + &:has(.hover-zone:nth-child(7):hover) { + --hover3d-rotate-x: -1; + --hover3d-rotate-y: -1; + --hover3d-shine: 0% 200%; + } + + &:has(.hover-zone:nth-child(8):hover) { + --hover3d-rotate-x: -1; + --hover3d-rotate-y: 0; + --hover3d-shine: 100% 200%; + } + + &:has(.hover-zone:nth-child(9):hover) { + --hover3d-rotate-x: -1; + --hover3d-rotate-y: 1; + --hover3d-shine: 200% 200%; + } + } + } + + @media (prefers-reduced-motion: reduce) { + .card-wrapper.hover-3d { + .card-container { + scale: 1 !important; + transform: none !important; + transition: none !important; + + &::before { + opacity: 0 !important; + transition: none !important; + } + } + } + } +} diff --git a/libs/ui/src/lib/membership-card/membership-card.component.stories.ts b/libs/ui/src/lib/membership-card/membership-card.component.stories.ts new file mode 100644 index 000000000..0d475bda7 --- /dev/null +++ b/libs/ui/src/lib/membership-card/membership-card.component.stories.ts @@ -0,0 +1,55 @@ +import { CommonModule } from '@angular/common'; +import '@angular/localize/init'; +import { MatButtonModule } from '@angular/material/button'; +import { ActivatedRoute, RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { addYears } from 'date-fns'; + +import { GfLogoComponent } from '../logo'; +import { GfMembershipCardComponent } from './membership-card.component'; + +export default { + title: 'Membership Card', + component: GfMembershipCardComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + GfLogoComponent, + IonIcon, + MatButtonModule, + RouterModule.forChild([]) + ], + providers: [{ provide: ActivatedRoute, useValue: {} }] + }) + ], + argTypes: { + hover3d: { + control: { type: 'boolean' } + }, + name: { + control: { type: 'select' }, + options: ['Basic', 'Premium'] + } + } +} as Meta; + +type Story = StoryObj; + +export const Basic: Story = { + args: { + hover3d: false, + name: 'Basic' + } +}; + +export const Premium: Story = { + args: { + expiresAt: addYears(new Date(), 1).toLocaleDateString(), + hasPermissionToCreateApiKey: true, + hover3d: false, + name: 'Premium' + } +}; diff --git a/libs/ui/src/lib/membership-card/membership-card.component.ts b/libs/ui/src/lib/membership-card/membership-card.component.ts new file mode 100644 index 000000000..be223758d --- /dev/null +++ b/libs/ui/src/lib/membership-card/membership-card.component.ts @@ -0,0 +1,54 @@ +import { publicRoutes } from '@ghostfolio/common/routes/routes'; + +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { refreshOutline } from 'ionicons/icons'; + +import { GfLogoComponent } from '../logo'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfLogoComponent, + IonIcon, + MatButtonModule, + RouterModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-membership-card', + styleUrls: ['./membership-card.component.scss'], + templateUrl: './membership-card.component.html' +}) +export class GfMembershipCardComponent { + @Input() public expiresAt: string; + @Input() public hasPermissionToCreateApiKey: boolean; + @Input() public hover3d = false; + @Input() public name: string; + + @Output() generateApiKeyClicked = new EventEmitter(); + + public routerLinkPricing = publicRoutes.pricing.routerLink; + + public constructor() { + addIcons({ refreshOutline }); + } + + public onGenerateApiKey(event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + this.generateApiKeyClicked.emit(); + } +} diff --git a/libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts b/libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts new file mode 100644 index 000000000..3f4dbbef7 --- /dev/null +++ b/libs/ui/src/lib/mocks/entity-logo-image-source.service.mock.ts @@ -0,0 +1,24 @@ +import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; + +import { DataSource } from '@prisma/client'; + +export class EntityLogoImageSourceServiceMock { + public getLogoUrlByAssetProfileIdentifier({ + dataSource, + symbol + }: AssetProfileIdentifier) { + if (dataSource === DataSource.YAHOO && symbol === 'AAPL') { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAJa0lEQVR4nM2bW2wU1xnHf3vx2t61za5nL/bGKwx2jQnwUh5Q3bqyHYhzIVLUFoIaSKtGSrkkNaQJqlQSKaHQFygQJamUSomaRKpSlYekqSF1HGix6EOktIldOQs0OCEs3ssw48Ve8F77MGuwza4ve2Y3/KR92Ln8z/d9OnPmnG/OZ/jss2FKRA3QCiwDPEAtUAEkgWvAGKAAF4HPgWgpjDIXUdsF3AN0Zn/NVmuFoazMgtlswmw2YTAYyGQgnU6TSqVIpdIkEnFisRsZ4AJwGjgFfAiEi2GkQeceUAn8CPgJ0GG315hsNis2m5XycsuihCYn40xMxJiYiKGq0RRaMP4I/AW4rpfBegXAAfQAu+z2JU6HowabzYbBoIc0ZDIwMTGBokRR1bEI8ApwDO2REUI0AGbgKeB5t1uyS1ItZrNJ1KY5SSZTyPJVQiFZBfYDL6GNIwUhEoDvAr+XJMcaSXIsuouLEo/HiUQUZFkZBHYCA4XoGAu4xwS8APzD5/Ou8Xo9JXcewGKx4PV68Pm8a9DGhxezti2KxfYAF/COJDk63W5n0bv7QkkmU4RCEWRZOQU8wiLeGIvpAY3AgNvt7PR6PXeM8wBmswmv14Pb7exEexQaF3rvQgOwCjhbV+dq8XicBZhYGjweJ3V1rhbgLJrN87KQADQBJ71eT73LJYnYVxJcLgmv11MPnASa57t+vgC4gJP19e4GSXLoYV9JkCQH9fXuBuAEmg95mSsAZcBxt9vZ7HTW6mlfXsbHJzh27CU6Ojppbm7h4sWLBWs5nbW43c5m4DiaLzmZay2wX5Ic7aV65s+dO8fu3U8zNDQEgM1mo7q6WkjT43GSSqXaZVnZD/wq1zX5esD3gGfr6ubsPbpx/vx5tm597KbzAK2tK6itFe95WR+eRfPpNnIFwAy86vN5jUZjIfOkxTE5Ocnu3U8zOjo64/hDD21Ej/aNRiM+n9cIvEqOHp+rhR5Jcqyx22uEG18IfX19DA4OzjhWX1/Pww8/rFsbdnsNkuRYA/xi9rnZAagB9pVq0AN4//3eGf+rq6s5cuSwLt1/Ok6nA+A5NB9vMjsAO10uyW6x5B00dSWRSPDpp/+5+b+lpYW3336LtrY23duyWCy4XJIdbeF0k+nPRCWwR5Lsujeej3Q6zbJly1m6dCnd3d1s27YVs7l4SSpJshMOy3uAo8ANmLkY2mK31/zJ5/Pq1qAsy/T1fcjg4CCKomK1VrJy5Uo6Ojpoalp+2/VDQ0P09X2I33+O8fFrlJdX4PXW09bWRldXJ+Xl5cI2ffVVgLGx6BbgHZgZgL81NjY8UF1dJdxIMpnktdf+wOuvv0EoFLrtfEVFBV1dXWzc+AB33dXA4OAg7733Vz7++GMymUxOzdbWVvbufYb169cL2Xbt2jgjI1/3Ag/CrQC4gMDq1SvMBsE81uTkJDt3PklfX5+QTj4OHjzA1q2PFnx/JpNhaMifBLxAeGoQvMdurxF2PpVK0dOzp2jOA+zb9xx+v7/g+w0GA3Z7jRktY33zLdBls1mFjXvrrbfp7e2d/8ICMRgMHDjwG5qamoR0sr52wa23QGdVlVgAotEoL7/8ipDGfDz//HM8+uiPhXWyAegArQdYgeUWi1he78SJkzkHPL3o7r6Xxx//mS5a2RxmE1BpBJorKsqFJ939/f2iEnkxmUw89dSTumpmff6WEWjVI6s7MvKlsEY+li9fzqpVC8pwLZiszyuMQH1ZmfjsKxodE9bIR3NzEyaTvknY7Iyz3gg49BCfnIwLa+SjslL8DTWbrM8OI1Au+v4HdFm750NRruquaTQaACqMQCrP7HNR2Gw2cZE8DA9/TiwW01Uz63PSCFxLp9PCgnb7EmGNfIyOjnL69GldNbM+jxuBaDqdEhZctmyZsMZcHDlyTNdekPU5agTkZFI8AOvWrRPWmAu/309Pz24SiYQuelmfw0bgcz1G8La271BWVtxM0gcf/J0nntjO5cuXhbWyPp8zAhdu3JgUHgQaGxtZu/bbwobNR39/P5s2PSKsk/X5vBGIAV/E4+K9YMuWLcIaCyGZLHhDCKBtrgC+AK5PvbxPjY+LDzD33deNz+cT1pmPzZs3Cd2f9fUU3MoHfDQxIR4Aq9XKrl07hHXmora2lm3btgppZH2dEYB+VY0m8+XjFsPmzZtZvXq1sE4+duzYjsfjKfj+TCaDqkaTQD/cCkAY6NPjMTCbzezf/0JR0tt3372Sxx7bJqSR9bEPCMHMDyNvqqo+K7q1a9eyd++zumhNUVVl4/DhQ1RWVgrpZH18c+r/9AC8q6rRsF4Tje3bfy78rE5hNps5evSocE4gkUigqtEQ8O5N7WnnrwO/k2X1t3p9Fn/xxReIxa5z/PjxGcdtNhvr199De3s7DQ0NxONx/H4/J06c4JNP/j3j2qoqGwcPHuTeezcI2yPLKsARpm21nb1Nrgb4csWKJl2/D/b29nLmzACJRIKWlhY2bFifc+2QyWQYGBjg7Nl/oSgqdXUeNm58kObmebf6zEs8nsDv/58KLGXaTvRc+wR/KUmOQ15v4SPtnUggEESWlWeAw9OP58piHJNlZUhVS7JdvySoanRqS+2x2edyBSAJ7Lh0KZDWI0/wTZNOp7l0KZBG+yx+2xw6Xx5rADg0OlqUGoWSEgyGAQ6RZzP1XIm8fbKsnAkGI8WwqyQEgxEiEWUAbWdITuYKQAL4YSgUuRCJ6J+ULDaRyFVCocgF4AdA3qXufKncMHD/lSuhr2VZuDijZMiywpUrocvA/cyzc3whuewLwH2BQPBKOCzrYV9RCYdlAoHgFaAbzfY5WWgy/79A2+ho+NydPCYEgxFGR8MXgDY0m+dlMV8zRoD2UChyKhAIokciVS+SyRSBQJBQKHIKrZRnZKH3LvZzTgjYIMvK/uHh8+k7YbKkqlGGh8+ns/uBN5Bd5i4UkaKpduBVSXKsdjprKdXewini8QSRyFVkWRlCm+ScKURHj7K5HuDXLpfkkCQHenxpnotEIoksK4TDsgIcQJvefiNlc9OpAZ4EehyOJW6HYwlWq1XXwslYLIaijKEoYyE0p19Gh/riYpTObgJ+Cnx/qnS2qsrKYrfgxONxxsdnlM7+E6109s/cgaWzufCg7cTqAjqMRmNzRUU5ZWVllJWZMJnyFU8nuHFjknQ6PVU8/VH2FyyGkcUMwGxqgJVoJW11gARYuL18fgQYpkTl8/8HqhBYlUKrXOwAAAAASUVORK5CYII='; + } + + return ''; + } + + public getLogoUrlByUrl(url: string) { + if (url === 'https://ghostfol.io') { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAAP1BMVEU2z8v////x/Pspzcn0/Px73drR8/KC3tz6/v6e5eOl5+WM4d7n+PhI08/i9/eu6edV1dJk2NVx2teV4+G87evvttLSAAABDElEQVRYhe2V3Y6EIAyFrQXlTwHd93/WpeBsdiKbtF7tJJwbCKFfDlKO0zQ0NPQJwqrn1ZO2zlk9PWPgusClRcsJmHb4pWUTItDDu4zMBJ5w0yog4HGvB0gCgOoBZrYFdL16AMsmmD5AcQ3ofj3AwbOAX38BIhMw02ZjtQ+tLnht66mCAGA2eka1G3eabUZwD/PLbeuHenKMBODVV0Dru/TTQLgKAc1BvQ/9yAEkOnlon66oehEBIHp7dbSyPoIc0NEADMDnAa4wAvVK+CADRGx/VpNSi+iF3jMTUH4rJQ0aISPmVk+JoJiZeJw1QnaqL2OmWKSFkxnrZWsbXK4TzO5tmS+8TYaGhv6xvgEEfAgHGc7HRgAAAABJRU5ErkJggg=='; + } + + return ''; + } +} diff --git a/libs/ui/src/lib/mocks/holdings.ts b/libs/ui/src/lib/mocks/holdings.ts new file mode 100644 index 000000000..5ab3e89eb --- /dev/null +++ b/libs/ui/src/lib/mocks/holdings.ts @@ -0,0 +1,293 @@ +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; + +export const holdings: PortfolioPosition[] = [ + { + activitiesCount: 1, + allocationInPercentage: 0.042990776363386086, + assetClass: 'EQUITY', + assetClassLabel: 'Equity', + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: new Date('2021-12-01T00:00:00.000Z'), + dividend: 0, + grossPerformance: 3856, + grossPerformancePercent: 0.46047289228564603, + grossPerformancePercentWithCurrencyEffect: 0.46047289228564603, + grossPerformanceWithCurrencyEffect: 3856, + holdings: [], + investment: 8374, + marketPrice: 244.6, + name: 'Apple Inc', + netPerformance: 3855, + netPerformancePercent: 0.460353475041796, + netPerformancePercentWithCurrencyEffect: 0.036440677966101696, + netPerformanceWithCurrencyEffect: 430, + quantity: 50, + sectors: [ + { + name: 'Technology', + weight: 1 + } + ], + symbol: 'AAPL', + tags: [], + url: 'https://www.apple.com', + valueInBaseCurrency: 12230 + }, + { + activitiesCount: 2, + allocationInPercentage: 0.02377401948293552, + assetClass: 'EQUITY', + assetClassLabel: 'Equity', + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + countries: [ + { + code: 'DE', + weight: 1, + continent: 'Europe', + name: 'Germany' + } + ], + currency: 'EUR', + dataSource: 'YAHOO', + dateOfFirstActivity: new Date('2021-04-23T00:00:00.000Z'), + dividend: 192, + grossPerformance: 2226.700251889169, + grossPerformancePercent: 0.49083842309827874, + grossPerformancePercentWithCurrencyEffect: 0.29306136948826367, + grossPerformanceWithCurrencyEffect: 1532.8272791336772, + holdings: [], + investment: 4536.523929471033, + marketPrice: 322.2, + name: 'Allianz SE', + netPerformance: 2222.2921914357685, + netPerformancePercent: 0.48986674069961134, + netPerformancePercentWithCurrencyEffect: 0.034489367670592026, + netPerformanceWithCurrencyEffect: 225.48257403052068, + quantity: 20, + sectors: [ + { + name: 'Financial Services', + weight: 1 + } + ], + symbol: 'ALV.DE', + tags: [], + url: 'https://www.allianz.com', + valueInBaseCurrency: 6763.224181360202 + }, + { + activitiesCount: 1, + allocationInPercentage: 0.08038536990007467, + assetClass: 'EQUITY', + assetClassLabel: 'Equity', + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: new Date('2018-10-01T00:00:00.000Z'), + dividend: 0, + grossPerformance: 12758.05, + grossPerformancePercent: 1.2619300787837724, + grossPerformancePercentWithCurrencyEffect: 1.2619300787837724, + grossPerformanceWithCurrencyEffect: 12758.05, + holdings: [], + investment: 10109.95, + marketPrice: 228.68, + name: 'Amazon.com, Inc.', + netPerformance: 12677.26, + netPerformancePercent: 1.253938941339967, + netPerformancePercentWithCurrencyEffect: -0.037866008722316276, + netPerformanceWithCurrencyEffect: -899.99926757812, + quantity: 100, + sectors: [ + { + name: 'Consumer Discretionary', + weight: 1 + } + ], + symbol: 'AMZN', + tags: [], + url: 'https://www.aboutamazon.com', + valueInBaseCurrency: 22868 + }, + { + activitiesCount: 1, + allocationInPercentage: 0.19216416482928922, + assetClass: 'LIQUIDITY', + assetClassLabel: 'Liquidity', + assetSubClass: 'CRYPTOCURRENCY', + assetSubClassLabel: 'Cryptocurrency', + countries: [], + currency: 'USD', + dataSource: 'COINGECKO', + dateOfFirstActivity: new Date('2017-08-16T00:00:00.000Z'), + dividend: 0, + grossPerformance: 52666.7898248, + grossPerformancePercent: 26.333394912400003, + grossPerformancePercentWithCurrencyEffect: 26.333394912400003, + grossPerformanceWithCurrencyEffect: 52666.7898248, + holdings: [], + investment: 1999.9999999999998, + marketPrice: 97364, + name: 'Bitcoin', + netPerformance: 52636.8898248, + netPerformancePercent: 26.3184449124, + netPerformancePercentWithCurrencyEffect: -0.04760906442310894, + netPerformanceWithCurrencyEffect: -2732.737808972287, + quantity: 0.5614682, + sectors: [], + symbol: 'bitcoin', + tags: [], + url: undefined, + valueInBaseCurrency: 54666.7898248 + }, + { + activitiesCount: 1, + allocationInPercentage: 0.04307127421937313, + assetClass: 'EQUITY', + assetClassLabel: 'Equity', + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: new Date('2023-01-03T00:00:00.000Z'), + dividend: 0, + grossPerformance: 5065.5, + grossPerformancePercent: 0.7047750229568411, + grossPerformancePercentWithCurrencyEffect: 0.7047750229568411, + grossPerformanceWithCurrencyEffect: 5065.5, + holdings: [], + investment: 7187.4, + marketPrice: 408.43, + name: 'Microsoft Corporation', + netPerformance: 5065.5, + netPerformancePercent: 0.7047750229568411, + netPerformancePercentWithCurrencyEffect: -0.015973588391056275, + netPerformanceWithCurrencyEffect: -198.899926757814, + quantity: 30, + sectors: [ + { + name: 'Technology', + weight: 1 + } + ], + symbol: 'MSFT', + tags: [], + url: 'https://www.microsoft.com', + valueInBaseCurrency: 12252.9 + }, + { + activitiesCount: 1, + allocationInPercentage: 0.18762679306394897, + assetClass: 'EQUITY', + assetClassLabel: 'Equity', + assetSubClass: 'STOCK', + assetSubClassLabel: 'Stock', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: new Date('2017-01-03T00:00:00.000Z'), + dividend: 0, + grossPerformance: 51227.500000005, + grossPerformancePercent: 23.843379101756675, + grossPerformancePercentWithCurrencyEffect: 23.843379101756675, + grossPerformanceWithCurrencyEffect: 51227.500000005, + holdings: [], + investment: 2148.499999995, + marketPrice: 355.84, + name: 'Tesla, Inc.', + netPerformance: 51197.500000005, + netPerformancePercent: 23.829415871596066, + netPerformancePercentWithCurrencyEffect: -0.12051410125545206, + netPerformanceWithCurrencyEffect: -7314.00091552734, + quantity: 150, + sectors: [ + { + name: 'Consumer Discretionary', + weight: 1 + } + ], + symbol: 'TSLA', + tags: [], + url: 'https://www.tesla.com', + valueInBaseCurrency: 53376 + }, + { + activitiesCount: 5, + allocationInPercentage: 0.053051250766657634, + assetClass: 'EQUITY', + assetClassLabel: 'Equity', + assetSubClass: 'ETF', + assetSubClassLabel: 'ETF', + countries: [ + { + code: 'US', + weight: 1, + continent: 'North America', + name: 'United States' + } + ], + currency: 'USD', + dataSource: 'YAHOO', + dateOfFirstActivity: new Date('2019-03-01T00:00:00.000Z'), + dividend: 0, + grossPerformance: 6845.8, + grossPerformancePercent: 1.0164758094605268, + grossPerformancePercentWithCurrencyEffect: 1.0164758094605268, + grossPerformanceWithCurrencyEffect: 6845.8, + holdings: [], + investment: 8246.2, + marketPrice: 301.84, + name: 'Vanguard Total Stock Market Index Fund ETF Shares', + netPerformance: 6746.3, + netPerformancePercent: 1.0017018833976383, + netPerformancePercentWithCurrencyEffect: 0.01085061564051406, + netPerformanceWithCurrencyEffect: 161.99969482422, + quantity: 50, + sectors: [ + { + name: 'Equity', + weight: 1 + } + ], + symbol: 'VTI', + tags: [], + url: 'https://www.vanguard.com', + valueInBaseCurrency: 15092 + } +]; diff --git a/libs/ui/src/lib/mocks/httpClient.mock.ts b/libs/ui/src/lib/mocks/httpClient.mock.ts new file mode 100644 index 000000000..6ef79af61 --- /dev/null +++ b/libs/ui/src/lib/mocks/httpClient.mock.ts @@ -0,0 +1,18 @@ +import { Observable } from 'rxjs'; + +export class HttpClientMock { + public constructor(private mockResponses: Map) {} + + public get(url: string, options?: any): Observable { + if (this.mockResponses.has(url) && options) { + return new Observable((subscriber) => { + subscriber.next(this.mockResponses.get(url)); + subscriber.complete(); + }); + } + + return new Observable((subscriber) => { + subscriber.error(new Error(`No mock data for URL: ${url}`)); + }); + } +} diff --git a/libs/ui/src/lib/no-transactions-info/index.ts b/libs/ui/src/lib/no-transactions-info/index.ts new file mode 100644 index 000000000..956d0304d --- /dev/null +++ b/libs/ui/src/lib/no-transactions-info/index.ts @@ -0,0 +1 @@ +export * from './no-transactions-info.component'; diff --git a/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.html b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.html new file mode 100644 index 000000000..f1a2a3f90 --- /dev/null +++ b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.html @@ -0,0 +1,14 @@ + diff --git a/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.scss b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.scss new file mode 100644 index 000000000..14d72b7da --- /dev/null +++ b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.scss @@ -0,0 +1,16 @@ +:host { + display: block; + + &.has-border { + border: 1px solid rgba(var(--dark-dividers)); + border-radius: 0.25rem; + } + + gf-logo { + opacity: 0.25; + } +} + +:host-context(.theme-dark) { + border-color: rgba(var(--light-dividers)); +} diff --git a/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.stories.ts b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.stories.ts new file mode 100644 index 000000000..faf536244 --- /dev/null +++ b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.stories.ts @@ -0,0 +1,23 @@ +import { GfLogoComponent } from '@ghostfolio/ui/logo'; + +import { RouterTestingModule } from '@angular/router/testing'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfNoTransactionsInfoComponent } from './no-transactions-info.component'; + +export default { + title: 'No Transactions Info', + component: GfNoTransactionsInfoComponent, + decorators: [ + moduleMetadata({ + imports: [GfLogoComponent, RouterTestingModule] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; diff --git a/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.ts b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.ts new file mode 100644 index 000000000..8691dc998 --- /dev/null +++ b/libs/ui/src/lib/no-transactions-info/no-transactions-info.component.ts @@ -0,0 +1,28 @@ +import { internalRoutes } from '@ghostfolio/common/routes/routes'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + HostBinding, + Input +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { RouterModule } from '@angular/router'; + +import { GfLogoComponent } from '../logo'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [GfLogoComponent, MatButtonModule, RouterModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-no-transactions-info-indicator', + styleUrls: ['./no-transactions-info.component.scss'], + templateUrl: './no-transactions-info.component.html' +}) +export class GfNoTransactionsInfoComponent { + @HostBinding('class.has-border') @Input() hasBorder = true; + + public routerLinkPortfolioActivities = + internalRoutes.portfolio.subRoutes.activities.routerLink; +} diff --git a/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts b/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts new file mode 100644 index 000000000..5d49ea9e5 --- /dev/null +++ b/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.component.ts @@ -0,0 +1,26 @@ +import { Component, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; + +import { AlertDialogParams } from './interfaces/interfaces'; + +@Component({ + imports: [MatButtonModule, MatDialogModule], + selector: 'gf-alert-dialog', + styleUrls: ['./alert-dialog.scss'], + templateUrl: './alert-dialog.html' +}) +export class GfAlertDialogComponent { + public discardLabel: string; + public message?: string; + public title: string; + + protected readonly dialogRef = + inject>(MatDialogRef); + + public initialize({ discardLabel, message, title }: AlertDialogParams) { + this.discardLabel = discardLabel; + this.message = message; + this.title = title; + } +} diff --git a/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.html b/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.html new file mode 100644 index 000000000..6602078d3 --- /dev/null +++ b/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.html @@ -0,0 +1,11 @@ +@if (title) { +
+} + +@if (message) { +
+} + +
+ +
diff --git a/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.scss b/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.scss new file mode 100644 index 000000000..dc9093b45 --- /dev/null +++ b/libs/ui/src/lib/notifications/alert-dialog/alert-dialog.scss @@ -0,0 +1,2 @@ +:host { +} diff --git a/libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..fa330c463 --- /dev/null +++ b/libs/ui/src/lib/notifications/alert-dialog/interfaces/interfaces.ts @@ -0,0 +1,5 @@ +export interface AlertDialogParams { + discardLabel: string; + message?: string; + title: string; +} diff --git a/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.component.ts b/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.component.ts new file mode 100644 index 000000000..0ae0adfff --- /dev/null +++ b/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.component.ts @@ -0,0 +1,45 @@ +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; + +import { Component, HostListener, inject } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; + +import { ConfirmDialogParams } from './interfaces/interfaces'; + +@Component({ + imports: [MatButtonModule, MatDialogModule], + selector: 'gf-confirmation-dialog', + styleUrls: ['./confirmation-dialog.scss'], + templateUrl: './confirmation-dialog.html' +}) +export class GfConfirmationDialogComponent { + public confirmLabel: string; + public confirmType: ConfirmationDialogType; + public discardLabel: string; + public message?: string; + public title: string; + + protected readonly dialogRef = + inject>(MatDialogRef); + + @HostListener('window:keyup', ['$event']) + public keyEvent(event: KeyboardEvent) { + if (event.key === 'Enter') { + this.dialogRef.close('confirm'); + } + } + + public initialize({ + confirmLabel, + confirmType, + discardLabel, + message, + title + }: ConfirmDialogParams) { + this.confirmLabel = confirmLabel; + this.confirmType = confirmType; + this.discardLabel = discardLabel; + this.message = message; + this.title = title; + } +} diff --git a/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.html b/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.html new file mode 100644 index 000000000..e9e2b693c --- /dev/null +++ b/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.html @@ -0,0 +1,20 @@ +@if (title) { +
+} + +@if (message) { +
+} + +
+ + +
diff --git a/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.scss b/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.scss new file mode 100644 index 000000000..dc9093b45 --- /dev/null +++ b/libs/ui/src/lib/notifications/confirmation-dialog/confirmation-dialog.scss @@ -0,0 +1,2 @@ +:host { +} diff --git a/libs/ui/src/lib/notifications/confirmation-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/notifications/confirmation-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..a7c380496 --- /dev/null +++ b/libs/ui/src/lib/notifications/confirmation-dialog/interfaces/interfaces.ts @@ -0,0 +1,9 @@ +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; + +export interface ConfirmDialogParams { + confirmLabel: string; + confirmType: ConfirmationDialogType; + discardLabel: string; + message?: string; + title: string; +} diff --git a/libs/ui/src/lib/notifications/index.ts b/libs/ui/src/lib/notifications/index.ts new file mode 100644 index 000000000..864083b16 --- /dev/null +++ b/libs/ui/src/lib/notifications/index.ts @@ -0,0 +1,3 @@ +export * from './interfaces/interfaces'; +export * from './notification.module'; +export * from './notification.service'; diff --git a/libs/ui/src/lib/notifications/interfaces/interfaces.ts b/libs/ui/src/lib/notifications/interfaces/interfaces.ts new file mode 100644 index 000000000..071597691 --- /dev/null +++ b/libs/ui/src/lib/notifications/interfaces/interfaces.ts @@ -0,0 +1,28 @@ +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; + +export interface AlertParams { + discardFn?: () => void; + discardLabel?: string; + message?: string; + title: string; +} + +export interface ConfirmParams { + confirmFn: () => void; + confirmLabel?: string; + confirmType?: ConfirmationDialogType; + disableClose?: boolean; + discardFn?: () => void; + discardLabel?: string; + message?: string; + title: string; +} + +export interface PromptParams { + confirmFn: (value: string) => void; + confirmLabel?: string; + defaultValue?: string; + discardLabel?: string; + title: string; + valueLabel?: string; +} diff --git a/libs/ui/src/lib/notifications/notification.module.ts b/libs/ui/src/lib/notifications/notification.module.ts new file mode 100644 index 000000000..542cae928 --- /dev/null +++ b/libs/ui/src/lib/notifications/notification.module.ts @@ -0,0 +1,18 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { MatDialogModule } from '@angular/material/dialog'; + +import { GfAlertDialogComponent } from './alert-dialog/alert-dialog.component'; +import { GfConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component'; +import { NotificationService } from './notification.service'; + +@NgModule({ + imports: [ + CommonModule, + GfAlertDialogComponent, + GfConfirmationDialogComponent, + MatDialogModule + ], + providers: [NotificationService] +}) +export class GfNotificationModule {} diff --git a/libs/ui/src/lib/notifications/notification.service.ts b/libs/ui/src/lib/notifications/notification.service.ts new file mode 100644 index 000000000..b9a2562f7 --- /dev/null +++ b/libs/ui/src/lib/notifications/notification.service.ts @@ -0,0 +1,106 @@ +import { ConfirmationDialogType } from '@ghostfolio/common/enums'; +import { translate } from '@ghostfolio/ui/i18n'; + +import { inject, Injectable } from '@angular/core'; +import { MatDialog } from '@angular/material/dialog'; +import { isFunction } from 'lodash'; + +import { GfAlertDialogComponent } from './alert-dialog/alert-dialog.component'; +import { GfConfirmationDialogComponent } from './confirmation-dialog/confirmation-dialog.component'; +import { + AlertParams, + ConfirmParams, + PromptParams +} from './interfaces/interfaces'; +import { GfPromptDialogComponent } from './prompt-dialog/prompt-dialog.component'; + +@Injectable() +export class NotificationService { + private dialogMaxWidth: string; + private dialogWidth: string; + + private readonly matDialog = inject(MatDialog); + + public alert(aParams: AlertParams) { + aParams.discardLabel ??= translate('CLOSE'); + + const dialog = this.matDialog.open(GfAlertDialogComponent, { + autoFocus: false, + maxWidth: this.dialogMaxWidth, + width: this.dialogWidth + }); + + dialog.componentInstance.initialize({ + discardLabel: aParams.discardLabel, + message: aParams.message, + title: aParams.title + }); + + return dialog.afterClosed().subscribe(() => { + if (isFunction(aParams.discardFn)) { + aParams.discardFn(); + } + }); + } + + public confirm(aParams: ConfirmParams) { + aParams.confirmLabel ??= translate('YES'); + aParams.discardLabel ??= translate('CANCEL'); + + const dialog = this.matDialog.open(GfConfirmationDialogComponent, { + autoFocus: false, + disableClose: aParams.disableClose ?? false, + maxWidth: this.dialogMaxWidth, + width: this.dialogWidth + }); + + dialog.componentInstance.initialize({ + confirmLabel: aParams.confirmLabel, + confirmType: aParams.confirmType ?? ConfirmationDialogType.Primary, + discardLabel: aParams.discardLabel, + message: aParams.message, + title: aParams.title + }); + + return dialog.afterClosed().subscribe((result) => { + if (result === 'confirm' && isFunction(aParams.confirmFn)) { + aParams.confirmFn(); + } else if (result === 'discard' && isFunction(aParams.discardFn)) { + aParams.discardFn(); + } + }); + } + + public prompt(aParams: PromptParams) { + aParams.confirmLabel ??= translate('OK'); + aParams.discardLabel ??= translate('CANCEL'); + + const dialog = this.matDialog.open(GfPromptDialogComponent, { + autoFocus: true, + maxWidth: this.dialogMaxWidth, + width: this.dialogWidth + }); + + dialog.componentInstance.initialize({ + confirmLabel: aParams.confirmLabel, + defaultValue: aParams.defaultValue, + discardLabel: aParams.discardLabel, + title: aParams.title, + valueLabel: aParams.valueLabel + }); + + return dialog.afterClosed().subscribe((result: string) => { + if (result !== 'discard' && isFunction(aParams.confirmFn)) { + aParams.confirmFn(result); + } + }); + } + + public setDialogMaxWidth(aDialogMaxWidth: string) { + this.dialogMaxWidth = aDialogMaxWidth; + } + + public setDialogWidth(aDialogWidth: string) { + this.dialogWidth = aDialogWidth; + } +} diff --git a/libs/ui/src/lib/notifications/prompt-dialog/interfaces/interfaces.ts b/libs/ui/src/lib/notifications/prompt-dialog/interfaces/interfaces.ts new file mode 100644 index 000000000..607577dfa --- /dev/null +++ b/libs/ui/src/lib/notifications/prompt-dialog/interfaces/interfaces.ts @@ -0,0 +1,7 @@ +export interface PromptDialogParams { + confirmLabel: string; + defaultValue?: string; + discardLabel: string; + title: string; + valueLabel?: string; +} diff --git a/libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.component.ts b/libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.component.ts new file mode 100644 index 000000000..2205e235c --- /dev/null +++ b/libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.component.ts @@ -0,0 +1,46 @@ +import { Component, inject } from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; + +import { PromptDialogParams } from './interfaces/interfaces'; + +@Component({ + imports: [ + MatButtonModule, + MatDialogModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule + ], + selector: 'gf-prompt-dialog', + templateUrl: './prompt-dialog.html' +}) +export class GfPromptDialogComponent { + public confirmLabel: string; + public defaultValue?: string; + public discardLabel: string; + public formControl = new FormControl(''); + public title: string; + public valueLabel?: string; + + protected readonly dialogRef = + inject>(MatDialogRef); + + public initialize({ + confirmLabel, + defaultValue, + discardLabel, + title, + valueLabel + }: PromptDialogParams) { + this.confirmLabel = confirmLabel; + this.defaultValue = defaultValue; + this.discardLabel = discardLabel; + this.formControl.setValue(defaultValue ?? null); + this.title = title; + this.valueLabel = valueLabel; + } +} diff --git a/libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.html b/libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.html new file mode 100644 index 000000000..d73c8dabb --- /dev/null +++ b/libs/ui/src/lib/notifications/prompt-dialog/prompt-dialog.html @@ -0,0 +1,26 @@ +@if (title) { +
+} + +
+ + @if (valueLabel) { + {{ valueLabel }} + } + + +
+ +
+ + +
diff --git a/libs/ui/src/lib/portfolio-filter-form/index.ts b/libs/ui/src/lib/portfolio-filter-form/index.ts new file mode 100644 index 000000000..51d22c034 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/index.ts @@ -0,0 +1,2 @@ +export * from './interfaces'; +export * from './portfolio-filter-form.component'; diff --git a/libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts b/libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts new file mode 100644 index 000000000..62feaa56a --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/interfaces/index.ts @@ -0,0 +1 @@ +export * from './portfolio-filter-form-value.interface'; diff --git a/libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts b/libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts new file mode 100644 index 000000000..21ff0ae3b --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/interfaces/portfolio-filter-form-value.interface.ts @@ -0,0 +1,8 @@ +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; + +export interface PortfolioFilterFormValue { + account: string; + assetClass: string; + holding: PortfolioPosition; + tag: string; +} diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html new file mode 100644 index 000000000..e017d33d6 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.html @@ -0,0 +1,75 @@ +
+
+ + Account + + + @for (account of accounts; track account.id) { + +
+ @if (account.platform?.url) { + + } + {{ account.name }} +
+
+ } +
+
+
+
+ + Holding + + {{ + filterForm.get('holding')?.value?.name + }} + + @for (holding of holdings; track holding.name) { + +
+ {{ holding.name }} +
+ {{ holding.symbol | gfSymbol }} · {{ holding.currency }} +
+
+ } +
+
+
+
+ + Tag + + + @for (tag of tags; track tag.id) { + {{ tag.label }} + } + + +
+
+ + Asset Class + + + @for (assetClass of assetClasses; track assetClass.id) { + {{ + assetClass.label + }} + } + + +
+
diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts new file mode 100644 index 000000000..710a4e9c5 --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.stories.ts @@ -0,0 +1,79 @@ +import '@angular/localize/init'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; + +import { GfPortfolioFilterFormComponent } from './portfolio-filter-form.component'; + +const meta: Meta = { + title: 'Portfolio Filter Form', + component: GfPortfolioFilterFormComponent, + decorators: [ + moduleMetadata({ + imports: [GfPortfolioFilterFormComponent] + }) + ] +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + accounts: [ + { + id: '733110b6-7c55-44eb-8cc5-c4c3e9d48a79', + name: 'Trading Account', + platform: { + name: 'Interactive Brokers', + url: 'https://interactivebrokers.com' + } + }, + { + id: '24ba27d6-e04b-4fb4-b856-b24c2ef0422a', + name: 'Investment Account', + platform: { + name: 'Fidelity', + url: 'https://fidelity.com' + } + } + ] as any, + assetClasses: [ + { id: 'COMMODITY', label: 'Commodity', type: 'ASSET_CLASS' }, + { id: 'EQUITY', label: 'Equity', type: 'ASSET_CLASS' }, + { id: 'FIXED_INCOME', label: 'Fixed Income', type: 'ASSET_CLASS' } + ] as any, + holdings: [ + { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Apple Inc.', + symbol: 'AAPL' + }, + { + currency: 'USD', + dataSource: 'YAHOO', + name: 'Microsoft Corporation', + symbol: 'MSFT' + } + ] as any, + tags: [ + { + id: 'EMERGENCY_FUND', + label: 'Emergency Fund', + type: 'TAG' + }, + { + id: 'RETIREMENT_FUND', + label: 'Retirement Fund', + type: 'TAG' + } + ] as any, + disabled: false + } +}; + +export const Disabled: Story = { + args: { + ...Default.args, + disabled: true + } +}; diff --git a/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts new file mode 100644 index 000000000..afbe5af4e --- /dev/null +++ b/libs/ui/src/lib/portfolio-filter-form/portfolio-filter-form.component.ts @@ -0,0 +1,177 @@ +import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; +import { Filter, PortfolioPosition } from '@ghostfolio/common/interfaces'; +import { GfSymbolPipe } from '@ghostfolio/common/pipes'; +import { AccountWithPlatform } from '@ghostfolio/common/types'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnDestroy, + OnInit, + forwardRef +} from '@angular/core'; +import { + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule +} from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { Subject, takeUntil } from 'rxjs'; + +import { GfEntityLogoComponent } from '../entity-logo/entity-logo.component'; +import { PortfolioFilterFormValue } from './interfaces'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + FormsModule, + GfEntityLogoComponent, + GfSymbolPipe, + MatFormFieldModule, + MatSelectModule, + ReactiveFormsModule + ], + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GfPortfolioFilterFormComponent) + } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-portfolio-filter-form', + styleUrls: ['./portfolio-filter-form.component.scss'], + templateUrl: './portfolio-filter-form.component.html' +}) +export class GfPortfolioFilterFormComponent + implements ControlValueAccessor, OnInit, OnChanges, OnDestroy +{ + @Input() accounts: AccountWithPlatform[] = []; + @Input() assetClasses: Filter[] = []; + @Input() holdings: PortfolioPosition[] = []; + @Input() tags: Filter[] = []; + @Input() disabled = false; + + public filterForm: FormGroup; + + private unsubscribeSubject = new Subject(); + + public constructor( + private changeDetectorRef: ChangeDetectorRef, + private formBuilder: FormBuilder + ) { + this.filterForm = this.formBuilder.group({ + account: new FormControl(null), + assetClass: new FormControl(null), + holding: new FormControl(null), + tag: new FormControl(null) + }); + } + + public ngOnInit() { + this.filterForm.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((value) => { + this.onChange(value as PortfolioFilterFormValue); + this.onTouched(); + }); + } + + public hasFilters() { + const formValue = this.filterForm.value; + + return Object.values(formValue).some((value) => { + return !!value; + }); + } + + public holdingComparisonFunction( + option: PortfolioPosition, + value: PortfolioPosition + ) { + if (value === null) { + return false; + } + + return ( + getAssetProfileIdentifier(option) === getAssetProfileIdentifier(value) + ); + } + + public ngOnChanges() { + if (this.disabled) { + this.filterForm.disable({ emitEvent: false }); + } else { + this.filterForm.enable({ emitEvent: false }); + } + + const tagControl = this.filterForm.get('tag'); + + if (this.tags.length === 0) { + tagControl?.disable({ emitEvent: false }); + } else if (!this.disabled) { + tagControl?.enable({ emitEvent: false }); + } + + this.changeDetectorRef.markForCheck(); + } + + public registerOnChange(fn: (value: PortfolioFilterFormValue) => void) { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean) { + this.disabled = isDisabled; + + if (this.disabled) { + this.filterForm.disable({ emitEvent: false }); + } else { + this.filterForm.enable({ emitEvent: false }); + } + + this.changeDetectorRef.markForCheck(); + } + + public writeValue(value: PortfolioFilterFormValue | null) { + if (value) { + this.filterForm.setValue( + { + account: value.account ?? null, + assetClass: value.assetClass ?? null, + holding: value.holding ?? null, + tag: value.tag ?? null + }, + { emitEvent: false } + ); + } else { + this.filterForm.reset({}, { emitEvent: false }); + } + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onChange = (_value: PortfolioFilterFormValue): void => { + // ControlValueAccessor onChange callback + }; + + private onTouched = (): void => { + // ControlValueAccessor onTouched callback + }; +} diff --git a/libs/ui/src/lib/portfolio-proportion-chart/index.ts b/libs/ui/src/lib/portfolio-proportion-chart/index.ts new file mode 100644 index 000000000..edf1fa198 --- /dev/null +++ b/libs/ui/src/lib/portfolio-proportion-chart/index.ts @@ -0,0 +1 @@ +export * from './portfolio-proportion-chart.component'; diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html new file mode 100644 index 000000000..c7de5ef4d --- /dev/null +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.html @@ -0,0 +1,13 @@ +@if (isLoading) { + +} + diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.scss b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.scss new file mode 100644 index 000000000..658f1a30a --- /dev/null +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.scss @@ -0,0 +1,4 @@ +:host { + aspect-ratio: 1; + display: block; +} diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts new file mode 100644 index 000000000..b5a3d6819 --- /dev/null +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.stories.ts @@ -0,0 +1,83 @@ +import { CommonModule } from '@angular/common'; +import '@angular/localize/init'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfPortfolioProportionChartComponent } from './portfolio-proportion-chart.component'; + +export default { + title: 'Portfolio Proportion Chart', + component: GfPortfolioProportionChartComponent, + decorators: [ + moduleMetadata({ + imports: [ + CommonModule, + GfPortfolioProportionChartComponent, + NgxSkeletonLoaderModule + ] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + baseCurrency: 'USD', + data: { + Africa: { name: 'Africa', value: 983.22461479889288 }, + Asia: { name: 'Asia', value: 12074.754633964973 }, + Europe: { name: 'Europe', value: 34432.837085290535 }, + 'North America': { name: 'North America', value: 26539.89987780503 }, + Oceania: { name: 'Oceania', value: 1402.220605072031 }, + 'South America': { name: 'South America', value: 4938.25202180719859 } + }, + keys: ['name'], + locale: 'en-US' + } +}; + +export const InPercentage: Story = { + args: { + data: { + US: { name: 'United States', value: 0.6515000000000001 }, + NL: { name: 'Netherlands', value: 0.006 }, + DE: { name: 'Germany', value: 0.0031 }, + GB: { name: 'United Kingdom', value: 0.0124 }, + CA: { name: 'Canada', value: 0.0247 }, + IE: { name: 'Ireland', value: 0.0112 }, + SE: { name: 'Sweden', value: 0.0016 }, + ES: { name: 'Spain', value: 0.0042 }, + AU: { name: 'Australia', value: 0.0022 }, + FR: { name: 'France', value: 0.0012 }, + UY: { name: 'Uruguay', value: 0.0012 }, + CH: { name: 'Switzerland', value: 0.004099999999999999 }, + LU: { name: 'Luxembourg', value: 0.0012 }, + BR: { name: 'Brazil', value: 0.0006 }, + HK: { name: 'Hong Kong', value: 0.0006 }, + IT: { name: 'Italy', value: 0.0005 }, + CN: { name: 'China', value: 0.002 }, + KR: { name: 'South Korea', value: 0.0006 }, + BM: { name: 'Bermuda', value: 0.0011 }, + ZA: { name: 'South Africa', value: 0.0004 }, + SG: { name: 'Singapore', value: 0.0003 }, + IL: { name: 'Israel', value: 0.001 }, + DK: { name: 'Denmark', value: 0.0002 }, + PE: { name: 'Peru', value: 0.0002 }, + NO: { name: 'Norway', value: 0.0002 }, + KY: { name: 'Cayman Islands', value: 0.0001 }, + IN: { name: 'India', value: 0.0001 }, + TW: { name: 'Taiwan', value: 0.0002 }, + GR: { name: 'Greece', value: 0.0001 }, + CL: { name: 'Chile', value: 0.0001 }, + MX: { name: 'Mexico', value: 0 }, + RU: { name: 'Russia', value: 0 }, + IS: { name: 'Iceland', value: 0 }, + JP: { name: 'Japan', value: 0 }, + BE: { name: 'Belgium', value: 0 } + }, + isInPercent: true, + keys: ['name'] + } +}; diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts new file mode 100644 index 000000000..7d0203e9c --- /dev/null +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts @@ -0,0 +1,486 @@ +import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; +import { UNKNOWN_KEY } from '@ghostfolio/common/config'; +import { getLocale, getSum, getTextColor } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { ColorScheme } from '@ghostfolio/common/types'; + +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + ViewChild +} from '@angular/core'; +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import { + ArcElement, + Chart, + type ChartData, + type ChartDataset, + DoughnutController, + LinearScale, + Tooltip, + type TooltipOptions +} from 'chart.js'; +import ChartDataLabels from 'chartjs-plugin-datalabels'; +import { isUUID } from 'class-validator'; +import Color from 'color'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import OpenColor from 'open-color'; + +import { translate } from '../i18n'; + +const { + blue, + cyan, + grape, + green, + indigo, + lime, + orange, + pink, + red, + teal, + violet, + yellow +} = OpenColor; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, NgxSkeletonLoaderModule], + selector: 'gf-portfolio-proportion-chart', + styleUrls: ['./portfolio-proportion-chart.component.scss'], + templateUrl: './portfolio-proportion-chart.component.html' +}) +export class GfPortfolioProportionChartComponent + implements AfterViewInit, OnChanges, OnDestroy +{ + @Input() baseCurrency: string; + @Input() colorScheme: ColorScheme; + @Input() cursor: string; + @Input() data: { + [symbol: string]: Pick & { + dataSource?: DataSource; + name: string; + value: number; + }; + } = {}; + @Input() isInPercent = false; + @Input() keys: string[] = []; + @Input() locale = getLocale(); + @Input() maxItems?: number; + @Input() showLabels = false; + + @Output() proportionChartClicked = new EventEmitter(); + + @ViewChild('chartCanvas') chartCanvas: ElementRef; + + public chart: Chart<'doughnut'>; + public isLoading = true; + + private readonly OTHER_KEY = 'OTHER'; + + private colorMap: { + [symbol: string]: string; + } = {}; + + public constructor() { + Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); + } + + public ngAfterViewInit() { + if (this.data) { + this.initialize(); + } + } + + public ngOnChanges() { + if (this.data) { + this.initialize(); + } + } + + public ngOnDestroy() { + this.chart?.destroy(); + } + + private initialize() { + this.isLoading = true; + const chartData: { + [symbol: string]: { + color?: string; + name: string; + subCategory?: { [symbol: string]: { value: Big } }; + value: Big; + }; + } = {}; + this.colorMap = { + [this.OTHER_KEY]: `rgba(${getTextColor(this.colorScheme)}, 0.24)`, + [UNKNOWN_KEY]: `rgba(${getTextColor(this.colorScheme)}, 0.12)` + }; + + if (this.keys.length > 0) { + Object.keys(this.data).forEach((symbol) => { + if (this.data[symbol][this.keys[0]]?.toUpperCase()) { + if (chartData[this.data[symbol][this.keys[0]].toUpperCase()]) { + chartData[this.data[symbol][this.keys[0]].toUpperCase()].value = + chartData[ + this.data[symbol][this.keys[0]].toUpperCase() + ].value.plus(this.data[symbol].value || 0); + + if ( + chartData[this.data[symbol][this.keys[0]].toUpperCase()] + .subCategory[this.data[symbol][this.keys[1]]] + ) { + chartData[ + this.data[symbol][this.keys[0]].toUpperCase() + ].subCategory[this.data[symbol][this.keys[1]]].value = chartData[ + this.data[symbol][this.keys[0]].toUpperCase() + ].subCategory[this.data[symbol][this.keys[1]]].value.plus( + this.data[symbol].value || 0 + ); + } else { + chartData[ + this.data[symbol][this.keys[0]].toUpperCase() + ].subCategory[this.data[symbol][this.keys[1]] ?? UNKNOWN_KEY] = { + value: new Big(this.data[symbol].value || 0) + }; + } + } else { + chartData[this.data[symbol][this.keys[0]].toUpperCase()] = { + name: this.data[symbol][this.keys[0]], + subCategory: {}, + value: new Big(this.data[symbol].value || 0) + }; + + if (this.data[symbol][this.keys[1]]) { + chartData[ + this.data[symbol][this.keys[0]].toUpperCase() + ].subCategory = { + [this.data[symbol][this.keys[1]]]: { + value: new Big(this.data[symbol].value || 0) + } + }; + } + } + } else { + if (chartData[UNKNOWN_KEY]) { + chartData[UNKNOWN_KEY].value = chartData[UNKNOWN_KEY].value.plus( + this.data[symbol].value || 0 + ); + } else { + chartData[UNKNOWN_KEY] = { + name: this.data[symbol].name, + subCategory: this.keys[1] + ? { [this.keys[1]]: { value: new Big(0) } } + : undefined, + value: new Big(this.data[symbol].value || 0) + }; + } + } + }); + } else { + Object.keys(this.data).forEach((symbol) => { + chartData[symbol] = { + name: this.data[symbol].name, + value: new Big(this.data[symbol].value || 0) + }; + }); + } + + if (this.isInPercent) { + const totalValueInPercentage = getSum( + Object.values(chartData).map(({ value }) => { + return value; + }) + ); + + const unknownValueInPercentage = new Big(1).minus(totalValueInPercentage); + + if (unknownValueInPercentage.gt(0)) { + // If total is below 100%, allocate the remaining percentage to UNKNOWN_KEY + if (chartData[UNKNOWN_KEY]) { + chartData[UNKNOWN_KEY].value = chartData[UNKNOWN_KEY].value.plus( + unknownValueInPercentage + ); + } else { + chartData[UNKNOWN_KEY] = { + name: UNKNOWN_KEY, + value: unknownValueInPercentage + }; + } + } + } + + let chartDataSorted = Object.entries(chartData) + .sort((a, b) => { + return a[1].value.minus(b[1].value).toNumber(); + }) + .reverse(); + + if (this.maxItems && chartDataSorted.length > this.maxItems) { + // Add surplus items to OTHER group + const rest = chartDataSorted.splice( + this.maxItems, + chartDataSorted.length - 1 + ); + + chartDataSorted.push([ + this.OTHER_KEY, + { name: this.OTHER_KEY, subCategory: {}, value: new Big(0) } + ]); + const otherItem = chartDataSorted[chartDataSorted.length - 1]; + + rest.forEach((restItem) => { + if (otherItem?.[1]) { + otherItem[1] = { + name: this.OTHER_KEY, + subCategory: {}, + value: otherItem[1].value.plus(restItem[1].value) + }; + } + }); + + // Sort data again + chartDataSorted = chartDataSorted + .sort((a, b) => { + return a[1].value.minus(b[1].value).toNumber(); + }) + .reverse(); + } + + chartDataSorted.forEach(([symbol, item], index) => { + if (this.colorMap[symbol]) { + // Reuse color + item.color = this.colorMap[symbol]; + } else { + item.color = + this.getColorPalette()[index % this.getColorPalette().length]; + } + }); + + const backgroundColorSubCategory: string[] = []; + const dataSubCategory: number[] = []; + const labelSubCategory: string[] = []; + + chartDataSorted.forEach(([, item]) => { + let lightnessRatio = 0.2; + + Object.keys(item.subCategory ?? {}).forEach((subCategory) => { + if (item.name === UNKNOWN_KEY) { + backgroundColorSubCategory.push(item.color); + } else { + backgroundColorSubCategory.push( + Color(item.color).lighten(lightnessRatio).hex() + ); + } + dataSubCategory.push(item.subCategory[subCategory].value.toNumber()); + labelSubCategory.push(subCategory); + + lightnessRatio += 0.1; + }); + }); + + const datasets: ChartDataset<'doughnut'>[] = [ + { + backgroundColor: chartDataSorted.map(([, item]) => { + return item.color; + }), + borderWidth: 0, + data: chartDataSorted.map(([, item]) => { + return item.value.toNumber(); + }) + } + ]; + + let labels = chartDataSorted.map(([, { name }]) => { + return name; + }); + + if (this.keys[1]) { + datasets.unshift({ + backgroundColor: backgroundColorSubCategory, + borderWidth: 0, + data: dataSubCategory + }); + + labels = labelSubCategory.concat(labels); + } + + if (datasets[0]?.data?.length === 0 || datasets[0]?.data?.[0] === 0) { + labels = ['']; + datasets[0].backgroundColor = [this.colorMap[UNKNOWN_KEY]]; + datasets[0].data[0] = Number.MAX_SAFE_INTEGER; + } + + if (datasets[1]?.data?.length === 0 || datasets[1]?.data?.[1] === 0) { + labels = ['']; + datasets[1].backgroundColor = [this.colorMap[UNKNOWN_KEY]]; + datasets[1].data[1] = Number.MAX_SAFE_INTEGER; + } + + const data: ChartData<'doughnut'> = { + datasets, + labels + }; + + if (this.chartCanvas) { + if (this.chart) { + this.chart.data = data; + this.chart.options.plugins ??= {}; + this.chart.options.plugins.tooltip = + this.getTooltipPluginConfiguration(data); + + this.chart.update(); + } else { + this.chart = new Chart<'doughnut'>(this.chartCanvas.nativeElement, { + data, + options: { + animation: false, + cutout: '70%', + layout: { + padding: this.showLabels === true ? 100 : 0 + }, + onClick: (_, activeElements, chart) => { + try { + const dataIndex = activeElements[0].index; + const symbol = chart.data.labels?.[dataIndex] as string; + + const dataSource = this.data[symbol].dataSource; + + if (dataSource) { + this.proportionChartClicked.emit({ dataSource, symbol }); + } + } catch {} + }, + onHover: (event, chartElement) => { + if (this.cursor) { + (event.native?.target as HTMLElement).style.cursor = + chartElement[0] ? this.cursor : 'default'; + } + }, + plugins: { + datalabels: { + color: (context) => { + return this.getColorPalette()[ + context.dataIndex % this.getColorPalette().length + ]; + }, + display: this.showLabels === true ? 'auto' : false, + labels: { + index: { + align: 'end', + anchor: 'end', + formatter: (value, context) => { + const symbol = context.chart.data.labels?.[ + context.dataIndex + ] as string; + + return value > 0 + ? isUUID(symbol) + ? (translate(this.data[symbol]?.name) ?? symbol) + : symbol + : ''; + }, + offset: 8 + } + } + }, + legend: { display: false }, + tooltip: this.getTooltipPluginConfiguration(data) + } + }, + plugins: [ChartDataLabels], + type: 'doughnut' + }); + } + } + + this.isLoading = false; + } + + private getColorPalette() { + return [ + blue[5], + teal[5], + lime[5], + orange[5], + pink[5], + violet[5], + indigo[5], + cyan[5], + green[5], + yellow[5], + red[5], + grape[5] + ]; + } + + private getTooltipPluginConfiguration( + data: ChartData<'doughnut'> + ): Partial> { + return { + ...getTooltipOptions({ + colorScheme: this.colorScheme, + currency: this.baseCurrency, + locale: this.locale + }), + // @ts-expect-error: no need to set all attributes in callbacks + callbacks: { + label: (context) => { + const labelIndex = + (data.datasets[context.datasetIndex - 1]?.data?.length ?? 0) + + context.dataIndex; + + let symbol = + (context.chart.data.labels?.[labelIndex] as string) ?? ''; + + if (symbol === this.OTHER_KEY) { + symbol = $localize`Other`; + } else if (symbol === UNKNOWN_KEY) { + symbol = $localize`No data available`; + } + + const name = translate(this.data[symbol]?.name); + + let sum = 0; + + for (const item of context.dataset.data) { + sum += item; + } + + const percentage = (context.parsed * 100) / sum; + + if ((context.raw as number) === Number.MAX_SAFE_INTEGER) { + return $localize`No data available`; + } else if (this.isInPercent) { + return [`${name ?? symbol}`, `${percentage.toFixed(2)}%`]; + } else { + const value = context.raw as number; + + return [ + `${name ?? symbol}`, + `${value.toLocaleString(this.locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + })} ${this.baseCurrency} (${percentage.toFixed(2)}%)` + ]; + } + }, + title: () => { + return ''; + } + } + }; + } +} diff --git a/libs/ui/src/lib/premium-indicator/index.ts b/libs/ui/src/lib/premium-indicator/index.ts new file mode 100644 index 000000000..a61db2559 --- /dev/null +++ b/libs/ui/src/lib/premium-indicator/index.ts @@ -0,0 +1 @@ +export * from './premium-indicator.component'; diff --git a/libs/ui/src/lib/premium-indicator/premium-indicator.component.html b/libs/ui/src/lib/premium-indicator/premium-indicator.component.html new file mode 100644 index 000000000..3141414e7 --- /dev/null +++ b/libs/ui/src/lib/premium-indicator/premium-indicator.component.html @@ -0,0 +1,7 @@ + diff --git a/libs/ui/src/lib/premium-indicator/premium-indicator.component.scss b/libs/ui/src/lib/premium-indicator/premium-indicator.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/premium-indicator/premium-indicator.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/premium-indicator/premium-indicator.component.stories.ts b/libs/ui/src/lib/premium-indicator/premium-indicator.component.stories.ts new file mode 100644 index 000000000..4cf61084c --- /dev/null +++ b/libs/ui/src/lib/premium-indicator/premium-indicator.component.stories.ts @@ -0,0 +1,28 @@ +import { CommonModule } from '@angular/common'; +import { RouterTestingModule } from '@angular/router/testing'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfPremiumIndicatorComponent } from './premium-indicator.component'; + +export default { + title: 'Premium Indicator', + component: GfPremiumIndicatorComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, RouterTestingModule] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; + +export const WithoutLink = { + args: { + enableLink: false + } +}; diff --git a/libs/ui/src/lib/premium-indicator/premium-indicator.component.ts b/libs/ui/src/lib/premium-indicator/premium-indicator.component.ts new file mode 100644 index 000000000..b3ccfd88f --- /dev/null +++ b/libs/ui/src/lib/premium-indicator/premium-indicator.component.ts @@ -0,0 +1,31 @@ +import { publicRoutes } from '@ghostfolio/common/routes/routes'; + +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Input +} from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { diamondOutline } from 'ionicons/icons'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, IonIcon, RouterModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-premium-indicator', + styleUrls: ['./premium-indicator.component.scss'], + templateUrl: './premium-indicator.component.html' +}) +export class GfPremiumIndicatorComponent { + @Input() enableLink = true; + + public routerLinkPricing = publicRoutes.pricing.routerLink; + + public constructor() { + addIcons({ diamondOutline }); + } +} diff --git a/libs/ui/src/lib/services/admin.service.ts b/libs/ui/src/lib/services/admin.service.ts new file mode 100644 index 000000000..145f134e3 --- /dev/null +++ b/libs/ui/src/lib/services/admin.service.ts @@ -0,0 +1,286 @@ +import { + DEFAULT_PAGE_SIZE, + HEADER_KEY_SKIP_INTERCEPTOR, + HEADER_KEY_TOKEN +} from '@ghostfolio/common/config'; +import { + CreatePlatformDto, + UpdateAssetProfileDto, + UpdatePlatformDto +} from '@ghostfolio/common/dtos'; +import { + AdminData, + AdminJobs, + AdminMarketData, + AdminUserResponse, + AdminUsersResponse, + AssetProfileIdentifier, + DataProviderGhostfolioStatusResponse, + DataProviderHistoricalResponse, + EnhancedSymbolProfile, + Filter +} from '@ghostfolio/common/interfaces'; +import { DateRange } from '@ghostfolio/common/types'; +import { GF_ENVIRONMENT, GfEnvironment } from '@ghostfolio/ui/environment'; +import { DataService } from '@ghostfolio/ui/services'; + +import { HttpClient, HttpHeaders, HttpParams } from '@angular/common/http'; +import { Inject, Injectable } from '@angular/core'; +import { SortDirection } from '@angular/material/sort'; +import { DataSource, MarketData, Platform } from '@prisma/client'; +import { JobStatus } from 'bull'; + +@Injectable({ + providedIn: 'root' +}) +export class AdminService { + public constructor( + private dataService: DataService, + @Inject(GF_ENVIRONMENT) private environment: GfEnvironment, + private http: HttpClient + ) {} + + public addAssetProfile({ dataSource, symbol }: AssetProfileIdentifier) { + return this.http.post( + `/api/v1/admin/profile-data/${dataSource}/${symbol}`, + null + ); + } + + public deleteJob(aId: string) { + return this.http.delete(`/api/v1/admin/queue/job/${aId}`); + } + + public deleteJobs({ status }: { status: JobStatus[] }) { + let params = new HttpParams(); + + if (status?.length > 0) { + params = params.append('status', status.join(',')); + } + + return this.http.delete('/api/v1/admin/queue/job', { + params + }); + } + + public deletePlatform(aId: string) { + return this.http.delete(`/api/v1/platform/${aId}`); + } + + public deleteProfileData({ dataSource, symbol }: AssetProfileIdentifier) { + return this.http.delete( + `/api/v1/admin/profile-data/${dataSource}/${symbol}` + ); + } + + public executeJob(aId: string) { + return this.http.get(`/api/v1/admin/queue/job/${aId}/execute`); + } + + public fetchAdminData() { + return this.http.get('/api/v1/admin'); + } + + public fetchAdminMarketData({ + filters, + skip, + sortColumn, + sortDirection, + take + }: { + filters?: Filter[]; + skip?: number; + sortColumn?: string; + sortDirection?: SortDirection; + take: number; + }) { + let params = this.dataService.buildFiltersAsQueryParams({ filters }); + + if (skip) { + params = params.append('skip', skip); + } + + if (sortColumn) { + params = params.append('sortColumn', sortColumn); + } + + if (sortDirection) { + params = params.append('sortDirection', sortDirection); + } + + if (take) { + params = params.append('take', take); + } + + return this.http.get('/api/v1/admin/market-data', { + params + }); + } + + public fetchGhostfolioDataProviderStatus(aApiKey: string) { + const headers = new HttpHeaders({ + [HEADER_KEY_SKIP_INTERCEPTOR]: 'true', + [HEADER_KEY_TOKEN]: `Api-Key ${aApiKey}` + }); + + return this.http.get( + `${this.environment.production ? 'https://ghostfol.io' : ''}/api/v2/data-providers/ghostfolio/status`, + { headers } + ); + } + + public fetchJobs({ status }: { status?: JobStatus[] }) { + let params = new HttpParams(); + + if (status?.length > 0) { + params = params.append('status', status.join(',')); + } + + return this.http.get('/api/v1/admin/queue/job', { + params + }); + } + + public fetchPlatforms() { + return this.http.get('/api/v1/platform'); + } + + public fetchUserById(id: string) { + return this.http.get(`/api/v1/admin/user/${id}`); + } + + public fetchUsers({ + skip, + take = DEFAULT_PAGE_SIZE + }: { + skip?: number; + take?: number; + }) { + let params = new HttpParams(); + + params = params.append('skip', skip); + params = params.append('take', take); + + return this.http.get('/api/v1/admin/user', { params }); + } + + public gather7Days() { + return this.http.post('/api/v1/admin/gather', {}); + } + + public gatherMax() { + return this.http.post('/api/v1/admin/gather/max', {}); + } + + public gatherProfileData() { + return this.http.post('/api/v1/admin/gather/profile-data', {}); + } + + public gatherProfileDataBySymbol({ + dataSource, + symbol + }: AssetProfileIdentifier) { + return this.http.post( + `/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`, + {} + ); + } + + public gatherSymbol({ + dataSource, + range, + symbol + }: { + range?: DateRange; + } & AssetProfileIdentifier) { + let params = new HttpParams(); + + if (range) { + params = params.append('range', range); + } + + const url = `/api/v1/admin/gather/${dataSource}/${symbol}`; + + return this.http.post(url, undefined, { params }); + } + + public fetchSymbolForDate({ + dataSource, + dateString, + symbol + }: { + dataSource: DataSource; + dateString: string; + symbol: string; + }) { + const url = `/api/v1/symbol/${dataSource}/${symbol}/${dateString}`; + + return this.http.get(url); + } + + public patchAssetProfile( + { dataSource, symbol }: AssetProfileIdentifier, + { + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource: newDataSource, + isActive, + name, + scraperConfiguration, + sectors, + symbol: newSymbol, + symbolMapping, + url + }: UpdateAssetProfileDto + ) { + return this.http.patch( + `/api/v1/admin/profile-data/${dataSource}/${symbol}`, + { + assetClass, + assetSubClass, + comment, + countries, + currency, + dataSource: newDataSource, + isActive, + name, + scraperConfiguration, + sectors, + symbol: newSymbol, + symbolMapping, + url + } + ); + } + + public postPlatform(aPlatform: CreatePlatformDto) { + return this.http.post(`/api/v1/platform`, aPlatform); + } + + public putPlatform(aPlatform: UpdatePlatformDto) { + return this.http.put( + `/api/v1/platform/${aPlatform.id}`, + aPlatform + ); + } + + public syncDemoUserAccount() { + return this.http.get(`/api/v1/admin/demo-user/sync`); + } + + public testMarketData({ + dataSource, + scraperConfiguration, + symbol + }: AssetProfileIdentifier & UpdateAssetProfileDto['scraperConfiguration']) { + return this.http.post<{ price: number }>( + `/api/v1/admin/market-data/${dataSource}/${symbol}/test`, + { + scraperConfiguration + } + ); + } +} diff --git a/libs/ui/src/lib/services/data.service.ts b/libs/ui/src/lib/services/data.service.ts new file mode 100644 index 000000000..37443cd20 --- /dev/null +++ b/libs/ui/src/lib/services/data.service.ts @@ -0,0 +1,883 @@ +import { + CreateAccessDto, + CreateAccountBalanceDto, + CreateAccountDto, + CreateOrderDto, + CreateTagDto, + CreateWatchlistItemDto, + DeleteOwnUserDto, + TransferBalanceDto, + UpdateAccessDto, + UpdateAccountDto, + UpdateBulkMarketDataDto, + UpdateOrderDto, + UpdateOwnAccessTokenDto, + UpdatePropertyDto, + UpdateTagDto, + UpdateUserSettingDto +} from '@ghostfolio/common/dtos'; +import { DATE_FORMAT } from '@ghostfolio/common/helper'; +import { + Access, + AccessTokenResponse, + AccountBalancesResponse, + AccountResponse, + AccountsResponse, + ActivitiesResponse, + ActivityResponse, + AiPromptResponse, + ApiKeyResponse, + AssetProfileIdentifier, + AssetResponse, + BenchmarkMarketDataDetailsResponse, + BenchmarkResponse, + CreateStripeCheckoutSessionResponse, + DataProviderHealthResponse, + DataProviderHistoricalResponse, + ExportResponse, + Filter, + ImportResponse, + InfoItem, + LookupResponse, + MarketDataDetailsResponse, + MarketDataOfMarketsResponse, + OAuthResponse, + PlatformsResponse, + PortfolioDetails, + PortfolioDividendsResponse, + PortfolioHoldingResponse, + PortfolioHoldingsResponse, + PortfolioInvestmentsResponse, + PortfolioPerformanceResponse, + PortfolioReportResponse, + PublicPortfolioResponse, + SymbolItem, + User, + UserItem, + WatchlistResponse +} from '@ghostfolio/common/interfaces'; +import { filterGlobalPermissions } from '@ghostfolio/common/permissions'; +import type { + AiPromptMode, + DateRange, + GroupBy +} from '@ghostfolio/common/types'; +import { translate } from '@ghostfolio/ui/i18n'; + +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable } from '@angular/core'; +import { SortDirection } from '@angular/material/sort'; +import { utc } from '@date-fns/utc'; +import { + AccountBalance, + DataSource, + MarketData, + Order as OrderModel, + Tag +} from '@prisma/client'; +import { format, parseISO } from 'date-fns'; +import { cloneDeep, groupBy, isNumber } from 'lodash'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +@Injectable({ + providedIn: 'root' +}) +export class DataService { + public constructor(private http: HttpClient) {} + + public buildFiltersAsQueryParams({ filters }: { filters?: Filter[] }) { + let params = new HttpParams(); + + if (filters?.length > 0) { + const { + ACCOUNT: filtersByAccount, + ASSET_CLASS: filtersByAssetClass, + ASSET_SUB_CLASS: filtersByAssetSubClass, + DATA_SOURCE: [filterByDataSource] = [], + HOLDING_TYPE: filtersByHoldingType, + PRESET_ID: filtersByPresetId, + SEARCH_QUERY: filtersBySearchQuery, + SYMBOL: [filterBySymbol] = [], + TAG: filtersByTag + } = groupBy(filters, (filter) => { + return filter.type; + }); + + if (filterByDataSource) { + params = params.append('dataSource', filterByDataSource.id); + } + + if (filterBySymbol) { + params = params.append('symbol', filterBySymbol.id); + } + + if (filtersByAccount) { + params = params.append( + 'accounts', + filtersByAccount + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + + if (filtersByAssetClass) { + params = params.append( + 'assetClasses', + filtersByAssetClass + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + + if (filtersByAssetSubClass) { + params = params.append( + 'assetSubClasses', + filtersByAssetSubClass + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + + if (filtersByHoldingType) { + params = params.append('holdingType', filtersByHoldingType[0].id); + } + + if (filtersByPresetId) { + params = params.append('presetId', filtersByPresetId[0].id); + } + + if (filtersBySearchQuery) { + params = params.append('query', filtersBySearchQuery[0].id); + } + + if (filtersByTag) { + params = params.append( + 'tags', + filtersByTag + .map(({ id }) => { + return id; + }) + .join(',') + ); + } + } + + return params; + } + + public createStripeCheckoutSession({ + couponId, + priceId + }: { + couponId?: string; + priceId: string; + }) { + return this.http.post( + '/api/v1/subscription/stripe/checkout-session', + { + couponId, + priceId + } + ); + } + + public fetchAccount(aAccountId: string) { + return this.http.get(`/api/v1/account/${aAccountId}`); + } + + public fetchAccountBalances(aAccountId: string) { + return this.http.get( + `/api/v1/account/${aAccountId}/balances` + ); + } + + public fetchAccounts({ filters }: { filters?: Filter[] } = {}) { + const params = this.buildFiltersAsQueryParams({ filters }); + + return this.http.get('/api/v1/account', { params }); + } + + public fetchActivities({ + filters, + range, + skip, + sortColumn, + sortDirection, + take + }: { + filters?: Filter[]; + range?: DateRange; + skip?: number; + sortColumn?: string; + sortDirection?: SortDirection; + take?: number; + }): Observable { + let params = this.buildFiltersAsQueryParams({ filters }); + + if (range) { + params = params.append('range', range); + } + + if (skip) { + params = params.append('skip', skip); + } + + if (sortColumn) { + params = params.append('sortColumn', sortColumn); + } + + if (sortDirection) { + params = params.append('sortDirection', sortDirection); + } + + if (take) { + params = params.append('take', take); + } + + return this.http.get('/api/v1/order', { params }).pipe( + map(({ activities, count }) => { + for (const activity of activities) { + activity.createdAt = parseISO(activity.createdAt); + activity.date = parseISO(activity.date); + } + return { activities, count }; + }) + ); + } + + public fetchActivity(aActivityId: string) { + return this.http.get(`/api/v1/order/${aActivityId}`).pipe( + map((activity) => { + activity.createdAt = parseISO(activity.createdAt as unknown as string); + activity.date = parseISO(activity.date as unknown as string); + + return activity; + }) + ); + } + + public fetchDividends({ + filters, + groupBy = 'month', + range + }: { + filters?: Filter[]; + groupBy?: GroupBy; + range: DateRange; + }) { + let params = this.buildFiltersAsQueryParams({ filters }); + params = params.append('groupBy', groupBy); + params = params.append('range', range); + + return this.http.get( + '/api/v1/portfolio/dividends', + { + params + } + ); + } + + public fetchDividendsImport({ dataSource, symbol }: AssetProfileIdentifier) { + return this.http.get( + `/api/v1/import/dividends/${dataSource}/${symbol}` + ); + } + + public fetchExchangeRateForDate({ + date, + symbol + }: { + date: Date; + symbol: string; + }) { + return this.http.get( + `/api/v1/exchange-rate/${symbol}/${format(date, DATE_FORMAT, { in: utc })}` + ); + } + + public deleteAccess(aId: string) { + return this.http.delete(`/api/v1/access/${aId}`); + } + + public deleteAccount(aId: string) { + return this.http.delete(`/api/v1/account/${aId}`); + } + + public deleteAccountBalance(aId: string) { + return this.http.delete(`/api/v1/account-balance/${aId}`); + } + + public deleteActivities({ filters }) { + const params = this.buildFiltersAsQueryParams({ filters }); + + return this.http.delete('/api/v1/order', { params }); + } + + public deleteActivity(aId: string) { + return this.http.delete(`/api/v1/order/${aId}`); + } + + public deleteBenchmark({ dataSource, symbol }: AssetProfileIdentifier) { + return this.http.delete(`/api/v1/benchmarks/${dataSource}/${symbol}`); + } + + public deleteOwnUser(aData: DeleteOwnUserDto) { + return this.http.delete(`/api/v1/user`, { body: aData }); + } + + public deleteTag(aId: string) { + return this.http.delete(`/api/v1/tags/${aId}`); + } + + public deleteUser(aId: string) { + return this.http.delete(`/api/v1/user/${aId}`); + } + + public deleteWatchlistItem({ dataSource, symbol }: AssetProfileIdentifier) { + return this.http.delete(`/api/v1/watchlist/${dataSource}/${symbol}`); + } + + public fetchAccesses() { + return this.http.get('/api/v1/access'); + } + + public fetchAsset({ + dataSource, + symbol + }: AssetProfileIdentifier): Observable { + return this.http.get(`/api/v1/asset/${dataSource}/${symbol}`).pipe( + map((data) => { + for (const item of data.marketData) { + item.date = parseISO(item.date); + } + return data; + }) + ); + } + + public fetchBenchmarkForUser({ + dataSource, + filters, + range, + startDate, + symbol, + withExcludedAccounts + }: { + filters?: Filter[]; + range: DateRange; + startDate: Date; + withExcludedAccounts?: boolean; + } & AssetProfileIdentifier) { + let params = this.buildFiltersAsQueryParams({ filters }); + + params = params.append('range', range); + + if (withExcludedAccounts) { + params = params.append('withExcludedAccounts', withExcludedAccounts); + } + + return this.http.get( + `/api/v1/benchmarks/${dataSource}/${symbol}/${format(startDate, DATE_FORMAT, { in: utc })}`, + { params } + ); + } + + public fetchBenchmarks() { + return this.http.get('/api/v1/benchmarks'); + } + + public fetchDataProviderHealth(dataSource: DataSource) { + return this.http.get( + `/api/v1/health/data-provider/${dataSource}` + ); + } + + public fetchExport({ + activityIds, + filters + }: { + activityIds?: string[]; + filters?: Filter[]; + } = {}) { + let params = this.buildFiltersAsQueryParams({ filters }); + + if (activityIds) { + params = params.append('activityIds', activityIds.join(',')); + } + + return this.http.get('/api/v1/export', { + params + }); + } + + public fetchHoldingDetail({ + dataSource, + symbol + }: { + dataSource: DataSource; + symbol: string; + }) { + return this.http.get( + `/api/v1/portfolio/holding/${dataSource}/${symbol}` + ); + } + + public fetchInfo(): InfoItem { + const info = cloneDeep((window as any).info); + const utmSource = window.localStorage.getItem('utm_source') as + | 'ios' + | 'trusted-web-activity'; + + info.globalPermissions = filterGlobalPermissions( + info.globalPermissions, + utmSource + ); + + return info; + } + + public fetchInvestments({ + filters, + groupBy = 'month', + range + }: { + filters?: Filter[]; + groupBy?: GroupBy; + range: DateRange; + }) { + let params = this.buildFiltersAsQueryParams({ filters }); + params = params.append('groupBy', groupBy); + params = params.append('range', range); + + return this.http.get( + '/api/v1/portfolio/investments', + { params } + ); + } + + public fetchMarketDataBySymbol({ + dataSource, + symbol + }: { + dataSource: DataSource; + symbol: string; + }): Observable { + return this.http + .get(`/api/v1/market-data/${dataSource}/${symbol}`) + .pipe( + map((data) => { + for (const item of data.marketData) { + item.date = parseISO(item.date); + } + return data; + }) + ); + } + + public fetchMarketDataOfMarkets({ + includeHistoricalData + }: { + includeHistoricalData?: number; + }): Observable { + let params = new HttpParams(); + + if (includeHistoricalData) { + params = params.append('includeHistoricalData', includeHistoricalData); + } + + return this.http.get('/api/v1/market-data/markets', { params }).pipe( + map((data) => { + for (const item of data.fearAndGreedIndex.CRYPTOCURRENCIES + ?.historicalData ?? []) { + item.date = parseISO(item.date); + } + + for (const item of data.fearAndGreedIndex.STOCKS?.historicalData ?? + []) { + item.date = parseISO(item.date); + } + + return data; + }) + ); + } + + public fetchPlatforms() { + return this.http.get('/api/v1/platforms'); + } + + public fetchPortfolioDetails({ + filters, + withMarkets = false + }: { + filters?: Filter[]; + withMarkets?: boolean; + } = {}): Observable { + let params = this.buildFiltersAsQueryParams({ filters }); + + if (withMarkets) { + params = params.append('withMarkets', withMarkets); + } + + return this.http + .get('/api/v1/portfolio/details', { + params + }) + .pipe( + map((response) => { + if (response.holdings) { + for (const symbol of Object.keys(response.holdings)) { + response.holdings[symbol].assetClassLabel = translate( + response.holdings[symbol].assetClass + ); + + response.holdings[symbol].assetSubClassLabel = translate( + response.holdings[symbol].assetSubClass + ); + + response.holdings[symbol].dateOfFirstActivity = response.holdings[ + symbol + ].dateOfFirstActivity + ? parseISO(response.holdings[symbol].dateOfFirstActivity) + : undefined; + + response.holdings[symbol].value = isNumber( + response.holdings[symbol].value + ) + ? response.holdings[symbol].value + : response.holdings[symbol].valueInPercentage; + } + } + + if (response.summary?.dateOfFirstActivity) { + response.summary.dateOfFirstActivity = parseISO( + response.summary.dateOfFirstActivity + ); + } + + return response; + }) + ); + } + + public fetchPortfolioHoldings({ + filters, + range + }: { + filters?: Filter[]; + range?: DateRange; + } = {}) { + let params = this.buildFiltersAsQueryParams({ filters }); + + if (range) { + params = params.append('range', range); + } + + return this.http + .get('/api/v1/portfolio/holdings', { + params + }) + .pipe( + map((response) => { + if (response.holdings) { + for (const symbol of Object.keys(response.holdings)) { + response.holdings[symbol].assetClassLabel = translate( + response.holdings[symbol].assetClass + ); + + response.holdings[symbol].assetSubClassLabel = translate( + response.holdings[symbol].assetSubClass + ); + + response.holdings[symbol].dateOfFirstActivity = response.holdings[ + symbol + ].dateOfFirstActivity + ? parseISO(response.holdings[symbol].dateOfFirstActivity) + : undefined; + + response.holdings[symbol].value = isNumber( + response.holdings[symbol].value + ) + ? response.holdings[symbol].value + : response.holdings[symbol].valueInPercentage; + } + } + + return response; + }) + ); + } + + public fetchPortfolioPerformance({ + filters, + range, + withExcludedAccounts = false, + withItems = false + }: { + filters?: Filter[]; + range: DateRange; + withExcludedAccounts?: boolean; + withItems?: boolean; + }): Observable { + let params = this.buildFiltersAsQueryParams({ filters }); + params = params.append('range', range); + + if (withExcludedAccounts) { + params = params.append('withExcludedAccounts', withExcludedAccounts); + } + + if (withItems) { + params = params.append('withItems', withItems); + } + + return this.http + .get(`/api/v2/portfolio/performance`, { + params + }) + .pipe( + map((response) => { + if (response.firstOrderDate) { + response.firstOrderDate = parseISO(response.firstOrderDate); + } + + return response; + }) + ); + } + + public fetchPortfolioReport() { + return this.http.get('/api/v1/portfolio/report'); + } + + public fetchPrompt({ + filters, + mode + }: { + filters?: Filter[]; + mode: AiPromptMode; + }) { + const params = this.buildFiltersAsQueryParams({ filters }); + + return this.http.get(`/api/v1/ai/prompt/${mode}`, { + params + }); + } + + public fetchPublicPortfolio(aAccessId: string) { + return this.http + .get(`/api/v1/public/${aAccessId}/portfolio`) + .pipe( + map((response) => { + if (response.holdings) { + for (const symbol of Object.keys(response.holdings)) { + response.holdings[symbol].valueInBaseCurrency = isNumber( + response.holdings[symbol].valueInBaseCurrency + ) + ? response.holdings[symbol].valueInBaseCurrency + : response.holdings[symbol].valueInPercentage; + } + } + + return response; + }) + ); + } + + public fetchSymbolItem({ + dataSource, + includeHistoricalData, + symbol + }: { + dataSource: DataSource | string; + includeHistoricalData?: number; + symbol: string; + }) { + let params = new HttpParams(); + + if (includeHistoricalData) { + params = params.append('includeHistoricalData', includeHistoricalData); + } + + return this.http.get(`/api/v1/symbol/${dataSource}/${symbol}`, { + params + }); + } + + public fetchSymbols({ + includeIndices = false, + query + }: { + includeIndices?: boolean; + query: string; + }) { + let params = new HttpParams().set('query', query); + + if (includeIndices) { + params = params.append('includeIndices', includeIndices); + } + + return this.http + .get('/api/v1/symbol/lookup', { params }) + .pipe( + map(({ items }) => { + return items; + }) + ); + } + + public fetchTags() { + return this.http.get('/api/v1/tags'); + } + + public fetchWatchlist() { + return this.http.get('/api/v1/watchlist'); + } + + public loginAnonymous(accessToken: string) { + return this.http.post('/api/v1/auth/anonymous', { + accessToken + }); + } + + public postAccess(aAccess: CreateAccessDto) { + return this.http.post('/api/v1/access', aAccess); + } + + public postAccount(aAccount: CreateAccountDto) { + return this.http.post('/api/v1/account', aAccount); + } + + public postAccountBalance(aAccountBalance: CreateAccountBalanceDto) { + return this.http.post( + '/api/v1/account-balance', + aAccountBalance + ); + } + + public postApiKey() { + return this.http.post('/api/v1/api-keys', {}); + } + + public postBenchmark(benchmark: AssetProfileIdentifier) { + return this.http.post('/api/v1/benchmarks', benchmark); + } + + public postMarketData({ + dataSource, + marketData, + symbol + }: { + dataSource: DataSource; + marketData: UpdateBulkMarketDataDto; + symbol: string; + }) { + const url = `/api/v1/market-data/${dataSource}/${symbol}`; + + return this.http.post(url, marketData); + } + + public postOrder(aOrder: CreateOrderDto) { + return this.http.post('/api/v1/order', aOrder); + } + + public postTag(aTag: CreateTagDto) { + return this.http.post(`/api/v1/tags`, aTag); + } + + public postUser() { + return this.http.post('/api/v1/user', {}); + } + + public postWatchlistItem(watchlistItem: CreateWatchlistItemDto) { + return this.http.post('/api/v1/watchlist', watchlistItem); + } + + public putAccess(aAccess: UpdateAccessDto) { + return this.http.put(`/api/v1/access/${aAccess.id}`, aAccess); + } + + public putAccount(aAccount: UpdateAccountDto) { + return this.http.put(`/api/v1/account/${aAccount.id}`, aAccount); + } + + public putAdminSetting(key: string, aData: UpdatePropertyDto) { + return this.http.put(`/api/v1/admin/settings/${key}`, aData); + } + + public putHoldingTags({ + dataSource, + symbol, + tags + }: { tags: Tag[] } & AssetProfileIdentifier) { + return this.http.put( + `/api/v1/portfolio/holding/${dataSource}/${symbol}/tags`, + { tags } + ); + } + + public putOrder(aOrder: UpdateOrderDto) { + return this.http.put(`/api/v1/order/${aOrder.id}`, aOrder); + } + + public putTag(aTag: UpdateTagDto) { + return this.http.put(`/api/v1/tags/${aTag.id}`, aTag); + } + + public putUserSetting(aData: UpdateUserSettingDto) { + return this.http.put('/api/v1/user/setting', aData); + } + + public redeemCoupon(couponCode: string) { + return this.http.post('/api/v1/subscription/redeem-coupon', { + couponCode + }); + } + + public transferAccountBalance({ + accountIdFrom, + accountIdTo, + balance + }: TransferBalanceDto) { + return this.http.post('/api/v1/account/transfer-balance', { + accountIdFrom, + accountIdTo, + balance + }); + } + + public updateOwnAccessToken(aAccessToken: UpdateOwnAccessTokenDto) { + return this.http.post( + '/api/v1/user/access-token', + aAccessToken + ); + } + + public updateUserAccessToken(aUserId: string) { + return this.http.post( + `/api/v1/user/${aUserId}/access-token`, + {} + ); + } + + public updateInfo() { + this.http.get('/api/v1/info').subscribe((info) => { + const utmSource = window.localStorage.getItem('utm_source') as + | 'ios' + | 'trusted-web-activity'; + + info.globalPermissions = filterGlobalPermissions( + info.globalPermissions, + utmSource + ); + + (window as any).info = info; + }); + } +} diff --git a/libs/ui/src/lib/services/index.ts b/libs/ui/src/lib/services/index.ts new file mode 100644 index 000000000..9cedba875 --- /dev/null +++ b/libs/ui/src/lib/services/index.ts @@ -0,0 +1,2 @@ +export * from './admin.service'; +export * from './data.service'; diff --git a/libs/ui/src/lib/shared/abstract-mat-form-field.ts b/libs/ui/src/lib/shared/abstract-mat-form-field.ts new file mode 100644 index 000000000..628f0a659 --- /dev/null +++ b/libs/ui/src/lib/shared/abstract-mat-form-field.ts @@ -0,0 +1,182 @@ +import { FocusMonitor } from '@angular/cdk/a11y'; +import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { + Component, + DoCheck, + ElementRef, + HostBinding, + HostListener, + Input, + OnDestroy +} from '@angular/core'; +import { ControlValueAccessor, NgControl, Validators } from '@angular/forms'; +import { MatFormFieldControl } from '@angular/material/form-field'; +import { Subject } from 'rxjs'; + +@Component({ + template: '', + standalone: false +}) +export abstract class AbstractMatFormField + implements ControlValueAccessor, DoCheck, MatFormFieldControl, OnDestroy +{ + @HostBinding() + public id = `${this.controlType}-${AbstractMatFormField.nextId++}`; + + @HostBinding('attr.aria-describedBy') public describedBy = ''; + + public readonly autofilled: boolean; + public errorState: boolean; + public focused = false; + public readonly stateChanges = new Subject(); + public readonly userAriaDescribedBy: string; + + protected onChange?: (value: T) => void; + protected onTouched?: () => void; + + private static nextId = 0; + + protected constructor( + protected _elementRef: ElementRef, + protected _focusMonitor: FocusMonitor, + public readonly ngControl: NgControl + ) { + if (this.ngControl) { + this.ngControl.valueAccessor = this; + } + + _focusMonitor + .monitor(this._elementRef.nativeElement, true) + .subscribe((origin) => { + this.focused = !!origin; + this.stateChanges.next(); + }); + } + + private _controlType: string; + + public get controlType(): string { + return this._controlType; + } + + protected set controlType(value: string) { + this._controlType = value; + this.id = `${this._controlType}-${AbstractMatFormField.nextId++}`; + } + + private _value: T; + + public get value(): T { + return this._value; + } + + public set value(value: T) { + this._value = value; + + if (this.onChange) { + this.onChange(value); + } + } + + public get empty(): boolean { + return !this._value; + } + + public _placeholder = ''; + + public get placeholder() { + return this._placeholder; + } + + @Input() + public set placeholder(placeholder: string) { + this._placeholder = placeholder; + this.stateChanges.next(); + } + + public _required = false; + + public get required() { + return ( + this._required || + this.ngControl.control?.hasValidator(Validators.required) + ); + } + + @Input() + public set required(required: any) { + this._required = coerceBooleanProperty(required); + this.stateChanges.next(); + } + + public _disabled = false; + + public get disabled() { + if (this.ngControl?.disabled !== null) { + return this.ngControl.disabled; + } + + return this._disabled; + } + + @Input() + public set disabled(disabled: any) { + this._disabled = coerceBooleanProperty(disabled); + + if (this.focused) { + this.focused = false; + this.stateChanges.next(); + } + } + + public abstract focus(): void; + + public get shouldLabelFloat(): boolean { + return this.focused || !this.empty; + } + + public ngDoCheck() { + if (this.ngControl) { + this.errorState = this.ngControl.invalid && this.ngControl.touched; + this.stateChanges.next(); + } + } + + public ngOnDestroy() { + this.stateChanges.complete(); + this._focusMonitor.stopMonitoring(this._elementRef.nativeElement); + } + + public registerOnChange(fn: (_: T) => void) { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + public setDescribedByIds(ids: string[]) { + this.describedBy = ids.join(' '); + } + + public writeValue(value: T) { + this.value = value; + } + + @HostListener('focusout') + public onBlur() { + this.focused = false; + + if (this.onTouched) { + this.onTouched(); + } + + this.stateChanges.next(); + } + + public onContainerClick() { + if (!this.focused) { + this.focus(); + } + } +} diff --git a/libs/ui/src/lib/symbol-autocomplete/index.ts b/libs/ui/src/lib/symbol-autocomplete/index.ts new file mode 100644 index 000000000..2964effa0 --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/index.ts @@ -0,0 +1 @@ +export * from './symbol-autocomplete.component'; diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html new file mode 100644 index 000000000..456cd9940 --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.html @@ -0,0 +1,49 @@ + + + + @if (!isLoading) { + @for (lookupItem of lookupItems; track lookupItem) { + + {{ lookupItem.name }} + @if (lookupItem.dataProviderInfo.isPremium) { + + } + + {{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency }} + @if (lookupItem.assetSubClass) { + · {{ lookupItem.assetSubClassString }} + } + @if (lookupItem.dataProviderInfo.name) { + · {{ lookupItem.dataProviderInfo.name }} + } + + + } @empty { + @if (control.value?.length > 1) { + Oops! Could not find any assets. + } + } + } + + +@if (isLoading) { + +} diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss new file mode 100644 index 000000000..71c06f26e --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.scss @@ -0,0 +1,8 @@ +:host { + display: block; + + .mat-mdc-progress-spinner { + right: 0; + top: calc(50% - 10px); + } +} diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.stories.ts b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.stories.ts new file mode 100644 index 000000000..de7a09a04 --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.stories.ts @@ -0,0 +1,115 @@ +import { LookupItem } from '@ghostfolio/common/interfaces'; + +import { CommonModule } from '@angular/common'; +import { HttpClient } from '@angular/common/http'; +import { importProvidersFrom } from '@angular/core'; +import { + FormControl, + FormsModule, + NgControl, + ReactiveFormsModule +} from '@angular/forms'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { applicationConfig, Meta, StoryObj } from '@storybook/angular'; + +import { HttpClientMock } from '../mocks/httpClient.mock'; +import { GfSymbolAutocompleteComponent } from './symbol-autocomplete.component'; + +const DEFAULT_OPTIONS: LookupItem[] = [ + { + assetClass: 'EQUITY', + assetSubClass: 'ETF', + currency: 'USD', + dataProviderInfo: { + dataSource: 'YAHOO', + isPremium: false + }, + dataSource: null, + name: 'Default 1', + symbol: 'D1' + }, + { + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + currency: 'USD', + dataProviderInfo: { + dataSource: 'YAHOO', + isPremium: false + }, + dataSource: null, + name: 'Default 2', + symbol: 'D2' + } +]; + +const FILTERED_OPTIONS: LookupItem[] = [ + { + assetClass: 'EQUITY', + assetSubClass: 'ETF', + currency: 'USD', + dataProviderInfo: { + dataSource: 'YAHOO', + isPremium: false + }, + dataSource: null, + name: 'Autocomplete 1', + symbol: 'A1' + }, + { + assetClass: 'EQUITY', + assetSubClass: 'STOCK', + currency: 'USD', + dataProviderInfo: { + dataSource: 'YAHOO', + isPremium: false + }, + dataSource: null, + name: 'Autocomplete 2', + symbol: 'A2' + } +]; + +export default { + title: 'Symbol Autocomplete', + component: GfSymbolAutocompleteComponent, + decorators: [ + applicationConfig({ + providers: [ + provideNoopAnimations(), + importProvidersFrom(CommonModule, FormsModule, ReactiveFormsModule), + { + provide: NgControl, + useValue: { + control: new FormControl(), + valueAccessor: null + } + }, + { + provide: HttpClient, + useValue: new HttpClientMock( + new Map([ + [ + '/api/v1/symbol/lookup', + { + items: FILTERED_OPTIONS + } + ] + ]) + ) + } + ] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: {} +}; + +export const WithDefaultItems: Story = { + args: { + defaultLookupItems: DEFAULT_OPTIONS + } +}; diff --git a/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts new file mode 100644 index 000000000..c74e8a077 --- /dev/null +++ b/libs/ui/src/lib/symbol-autocomplete/symbol-autocomplete.component.ts @@ -0,0 +1,231 @@ +import { LookupItem } from '@ghostfolio/common/interfaces'; +import { GfSymbolPipe } from '@ghostfolio/common/pipes'; +import { DataService } from '@ghostfolio/ui/services'; + +import { FocusMonitor } from '@angular/cdk/a11y'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + DoCheck, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + SimpleChanges, + ViewChild +} from '@angular/core'; +import { + FormControl, + FormsModule, + NgControl, + ReactiveFormsModule +} from '@angular/forms'; +import { + MatAutocomplete, + MatAutocompleteModule, + MatAutocompleteSelectedEvent +} from '@angular/material/autocomplete'; +import { + MatFormFieldControl, + MatFormFieldModule +} from '@angular/material/form-field'; +import { MatInput, MatInputModule } from '@angular/material/input'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { isString } from 'lodash'; +import { Subject, tap } from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + filter, + switchMap, + takeUntil +} from 'rxjs/operators'; + +import { translate } from '../i18n'; +import { GfPremiumIndicatorComponent } from '../premium-indicator'; +import { AbstractMatFormField } from '../shared/abstract-mat-form-field'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[attr.aria-describedBy]': 'describedBy', + '[id]': 'id' + }, + imports: [ + FormsModule, + GfPremiumIndicatorComponent, + GfSymbolPipe, + MatAutocompleteModule, + MatFormFieldModule, + MatInputModule, + MatProgressSpinnerModule, + ReactiveFormsModule + ], + providers: [ + { + provide: MatFormFieldControl, + useExisting: GfSymbolAutocompleteComponent + } + ], + selector: 'gf-symbol-autocomplete', + schemas: [CUSTOM_ELEMENTS_SCHEMA], + styleUrls: ['./symbol-autocomplete.component.scss'], + templateUrl: 'symbol-autocomplete.component.html' +}) +export class GfSymbolAutocompleteComponent + extends AbstractMatFormField + implements DoCheck, OnChanges, OnDestroy, OnInit +{ + @Input() public defaultLookupItems: LookupItem[] = []; + @Input() public isLoading = false; + + @ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete; + + @Input() private includeIndices = false; + + @ViewChild(MatInput) private input: MatInput; + + public control = new FormControl(); + public lookupItems: (LookupItem & { assetSubClassString: string })[] = []; + + private unsubscribeSubject = new Subject(); + + public constructor( + public readonly _elementRef: ElementRef, + public readonly _focusMonitor: FocusMonitor, + public readonly changeDetectorRef: ChangeDetectorRef, + public readonly dataService: DataService, + public readonly ngControl: NgControl + ) { + super(_elementRef, _focusMonitor, ngControl); + + this.controlType = 'symbol-autocomplete'; + } + + public ngOnInit() { + if (this.disabled) { + this.control.disable(); + } + + this.control.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe(() => { + if (super.value) { + super.value.dataSource = null; + } + }); + + this.control.valueChanges + .pipe( + filter((query) => { + if (query?.length === 0) { + this.showDefaultOptions(); + + return false; + } + + return isString(query); + }), + tap(() => { + this.isLoading = true; + + this.changeDetectorRef.markForCheck(); + }), + debounceTime(400), + distinctUntilChanged(), + takeUntil(this.unsubscribeSubject), + switchMap((query: string) => { + return this.dataService.fetchSymbols({ + query, + includeIndices: this.includeIndices + }); + }) + ) + .subscribe((filteredLookupItems) => { + this.lookupItems = filteredLookupItems.map((lookupItem) => { + return { + ...lookupItem, + assetSubClassString: translate(lookupItem.assetSubClass) + }; + }); + + this.isLoading = false; + + this.changeDetectorRef.markForCheck(); + }); + } + + public ngOnChanges(changes: SimpleChanges) { + if (changes['defaultLookupItems'] && this.defaultLookupItems?.length) { + this.showDefaultOptions(); + } + } + + public displayFn(aLookupItem: LookupItem) { + return aLookupItem?.symbol ?? ''; + } + + public get empty() { + return this.input?.empty; + } + + public focus() { + this.input.focus(); + } + + public isValueInOptions(value: string) { + return this.lookupItems.some((item) => { + return item.symbol === value; + }); + } + + public ngDoCheck() { + if (this.ngControl) { + this.validateRequired(); + this.errorState = this.ngControl.invalid && this.ngControl.touched; + this.stateChanges.next(); + } + } + + public onUpdateSymbol(event: MatAutocompleteSelectedEvent) { + super.value = { + dataSource: event.option.value.dataSource, + symbol: event.option.value.symbol + } as LookupItem; + } + + public set value(value: LookupItem) { + this.control.setValue(value); + super.value = value; + } + + public ngOnDestroy() { + super.ngOnDestroy(); + + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private showDefaultOptions() { + this.lookupItems = this.defaultLookupItems.map((lookupItem) => { + return { + ...lookupItem, + assetSubClassString: translate(lookupItem.assetSubClass) + }; + }); + + this.changeDetectorRef.markForCheck(); + } + + private validateRequired() { + const requiredCheck = super.required + ? !super.value?.dataSource || !super.value?.symbol + : false; + if (requiredCheck) { + this.ngControl.control.setErrors({ invalidData: true }); + } + } +} diff --git a/libs/ui/src/lib/tags-selector/index.ts b/libs/ui/src/lib/tags-selector/index.ts new file mode 100644 index 000000000..360bce671 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/index.ts @@ -0,0 +1 @@ +export * from './tags-selector.component'; diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.html b/libs/ui/src/lib/tags-selector/tags-selector.component.html new file mode 100644 index 000000000..c3c02f3c7 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.html @@ -0,0 +1,60 @@ +
+
+ @if (readonly) { +
Tags
+ @if (tags?.length > 0) { + + @for (tag of tags; track tag) { + {{ tag.name }} + } + + } @else { +
-
+ } + } @else { + + Tags + + @for (tag of tagsSelected(); track tag.id) { + + {{ tag.name }} + + + } + + + + @for (tag of filteredOptions | async; track tag.id) { + + {{ tag.name }} + + } + + @if (hasPermissionToCreateTag && tagInputControl.value) { + + + + Create "{{ + tagInputControl.value.trim() + }}" + + + } + + + } +
+
diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.scss b/libs/ui/src/lib/tags-selector/tags-selector.component.scss new file mode 100644 index 000000000..5d4e87f30 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.scss @@ -0,0 +1,3 @@ +:host { + display: block; +} diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.stories.ts b/libs/ui/src/lib/tags-selector/tags-selector.component.stories.ts new file mode 100644 index 000000000..d11175fd1 --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.stories.ts @@ -0,0 +1,96 @@ +import { CommonModule } from '@angular/common'; +import '@angular/localize/init'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { Meta, moduleMetadata, StoryObj } from '@storybook/angular'; + +import { GfTagsSelectorComponent } from './tags-selector.component'; + +export default { + title: 'Tags Selector', + component: GfTagsSelectorComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, NoopAnimationsModule] + }) + ] +} as Meta; + +type Story = StoryObj; + +const OPTIONS = [ + { + id: '3ef7e6d9-4598-4eb2-b0e8-00e61cfc0ea6', + name: 'Gambling', + userId: '081aa387-487d-4438-83a4-3060eb2a016e' + }, + { + id: 'EMERGENCY_FUND', + name: 'Emergency Fund', + userId: null + }, + { + id: 'RETIREMENT_FUND', + name: 'Retirement Fund', + userId: null + } +]; + +export const Default: Story = { + args: { + tags: [ + { + id: 'EMERGENCY_FUND', + name: 'Emergency Fund', + userId: null + } + ], + tagsAvailable: OPTIONS + } +}; + +export const CreateCustomTags: Story = { + args: { + hasPermissionToCreateTag: true, + tags: [ + { + id: 'EMERGENCY_FUND', + name: 'Emergency Fund', + userId: null + } + ], + tagsAvailable: OPTIONS + } +}; + +export const Readonly: Story = { + args: { + readonly: true, + tags: [ + { + id: 'EMERGENCY_FUND', + name: 'Emergency Fund', + userId: null + }, + { + id: 'RETIREMENT_FUND', + name: 'Retirement Fund', + userId: null + } + ], + tagsAvailable: OPTIONS + } +}; + +export const WithoutValue: Story = { + args: { + tags: [], + tagsAvailable: OPTIONS + } +}; + +export const WithoutOptions: Story = { + args: { + tags: [], + tagsAvailable: [] + } +}; diff --git a/libs/ui/src/lib/tags-selector/tags-selector.component.ts b/libs/ui/src/lib/tags-selector/tags-selector.component.ts new file mode 100644 index 000000000..7f1a8805e --- /dev/null +++ b/libs/ui/src/lib/tags-selector/tags-selector.component.ts @@ -0,0 +1,184 @@ +import { COMMA, ENTER } from '@angular/cdk/keycodes'; +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + CUSTOM_ELEMENTS_SCHEMA, + ElementRef, + Input, + OnChanges, + OnDestroy, + OnInit, + signal, + ViewChild +} from '@angular/core'; +import { + ControlValueAccessor, + FormControl, + FormsModule, + NG_VALUE_ACCESSOR, + ReactiveFormsModule +} from '@angular/forms'; +import { + MatAutocompleteModule, + MatAutocompleteSelectedEvent +} from '@angular/material/autocomplete'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { IonIcon } from '@ionic/angular/standalone'; +import { Tag } from '@prisma/client'; +import { addIcons } from 'ionicons'; +import { addCircleOutline, closeOutline } from 'ionicons/icons'; +import { BehaviorSubject, Subject, takeUntil } from 'rxjs'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + FormsModule, + IonIcon, + MatAutocompleteModule, + MatChipsModule, + MatFormFieldModule, + MatInputModule, + ReactiveFormsModule + ], + providers: [ + { + multi: true, + provide: NG_VALUE_ACCESSOR, + useExisting: GfTagsSelectorComponent + } + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-tags-selector', + styleUrls: ['./tags-selector.component.scss'], + templateUrl: 'tags-selector.component.html' +}) +export class GfTagsSelectorComponent + implements ControlValueAccessor, OnChanges, OnDestroy, OnInit +{ + @Input() hasPermissionToCreateTag = false; + @Input() readonly = false; + @Input() tags: Tag[]; + @Input() tagsAvailable: Tag[]; + + @ViewChild('tagInput') tagInput: ElementRef; + + public filteredOptions: Subject = new BehaviorSubject([]); + public readonly separatorKeysCodes: number[] = [COMMA, ENTER]; + public readonly tagInputControl = new FormControl(''); + public readonly tagsSelected = signal([]); + + private unsubscribeSubject = new Subject(); + + public constructor() { + this.tagInputControl.valueChanges + .pipe(takeUntil(this.unsubscribeSubject)) + .subscribe((value) => { + this.filteredOptions.next(this.filterTags(value)); + }); + + addIcons({ addCircleOutline, closeOutline }); + } + + public ngOnInit() { + this.tagsSelected.set(this.tags); + this.updateFilters(); + } + + public ngOnChanges() { + this.tagsSelected.set(this.tags); + this.updateFilters(); + } + + public onAddTag(event: MatAutocompleteSelectedEvent) { + let tag = this.tagsAvailable.find(({ id }) => { + return id === event.option.value; + }); + + if (!tag && this.hasPermissionToCreateTag) { + tag = { + id: undefined, + name: event.option.value as string, + userId: null + }; + } + + this.tagsSelected.update((tags) => { + return [...(tags ?? []), tag]; + }); + + const newTags = this.tagsSelected(); + this.onChange(newTags); + this.onTouched(); + this.tagInput.nativeElement.value = ''; + this.tagInputControl.setValue(undefined); + } + + public onRemoveTag(tag: Tag) { + this.tagsSelected.update((tags) => { + return tags.filter(({ id }) => { + return id !== tag.id; + }); + }); + + const newTags = this.tagsSelected(); + this.onChange(newTags); + this.onTouched(); + this.updateFilters(); + } + + public registerOnChange(fn: (value: Tag[]) => void) { + this.onChange = fn; + } + + public registerOnTouched(fn: () => void) { + this.onTouched = fn; + } + + public setDisabledState(isDisabled: boolean) { + if (isDisabled) { + this.tagInputControl.disable(); + } else { + this.tagInputControl.enable(); + } + } + + public writeValue(value: Tag[]) { + this.tagsSelected.set(value || []); + this.updateFilters(); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } + + private filterTags(query: string = ''): Tag[] { + const tags = this.tagsSelected() ?? []; + const tagIds = tags.map(({ id }) => { + return id; + }); + + return this.tagsAvailable.filter(({ id, name }) => { + return ( + !tagIds.includes(id) && name.toLowerCase().includes(query.toLowerCase()) + ); + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private onChange = (_value: Tag[]): void => { + // ControlValueAccessor onChange callback + }; + + private onTouched = (): void => { + // ControlValueAccessor onTouched callback + }; + + private updateFilters() { + this.filteredOptions.next(this.filterTags()); + } +} diff --git a/libs/ui/src/lib/toggle/index.ts b/libs/ui/src/lib/toggle/index.ts new file mode 100644 index 000000000..251899391 --- /dev/null +++ b/libs/ui/src/lib/toggle/index.ts @@ -0,0 +1 @@ +export * from './toggle.component'; diff --git a/libs/ui/src/lib/toggle/toggle.component.html b/libs/ui/src/lib/toggle/toggle.component.html new file mode 100644 index 000000000..ac2256daa --- /dev/null +++ b/libs/ui/src/lib/toggle/toggle.component.html @@ -0,0 +1,18 @@ + + @for (option of options; track option) { + {{ option.label }} + } + diff --git a/libs/ui/src/lib/toggle/toggle.component.scss b/libs/ui/src/lib/toggle/toggle.component.scss new file mode 100644 index 000000000..24d1a2975 --- /dev/null +++ b/libs/ui/src/lib/toggle/toggle.component.scss @@ -0,0 +1,40 @@ +:host { + display: block; + + .mat-mdc-radio-button { + border-radius: 1rem; + margin: 0 0.25rem; + + &.mat-mdc-radio-checked { + background-color: rgba(var(--dark-dividers)); + } + + ::ng-deep { + .mdc-radio { + display: none; + } + + label { + color: rgba(var(--dark-primary-text), 1); + cursor: inherit; + margin: 0; + padding: 0.15rem 0.75rem; + } + } + } +} + +:host-context(.theme-dark) { + .mat-mdc-radio-button { + &.mat-mdc-radio-checked { + background-color: rgba(var(--light-dividers)); + border: 1px solid rgba(var(--light-disabled-text)); + } + + ::ng-deep { + label { + color: rgba(var(--light-primary-text), 1); + } + } + } +} diff --git a/libs/ui/src/lib/toggle/toggle.component.stories.ts b/libs/ui/src/lib/toggle/toggle.component.stories.ts new file mode 100644 index 000000000..36468ea3c --- /dev/null +++ b/libs/ui/src/lib/toggle/toggle.component.stories.ts @@ -0,0 +1,33 @@ +import { CommonModule } from '@angular/common'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MatRadioModule } from '@angular/material/radio'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { GfToggleComponent } from './toggle.component'; + +export default { + title: 'Toggle', + component: GfToggleComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, MatRadioModule, ReactiveFormsModule] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + defaultValue: '1d', + isLoading: false, + options: [ + { label: 'Today', value: '1d' }, + { label: 'YTD', value: 'ytd' }, + { label: '1Y', value: '1y' }, + { label: '5Y', value: '5y' }, + { label: 'Max', value: 'max' } + ] + } +}; diff --git a/libs/ui/src/lib/toggle/toggle.component.ts b/libs/ui/src/lib/toggle/toggle.component.ts new file mode 100644 index 000000000..be460f7fa --- /dev/null +++ b/libs/ui/src/lib/toggle/toggle.component.ts @@ -0,0 +1,38 @@ +import { ToggleOption } from '@ghostfolio/common/interfaces'; + +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + Output +} from '@angular/core'; +import { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatRadioModule } from '@angular/material/radio'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, MatRadioModule, ReactiveFormsModule], + selector: 'gf-toggle', + styleUrls: ['./toggle.component.scss'], + templateUrl: './toggle.component.html' +}) +export class GfToggleComponent implements OnChanges { + @Input() defaultValue: string; + @Input() isLoading: boolean; + @Input() options: ToggleOption[] = []; + + @Output() valueChange = new EventEmitter>(); + + public optionFormControl = new FormControl(undefined); + + public ngOnChanges() { + this.optionFormControl.setValue(this.defaultValue); + } + + public onValueChange() { + this.valueChange.emit({ value: this.optionFormControl.value }); + } +} diff --git a/libs/ui/src/lib/top-holdings/index.ts b/libs/ui/src/lib/top-holdings/index.ts new file mode 100644 index 000000000..a5bc960a9 --- /dev/null +++ b/libs/ui/src/lib/top-holdings/index.ts @@ -0,0 +1 @@ +export * from './top-holdings.component'; diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.html b/libs/ui/src/lib/top-holdings/top-holdings.component.html new file mode 100644 index 000000000..7a2a84126 --- /dev/null +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.html @@ -0,0 +1,183 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name + +
{{ element?.name | titlecase }}
+
+ Value + +
+ +
+
+ Allocation + % + +
+ +
+
+
+
+ + + + + + + + + + + + + + + + + + + + + +
+
+
{{ parentHolding?.name }}
+
+
+ {{ + parentHolding?.symbol | gfSymbol + }} +
+
+ Name + +
+ +
+
+ Value + +
+ +
+
+ Allocation + % +
+
+
+
+
+ + + +@if (isLoading) { + +} + +@if (dataSource.data.length > pageSize && !isLoading) { +
+ +
+} + +@if (dataSource.data.length === 0 && !isLoading) { +
+ No data available +
+} diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.scss b/libs/ui/src/lib/top-holdings/top-holdings.component.scss new file mode 100644 index 000000000..b3e811a2c --- /dev/null +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.scss @@ -0,0 +1,35 @@ +:host { + display: block; + + .holdings-table { + table-layout: auto; + + tr { + &:not(.expanded) + tr.holding-detail td { + border-bottom: 0; + } + + &.expanded { + > td { + font-weight: bold; + } + } + + &.holding-detail { + height: 0; + } + + .holding-parents-table { + --table-padding: 0.5em; + + tr { + height: auto; + + td { + padding: var(--table-padding); + } + } + } + } + } +} diff --git a/libs/ui/src/lib/top-holdings/top-holdings.component.ts b/libs/ui/src/lib/top-holdings/top-holdings.component.ts new file mode 100644 index 000000000..75a96fc5c --- /dev/null +++ b/libs/ui/src/lib/top-holdings/top-holdings.component.ts @@ -0,0 +1,108 @@ +import { getLocale } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + HoldingWithParents +} from '@ghostfolio/common/interfaces'; +import { GfSymbolPipe } from '@ghostfolio/common/pipes'; + +import { + animate, + state, + style, + transition, + trigger +} from '@angular/animations'; +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + ViewChild +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import { Subject } from 'rxjs'; + +import { GfValueComponent } from '../value/value.component'; + +@Component({ + animations: [ + trigger('detailExpand', [ + state('collapsed,void', style({ height: '0px', minHeight: '0' })), + state('expanded', style({ height: '*' })), + transition( + 'expanded <=> collapsed', + animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)') + ) + ]) + ], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + GfSymbolPipe, + GfValueComponent, + MatButtonModule, + MatPaginatorModule, + MatTableModule, + NgxSkeletonLoaderModule + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-top-holdings', + styleUrls: ['./top-holdings.component.scss'], + templateUrl: './top-holdings.component.html' +}) +export class GfTopHoldingsComponent implements OnChanges, OnDestroy { + @Input() baseCurrency: string; + @Input() locale = getLocale(); + @Input() pageSize = Number.MAX_SAFE_INTEGER; + @Input() topHoldings: HoldingWithParents[]; + + @Output() holdingClicked = new EventEmitter(); + + @ViewChild(MatPaginator) paginator: MatPaginator; + + public dataSource = new MatTableDataSource(); + public displayedColumns: string[] = [ + 'name', + 'valueInBaseCurrency', + 'allocationInPercentage' + ]; + public isLoading = true; + + private unsubscribeSubject = new Subject(); + + public ngOnChanges() { + this.isLoading = true; + + this.dataSource = new MatTableDataSource(this.topHoldings); + this.dataSource.paginator = this.paginator; + + if (this.topHoldings) { + this.isLoading = false; + } + } + + public onClickHolding(assetProfileIdentifier: AssetProfileIdentifier) { + this.holdingClicked.emit(assetProfileIdentifier); + } + + public onShowAllHoldings() { + this.pageSize = Number.MAX_SAFE_INTEGER; + + setTimeout(() => { + this.dataSource.paginator = this.paginator; + }); + } + + public ngOnDestroy() { + this.unsubscribeSubject.next(); + this.unsubscribeSubject.complete(); + } +} diff --git a/libs/ui/src/lib/treemap-chart/index.ts b/libs/ui/src/lib/treemap-chart/index.ts new file mode 100644 index 000000000..62f54ac11 --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/index.ts @@ -0,0 +1 @@ +export * from './treemap-chart.component'; diff --git a/libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts b/libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts new file mode 100644 index 000000000..e8d182adb --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/interfaces/interfaces.ts @@ -0,0 +1,21 @@ +import { PortfolioPosition } from '@ghostfolio/common/interfaces'; + +import { ScriptableContext, TooltipItem } from 'chart.js'; +import { TreemapDataPoint } from 'chartjs-chart-treemap'; + +export interface GetColorParams { + annualizedNetPerformancePercent: number; + negativeNetPerformancePercentsRange: { max: number; min: number }; + positiveNetPerformancePercentsRange: { max: number; min: number }; +} + +interface GfTreemapDataPoint extends TreemapDataPoint { + _data: PortfolioPosition; +} + +export interface GfTreemapScriptableContext extends ScriptableContext<'treemap'> { + raw: GfTreemapDataPoint; +} +export interface GfTreemapTooltipItem extends TooltipItem<'treemap'> { + raw: GfTreemapDataPoint; +} diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.html b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html new file mode 100644 index 000000000..c7de5ef4d --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.html @@ -0,0 +1,13 @@ +@if (isLoading) { + +} + diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss b/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss new file mode 100644 index 000000000..d041372c8 --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.scss @@ -0,0 +1,4 @@ +:host { + aspect-ratio: 16 / 9; + display: block; +} diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts new file mode 100644 index 000000000..c8951ce6b --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.stories.ts @@ -0,0 +1,45 @@ +import { CommonModule } from '@angular/common'; +import '@angular/localize/init'; +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { holdings } from '../mocks/holdings'; +import { GfTreemapChartComponent } from './treemap-chart.component'; + +export default { + title: 'Treemap Chart', + component: GfTreemapChartComponent, + decorators: [ + moduleMetadata({ + imports: [CommonModule, NgxSkeletonLoaderModule] + }) + ], + argTypes: { + colorScheme: { + control: { + type: 'select' + }, + options: ['DARK', 'LIGHT'] + }, + cursor: { + control: { + type: 'select' + }, + options: ['', 'pointer'] + } + } +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + holdings, + baseCurrency: 'USD', + colorScheme: 'LIGHT', + cursor: undefined, + dateRange: 'mtd', + locale: 'en-US' + } +}; diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts new file mode 100644 index 000000000..a80876f6a --- /dev/null +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -0,0 +1,401 @@ +import { + getAnnualizedPerformancePercent, + getIntervalFromDateRange +} from '@ghostfolio/common/calculation-helper'; +import { getTooltipOptions } from '@ghostfolio/common/chart-helper'; +import { getLocale } from '@ghostfolio/common/helper'; +import { + AssetProfileIdentifier, + PortfolioPosition +} from '@ghostfolio/common/interfaces'; +import { ColorScheme, DateRange } from '@ghostfolio/common/types'; + +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnDestroy, + Output, + ViewChild +} from '@angular/core'; +import { DataSource } from '@prisma/client'; +import { Big } from 'big.js'; +import type { ChartData, TooltipOptions } from 'chart.js'; +import { LinearScale } from 'chart.js'; +import { Chart, Tooltip } from 'chart.js'; +import { TreemapController, TreemapElement } from 'chartjs-chart-treemap'; +import { isUUID } from 'class-validator'; +import { differenceInDays, max } from 'date-fns'; +import { orderBy } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import OpenColor from 'open-color'; + +import type { + GetColorParams, + GfTreemapScriptableContext, + GfTreemapTooltipItem +} from './interfaces/interfaces'; + +const { gray, green, red } = OpenColor; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, NgxSkeletonLoaderModule], + selector: 'gf-treemap-chart', + styleUrls: ['./treemap-chart.component.scss'], + templateUrl: './treemap-chart.component.html' +}) +export class GfTreemapChartComponent + implements AfterViewInit, OnChanges, OnDestroy +{ + @Input() baseCurrency: string; + @Input() colorScheme: ColorScheme; + @Input() cursor: string; + @Input() dateRange: DateRange; + @Input() holdings: PortfolioPosition[]; + @Input() locale = getLocale(); + + @Output() treemapChartClicked = new EventEmitter(); + + @ViewChild('chartCanvas') chartCanvas: ElementRef; + + public chart: Chart<'treemap'>; + public isLoading = true; + + public constructor() { + Chart.register(LinearScale, Tooltip, TreemapController, TreemapElement); + } + public ngAfterViewInit() { + if (this.holdings) { + this.initialize(); + } + } + + public ngOnChanges() { + if (this.holdings) { + this.initialize(); + } + } + + public ngOnDestroy() { + this.chart?.destroy(); + } + + private getColor({ + annualizedNetPerformancePercent, + negativeNetPerformancePercentsRange, + positiveNetPerformancePercentsRange + }: GetColorParams) { + if (Math.abs(annualizedNetPerformancePercent) === 0) { + return { + backgroundColor: gray[3], + fontColor: gray[9] + }; + } + + if (annualizedNetPerformancePercent > 0) { + let backgroundIndex: number; + const range = + positiveNetPerformancePercentsRange.max - + positiveNetPerformancePercentsRange.min; + + if ( + annualizedNetPerformancePercent >= + positiveNetPerformancePercentsRange.max - range * 0.25 + ) { + backgroundIndex = 9; + } else if ( + annualizedNetPerformancePercent >= + positiveNetPerformancePercentsRange.max - range * 0.5 + ) { + backgroundIndex = 7; + } else if ( + annualizedNetPerformancePercent >= + positiveNetPerformancePercentsRange.max - range * 0.75 + ) { + backgroundIndex = 5; + } else { + backgroundIndex = 3; + } + + return { + backgroundColor: green[backgroundIndex], + fontColor: green[backgroundIndex <= 4 ? 9 : 0] + }; + } else { + let backgroundIndex: number; + const range = + negativeNetPerformancePercentsRange.min - + negativeNetPerformancePercentsRange.max; + + if ( + annualizedNetPerformancePercent <= + negativeNetPerformancePercentsRange.min + range * 0.25 + ) { + backgroundIndex = 9; + } else if ( + annualizedNetPerformancePercent <= + negativeNetPerformancePercentsRange.min + range * 0.5 + ) { + backgroundIndex = 7; + } else if ( + annualizedNetPerformancePercent <= + negativeNetPerformancePercentsRange.min + range * 0.75 + ) { + backgroundIndex = 5; + } else { + backgroundIndex = 3; + } + + return { + backgroundColor: red[backgroundIndex], + fontColor: red[backgroundIndex <= 4 ? 9 : 0] + }; + } + } + + private initialize() { + this.isLoading = true; + + const { endDate, startDate } = getIntervalFromDateRange(this.dateRange); + + const netPerformancePercentsWithCurrencyEffect = this.holdings.map( + ({ dateOfFirstActivity, netPerformancePercentWithCurrencyEffect }) => { + return getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays( + endDate, + max([dateOfFirstActivity ?? new Date(0), startDate]) + ), + netPerformancePercentage: new Big( + netPerformancePercentWithCurrencyEffect + ) + }).toNumber(); + } + ); + + const positiveNetPerformancePercents = + netPerformancePercentsWithCurrencyEffect.filter( + (annualizedNetPerformancePercent) => { + return annualizedNetPerformancePercent > 0; + } + ); + + const positiveNetPerformancePercentsRange = { + max: Math.max(...positiveNetPerformancePercents), + min: Math.min(...positiveNetPerformancePercents) + }; + + const negativeNetPerformancePercents = + netPerformancePercentsWithCurrencyEffect.filter( + (annualizedNetPerformancePercent) => { + return annualizedNetPerformancePercent < 0; + } + ); + + const negativeNetPerformancePercentsRange = { + max: Math.max(...negativeNetPerformancePercents), + min: Math.min(...negativeNetPerformancePercents) + }; + + const data: ChartData<'treemap'> = { + datasets: [ + { + backgroundColor: (context: GfTreemapScriptableContext) => { + let annualizedNetPerformancePercent = + getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays( + endDate, + max([ + context.raw._data.dateOfFirstActivity ?? new Date(0), + startDate + ]) + ), + netPerformancePercentage: new Big( + context.raw._data.netPerformancePercentWithCurrencyEffect + ) + }).toNumber(); + + // Round to 2 decimal places + annualizedNetPerformancePercent = + Math.round(annualizedNetPerformancePercent * 100) / 100; + + const { backgroundColor } = this.getColor({ + annualizedNetPerformancePercent, + negativeNetPerformancePercentsRange, + positiveNetPerformancePercentsRange + }); + + return backgroundColor; + }, + borderRadius: 4, + key: 'allocationInPercentage', + labels: { + align: 'left', + color: (context: GfTreemapScriptableContext) => { + let annualizedNetPerformancePercent = + getAnnualizedPerformancePercent({ + daysInMarket: differenceInDays( + endDate, + max([ + context.raw._data.dateOfFirstActivity ?? new Date(0), + startDate + ]) + ), + netPerformancePercentage: new Big( + context.raw._data.netPerformancePercentWithCurrencyEffect + ) + }).toNumber(); + + // Round to 2 decimal places + annualizedNetPerformancePercent = + Math.round(annualizedNetPerformancePercent * 100) / 100; + + const { fontColor } = this.getColor({ + annualizedNetPerformancePercent, + negativeNetPerformancePercentsRange, + positiveNetPerformancePercentsRange + }); + + return fontColor; + }, + display: true, + font: [{ size: 16 }, { lineHeight: 1.5, size: 14 }], + formatter: ({ raw }: GfTreemapScriptableContext) => { + // Round to 4 decimal places + let netPerformancePercentWithCurrencyEffect = + Math.round( + raw._data.netPerformancePercentWithCurrencyEffect * 10000 + ) / 10000; + + if (Math.abs(netPerformancePercentWithCurrencyEffect) === 0) { + netPerformancePercentWithCurrencyEffect = Math.abs( + netPerformancePercentWithCurrencyEffect + ); + } + + const name = raw._data.name; + const symbol = raw._data.symbol; + + return [ + isUUID(symbol) ? (name ?? symbol) : symbol, + `${netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''}${(netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%` + ]; + }, + hoverColor: undefined, + position: 'top' + }, + spacing: 1, + // @ts-expect-error: should be PortfolioPosition[] + tree: this.holdings + } + ] + }; + + if (this.chartCanvas) { + if (this.chart) { + this.chart.data = data; + this.chart.options.plugins ??= {}; + this.chart.options.plugins.tooltip = + this.getTooltipPluginConfiguration(); + + this.chart.update(); + } else { + this.chart = new Chart<'treemap'>(this.chartCanvas.nativeElement, { + data, + options: { + animation: false, + onClick: (_, activeElements, chart: Chart<'treemap'>) => { + try { + const dataIndex = activeElements[0].index; + const datasetIndex = activeElements[0].datasetIndex; + + const dataset = orderBy( + chart.data.datasets[datasetIndex].tree, + ['allocationInPercentage'], + ['desc'] + ) as PortfolioPosition[]; + + const dataSource: DataSource = dataset[dataIndex].dataSource; + const symbol: string = dataset[dataIndex].symbol; + + this.treemapChartClicked.emit({ dataSource, symbol }); + } catch {} + }, + onHover: (event, chartElement) => { + if (this.cursor) { + (event.native?.target as HTMLElement).style.cursor = + chartElement[0] ? this.cursor : 'default'; + } + }, + plugins: { + tooltip: this.getTooltipPluginConfiguration() + } + }, + type: 'treemap' + }); + } + } + + this.isLoading = false; + } + + private getTooltipPluginConfiguration(): Partial> { + return { + ...getTooltipOptions({ + colorScheme: this.colorScheme, + currency: this.baseCurrency, + locale: this.locale + }), + // @ts-expect-error: no need to set all attributes in callbacks + callbacks: { + label: ({ raw }: GfTreemapTooltipItem) => { + const allocationInPercentage = `${(raw._data.allocationInPercentage * 100).toFixed(2)}%`; + const name = raw._data.name; + const sign = + raw._data.netPerformancePercentWithCurrencyEffect > 0 ? '+' : ''; + const symbol = raw._data.symbol; + + const netPerformanceInPercentageWithSign = `${sign}${(raw._data.netPerformancePercentWithCurrencyEffect * 100).toFixed(2)}%`; + + if (raw._data.valueInBaseCurrency !== null) { + const value = raw._data.valueInBaseCurrency; + + return [ + `${name ?? symbol} (${allocationInPercentage})`, + `${value?.toLocaleString(this.locale, { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + })} ${this.baseCurrency}`, + '', + $localize`Change` + ' (' + $localize`Performance` + ')', + `${sign}${raw._data.netPerformanceWithCurrencyEffect.toLocaleString( + this.locale, + { + maximumFractionDigits: 2, + minimumFractionDigits: 2 + } + )} ${this.baseCurrency} (${netPerformanceInPercentageWithSign})` + ]; + } else { + return [ + `${name ?? symbol} (${allocationInPercentage})`, + '', + $localize`Performance`, + netPerformanceInPercentageWithSign + ]; + } + }, + title: () => { + return ''; + } + }, + xAlign: 'center', + yAlign: 'center' + }; + } +} diff --git a/libs/ui/src/lib/trend-indicator/index.ts b/libs/ui/src/lib/trend-indicator/index.ts new file mode 100644 index 000000000..3e5ebf36e --- /dev/null +++ b/libs/ui/src/lib/trend-indicator/index.ts @@ -0,0 +1 @@ +export * from './trend-indicator.component'; diff --git a/libs/ui/src/lib/trend-indicator/trend-indicator.component.html b/libs/ui/src/lib/trend-indicator/trend-indicator.component.html new file mode 100644 index 000000000..b9f65a2ea --- /dev/null +++ b/libs/ui/src/lib/trend-indicator/trend-indicator.component.html @@ -0,0 +1,36 @@ +@if (isLoading) { + +} @else { + @if (marketState === 'closed' && dateRange === '1d') { + + } @else if (marketState === 'delayed' && dateRange === '1d') { + + } @else if (value <= -0.0005) { + + } @else if (value > -0.0005 && value < 0.0005) { + + } @else { + + } +} diff --git a/libs/ui/src/lib/trend-indicator/trend-indicator.component.scss b/libs/ui/src/lib/trend-indicator/trend-indicator.component.scss new file mode 100644 index 000000000..b78b0417f --- /dev/null +++ b/libs/ui/src/lib/trend-indicator/trend-indicator.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + + .rotate-45-down { + transform: rotateY(0deg) rotate(-45deg); + } + + .rotate-45-up { + transform: rotateY(0deg) rotate(45deg); + } +} diff --git a/libs/ui/src/lib/trend-indicator/trend-indicator.component.stories.ts b/libs/ui/src/lib/trend-indicator/trend-indicator.component.stories.ts new file mode 100644 index 000000000..1db9e2995 --- /dev/null +++ b/libs/ui/src/lib/trend-indicator/trend-indicator.component.stories.ts @@ -0,0 +1,53 @@ +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfTrendIndicatorComponent } from './trend-indicator.component'; + +export default { + title: 'Trend Indicator', + component: GfTrendIndicatorComponent, + decorators: [ + moduleMetadata({ + imports: [NgxSkeletonLoaderModule] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { + isLoading: true + } +}; + +export const Default: Story = { + args: {} +}; + +export const Delayed: Story = { + args: { + dateRange: '1d', + marketState: 'delayed' + } +}; + +export const Down: Story = { + args: { + value: -1 + } +}; + +export const Up: Story = { + args: { + value: 1 + } +}; + +export const MarketClosed: Story = { + args: { + dateRange: '1d', + marketState: 'closed' + } +}; diff --git a/libs/ui/src/lib/trend-indicator/trend-indicator.component.ts b/libs/ui/src/lib/trend-indicator/trend-indicator.component.ts new file mode 100644 index 000000000..1e49b4a6c --- /dev/null +++ b/libs/ui/src/lib/trend-indicator/trend-indicator.component.ts @@ -0,0 +1,44 @@ +import { DateRange, MarketState } from '@ghostfolio/common/types'; + +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Input +} from '@angular/core'; +import { IonIcon } from '@ionic/angular/standalone'; +import { addIcons } from 'ionicons'; +import { + arrowDownCircleOutline, + arrowForwardCircleOutline, + arrowUpCircleOutline, + pauseCircleOutline, + timeOutline +} from 'ionicons/icons'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IonIcon, NgxSkeletonLoaderModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-trend-indicator', + styleUrls: ['./trend-indicator.component.scss'], + templateUrl: './trend-indicator.component.html' +}) +export class GfTrendIndicatorComponent { + @Input() dateRange: DateRange; + @Input() isLoading = false; + @Input() marketState: MarketState = 'open'; + @Input() size: 'large' | 'medium' | 'small' = 'small'; + @Input() value = 0; + + public constructor() { + addIcons({ + arrowDownCircleOutline, + arrowForwardCircleOutline, + arrowUpCircleOutline, + pauseCircleOutline, + timeOutline + }); + } +} diff --git a/libs/ui/src/lib/value/index.ts b/libs/ui/src/lib/value/index.ts new file mode 100644 index 000000000..9dc866865 --- /dev/null +++ b/libs/ui/src/lib/value/index.ts @@ -0,0 +1 @@ +export * from './value.component'; diff --git a/libs/ui/src/lib/value/value.component.html b/libs/ui/src/lib/value/value.component.html new file mode 100644 index 000000000..14080c16d --- /dev/null +++ b/libs/ui/src/lib/value/value.component.html @@ -0,0 +1,100 @@ +@if (icon) { +
+ +
+} +
+ + @if (value || value === 0 || value === null) { +
+ @if (isNumber || value === null) { + @if (colorizeSign && !useAbsoluteValue) { + @if (+value > 0) { +
+
+ } + @if (+value < 0) { +
-
+ } + } + @if (isPercent) { +
+ @if (value === null) { + *****% + } @else { + {{ formattedValue }}% + } +
+ } @else { +
+ @if (value === null) { + ***** + } @else { + {{ formattedValue }} + } +
+ } + @if (unit) { + @if (size === 'medium') { + + {{ unit }} + + } @else { +
+ {{ unit }} +
+ } + } + } + @if (isString) { +
+ {{ formattedValue }} +
+ } +
+ } + + @if (value === undefined) { + + } + + @if (size === 'large') { +
+ + @if (subLabel) { + {{ subLabel }} + } +
+ } @else { + + + + } +
diff --git a/libs/ui/src/lib/value/value.component.scss b/libs/ui/src/lib/value/value.component.scss new file mode 100644 index 000000000..087bf31e1 --- /dev/null +++ b/libs/ui/src/lib/value/value.component.scss @@ -0,0 +1,10 @@ +:host { + display: flex; + flex-direction: row; + font-variant-numeric: tabular-nums; + + .h2 { + font-variant-numeric: initial; + line-height: 1; + } +} diff --git a/libs/ui/src/lib/value/value.component.stories.ts b/libs/ui/src/lib/value/value.component.stories.ts new file mode 100644 index 000000000..a1f9d06a0 --- /dev/null +++ b/libs/ui/src/lib/value/value.component.stories.ts @@ -0,0 +1,93 @@ +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfValueComponent } from './value.component'; + +export default { + title: 'Value', + component: GfValueComponent, + decorators: [ + moduleMetadata({ + imports: [NgxSkeletonLoaderModule] + }) + ], + argTypes: { + deviceType: { + control: 'select', + options: ['desktop', 'mobile'] + } + } +} as Meta; + +type Story = StoryObj; + +export const Loading: Story = { + args: { + value: undefined + } +}; + +export const Currency: Story = { + args: { + isCurrency: true, + locale: 'en-US', + unit: 'USD', + value: 7 + } +}; + +export const DateValue: Story = { + args: { + deviceType: 'desktop', + isDate: true, + locale: 'en-US', + value: new Date().toISOString() + }, + name: 'Date' +}; + +export const Label: Story = { + args: { + locale: 'en-US', + value: 7.25 + } +}; + +export const PerformancePositive: Story = { + args: { + colorizeSign: true, + isPercent: true, + locale: 'en-US', + value: 0.0136810853673890378 + }, + name: 'Performance (positive)' +}; + +export const PerformanceNegative: Story = { + args: { + colorizeSign: true, + isPercent: true, + locale: 'en-US', + value: -0.0136810853673890378 + }, + name: 'Performance (negative)' +}; + +export const PerformanceCloseToZero: Story = { + args: { + colorizeSign: true, + isPercent: true, + locale: 'en-US', + value: -2.388915360475e-8 + }, + name: 'Performance (negative zero)' +}; + +export const Precision: Story = { + args: { + locale: 'en-US', + precision: 3, + value: 7.2534802394809285309 + } +}; diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts new file mode 100644 index 000000000..795e16491 --- /dev/null +++ b/libs/ui/src/lib/value/value.component.ts @@ -0,0 +1,148 @@ +import { getLocale } from '@ghostfolio/common/helper'; + +import { CommonModule } from '@angular/common'; +import { + CUSTOM_ELEMENTS_SCHEMA, + ChangeDetectionStrategy, + Component, + Input, + OnChanges, + computed, + input +} from '@angular/core'; +import { IonIcon } from '@ionic/angular/standalone'; +import { isNumber } from 'lodash'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [CommonModule, IonIcon, NgxSkeletonLoaderModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], + selector: 'gf-value', + styleUrls: ['./value.component.scss'], + templateUrl: './value.component.html' +}) +export class GfValueComponent implements OnChanges { + @Input() colorizeSign = false; + @Input() deviceType: string; + @Input() icon = ''; + @Input() isAbsolute = false; + @Input() isCurrency = false; + @Input() isDate = false; + @Input() isPercent = false; + @Input() locale: string; + @Input() position = ''; + @Input() size: 'large' | 'medium' | 'small' = 'small'; + @Input() subLabel = ''; + @Input() unit = ''; + @Input() value: number | string = ''; + + public absoluteValue = 0; + public formattedValue = ''; + public isNumber = false; + public isString = false; + public useAbsoluteValue = false; + + public readonly precision = input(); + + private readonly formatOptions = computed(() => { + const digits = this.hasPrecision ? this.precision() : 2; + + return { + maximumFractionDigits: digits, + minimumFractionDigits: digits + }; + }); + + private get hasPrecision() { + const precision = this.precision(); + return precision !== undefined && precision >= 0; + } + + public ngOnChanges() { + this.initializeVariables(); + + if (this.value || this.value === 0) { + if (isNumber(this.value)) { + this.isNumber = true; + this.isString = false; + this.absoluteValue = Math.abs(this.value); + + if (this.colorizeSign) { + if (this.isCurrency) { + try { + this.formattedValue = this.absoluteValue.toLocaleString( + this.locale, + this.formatOptions() + ); + } catch {} + } else if (this.isPercent) { + try { + this.formattedValue = (this.absoluteValue * 100).toLocaleString( + this.locale, + this.formatOptions() + ); + } catch {} + } + } else if (this.isCurrency) { + try { + this.formattedValue = this.value?.toLocaleString( + this.locale, + this.formatOptions() + ); + } catch {} + } else if (this.isPercent) { + try { + this.formattedValue = (this.value * 100).toLocaleString( + this.locale, + this.formatOptions() + ); + } catch {} + } else if (this.hasPrecision) { + try { + this.formattedValue = this.value?.toLocaleString( + this.locale, + this.formatOptions() + ); + } catch {} + } else { + this.formattedValue = this.value?.toLocaleString(this.locale); + } + + if (this.isAbsolute) { + // Remove algebraic sign + this.formattedValue = this.formattedValue.replace(/^-/, ''); + } + } else { + this.isNumber = false; + this.isString = true; + + if (this.isDate) { + this.formattedValue = new Date(this.value).toLocaleDateString( + this.locale, + { + day: '2-digit', + month: '2-digit', + year: this.deviceType === 'mobile' ? '2-digit' : 'numeric' + } + ); + } else { + this.formattedValue = this.value; + } + } + } + + if (this.formattedValue === '0.00') { + this.useAbsoluteValue = true; + } + } + + private initializeVariables() { + this.absoluteValue = 0; + this.formattedValue = ''; + this.isNumber = false; + this.isString = false; + this.locale = this.locale || getLocale(); + this.useAbsoluteValue = false; + } +} diff --git a/libs/ui/src/lib/world-map-chart/index.ts b/libs/ui/src/lib/world-map-chart/index.ts new file mode 100644 index 000000000..69451793f --- /dev/null +++ b/libs/ui/src/lib/world-map-chart/index.ts @@ -0,0 +1 @@ +export * from './world-map-chart.component'; diff --git a/libs/ui/src/lib/world-map-chart/world-map-chart.component.html b/libs/ui/src/lib/world-map-chart/world-map-chart.component.html new file mode 100644 index 000000000..87bc08672 --- /dev/null +++ b/libs/ui/src/lib/world-map-chart/world-map-chart.component.html @@ -0,0 +1,11 @@ +@if (isLoading) { + +} + +
diff --git a/libs/ui/src/lib/world-map-chart/world-map-chart.component.scss b/libs/ui/src/lib/world-map-chart/world-map-chart.component.scss new file mode 100644 index 000000000..9fd9418bb --- /dev/null +++ b/libs/ui/src/lib/world-map-chart/world-map-chart.component.scss @@ -0,0 +1,32 @@ +:host { + display: block; + height: 100%; + + ::ng-deep { + .loader { + height: 100% !important; + } + + .svgMap-map-wrapper { + background: transparent; + + .svgMap-country { + stroke: #e5e5e5; + } + + .svgMap-map-controls-wrapper { + display: none; + } + } + } +} + +:host-context(.theme-dark) { + ::ng-deep { + .svgMap-map-wrapper { + .svgMap-country { + stroke: #414141; + } + } + } +} diff --git a/libs/ui/src/lib/world-map-chart/world-map-chart.component.stories.ts b/libs/ui/src/lib/world-map-chart/world-map-chart.component.stories.ts new file mode 100644 index 000000000..bdc983ae3 --- /dev/null +++ b/libs/ui/src/lib/world-map-chart/world-map-chart.component.stories.ts @@ -0,0 +1,57 @@ +import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; + +import { moduleMetadata } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; + +import { GfWorldMapChartComponent } from './world-map-chart.component'; + +const VWRL_COUNTRY_ALLOCATION = { + US: { name: 'United States', value: 60.5 }, + JP: { name: 'Japan', value: 5.2 }, + GB: { name: 'United Kingdom', value: 3.8 }, + CA: { name: 'Canada', value: 2.5 }, + FR: { name: 'France', value: 2.4 }, + CH: { name: 'Switzerland', value: 2.3 }, + DE: { name: 'Germany', value: 2.2 }, + AU: { name: 'Australia', value: 2.1 }, + CN: { name: 'China', value: 1.9 }, + IN: { name: 'India', value: 1.8 }, + TW: { name: 'Taiwan', value: 1.5 }, + KR: { name: 'South Korea', value: 1.4 }, + NL: { name: 'Netherlands', value: 1.3 }, + DK: { name: 'Denmark', value: 1.1 }, + SE: { name: 'Sweden', value: 1.0 } +}; + +export default { + title: 'World Map Chart', + component: GfWorldMapChartComponent, + decorators: [ + moduleMetadata({ + imports: [NgxSkeletonLoaderModule] + }) + ] +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + countries: Object.fromEntries( + Object.entries(VWRL_COUNTRY_ALLOCATION).map(([countryCode, country]) => { + return [countryCode, { ...country, value: 150 * country.value }]; + }) + ), + format: `{0} ${DEFAULT_CURRENCY}`, + isInPercent: false + } +}; + +export const InPercentage: Story = { + args: { + countries: VWRL_COUNTRY_ALLOCATION, + format: '{0}%', + isInPercent: true + } +}; diff --git a/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts b/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts new file mode 100644 index 000000000..2a926cf7c --- /dev/null +++ b/libs/ui/src/lib/world-map-chart/world-map-chart.component.ts @@ -0,0 +1,104 @@ +import { getLocale, getNumberFormatGroup } from '@ghostfolio/common/helper'; + +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + Input, + OnChanges, + OnDestroy +} from '@angular/core'; +import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; +import svgMap from 'svgmap'; + +@Component({ + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [NgxSkeletonLoaderModule], + selector: 'gf-world-map-chart', + styleUrls: ['./world-map-chart.component.scss'], + templateUrl: './world-map-chart.component.html' +}) +export class GfWorldMapChartComponent implements OnChanges, OnDestroy { + @Input() countries: { [code: string]: { name?: string; value: number } }; + @Input() format: string; + @Input() isInPercent = false; + @Input() locale = getLocale(); + + public isLoading = true; + public svgMapElement; + + public constructor(private changeDetectorRef: ChangeDetectorRef) {} + + public ngOnChanges() { + // Create a copy before manipulating countries object + this.countries = structuredClone(this.countries); + + if (this.countries) { + this.isLoading = true; + + this.destroySvgMap(); + + this.initialize(); + } + } + + public ngOnDestroy() { + this.destroySvgMap(); + } + + private initialize() { + if (this.isInPercent) { + // Convert value of countries to percentage + let sum = 0; + Object.keys(this.countries).map((country) => { + sum += this.countries[country].value; + }); + + Object.keys(this.countries).map((country) => { + this.countries[country].value = Number( + ((this.countries[country].value * 100) / sum).toFixed(2) + ); + }); + } else { + // Convert value to fixed-point notation + Object.keys(this.countries).map((country) => { + this.countries[country].value = Number( + this.countries[country].value.toFixed(2) + ); + }); + } + + this.svgMapElement = new svgMap({ + colorMax: '#22bdb9', + colorMin: '#c3f1f0', + colorNoData: 'transparent', + data: { + applyData: 'value', + data: { + value: { + format: this.format, + thousandSeparator: getNumberFormatGroup(this.locale) + } + }, + values: this.countries + }, + hideFlag: true, + minZoom: 1.06, + maxZoom: 1.06, + targetElementID: 'svgMap' + }); + + setTimeout(() => { + this.isLoading = false; + + this.changeDetectorRef.markForCheck(); + }, 500); + } + + private destroySvgMap() { + this.svgMapElement?.mapWrapper?.remove(); + this.svgMapElement?.tooltip?.remove(); + + this.svgMapElement = null; + } +} diff --git a/libs/ui/src/test-setup.ts b/libs/ui/src/test-setup.ts new file mode 100644 index 000000000..58c511e08 --- /dev/null +++ b/libs/ui/src/test-setup.ts @@ -0,0 +1,3 @@ +import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'; + +setupZoneTestEnv(); diff --git a/libs/ui/tsconfig.json b/libs/ui/tsconfig.json new file mode 100644 index 000000000..04f4630bc --- /dev/null +++ b/libs/ui/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + }, + { + "path": "./.storybook/tsconfig.json" + } + ], + "compilerOptions": { + "lib": ["dom", "es2022"], + "module": "preserve", + "target": "es2020", + // TODO: Remove once solved in tsconfig.base.json + "strict": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "angularCompilerOptions": { + "strictInjectionParameters": true, + "strictInputAccessModifiers": true, + "strictTemplates": true + } +} diff --git a/libs/ui/tsconfig.lib.json b/libs/ui/tsconfig.lib.json new file mode 100644 index 000000000..6fbed6f1c --- /dev/null +++ b/libs/ui/tsconfig.lib.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "target": "es2015", + "declaration": true, + "declarationMap": true, + "inlineSources": true, + "types": [], + "lib": ["dom", "es2022"] + }, + "exclude": [ + "src/test-setup.ts", + "**/*.spec.ts", + "**/*.test.ts", + "**/*.stories.ts", + "**/*.stories.js", + "jest.config.ts" + ], + "include": ["**/*.ts"] +} diff --git a/libs/ui/tsconfig.spec.json b/libs/ui/tsconfig.spec.json new file mode 100644 index 000000000..36a2e401c --- /dev/null +++ b/libs/ui/tsconfig.spec.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "preserve", + "isolatedModules": true, + "types": ["jest", "node"], + "target": "es2016" + }, + "files": ["src/test-setup.ts"], + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"] +} diff --git a/nx.json b/nx.json new file mode 100644 index 000000000..9b6540fdb --- /dev/null +++ b/nx.json @@ -0,0 +1,129 @@ +{ + "defaultProject": "api", + "generators": { + "@nx/angular:application": { + "linter": "eslint", + "unitTestRunner": "jest", + "e2eTestRunner": "cypress" + }, + "@nx/angular:library": { + "linter": "eslint", + "unitTestRunner": "jest" + }, + "@nx/angular:component": { + "type": "component" + }, + "@nx/nest": {}, + "@schematics/angular:component": { + "type": "component" + }, + "@nx/angular:directive": { + "type": "directive" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@nx/angular:service": { + "type": "service" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@nx/angular:scam": { + "type": "component" + }, + "@nx/angular:scam-directive": { + "type": "directive" + }, + "@nx/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@nx/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@nx/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@nx/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@nx/angular:resolver": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } + }, + "$schema": "./node_modules/nx/schemas/nx-schema.json", + "targetDefaults": { + "build": { + "dependsOn": ["^build"], + "inputs": ["production", "^production"], + "cache": true + }, + "e2e": { + "inputs": ["default", "^production"], + "cache": true + }, + "build-storybook": { + "inputs": [ + "default", + "^production", + "{workspaceRoot}/.storybook/**/*", + "{projectRoot}/.storybook/**/*", + "{projectRoot}/tsconfig.storybook.json" + ], + "cache": true + }, + "lint": { + "cache": true + }, + "@nx/jest:jest": { + "inputs": ["default", "^production", "{workspaceRoot}/jest.preset.js"], + "cache": true, + "options": { + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + } + }, + "namedInputs": { + "default": ["{projectRoot}/**/*", "sharedGlobals"], + "sharedGlobals": [ + "{workspaceRoot}/angular.json", + "{workspaceRoot}/tsconfig.base.json", + "{workspaceRoot}/nx.json" + ], + "production": [ + "default", + "!{projectRoot}/**/?(*.)+(spec|test).[jt]s?(x)?(.snap)", + "!{projectRoot}/**/*.stories.@(js|jsx|ts|tsx|mdx)", + "!{projectRoot}/.storybook/**/*", + "!{projectRoot}/eslint.config.cjs", + "!{projectRoot}/jest.config.[jt]s", + "!{projectRoot}/src/test-setup.[jt]s", + "!{projectRoot}/tsconfig.storybook.json", + "!{projectRoot}/tsconfig.spec.json", + "!{projectRoot}/webpack.config.js" + ] + }, + "parallel": 1, + "defaultBase": "origin/main" +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..ad615c3a0 --- /dev/null +++ b/package.json @@ -0,0 +1,208 @@ +{ + "name": "ghostfolio", + "version": "2.243.0", + "homepage": "https://ghostfol.io", + "license": "AGPL-3.0", + "repository": "https://github.com/ghostfolio/ghostfolio", + "scripts": { + "affected": "nx affected", + "affected:apps": "nx affected:apps", + "affected:build": "nx affected:build", + "affected:dep-graph": "nx affected:dep-graph", + "affected:libs": "nx affected:libs", + "affected:lint": "nx affected:lint", + "affected:test": "nx affected:test", + "analyze:client": "nx run client:build:production --stats-json && webpack-bundle-analyzer -p 1234 dist/apps/client/en/stats.json", + "angular": "node --max_old_space_size=32768 ./node_modules/@angular/cli/bin/ng", + "build:production": "nx run api:copy-assets && nx run api:build:production && nx run client:copy-assets && nx run client:build:production && nx run ui:build-storybook && npm run replace-placeholders-in-build", + "build:storybook": "nx run ui:build-storybook", + "database:format-schema": "prisma format", + "database:generate-typings": "prisma generate", + "database:gui": "prisma studio", + "database:gui:prod": "npx dotenv-cli -e .env.prod -- prisma studio", + "database:migrate": "prisma migrate deploy", + "database:push": "prisma db push", + "database:seed": "prisma db seed", + "database:setup": "npm run database:push && npm run database:seed", + "database:validate-schema": "prisma validate", + "dep-graph": "nx dep-graph", + "extract-locales": "nx run client:extract-i18n --output-path ./apps/client/src/locales", + "format": "nx format:write", + "format:check": "nx format:check", + "format:write": "nx format:write", + "help": "nx help", + "lint": "nx run-many --target=lint --all", + "ng": "nx", + "nx": "nx", + "postinstall": "prisma generate", + "prepare": "husky", + "prisma": "prisma", + "replace-placeholders-in-build": "node ./replace.build.mjs", + "start": "node dist/apps/api/main", + "start:client": "nx run client:copy-assets && nx run client:serve --configuration=development-en --hmr -o", + "start:production": "npm run database:migrate && npm run database:seed && node main", + "start:server": "nx run api:copy-assets && nx run api:serve --watch", + "start:storybook": "nx run ui:storybook", + "test": "npx dotenv-cli -e .env.example -- npx nx run-many --target=test --all --parallel=4", + "test:api": "npx dotenv-cli -e .env.example -- nx test api", + "test:common": "npx dotenv-cli -e .env.example -- nx test common", + "test:single": "nx run api:test --test-file object.helper.spec.ts", + "test:ui": "npx dotenv-cli -e .env.example -- nx test ui", + "ts-node": "ts-node", + "update": "nx migrate latest", + "watch:server": "nx run api:copy-assets && nx run api:build --watch", + "watch:test": "nx test --watch", + "workspace-generator": "nx workspace-generator" + }, + "dependencies": { + "@angular/animations": "21.1.1", + "@angular/cdk": "21.1.1", + "@angular/common": "21.1.1", + "@angular/compiler": "21.1.1", + "@angular/core": "21.1.1", + "@angular/forms": "21.1.1", + "@angular/material": "21.1.1", + "@angular/platform-browser": "21.1.1", + "@angular/platform-browser-dynamic": "21.1.1", + "@angular/router": "21.1.1", + "@angular/service-worker": "21.1.1", + "@codewithdan/observable-store": "2.2.15", + "@date-fns/utc": "2.1.1", + "@internationalized/number": "3.6.5", + "@ionic/angular": "8.7.8", + "@keyv/redis": "4.4.0", + "@nestjs/bull": "11.0.4", + "@nestjs/cache-manager": "3.1.0", + "@nestjs/common": "11.1.14", + "@nestjs/config": "4.0.3", + "@nestjs/core": "11.1.14", + "@nestjs/event-emitter": "3.0.1", + "@nestjs/jwt": "11.0.2", + "@nestjs/passport": "11.0.5", + "@nestjs/platform-express": "11.1.14", + "@nestjs/schedule": "6.1.1", + "@nestjs/serve-static": "5.0.4", + "@openrouter/ai-sdk-provider": "0.7.2", + "@prisma/client": "6.19.0", + "@simplewebauthn/browser": "13.2.2", + "@simplewebauthn/server": "13.2.2", + "ai": "4.3.16", + "alphavantage": "2.2.0", + "big.js": "7.0.1", + "bootstrap": "4.6.2", + "bull": "4.16.5", + "chart.js": "4.5.1", + "chartjs-adapter-date-fns": "3.0.0", + "chartjs-chart-treemap": "3.1.0", + "chartjs-plugin-annotation": "3.1.0", + "chartjs-plugin-datalabels": "2.2.0", + "cheerio": "1.2.0", + "class-transformer": "0.5.1", + "class-validator": "0.14.3", + "color": "5.0.3", + "countries-and-timezones": "3.8.0", + "countries-list": "3.2.2", + "countup.js": "2.9.0", + "date-fns": "4.1.0", + "dotenv": "17.2.3", + "dotenv-expand": "12.0.3", + "envalid": "8.1.1", + "fast-redact": "3.5.0", + "fuse.js": "7.1.0", + "google-spreadsheet": "3.2.0", + "helmet": "7.0.0", + "http-status-codes": "2.3.0", + "ionicons": "8.0.13", + "jsonpath": "1.1.1", + "lodash": "4.17.23", + "marked": "17.0.2", + "ms": "3.0.0-canary.1", + "ng-extract-i18n-merge": "3.2.1", + "ngx-device-detector": "11.0.0", + "ngx-markdown": "21.1.0", + "ngx-skeleton-loader": "12.0.0", + "open-color": "1.9.1", + "papaparse": "5.3.1", + "passport": "0.7.0", + "passport-google-oauth20": "2.0.0", + "passport-headerapikey": "1.2.2", + "passport-jwt": "4.0.1", + "passport-openidconnect": "0.1.2", + "reflect-metadata": "0.2.2", + "rxjs": "7.8.1", + "stripe": "20.3.0", + "svgmap": "2.14.0", + "tablemark": "4.1.0", + "twitter-api-v2": "1.29.0", + "yahoo-finance2": "3.13.0", + "zone.js": "0.16.0" + }, + "devDependencies": { + "@angular-devkit/build-angular": "21.1.1", + "@angular-devkit/core": "21.1.1", + "@angular-devkit/schematics": "21.1.1", + "@angular-eslint/eslint-plugin": "21.1.0", + "@angular-eslint/eslint-plugin-template": "21.1.0", + "@angular-eslint/template-parser": "21.1.0", + "@angular/cli": "21.1.1", + "@angular/compiler-cli": "21.1.1", + "@angular/language-service": "21.1.1", + "@angular/localize": "21.1.1", + "@angular/pwa": "21.1.1", + "@eslint/eslintrc": "3.3.1", + "@eslint/js": "9.35.0", + "@nestjs/schematics": "11.0.9", + "@nestjs/testing": "11.1.14", + "@nx/angular": "22.4.5", + "@nx/eslint-plugin": "22.4.5", + "@nx/jest": "22.4.5", + "@nx/js": "22.4.5", + "@nx/module-federation": "22.4.5", + "@nx/nest": "22.4.5", + "@nx/node": "22.4.5", + "@nx/storybook": "22.4.5", + "@nx/web": "22.4.5", + "@nx/workspace": "22.4.5", + "@schematics/angular": "21.1.1", + "@storybook/addon-docs": "10.1.10", + "@storybook/angular": "10.1.10", + "@trivago/prettier-plugin-sort-imports": "5.2.2", + "@types/big.js": "6.2.2", + "@types/fast-redact": "3.0.4", + "@types/google-spreadsheet": "3.1.5", + "@types/jest": "30.0.0", + "@types/jsonpath": "0.2.4", + "@types/lodash": "4.17.23", + "@types/node": "22.15.17", + "@types/papaparse": "5.3.7", + "@types/passport-google-oauth20": "2.0.16", + "@types/passport-openidconnect": "0.1.3", + "@typescript-eslint/eslint-plugin": "8.43.0", + "@typescript-eslint/parser": "8.43.0", + "eslint": "9.35.0", + "eslint-config-prettier": "10.1.8", + "eslint-plugin-import": "2.32.0", + "eslint-plugin-storybook": "10.1.10", + "husky": "9.1.7", + "jest": "30.2.0", + "jest-environment-jsdom": "30.2.0", + "jest-preset-angular": "16.0.0", + "nx": "22.4.5", + "prettier": "3.8.1", + "prettier-plugin-organize-attributes": "1.0.0", + "prisma": "6.19.0", + "react": "18.2.0", + "react-dom": "18.2.0", + "replace-in-file": "8.3.0", + "shx": "0.4.0", + "storybook": "10.1.10", + "ts-jest": "29.4.0", + "ts-node": "10.9.2", + "tslib": "2.8.1", + "typescript": "5.9.2", + "webpack-bundle-analyzer": "4.10.2" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/tools/tsconfig.tools.json b/tools/tsconfig.tools.json new file mode 100644 index 000000000..5f6f15d74 --- /dev/null +++ b/tools/tsconfig.tools.json @@ -0,0 +1,11 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "outDir": "../dist/out-tsc/tools", + "rootDir": ".", + "module": "commonjs", + "target": "es5", + "types": ["node"] + }, + "include": ["**/*.ts"] +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 000000000..909e1757a --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,40 @@ +{ + "compileOnSave": false, + "compilerOptions": { + "rootDir": ".", + "sourceMap": true, + "declaration": false, + "moduleResolution": "bundler", + "emitDecoratorMetadata": true, + "esModuleInterop": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "es2015", + "module": "esnext", + "typeRoots": ["node_modules/@types"], + "lib": ["es2017", "dom"], + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "baseUrl": ".", + "paths": { + "@ghostfolio/api/*": ["apps/api/src/*"], + "@ghostfolio/client/*": ["apps/client/src/app/*"], + "@ghostfolio/common/*": ["libs/common/src/lib/*"], + "@ghostfolio/ui/*": ["libs/ui/src/lib/*"] + }, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "strict": false, + "strictNullChecks": false, + "strictPropertyInitialization": false, + "noImplicitReturns": false, + "noImplicitAny": false, + "noImplicitThis": false, + "noImplicitOverride": false, + "noPropertyAccessFromIndexSignature": false, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowUnreachableCode": true + }, + "exclude": ["node_modules", "tmp"] +}