Browse Source

Merge remote-tracking branch 'origin/dockerpush' into MR/Upstream-2024-11-13

pull/5027/head
Dan 8 months ago
parent
commit
b8d839f21b
  1. 1
      .admin.cred
  2. 25
      .env.dev
  3. 3
      .env.example
  4. 47
      .github/workflows/docker-image-branch.yml
  5. 47
      .github/workflows/docker-image-dev.yml
  6. 5
      .github/workflows/docker-image.yml
  7. 47
      .github/workflows/main.yml
  8. 89
      CHANGELOG.md
  9. 1
      Dockerfile
  10. 11
      apps/api/project.json
  11. 1
      apps/api/src/app/account-balance/account-balance.service.ts
  12. 2
      apps/api/src/app/account/account.service.ts
  13. 41
      apps/api/src/app/admin/admin.controller.ts
  14. 2
      apps/api/src/app/admin/admin.module.ts
  15. 19
      apps/api/src/app/admin/admin.service.ts
  16. 6
      apps/api/src/app/admin/update-asset-profile.dto.ts
  17. 13
      apps/api/src/app/import/import.service.ts
  18. 93
      apps/api/src/app/order/order.service.ts
  19. 595
      apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts
  20. 54
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  21. 25
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  22. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  23. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts
  24. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts
  25. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  26. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts
  27. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts
  28. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts
  29. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts
  30. 21
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts
  31. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts
  32. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  33. 3
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts
  34. 135
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  35. 1
      apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts
  36. 5
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  37. 32
      apps/api/src/app/portfolio/portfolio.controller.ts
  38. 80
      apps/api/src/app/portfolio/portfolio.service.ts
  39. 5
      apps/api/src/app/tag/tag.service.ts
  40. 3
      apps/api/src/app/user/update-user-setting.dto.ts
  41. 6
      apps/api/src/app/user/user.controller.ts
  42. 25
      apps/api/src/helper/dateQueryHelper.ts
  43. 1
      apps/api/src/helper/portfolio.helper.ts
  44. 2
      apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts
  45. 7
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  46. 25
      apps/api/src/services/data-provider/data-provider.service.ts
  47. 32
      apps/api/src/services/data-provider/manual/manual.service.ts
  48. 9
      apps/api/src/services/market-data/market-data.service.ts
  49. 159
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  50. 49
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  51. 12
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts
  52. 69
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts
  53. 19
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  54. 6
      apps/client-e2e/project.json
  55. 98
      apps/client/project.json
  56. 11
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  57. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  58. 62
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  59. 44
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  60. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  61. 13
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  62. 8
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  63. 1
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss
  64. 16
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts
  65. 9
      apps/client/src/app/components/header/header.component.ts
  66. 30
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  67. 54
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  68. 3
      apps/client/src/app/components/toggle/toggle.component.ts
  69. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  70. 13
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  71. 3
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  72. 45
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  73. 30
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  74. 22
      apps/client/src/app/services/admin.service.ts
  75. 11
      apps/client/src/app/services/data.service.ts
  76. 2
      apps/client/src/app/services/import-activities.service.ts
  77. 6
      apps/client/src/app/services/user/user.service.ts
  78. 221
      apps/client/src/locales/messages.it.xlf
  79. 6
      apps/ui-e2e/project.json
  80. 46
      libs/common/src/lib/chunkhelper.ts
  81. 12
      libs/common/src/lib/config.ts
  82. 3
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  83. 3
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  84. 2
      libs/common/src/lib/interfaces/historical-data-item.interface.ts
  85. 6
      libs/common/src/lib/interfaces/symbol-metrics.interface.ts
  86. 9
      libs/common/src/lib/types/date-range.type.ts
  87. 51
      libs/ui/src/lib/activities-table/activities-table.component.scss
  88. 38
      libs/ui/src/lib/activity-type/activity-type.component.html
  89. 2
      libs/ui/src/lib/assistant/assistant.component.ts
  90. 6
      libs/ui/src/lib/assistant/assistant.html
  91. 1
      libs/ui/src/lib/i18n.ts
  92. 185
      migrations.json
  93. 6
      package-lock.json
  94. 2
      package.json
  95. 22
      prisma/migrations/20231108082445_added_tags_to_holding/migration.sql
  96. 3
      prisma/schema.prisma
  97. 17248
      yarn-error.log

1
.admin.cred

@ -0,0 +1 @@
14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51

25
.env.dev

@ -1,25 +0,0 @@
COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# DEVELOPMENT
# Nx 18 enables using plugins to infer targets by default
# This is disabled for existing workspaces to maintain compatibility
# For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

3
.env.example

@ -1,4 +1,4 @@
COMPOSE_PROJECT_NAME=ghostfolio
COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
@ -10,7 +10,6 @@ POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

47
.github/workflows/docker-image-branch.yml

@ -0,0 +1,47 @@
name: Docker image CD - Branch
on:
push:
branches:
- '*'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: dandevaud/ghostfolio:${{ github.ref_name }}
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

47
.github/workflows/docker-image-dev.yml

@ -0,0 +1,47 @@
name: Docker image CD - DEV
on:
push:
branches:
- 'dockerpush'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: dandevaud/ghostfolio:beta
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

5
.github/workflows/docker-image.yml

@ -4,9 +4,6 @@ on:
push:
tags:
- '*.*.*'
pull_request:
branches:
- 'main'
jobs:
build_and_push:
@ -19,7 +16,7 @@ jobs:
id: meta
uses: docker/metadata-action@v4
with:
images: ghostfolio/ghostfolio
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}

47
.github/workflows/main.yml

@ -0,0 +1,47 @@
name: Docker image CD - DEV
on:
push:
branches:
- 'main'
jobs:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v4
with:
images: dandevaud/ghostfolio
tags: |
type=semver,pattern={{major}}
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v2
- name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKER_HUB_USERNAME }}
password: ${{ secrets.DOCKER_HUB_ACCESS_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64,linux/arm/v7,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: dandevaud/ghostfolio:main
labels: ${{ steps.meta.output.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

89
CHANGELOG.md

@ -9,6 +9,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Restructured the resources page
- Renamed the static portfolio analysis rule from _Allocation Cluster Risk_ to _Economic Market Cluster Risk_ (Developed Markets and Emerging Markets)
- Improved the language localization for German (`de`)
- Switched the `consistent-generic-constructors` rule from `warn` to `error` in the `eslint` configuration
- Switched the `consistent-indexed-object-style` rule from `warn` to `off` in the `eslint` configuration
- Switched the `consistent-type-assertions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-optional-chain` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `Nx` from version `20.0.3` to `20.0.6`
## 2.119.0 - 2024-10-26
### Changed
- Switched the `consistent-type-definitions` rule from `warn` to `error` in the `eslint` configuration
- Switched the `no-empty-function` rule from `warn` to `error` in the `eslint` configuration
- Switched the `prefer-function-type` rule from `warn` to `error` in the `eslint` configuration
- Upgraded `prisma` from version `5.20.0` to `5.21.1`
### Fixed
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
- Fixed an issue with the X-axis scale of the portfolio evolution chart on the analysis page
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Fixed an issue in the calculation of the static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
## 2.118.0 - 2024-10-23
### Added
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Developed Markets)
- Added a new static portfolio analysis rule: _Allocation Cluster Risk_ (Emerging Markets)
- Added support for mutual funds in the _EOD Historical Data_ service
### Changed
- Improved the font colors of the chart of the holdings tab on the home page (experimental)
- Optimized the dialog sizes for mobile (full screen)
- Optimized the git-hook via `husky` to lint only affected projects before a commit
- Upgraded `angular` from version `18.1.1` to `18.2.8`
- Upgraded `Nx` from version `19.5.6` to `20.0.3`
### Fixed
- Fixed the warning `export was not found` in connection with `GetValuesParams`
- Quoted the password for the _Redis_ service `healthcheck` in the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
## 2.117.0 - 2024-10-19
### Added
- Added the logotype to the footer
- Added the data providers management to the admin control panel
### Changed
- Improved the backgrounds of the chart of the holdings tab on the home page (experimental)
- Improved the language localization for German (`de`)
### Fixed
- Fixed an issue in the carousel component for the testimonial section on the landing page
## 2.116.0 - 2024-10-17
### Added
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Compare with..._ on the Frequently Asked Questions (FAQ) page
- Extended the content of the _Self-Hosting_ section by the benchmarks concept for _Markets_ on the Frequently Asked Questions (FAQ) page
- Set the permissions (`chmod 0700`) on `entrypoint.sh` in the `Dockerfile`
### Changed
- Improved the empty state in the benchmarks of the markets overview
- Disabled the text hover effect in the chart of the holdings tab on the home page (experimental)
- Improved the usability to customize the rule thresholds in the _X-ray_ section by introducing units (experimental)
- Switched to adjusted market prices (splits and dividends) in the get historical functionality of the _EOD Historical Data_ service
- Improved the language localization for German (`de`)
### Fixed
- Fixed the usage of the environment variable `PROCESSOR_PORTFOLIO_SNAPSHOT_COMPUTATION_CONCURRENCY`
## 2.115.0 - 2024-10-14
### Changed
- Extended the assistant by a holding selector
- Separated the _FIRE_ / _X-ray_ page
- Improved the language localization for Italian (`it`)
@ -1452,9 +1539,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Introduced the lazy-loaded activities table to the position detail dialog (experimental)
- Improved the font weight in the value component
- Improved the language localization for Türkçe (`tr`)
- Upgraded `angular` from version `17.0.4` to `17.0.7`
- Upgraded to _Inter_ 4 font family
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
### Fixed

1
Dockerfile

@ -61,6 +61,7 @@ RUN apt-get update && apt-get install -y --no-install-suggests \
COPY --chown=node:node --from=builder /ghostfolio/dist/apps /ghostfolio/apps
COPY --chown=node:node ./docker/entrypoint.sh /ghostfolio/entrypoint.sh
RUN chmod 0700 /ghostfolio/entrypoint.sh
WORKDIR /ghostfolio/apps/api
EXPOSE ${PORT:-3333}
USER node

11
apps/api/project.json

@ -4,6 +4,7 @@
"sourceRoot": "apps/api/src",
"projectType": "application",
"prefix": "api",
"tags": [],
"generators": {},
"targets": {
"build": {
@ -60,6 +61,13 @@
"buildTarget": "api:build"
}
},
"profile": {
"executor": "@nx/js:node",
"options": {
"buildTarget": "api:build",
"runtimeArgs": ["--perf-basic-prof-only-functions"]
}
},
"lint": {
"executor": "@nx/eslint:lint",
"options": {
@ -73,6 +81,5 @@
},
"outputs": ["{workspaceRoot}/coverage/apps/api"]
}
},
"tags": []
}
}

1
apps/api/src/app/account-balance/account-balance.service.ts

@ -95,6 +95,7 @@ export class AccountBalanceService {
return accountBalance;
}
@LogPerformance
public async getAccountBalanceItems({
filters,
userCurrency,

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

@ -1,5 +1,6 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
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 { DATE_FORMAT } from '@ghostfolio/common/helper';
@ -148,6 +149,7 @@ export class AccountService {
});
}
@LogPerformance
public async getCashDetails({
currency,
filters = [],

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

@ -105,6 +105,23 @@ export class AdminController {
this.dataGatheringService.gatherMax();
}
@HasPermission(permissions.accessAdminControl)
@Post('gather/missing')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async gatherMissing(): Promise<void> {
const assetProfileIdentifiers =
await this.dataGatheringService.getAllAssetProfileIdentifiers();
const promises = assetProfileIdentifiers.map(({ dataSource, symbol }) => {
return this.dataGatheringService.gatherSymbolMissingOnly({
dataSource,
symbol
});
});
await Promise.all(promises);
}
@HasPermission(permissions.accessAdminControl)
@Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@ -158,7 +175,22 @@ export class AdminController {
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
await this.dataGatheringService.gatherSymbol({ dataSource, symbol });
return;
}
@Post('gatherMissing/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@HasPermission(permissions.accessAdminControl)
public async gatherSymbolMissingOnly(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<void> {
await this.dataGatheringService.gatherSymbolMissingOnly({
dataSource,
symbol
});
return;
}
@ -335,7 +367,12 @@ export class AdminController {
return this.adminService.patchAssetProfileData({
...assetProfileData,
dataSource,
symbol
symbol,
tags: {
connect: assetProfileData.tags?.map(({ id }) => {
return { id };
})
}
});
}

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

@ -10,6 +10,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
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 { SymbolProfileOverwriteModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile-overwrite.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
@ -33,6 +34,7 @@ import { QueueModule } from './queue/queue.module';
QueueModule,
SubscriptionModule,
SymbolProfileModule,
SymbolProfileOverwriteModule,
TransformDataSourceInRequestModule
],
controllers: [AdminController],

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

@ -36,11 +36,11 @@ import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
AssetClass,
AssetSubClass,
DataSource,
Prisma,
PrismaClient,
Property,
SymbolProfile
SymbolProfile,
DataSource
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@ -259,7 +259,8 @@ export class AdminService {
},
scraperConfiguration: true,
sectors: true,
symbol: true
symbol: true,
tags: true
}
}),
this.prismaService.symbolProfile.count({ where })
@ -313,7 +314,8 @@ export class AdminService {
name,
Order,
sectors,
symbol
symbol,
tags
}) => {
const countriesCount = countries
? Object.keys(countries).length
@ -348,7 +350,9 @@ export class AdminService {
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date,
isUsedByUsersWithSubscription: await isUsedByUsersWithSubscription
isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
tags
};
}
)
@ -442,6 +446,7 @@ export class AdminService {
dataSource,
holdings,
name,
tags,
scraperConfiguration,
sectors,
symbol,
@ -466,6 +471,7 @@ export class AdminService {
sectors,
symbol,
symbolMapping,
tags,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
@ -631,7 +637,8 @@ export class AdminService {
date: dateOfFirstActivity,
id: undefined,
name: symbol,
sectorsCount: 0
sectorsCount: 0,
tags: []
};
}
);

6
apps/api/src/app/admin/update-asset-profile.dto.ts

@ -1,6 +1,6 @@
import { IsCurrencyCode } from '@ghostfolio/api/validators/is-currency-code';
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { AssetClass, AssetSubClass, Prisma, Tag } from '@prisma/client';
import {
IsArray,
IsEnum,
@ -35,6 +35,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
name?: string;
@IsArray()
@IsOptional()
tags?: Tag[];
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;

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

@ -618,18 +618,17 @@ export class ImportService {
)?.[symbol]
};
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (
type === 'BUY' ||
type === 'DIVIDEND' ||
type === 'SELL' ||
type === 'STAKE'
) {
if (!assetProfile?.name) {
throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (assetProfile.currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
);
}
}
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =

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

@ -27,7 +27,8 @@ import {
Order,
Prisma,
Tag,
Type as ActivityType
Type as ActivityType,
SymbolProfile
} from '@prisma/client';
import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
@ -50,34 +51,40 @@ export class OrderService {
public async assignTags({
dataSource,
symbol,
tags,
userId
tags
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
const symbolProfile: SymbolProfile =
await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
}
});
return 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(({ id }) => {
return { id };
})
])[0];
return await this.symbolProfileService.updateSymbolProfile({
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
currency: symbolProfile.currency,
dataSource,
holdings: symbolProfile.holdings,
name: symbolProfile.name,
sectors: symbolProfile.sectors,
symbol,
tags: {
connectOrCreate: tags.map(({ id, name }) => {
return {
create: {
id,
name
},
where: {
id
}
},
where: { id }
};
})
)
);
},
url: symbolProfile.url
});
}
public async createOrder(
@ -297,6 +304,7 @@ export class OrderService {
});
}
@LogPerformance
public async getOrders({
endDate,
filters,
@ -451,13 +459,34 @@ export class OrderService {
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
where.AND = [
{
OR: [
{
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return {
id: id
};
})
}
}
},
{
SymbolProfile: {
tags: {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
}
}
}
]
}
};
];
}
if (sortColumn) {
@ -489,7 +518,11 @@ export class OrderService {
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
SymbolProfile: {
include: {
tags: true
}
},
tags: true
}
}),

595
apps/api/src/app/portfolio/calculator/constantPortfolioReturn/portfolio-calculator.ts

@ -0,0 +1,595 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
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 { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Inject, Logger } from '@nestjs/common';
import { Big } from 'big.js';
import {
addDays,
eachDayOfInterval,
endOfDay,
format,
isAfter,
isBefore,
subDays
} from 'date-fns';
import { CurrentRateService } from '../../current-rate.service';
import { DateQuery } from '../../interfaces/date-query.interface';
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface';
import { TWRPortfolioCalculator } from '../twr/portfolio-calculator';
export class CPRPortfolioCalculator extends TWRPortfolioCalculator {
private holdings: { [date: string]: { [symbol: string]: Big } } = {};
private holdingCurrencies: { [symbol: string]: string } = {};
constructor(
{
accountBalanceItems,
activities,
configurationService,
currency,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
userId,
filters
}: {
accountBalanceItems: HistoricalDataItem[];
activities: Activity[];
configurationService: ConfigurationService;
currency: string;
currentRateService: CurrentRateService;
exchangeRateDataService: ExchangeRateDataService;
portfolioSnapshotService: PortfolioSnapshotService;
redisCacheService: RedisCacheService;
filters: Filter[];
userId: string;
},
@Inject()
private orderService: OrderService
) {
super({
accountBalanceItems,
activities,
configurationService,
currency,
filters,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
userId
});
}
@LogPerformance
public async getPerformanceWithTimeWeightedReturn({
start,
end
}: {
start: Date;
end: Date;
}): Promise<{ chart: HistoricalDataItem[] }> {
const item = await super.getPerformance({
end,
start
});
const itemResult = item.chart;
const dates = itemResult.map((item) => parseDate(item.date));
const timeWeighted = await this.getTimeWeightedChartData({
dates
});
item.chart = itemResult.map((itemInt) => {
const timeWeightedItem = timeWeighted.find(
(timeWeightedItem) => timeWeightedItem.date === itemInt.date
);
if (timeWeightedItem) {
itemInt.timeWeightedPerformance =
timeWeightedItem.netPerformanceInPercentage;
itemInt.timeWeightedPerformanceWithCurrencyEffect =
timeWeightedItem.netPerformanceInPercentageWithCurrencyEffect;
}
return itemInt;
});
return item;
}
@LogPerformance
public async getUnfilteredNetWorth(currency: string): Promise<Big> {
const activities = await this.orderService.getOrders({
userId: this.userId,
userCurrency: currency,
types: ['BUY', 'SELL', 'STAKE'],
withExcludedAccounts: true
});
const orders = this.activitiesToPortfolioOrder(activities.activities);
const start = orders.reduce(
(date, order) =>
parseDate(date.date).getTime() < parseDate(order.date).getTime()
? date
: order,
{ date: orders[0].date }
).date;
const end = new Date(Date.now());
const holdings = await this.getHoldings(orders, parseDate(start), end);
const marketMap = await this.currentRateService.getValues({
dataGatheringItems: this.mapToDataGatheringItems(orders),
dateQuery: { in: [end] }
});
const endString = format(end, DATE_FORMAT);
const exchangeRates = await Promise.all(
Object.keys(holdings[endString]).map(async (holding) => {
const symbolCurrency = this.getCurrencyFromActivities(orders, holding);
const exchangeRate =
await this.exchangeRateDataService.toCurrencyAtDate(
1,
symbolCurrency,
this.currency,
end
);
return { symbolCurrency, exchangeRate };
})
);
const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>(
(all, currency): { [currency: string]: number } => {
all[currency.symbolCurrency] ??= currency.exchangeRate;
return all;
},
{}
);
const totalInvestment = await Object.keys(holdings[endString]).reduce(
(sum, holding) => {
if (!holdings[endString][holding].toNumber()) {
return sum;
}
const symbol = marketMap.values.find((m) => m.symbol === holding);
if (symbol?.marketPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${end})`,
'PortfolioCalculator'
);
return sum;
} else {
const symbolCurrency = this.getCurrency(holding);
const price = new Big(currencyRates[symbolCurrency]).mul(
symbol.marketPrice
);
return sum.plus(new Big(price).mul(holdings[endString][holding]));
}
},
new Big(0)
);
return totalInvestment;
}
@LogPerformance
protected async getTimeWeightedChartData({
dates
}: {
dates?: Date[];
}): Promise<HistoricalDataItem[]> {
dates = dates.sort((a, b) => a.getTime() - b.getTime());
const start = dates[0];
const end = dates[dates.length - 1];
const marketMapTask = this.computeMarketMap({
gte: start,
lt: addDays(end, 1)
});
const timelineHoldings = await this.getHoldings(
this.activities,
start,
end
);
const data: HistoricalDataItem[] = [];
const startString = format(start, DATE_FORMAT);
data.push({
date: startString,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
this.marketMap = await marketMapTask;
let totalInvestment = Object.keys(timelineHoldings[startString]).reduce(
(sum, holding) => {
return sum.plus(
timelineHoldings[startString][holding].mul(
this.marketMap[startString][holding] ?? new Big(0)
)
);
},
new Big(0)
);
let previousNetPerformanceInPercentage = new Big(0);
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (let i = 1; i < dates.length; i++) {
const date = format(dates[i], DATE_FORMAT);
const previousDate = format(dates[i - 1], DATE_FORMAT);
const holdings = timelineHoldings[previousDate];
let newTotalInvestment = new Big(0);
let netPerformanceInPercentage = new Big(0);
let netPerformanceInPercentageWithCurrencyEffect = new Big(0);
for (const holding of Object.keys(holdings)) {
({
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
} = await this.handleSingleHolding(
previousDate,
holding,
date,
totalInvestment,
timelineHoldings,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
));
}
totalInvestment = newTotalInvestment;
previousNetPerformanceInPercentage = previousNetPerformanceInPercentage
.plus(1)
.mul(netPerformanceInPercentage.plus(1))
.minus(1);
previousNetPerformanceInPercentageWithCurrencyEffect =
previousNetPerformanceInPercentageWithCurrencyEffect
.plus(1)
.mul(netPerformanceInPercentageWithCurrencyEffect.plus(1))
.minus(1);
data.push({
date,
netPerformanceInPercentage: previousNetPerformanceInPercentage
.mul(100)
.toNumber(),
netPerformanceInPercentageWithCurrencyEffect:
previousNetPerformanceInPercentageWithCurrencyEffect
.mul(100)
.toNumber()
});
}
return data;
}
@LogPerformance
protected async handleSingleHolding(
previousDate: string,
holding: string,
date: string,
totalInvestment: Big,
timelineHoldings: { [date: string]: { [symbol: string]: Big } },
netPerformanceInPercentage: Big,
netPerformanceInPercentageWithCurrencyEffect: Big,
newTotalInvestment: Big
) {
const previousPrice =
Object.keys(this.marketMap).indexOf(previousDate) > 0
? this.marketMap[previousDate][holding]
: undefined;
const priceDictionary = this.marketMap[date];
let currentPrice =
priceDictionary !== undefined ? priceDictionary[holding] : previousPrice;
currentPrice ??= previousPrice;
const previousHolding = timelineHoldings[previousDate][holding];
const priceInBaseCurrency = currentPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
currentPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(date)
)
)
: new Big(0);
if (previousHolding.eq(0)) {
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
if (previousPrice === undefined || currentPrice === undefined) {
Logger.warn(
`Missing historical market data for ${holding} (${previousPrice === undefined ? previousDate : date}})`,
'PortfolioCalculator'
);
return {
netPerformanceInPercentage: netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect:
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment: newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
)
};
}
const previousPriceInBaseCurrency = previousPrice
? new Big(
await this.exchangeRateDataService.toCurrencyAtDate(
previousPrice?.toNumber() ?? 0,
this.getCurrency(holding),
this.currency,
parseDate(previousDate)
)
)
: new Big(0);
const portfolioWeight = totalInvestment.toNumber()
? previousHolding.mul(previousPriceInBaseCurrency).div(totalInvestment)
: new Big(0);
netPerformanceInPercentage = netPerformanceInPercentage.plus(
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight)
);
netPerformanceInPercentageWithCurrencyEffect =
netPerformanceInPercentageWithCurrencyEffect.plus(
priceInBaseCurrency
.div(previousPriceInBaseCurrency)
.minus(1)
.mul(portfolioWeight)
);
newTotalInvestment = newTotalInvestment.plus(
timelineHoldings[date][holding].mul(priceInBaseCurrency)
);
return {
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
newTotalInvestment
};
}
@LogPerformance
protected getCurrency(symbol: string) {
return this.getCurrencyFromActivities(this.activities, symbol);
}
@LogPerformance
protected getCurrencyFromActivities(
activities: PortfolioOrder[],
symbol: string
) {
if (!this.holdingCurrencies[symbol]) {
this.holdingCurrencies[symbol] = activities.find(
(a) => a.SymbolProfile.symbol === symbol
).SymbolProfile.currency;
}
return this.holdingCurrencies[symbol];
}
@LogPerformance
protected async getHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
if (
this.holdings &&
Object.keys(this.holdings).some((h) =>
isAfter(parseDate(h), subDays(end, 1))
) &&
Object.keys(this.holdings).some((h) =>
isBefore(parseDate(h), addDays(start, 1))
)
) {
return this.holdings;
}
this.computeHoldings(activities, start, end);
return this.holdings;
}
@LogPerformance
protected async computeHoldings(
activities: PortfolioOrder[],
start: Date,
end: Date
) {
const investmentByDate = this.getInvestmentByDate(activities);
this.calculateHoldings(investmentByDate, start, end);
}
private calculateHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
end: Date
) {
const transactionDates = Object.keys(investmentByDate).sort();
const dates = eachDayOfInterval({ start, end }, { step: 1 })
.map((date) => {
return resetHours(date);
})
.sort((a, b) => a.getTime() - b.getTime());
const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {};
this.calculateInitialHoldings(investmentByDate, start, currentHoldings);
for (let i = 1; i < dates.length; i++) {
const dateString = format(dates[i], DATE_FORMAT);
const previousDateString = format(dates[i - 1], DATE_FORMAT);
if (transactionDates.some((d) => d === dateString)) {
const holdings = { ...currentHoldings[previousDateString] };
investmentByDate[dateString].forEach((trade) => {
holdings[trade.SymbolProfile.symbol] ??= new Big(0);
holdings[trade.SymbolProfile.symbol] = holdings[
trade.SymbolProfile.symbol
].plus(trade.quantity.mul(getFactor(trade.type)));
});
currentHoldings[dateString] = holdings;
} else {
currentHoldings[dateString] = currentHoldings[previousDateString];
}
}
this.holdings = currentHoldings;
}
@LogPerformance
protected calculateInitialHoldings(
investmentByDate: { [date: string]: PortfolioOrder[] },
start: Date,
currentHoldings: { [date: string]: { [symbol: string]: Big } }
) {
const preRangeTrades = Object.keys(investmentByDate)
.filter((date) => resetHours(new Date(date)) <= start)
.map((date) => investmentByDate[date])
.reduce((a, b) => a.concat(b), [])
.reduce((groupBySymbol, trade) => {
if (!groupBySymbol[trade.SymbolProfile.symbol]) {
groupBySymbol[trade.SymbolProfile.symbol] = [];
}
groupBySymbol[trade.SymbolProfile.symbol].push(trade);
return groupBySymbol;
}, {});
currentHoldings[format(start, DATE_FORMAT)] = {};
for (const symbol of Object.keys(preRangeTrades)) {
const trades: PortfolioOrder[] = preRangeTrades[symbol];
const startQuantity = trades.reduce((sum, trade) => {
return sum.plus(trade.quantity.mul(getFactor(trade.type)));
}, new Big(0));
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity;
}
}
@LogPerformance
protected getInvestmentByDate(activities: PortfolioOrder[]): {
[date: string]: PortfolioOrder[];
} {
return activities.reduce((groupedByDate, order) => {
if (!groupedByDate[order.date]) {
groupedByDate[order.date] = [];
}
groupedByDate[order.date].push(order);
return groupedByDate;
}, {});
}
@LogPerformance
protected mapToDataGatheringItems(
orders: PortfolioOrder[]
): IDataGatheringItem[] {
return orders
.map((activity) => {
return {
symbol: activity.SymbolProfile.symbol,
dataSource: activity.SymbolProfile.dataSource
};
})
.filter(
(gathering, i, arr) =>
arr.findIndex((t) => t.symbol === gathering.symbol) === i
);
}
@LogPerformance
protected async computeMarketMap(dateQuery: DateQuery): Promise<{
[date: string]: { [symbol: string]: Big };
}> {
const dataGatheringItems: IDataGatheringItem[] =
this.mapToDataGatheringItems(this.activities);
const { values: marketSymbols } = await this.currentRateService.getValues({
dataGatheringItems,
dateQuery
});
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
);
}
}
return marketSymbolMap;
}
@LogPerformance
protected activitiesToPortfolioOrder(
activities: Activity[]
): PortfolioOrder[] {
return activities
.map(
({
date,
fee,
quantity,
SymbolProfile,
tags = [],
type,
unitPrice
}) => {
if (isAfter(date, new Date(Date.now()))) {
// Adapt date to today if activity is in future (e.g. liability)
// to include it in the interval
date = endOfDay(new Date(Date.now()));
}
return {
SymbolProfile,
tags,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
)
.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
}
}

54
apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts

@ -1,6 +1,7 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
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 { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
@ -8,13 +9,15 @@ import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { OrderService } from '../../order/order.service';
import { CPRPortfolioCalculator } from './constantPortfolioReturn/portfolio-calculator';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return
TWR = 'TWR' // Time-Weighted Rate of Return
TWR = 'TWR', // Time-Weighted Rate of Return
CPR = 'CPR' // Constant Portfolio Rate of Return
}
@Injectable()
@ -24,9 +27,11 @@ export class PortfolioCalculatorFactory {
private readonly currentRateService: CurrentRateService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioSnapshotService: PortfolioSnapshotService,
private readonly redisCacheService: RedisCacheService
private readonly redisCacheService: RedisCacheService,
private readonly orderService: OrderService
) {}
@LogPerformance
public createCalculator({
accountBalanceItems = [],
activities,
@ -57,18 +62,37 @@ export class PortfolioCalculatorFactory {
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TWRPortfolioCalculator({
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
filters,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
return new CPRPortfolioCalculator(
{
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
},
this.orderService
);
case PerformanceCalculationType.CPR:
return new CPRPortfolioCalculator(
{
accountBalanceItems,
activities,
currency,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService,
filters
},
this.orderService
);
default:
throw new Error('Invalid calculation type');
}

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

@ -57,12 +57,12 @@ export abstract class PortfolioCalculator {
protected accountBalanceItems: HistoricalDataItem[];
protected activities: PortfolioOrder[];
private configurationService: ConfigurationService;
private currency: string;
private currentRateService: CurrentRateService;
protected configurationService: ConfigurationService;
protected currency: string;
protected currentRateService: CurrentRateService;
private dataProviderInfos: DataProviderInfo[];
private endDate: Date;
private exchangeRateDataService: ExchangeRateDataService;
protected exchangeRateDataService: ExchangeRateDataService;
private filters: Filter[];
private portfolioSnapshotService: PortfolioSnapshotService;
private redisCacheService: RedisCacheService;
@ -70,7 +70,8 @@ export abstract class PortfolioCalculator {
private snapshotPromise: Promise<void>;
private startDate: Date;
private transactionPoints: TransactionPoint[];
private userId: string;
protected userId: string;
protected marketMap: { [date: string]: { [symbol: string]: Big } } = {};
public constructor({
accountBalanceItems,
@ -620,10 +621,12 @@ export abstract class PortfolioCalculator {
};
}
@LogPerformance
public getDataProviderInfos() {
return this.dataProviderInfos;
}
@LogPerformance
public async getDividendInBaseCurrency() {
await this.snapshotPromise;
@ -634,18 +637,21 @@ export abstract class PortfolioCalculator {
);
}
@LogPerformance
public async getFeesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalFeesWithCurrencyEffect;
}
@LogPerformance
public async getInterestInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalInterestWithCurrencyEffect;
}
@LogPerformance
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
@ -663,6 +669,7 @@ export abstract class PortfolioCalculator {
});
}
@LogPerformance
public getInvestmentsByGroup({
data,
groupBy
@ -686,12 +693,14 @@ export abstract class PortfolioCalculator {
}));
}
@LogPerformance
public async getLiabilitiesInBaseCurrency() {
await this.snapshotPromise;
return this.snapshot.totalLiabilitiesWithCurrencyEffect;
}
@LogPerformance
public async getPerformance({ end, start }) {
await this.snapshotPromise;
@ -760,6 +769,7 @@ export abstract class PortfolioCalculator {
return { chart };
}
@LogPerformance
public async getSnapshot() {
await this.snapshotPromise;
@ -815,6 +825,7 @@ export abstract class PortfolioCalculator {
return this.transactionPoints;
}
@LogPerformance
public async getValuablesInBaseCurrency() {
await this.snapshotPromise;
@ -890,7 +901,7 @@ export abstract class PortfolioCalculator {
}
@LogPerformance
private computeTransactionPoints() {
protected computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
@ -1029,7 +1040,7 @@ export abstract class PortfolioCalculator {
}
@LogPerformance
private async initialize() {
protected async initialize() {
const startTimeTotal = performance.now();
let cachedPortfolioSnapshot: PortfolioSnapshot;

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts

@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts

@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts

@ -93,7 +93,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts

@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts

@ -93,7 +93,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts

@ -80,7 +80,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts

@ -79,7 +79,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

21
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -93,7 +93,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -155,25 +156,25 @@ describe('PortfolioCalculator', () => {
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.25'),
grossPerformancePercentage: new Big('0.11136043941322258691'),
grossPerformance: new Big('33.87'),
grossPerformancePercentage: new Big('0.11343693482483756447'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11136043941322258691'
'0.11343693482483756447'
),
grossPerformanceWithCurrencyEffect: new Big('33.25'),
grossPerformanceWithCurrencyEffect: new Big('33.87'),
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'),
netPerformance: new Big('14.87'),
netPerformancePercentage: new Big('0.04980239801728180052'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04772590260566682296')
max: new Big('0.04980239801728180052')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'),
'5y': new Big('14.25'),
max: new Big('14.25'),
'5y': new Big('14.87'),
max: new Big('14.87'),
wtd: new Big('-5.39')
},
quantity: new Big('1'),

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts

@ -74,7 +74,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell-partially.spec.ts

@ -94,7 +94,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

3
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -94,7 +94,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});

135
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts

@ -137,6 +137,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let grossPerformanceFromDividends = new Big(0);
let grossPerformanceFromDividendsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
@ -198,6 +200,8 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformanceValuesPercentage: {},
unitPrices: {},
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
@ -266,7 +270,9 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
totalValuablesInBaseCurrency: new Big(0),
netPerformanceValuesPercentage: {},
unitPrices: {}
};
}
@ -499,6 +505,13 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
.mul(order.quantity)
.mul(getFactor(order.type));
}
} else if (order.type === 'STAKE') {
transactionInvestment = new Big(0);
transactionInvestmentWithCurrencyEffect = new Big(0);
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
@ -554,28 +567,27 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
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
);
({
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect
} = this.handleSellOrder(
order,
lastAveragePrice,
lastAveragePriceWithCurrencyEffect,
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect
));
({
grossPerformanceFromDividends,
grossPerformanceFromDividendsWithCurrencyEffect
} = this.handleDividend(
order,
grossPerformanceFromDividends,
grossPerformanceFromDividendsWithCurrencyEffect,
currentExchangeRate,
exchangeRateAtOrderDate
));
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
@ -597,19 +609,21 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
'grossPerformanceFromSellsWithCurrencyEffect',
grossPerformanceFromSellsWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
.plus(grossPerformanceFromSells)
.plus(grossPerformanceFromDividends);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
.plus(grossPerformanceFromSellsWithCurrencyEffect)
.plus(grossPerformanceFromDividendsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
@ -958,7 +972,72 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect,
netPerformanceValuesPercentage: {},
unitPrices: {}
};
}
private handleSellOrder(
order: PortfolioOrderItem,
lastAveragePrice,
lastAveragePriceWithCurrencyEffect,
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect
) {
if (order.type === 'SELL') {
const grossPerformanceFromSell = order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity);
const grossPerformanceFromSellWithCurrencyEffect =
order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
}
return {
grossPerformanceFromSells,
grossPerformanceFromSellsWithCurrencyEffect
};
}
private handleDividend(
order: PortfolioOrderItem,
grossPerformanceFromDividends,
grossPerformanceFromDividendsWithCurrencyEffect,
currentExchangeRate: number,
exchangeRateAtDateOfOrder: number
) {
if (order.type === 'DIVIDEND') {
const grossPerformanceFromDividend = order.unitPrice
.mul(currentExchangeRate)
.mul(order.quantity);
const grossPerformanceFromDividendWithCurrencyEffect = order.unitPrice
.mul(exchangeRateAtDateOfOrder)
.mul(order.quantity);
grossPerformanceFromDividends = grossPerformanceFromDividends.plus(
grossPerformanceFromDividend
);
grossPerformanceFromDividendsWithCurrencyEffect =
grossPerformanceFromDividendsWithCurrencyEffect.plus(
grossPerformanceFromDividendWithCurrencyEffect
);
}
return {
grossPerformanceFromDividends,
grossPerformanceFromDividendsWithCurrencyEffect
};
}
}

1
apps/api/src/app/portfolio/interfaces/portfolio-holding-detail.interface.ts

@ -11,6 +11,7 @@ export interface PortfolioHoldingDetail {
averagePrice: number;
dataProviderInfo: DataProviderInfo;
dividendInBaseCurrency: number;
stakeRewards: number;
dividendYieldPercent: number;
dividendYieldPercentWithCurrencyEffect: number;
feeInBaseCurrency: number;

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

@ -10,3 +10,8 @@ export interface PortfolioOrderItem extends PortfolioOrder {
unitPriceInBaseCurrency?: Big;
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
}
export interface WithCurrencyEffect<T> {
Value: T;
WithCurrencyEffect: T;
}

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

@ -5,7 +5,10 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { PerformanceLoggingInterceptor } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import {
LogPerformance,
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';
@ -158,6 +161,24 @@ export class PortfolioController {
portfolioPosition.investment / totalInvestment;
portfolioPosition.valueInPercentage =
portfolioPosition.valueInBaseCurrency / totalValue;
(portfolioPosition.assetClass = hasDetails
? portfolioPosition.assetClass
: undefined),
(portfolioPosition.assetSubClass = hasDetails
? portfolioPosition.assetSubClass
: undefined),
(portfolioPosition.countries = hasDetails
? portfolioPosition.countries
: []),
(portfolioPosition.currency = hasDetails
? portfolioPosition.currency
: undefined),
(portfolioPosition.markets = hasDetails
? portfolioPosition.markets
: undefined),
(portfolioPosition.sectors = hasDetails
? portfolioPosition.sectors
: []);
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -474,6 +495,7 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@Version('2')
@LogPerformance
public async getPerformanceV2(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@ -482,10 +504,9 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
@Query('withExcludedAccounts') withExcludedAccounts = false,
@Query('timeWeightedPerformance') calculateTimeWeightedPerformance = false
): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
@ -499,7 +520,8 @@ export class PortfolioController {
filters,
impersonationId,
withExcludedAccounts,
userId: this.request.user.id
userId: this.request.user.id,
calculateTimeWeightedPerformance
});
if (

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

@ -5,6 +5,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
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 { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
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 { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
@ -77,6 +78,7 @@ import {
} from 'date-fns';
import { isEmpty, last, uniq } from 'lodash';
import { CPRPortfolioCalculator } from './calculator/constantPortfolioReturn/portfolio-calculator';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import {
PerformanceCalculationType,
@ -106,6 +108,7 @@ export class PortfolioService {
private readonly userService: UserService
) {}
@LogPerformance
public async getAccounts({
filters,
userId,
@ -196,6 +199,7 @@ export class PortfolioService {
});
}
@LogPerformance
public async getAccountsWithAggregations({
filters,
userId,
@ -232,6 +236,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getDividends({
activities,
groupBy
@ -253,6 +258,7 @@ export class PortfolioService {
return dividends;
}
@LogPerformance
public async getInvestments({
dateRange,
filters,
@ -329,6 +335,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getDetails({
dateRange = 'max',
filters,
@ -619,6 +626,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getPosition(
aDataSource: DataSource,
aImpersonationId: string,
@ -638,6 +646,7 @@ export class PortfolioService {
return {
averagePrice: undefined,
dataProviderInfo: undefined,
stakeRewards: undefined,
dividendInBaseCurrency: undefined,
dividendYieldPercent: undefined,
dividendYieldPercentWithCurrencyEffect: undefined,
@ -728,6 +737,16 @@ export class PortfolioService {
)
});
const stakeRewards = getSum(
activities
.filter(({ SymbolProfile, type }) => {
return symbol === SymbolProfile.symbol && type === 'STAKE';
})
.map(({ quantity }) => {
return new Big(quantity);
})
);
const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol: aSymbol }],
'day',
@ -798,6 +817,7 @@ export class PortfolioService {
transactionCount,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
stakeRewards: stakeRewards.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect:
@ -815,7 +835,7 @@ export class PortfolioService {
grossPerformanceWithCurrencyEffect:
position.grossPerformanceWithCurrencyEffect?.toNumber(),
historicalData: historicalDataArray,
investment: position.investment?.toNumber(),
investment: position.investmentWithCurrencyEffect?.toNumber(),
netPerformance: position.netPerformance?.toNumber(),
netPerformancePercent: position.netPerformancePercentage?.toNumber(),
netPerformancePercentWithCurrencyEffect:
@ -885,6 +905,7 @@ export class PortfolioService {
SymbolProfile,
averagePrice: 0,
dataProviderInfo: undefined,
stakeRewards: 0,
dividendInBaseCurrency: 0,
dividendYieldPercent: 0,
dividendYieldPercentWithCurrencyEffect: 0,
@ -909,6 +930,7 @@ export class PortfolioService {
}
}
@LogPerformance
public async getPositions({
dateRange = 'max',
filters,
@ -1038,6 +1060,7 @@ export class PortfolioService {
dataProviderResponses[symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[symbol].name,
netPerformance: netPerformance?.toNumber() ?? null,
tags: symbolProfileMap[symbol].tags,
netPerformancePercentage:
netPerformancePercentage?.toNumber() ?? null,
netPerformancePercentageWithCurrencyEffect:
@ -1057,17 +1080,20 @@ export class PortfolioService {
};
}
@LogPerformance
public async getPerformance({
dateRange = 'max',
filters,
impersonationId,
userId
userId,
calculateTimeWeightedPerformance = false
}: {
dateRange?: DateRange;
filters?: Filter[];
impersonationId: string;
userId: string;
withExcludedAccounts?: boolean;
calculateTimeWeightedPerformance?: boolean;
}): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId });
@ -1113,15 +1139,14 @@ export class PortfolioService {
currency: userCurrency
});
const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const range = { end: endDate, start: startDate };
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
const { chart } = await (calculateTimeWeightedPerformance
? (
portfolioCalculator as CPRPortfolioCalculator
).getPerformanceWithTimeWeightedReturn(range)
: portfolioCalculator.getPerformance(range));
const {
netPerformance,
@ -1146,9 +1171,8 @@ export class PortfolioService {
return {
chart,
errors,
hasErrors,
firstOrderDate: parseDate(historicalData[0]?.date),
hasErrors: false,
firstOrderDate: parseDate(chart[0]?.date),
performance: {
netPerformance,
netPerformanceWithCurrencyEffect,
@ -1162,6 +1186,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId, this.request.user.id);
const userSettings = this.request.user.Settings.settings as UserSettings;
@ -1254,6 +1279,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async updateTags({
dataSource,
impersonationId,
@ -1268,7 +1294,6 @@ export class PortfolioService {
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
@ -1411,6 +1436,7 @@ export class PortfolioService {
return { markets, marketsAdvanced };
}
@LogPerformance
private async getCashPositions({
cashDetails,
userCurrency,
@ -1523,6 +1549,7 @@ export class PortfolioService {
return dividendsByGroup;
}
@LogPerformance
private getEmergencyFundPositionsValueInBaseCurrency({
holdings
}: {
@ -1670,6 +1697,7 @@ export class PortfolioService {
return { markets, marketsAdvanced };
}
@LogPerformance
private getStreaks({
investments,
savingsRate
@ -1692,6 +1720,7 @@ export class PortfolioService {
return { currentStreak, longestStreak };
}
@LogPerformance
private async getSummary({
balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency,
@ -1717,7 +1746,6 @@ export class PortfolioService {
userId,
withExcludedAccounts: true
});
const excludedActivities: Activity[] = [];
const nonExcludedActivities: Activity[] = [];
@ -1782,7 +1810,9 @@ export class PortfolioService {
.plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const committedFunds = new Big(totalBuy)
.minus(totalSell)
.minus(dividendInBaseCurrency);
const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency,
@ -1802,7 +1832,6 @@ export class PortfolioService {
currency: userCurrency,
withExcludedAccounts: true
});
const excludedBalanceInBaseCurrency = new Big(
cashDetailsWithExcludedAccounts.balanceInBaseCurrency
).minus(balanceInBaseCurrency);
@ -1811,12 +1840,17 @@ export class PortfolioService {
.plus(totalOfExcludedActivities)
.toNumber();
const netWorth = new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();
const netWorth =
portfolioCalculator instanceof CPRPortfolioCalculator
? await (portfolioCalculator as CPRPortfolioCalculator)
.getUnfilteredNetWorth(this.getUserCurrency())
.then((value) => value.toNumber())
: new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -1880,6 +1914,7 @@ export class PortfolioService {
};
}
@LogPerformance
private getSumOfActivityType({
activities,
activityType,
@ -1921,6 +1956,7 @@ export class PortfolioService {
return impersonationUserId || aUserId;
}
@LogPerformance
private async getValueOfAccountsAndPlatforms({
activities,
filters = [],

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

@ -51,7 +51,7 @@ export class TagService {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true }
select: { orders: true, symbolProfile: true }
}
}
});
@ -61,7 +61,8 @@ export class TagService {
id,
name,
userId,
activityCount: _count.orders
activityCount: _count.orders,
holdingCount: _count.symbolProfile
};
});
}

3
apps/api/src/app/user/update-user-setting.dto.ts

@ -37,6 +37,9 @@ export class UpdateUserSettingDto {
@IsIn([
'1d',
'1w',
'1m',
'3m',
'1y',
'5y',
'max',

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

@ -158,6 +158,12 @@ export class UserController {
}
}
for (const key in data) {
if (data[key] !== false && data[key] !== null) {
userSettings[key] = data[key];
}
}
return this.userService.updateUserSetting({
emitPortfolioChangedEvent,
userSettings,

25
apps/api/src/helper/dateQueryHelper.ts

@ -0,0 +1,25 @@
import { resetHours } from '@ghostfolio/common/helper';
import { addDays } from 'date-fns';
import { DateQuery } from '../app/portfolio/interfaces/date-query.interface';
export class DateQueryHelper {
public handleDateQueryIn(dateQuery: DateQuery): {
query: DateQuery;
dates: Date[];
} {
let dates = [];
let query = dateQuery;
if (dateQuery.in?.length > 0) {
dates = dateQuery.in;
const end = Math.max(...dates.map((d) => d.getTime()));
const start = Math.min(...dates.map((d) => d.getTime()));
query = {
gte: resetHours(new Date(start)),
lt: resetHours(addDays(end, 1))
};
}
return { query, dates };
}
}

1
apps/api/src/helper/portfolio.helper.ts

@ -5,6 +5,7 @@ export function getFactor(activityType: ActivityType) {
switch (activityType) {
case 'BUY':
case 'STAKE':
factor = 1;
break;
case 'SELL':

2
apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts

@ -43,7 +43,7 @@ export function LogPerformance(
) {
const originalMethod = descriptor.value;
descriptor.value = async function (...args: any[]) {
descriptor.value = function (...args: any[]) {
const startTime = performance.now();
const performanceLoggingService = new PerformanceLoggingService();

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

@ -15,7 +15,6 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static countriesMapping = {
'Russian Federation': 'Russia'
};
private static holdingsWeightTreshold = 0.85;
private static sectorsMapping = {
'Consumer Discretionary': 'Consumer Cyclical',
'Consumer Defensive': 'Consumer Staples',
@ -123,10 +122,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
});
});
if (
holdings?.weight < TrackinsightDataEnhancerService.holdingsWeightTreshold
) {
// Skip if data is inaccurate
if (holdings?.weight < 1 - Math.min(holdings?.count * 0.000015, 0.95)) {
// Skip if data is inaccurate, dependent on holdings count there might be rounding issues
return response;
}

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

@ -1,4 +1,5 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
@ -354,6 +355,7 @@ export class DataProviderService {
return result;
}
@LogPerformance
public async getQuotes({
items,
requestTimeout,
@ -483,6 +485,8 @@ export class DataProviderService {
}
response[symbol] = dataProviderResponse;
const quotesCacheTTL =
this.getAppropriateCacheTTL(dataProviderResponse);
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
@ -490,7 +494,7 @@ export class DataProviderService {
dataSource: DataSource[dataSource]
}),
JSON.stringify(response[symbol]),
this.configurationService.get('CACHE_QUOTES_TTL')
quotesCacheTTL
);
for (const {
@ -573,6 +577,25 @@ export class DataProviderService {
return response;
}
private getAppropriateCacheTTL(dataProviderResponse: IDataProviderResponse) {
let quotesCacheTTL = this.configurationService.get('CACHE_QUOTES_TTL');
if (dataProviderResponse.dataSource === 'MANUAL') {
quotesCacheTTL = 14400; // 4h Cache for Manual Service
} else if (dataProviderResponse.marketState === 'closed') {
const date = new Date();
const dayOfWeek = date.getDay();
if (dayOfWeek === 0 || dayOfWeek === 6) {
quotesCacheTTL = 14400;
} else if (date.getHours() > 16) {
quotesCacheTTL = 14400;
} else {
quotesCacheTTL = 900;
}
}
return quotesCacheTTL;
}
public async search({
includeIndices = false,
query,

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

@ -12,6 +12,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { BatchPrismaClient } from '@ghostfolio/common/chunkhelper';
import {
DATE_FORMAT,
extractNumberFromString,
@ -149,18 +150,25 @@ export class ManualService implements DataProviderInterface {
})
);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: symbols.length,
where: {
symbol: {
in: symbols
}
}
});
const batch = new BatchPrismaClient(this.prismaService);
const marketData = await batch
.over(symbols)
.with((prisma, _symbols) =>
prisma.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: symbols.length,
where: {
symbol: {
in: _symbols
}
}
})
)
.then((_result) => _result.flat());
const symbolProfilesWithScraperConfigurationAndInstantMode =
symbolProfiles.filter(({ scraperConfiguration }) => {

9
apps/api/src/services/market-data/market-data.service.ts

@ -12,11 +12,14 @@ import {
MarketDataState,
Prisma
} from '@prisma/client';
import AwaitLock from 'await-lock';
@Injectable()
export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {}
lock = new AwaitLock();
public async deleteMany({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.deleteMany({
where: {
@ -117,7 +120,6 @@ export class MarketDataService {
where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> {
const { data, where } = params;
return this.prismaService.marketData.upsert({
where,
create: {
@ -141,7 +143,7 @@ export class MarketDataService {
data: Prisma.MarketDataUpdateInput[];
}): Promise<MarketData[]> {
const upsertPromises = data.map(
({ dataSource, date, marketPrice, symbol, state }) => {
async ({ dataSource, date, marketPrice, symbol, state }) => {
return this.prismaService.marketData.upsert({
create: {
dataSource: dataSource as DataSource,
@ -164,7 +166,6 @@ export class MarketDataService {
});
}
);
return this.prismaService.$transaction(upsertPromises);
return await Promise.all(upsertPromises);
}
}

159
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -1,20 +1,25 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import {
DATA_GATHERING_QUEUE,
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_MISSING_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 { DataSource, Prisma } from '@prisma/client';
import { Job } from 'bull';
import { isNumber } from 'class-validator';
import {
addDays,
format,
@ -22,7 +27,9 @@ import {
getMonth,
getYear,
isBefore,
parseISO
parseISO,
eachDayOfInterval,
isEqual
} from 'date-fns';
import { DataGatheringService } from './data-gathering.service';
@ -150,4 +157,148 @@ export class DataGatheringProcessor {
throw new Error(error);
}
}
@Process({
concurrency: parseInt(
process.env.PROCESSOR_CONCURRENCY_GATHER_HISTORICAL_MARKET_DATA ??
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY.toString(),
10
),
name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
})
public async gatherMissingHistoricalMarketData(job: Job<IDataGatheringItem>) {
try {
const { dataSource, date, symbol } = job.data;
Logger.log(
`Historical market data gathering for missing values has been started for ${symbol} (${dataSource}) at ${format(
date,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
const entries = await this.marketDataService.marketDataItems({
where: {
AND: {
symbol: {
equals: symbol
},
dataSource: {
equals: dataSource
}
}
},
orderBy: {
date: 'asc'
},
take: 1
});
const firstEntry = entries[0];
const marketData = await this.marketDataService
.getRange({
assetProfileIdentifiers: [{ dataSource, symbol }],
dateQuery: {
gte: addDays(firstEntry.date, -10)
}
})
.then((md) => md.map((m) => m.date));
let dates = eachDayOfInterval(
{
start: firstEntry.date,
end: new Date()
},
{
step: 1
}
);
dates = dates.filter((d) => !marketData.some((md) => isEqual(md, d)));
const historicalData = await this.dataProviderService.getHistoricalRaw({
dataGatheringItems: [{ dataSource, symbol }],
from: firstEntry.date,
to: new Date()
});
const data: Prisma.MarketDataUpdateInput[] =
this.mapToMarketUpsertDataInputs(
dates,
historicalData,
symbol,
dataSource
);
await this.marketDataService.updateMany({ data });
Logger.log(
`Historical market data gathering for missing values has been completed for ${symbol} (${dataSource}) at ${format(
date,
DATE_FORMAT
)}`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
} catch (error) {
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw new Error(error);
}
}
private mapToMarketUpsertDataInputs(
missingMarketData: Date[],
historicalData: Record<
string,
Record<string, IDataProviderHistoricalResponse>
>,
symbol: string,
dataSource: DataSource
): Prisma.MarketDataUpdateInput[] {
return missingMarketData.map((date) => {
if (
isNumber(
historicalData[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
)
) {
return {
date,
symbol,
dataSource,
marketPrice:
historicalData[symbol]?.[format(date, DATE_FORMAT)]?.marketPrice
};
} else {
let earlierDate = date;
let index = 0;
while (
!isNumber(
historicalData[symbol]?.[format(earlierDate, DATE_FORMAT)]
?.marketPrice
)
) {
earlierDate = addDays(earlierDate, -1);
index++;
if (index > 10) {
break;
}
}
if (
isNumber(
historicalData[symbol]?.[format(earlierDate, DATE_FORMAT)]
?.marketPrice
)
) {
return {
date,
symbol,
dataSource,
marketPrice:
historicalData[symbol]?.[format(earlierDate, DATE_FORMAT)]
?.marketPrice
};
}
}
});
}
}

49
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -13,6 +13,8 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import {
@ -111,6 +113,24 @@ export class DataGatheringService {
});
}
public async gatherSymbolMissingOnly({
dataSource,
symbol
}: AssetProfileIdentifier) {
const dataGatheringItems = (await this.getSymbolsMax()).filter(
(dataGatheringItem) => {
return (
dataGatheringItem.dataSource === dataSource &&
dataGatheringItem.symbol === symbol
);
}
);
await this.gatherMissingDataSymbols({
dataGatheringItems,
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
});
}
public async gatherSymbolForDate({
dataSource,
date,
@ -293,6 +313,35 @@ export class DataGatheringService {
);
}
public async gatherMissingDataSymbols({
dataGatheringItems,
priority
}: {
dataGatheringItems: IDataGatheringItem[];
priority: number;
}) {
await this.addJobsToQueue(
dataGatheringItems.map(({ dataSource, date, symbol }) => {
return {
data: {
dataSource,
date,
symbol
},
name: GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
opts: {
...GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
priority,
jobId: `${getAssetProfileIdentifier({
dataSource,
symbol
})}-missing-${format(date, DATE_FORMAT)}`
}
};
})
);
}
public async getAllAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[]
> {

12
apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { SymbolProfileOverwriteService } from './symbol-profile-overwrite.service';
@Module({
imports: [PrismaModule],
providers: [SymbolProfileOverwriteService],
exports: [SymbolProfileOverwriteService]
})
export class SymbolProfileOverwriteModule {}

69
apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts

@ -0,0 +1,69 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfileOverrides } from '@prisma/client';
@Injectable()
export class SymbolProfileOverwriteService {
public constructor(private readonly prismaService: PrismaService) {}
public async add(
assetProfileOverwrite: Prisma.SymbolProfileOverridesCreateInput
): Promise<SymbolProfileOverrides | never> {
return this.prismaService.symbolProfileOverrides.create({
data: assetProfileOverwrite
});
}
public async delete(symbolProfileId: string) {
return this.prismaService.symbolProfileOverrides.delete({
where: { symbolProfileId: symbolProfileId }
});
}
public updateSymbolProfileOverrides({
assetClass,
assetSubClass,
name,
countries,
sectors,
url,
symbolProfileId
}: Prisma.SymbolProfileOverridesUpdateInput & { symbolProfileId: string }) {
return this.prismaService.symbolProfileOverrides.update({
data: {
assetClass,
assetSubClass,
name,
countries,
sectors,
url
},
where: { symbolProfileId: symbolProfileId }
});
}
public async GetSymbolProfileId(
Symbol: string,
datasource: DataSource
): Promise<string> {
const SymbolProfileId = await this.prismaService.symbolProfile
.findFirst({
where: {
symbol: Symbol,
dataSource: datasource
}
})
.then((s) => s.id);
const symbolProfileIdSaved = await this.prismaService.symbolProfileOverrides
.findFirst({
where: {
symbolProfileId: SymbolProfileId
}
})
.then((s) => s?.symbolProfileId);
return symbolProfileIdSaved;
}
}

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

@ -1,3 +1,4 @@
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
@ -10,7 +11,12 @@ 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 {
Prisma,
SymbolProfile,
SymbolProfileOverrides,
Tag
} from '@prisma/client';
import { continents, countries } from 'countries-list';
@Injectable()
@ -35,6 +41,7 @@ export class SymbolProfileService {
});
}
@LogPerformance
public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> {
@ -51,6 +58,7 @@ export class SymbolProfileService {
select: { date: true },
take: 1
},
tags: true,
SymbolProfileOverrides: true
},
where: {
@ -76,7 +84,8 @@ export class SymbolProfileService {
_count: {
select: { Order: true }
},
SymbolProfileOverrides: true
SymbolProfileOverrides: true,
tags: true
},
where: {
id: {
@ -134,6 +143,7 @@ export class SymbolProfileService {
dataSource,
holdings,
name,
tags,
scraperConfiguration,
sectors,
symbol,
@ -150,6 +160,7 @@ export class SymbolProfileService {
currency,
holdings,
name,
tags,
scraperConfiguration,
sectors,
symbolMapping,
@ -166,6 +177,7 @@ export class SymbolProfileService {
Order?: {
date: Date;
}[];
tags?: Tag[];
SymbolProfileOverrides: SymbolProfileOverrides;
})[]
): EnhancedSymbolProfile[] {
@ -180,7 +192,8 @@ export class SymbolProfileService {
holdings: this.getHoldings(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
symbolMapping: this.getSymbolMapping(symbolProfile),
tags: symbolProfile?.tags
};
item.activitiesCount = symbolProfile._count.Order;

6
apps/client-e2e/project.json

@ -3,6 +3,8 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/client-e2e/src",
"projectType": "application",
"tags": [],
"implicitDependencies": ["client"],
"targets": {
"e2e": {
"executor": "@nx/cypress:cypress",
@ -17,7 +19,5 @@
}
}
}
},
"tags": [],
"implicitDependencies": ["client"]
}
}

98
apps/client/project.json

@ -2,13 +2,59 @@
"name": "client",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"projectType": "application",
"sourceRoot": "apps/client/src",
"prefix": "gf",
"i18n": {
"locales": {
"ca": {
"baseHref": "/ca/",
"translation": "apps/client/src/locales/messages.ca.xlf"
},
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
},
"es": {
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"fr": {
"baseHref": "/fr/",
"translation": "apps/client/src/locales/messages.fr.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
},
"pl": {
"baseHref": "/pl/",
"translation": "apps/client/src/locales/messages.pl.xlf"
},
"pt": {
"baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf"
},
"tr": {
"baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf"
},
"zh": {
"baseHref": "/zh/",
"translation": "apps/client/src/locales/messages.zh.xlf"
}
},
"sourceLocale": "en"
},
"tags": [],
"generators": {
"@schematics/angular:component": {
"style": "scss"
}
},
"sourceRoot": "apps/client/src",
"prefix": "gf",
"targets": {
"build": {
"executor": "@nx/angular:webpack-browser",
@ -242,51 +288,5 @@
},
"outputs": ["{workspaceRoot}/coverage/apps/client"]
}
},
"i18n": {
"locales": {
"ca": {
"baseHref": "/ca/",
"translation": "apps/client/src/locales/messages.ca.xlf"
},
"de": {
"baseHref": "/de/",
"translation": "apps/client/src/locales/messages.de.xlf"
},
"es": {
"baseHref": "/es/",
"translation": "apps/client/src/locales/messages.es.xlf"
},
"fr": {
"baseHref": "/fr/",
"translation": "apps/client/src/locales/messages.fr.xlf"
},
"it": {
"baseHref": "/it/",
"translation": "apps/client/src/locales/messages.it.xlf"
},
"nl": {
"baseHref": "/nl/",
"translation": "apps/client/src/locales/messages.nl.xlf"
},
"pl": {
"baseHref": "/pl/",
"translation": "apps/client/src/locales/messages.pl.xlf"
},
"pt": {
"baseHref": "/pt/",
"translation": "apps/client/src/locales/messages.pt.xlf"
},
"tr": {
"baseHref": "/tr/",
"translation": "apps/client/src/locales/messages.tr.xlf"
},
"zh": {
"baseHref": "/zh/",
"translation": "apps/client/src/locales/messages.zh.xlf"
}
},
"sourceLocale": "en"
},
"tags": []
}
}

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

@ -259,6 +259,17 @@ export class AdminMarketDataComponent
});
}
public onGatherMissing() {
this.adminService
.gatherMissingOnly()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
public onGatherProfileData() {
this.adminService
.gatherProfileData()

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

@ -194,6 +194,9 @@
<button mat-menu-item (click)="onGatherMax()">
<ng-container i18n>Gather All Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherMissing()">
<ng-container i18n>Gather All Missing Data</ng-container>
</button>
<button mat-menu-item (click)="onGatherProfileData()">
<ng-container i18n>Gather Profile Data</ng-container>
</button>

62
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -13,22 +13,27 @@ import {
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Inject,
OnDestroy,
OnInit
OnInit,
ViewChild
} from '@angular/core';
import { FormBuilder, FormControl, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import {
AssetClass,
AssetSubClass,
MarketData,
SymbolProfile
SymbolProfile,
Tag
} from '@prisma/client';
import { format } from 'date-fns';
import { parse as csvToJson } from 'papaparse';
@ -45,6 +50,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
styleUrls: ['./asset-profile-dialog.component.scss']
})
export class AssetProfileDialog implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public separatorKeysCodes: number[] = [ENTER, COMMA];
public assetProfileClass: string;
public assetClasses = Object.keys(AssetClass).map((assetClass) => {
return { id: assetClass, label: translate(assetClass) };
@ -63,6 +70,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
csvString: ''
}),
name: ['', Validators.required],
tags: new FormControl<Tag[]>(undefined),
scraperConfiguration: '',
sectors: '',
symbolMapping: '',
@ -81,6 +89,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
[name: string]: { name: string; value: number };
};
public HoldingTags: { id: string; name: string; userId: string }[];
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
@ -109,6 +119,18 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public initialize() {
this.adminService
.fetchTags()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((tags) => {
this.HoldingTags = tags.map(({ id, name, userId }) => {
return { id, name, userId };
});
this.dataService.updateInfo();
this.changeDetectorRef.markForCheck();
});
this.adminService
.fetchAdminMarketDataBySymbol({
dataSource: this.data.dataSource,
@ -149,6 +171,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '',
tags: this.assetProfile?.tags ?? [],
countries: JSON.stringify(
this.assetProfile?.countries?.map(({ code, weight }) => {
return { code, weight };
@ -164,7 +187,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
),
sectors: JSON.stringify(this.assetProfile?.sectors ?? []),
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {}),
url: this.assetProfile?.url ?? ''
url: this.assetProfile?.url
});
this.assetProfileForm.markAsPristine();
@ -200,6 +223,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe();
}
public onGatherSymbolMissingOnly({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService
.gatherSymbolMissingOnly({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
}
public onImportHistoricalData() {
try {
const marketData = csvToJson(
@ -294,9 +327,10 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfileForm.get('assetClass').value,
assetSubClass: this.assetProfileForm.get('assetSubClass').value,
comment: this.assetProfileForm.get('comment').value || null,
tags: this.assetProfileForm.get('tags').value,
currency: this.assetProfileForm.get('currency').value,
name: this.assetProfileForm.get('name').value,
url: this.assetProfileForm.get('url').value || null
url: this.assetProfileForm.get('url').value
};
try {
@ -364,6 +398,26 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
});
}
public onRemoveTag(aTag: Tag) {
this.assetProfileForm.controls['tags'].setValue(
this.assetProfileForm.controls['tags'].value.filter(({ id }) => {
return id !== aTag.id;
})
);
this.assetProfileForm.markAsDirty();
}
public onAddTag(event: MatAutocompleteSelectedEvent) {
this.assetProfileForm.controls['tags'].setValue([
...(this.assetProfileForm.controls['tags'].value ?? []),
this.HoldingTags.find(({ id }) => {
return id === event.option.value;
})
]);
this.tagInput.nativeElement.value = '';
this.assetProfileForm.markAsDirty();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

44
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -31,6 +31,19 @@
>
<ng-container i18n>Gather Historical Data</ng-container>
</button>
<button
mat-menu-item
type="button"
[disabled]="assetProfileForm.dirty"
(click)="
onGatherSymbolMissingOnly({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>
<ng-container i18n>Gather Missing Historical Data</ng-container>
</button>
<button
mat-menu-item
type="button"
@ -267,6 +280,37 @@
</mat-select>
</mat-form-field>
</div>
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-grid #tagsChipList>
<mat-chip-row
*ngFor="let tag of assetProfileForm.controls['tags']?.value"
matChipRemove
[removable]="true"
(removed)="onRemoveTag(tag)"
>
{{ tag.name }}
<ion-icon class="ml-2" matPrefix name="close-outline"></ion-icon>
</mat-chip-row>
<input
#tagInput
name="close-outline"
[matAutocomplete]="autocompleteTags"
[matChipInputFor]="tagsChipList"
[matChipInputSeparatorKeyCodes]="separatorKeysCodes"
/>
</mat-chip-grid>
<mat-autocomplete
#autocompleteTags="matAutocomplete"
(optionSelected)="onAddTag($event)"
>
<mat-option *ngFor="let tag of HoldingTags" [value]="tag.id">
{{ tag.name }}
</mat-option>
</mat-autocomplete>
</mat-form-field>
</div>
<div class="d-flex my-3">
<div class="w-50">
<mat-checkbox

4
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts

@ -9,8 +9,10 @@ import { TextFieldModule } from '@angular/cdk/text-field';
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu';
@ -28,6 +30,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfAssetProfileIconComponent,
GfCurrencySelectorComponent,
GfPortfolioProportionChartComponent,
MatAutocompleteModule,
MatChipsModule,
GfValueComponent,
MatButtonModule,
MatCheckboxModule,

13
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -61,6 +61,19 @@
{{ element.activityCount }}
</td>
</ng-container>
<ng-container matColumnDef="holdings">
<th
*matHeaderCellDef
class="px-1"
mat-header-cell
mat-sort-header="holdingCount"
>
<ng-container i18n>Holdings</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ element.holdingCount }}
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th

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

@ -36,7 +36,13 @@ export class AdminTagComponent implements OnInit, OnDestroy {
public dataSource = new MatTableDataSource<Tag>();
public deviceType: string;
public displayedColumns = ['name', 'userId', 'activities', 'actions'];
public displayedColumns = [
'name',
'userId',
'activities',
'holdings',
'actions'
];
public tags: Tag[];
private unsubscribeSubject = new Subject<void>();

1
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss

@ -7,5 +7,6 @@
ngx-skeleton-loader {
height: 100%;
}
margin-bottom: 0.5rem;
}
}

16
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.ts

@ -54,6 +54,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() isLoading: boolean;
@Input() locale = getLocale();
@Input() performanceDataItems: LineChartItem[];
@Input() timeWeightedPerformanceDataItems: LineChartItem[];
@Input() user: User;
@Output() benchmarkChanged = new EventEmitter<string>();
@ -84,7 +85,10 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
permissions.accessAdminControl
);
if (this.performanceDataItems) {
if (
this.performanceDataItems ||
this.timeWeightedPerformanceDataItems?.length > 0
) {
this.initialize();
}
}
@ -115,6 +119,16 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}),
label: $localize`Portfolio`
},
{
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
borderDash: [5, 5],
data: this.timeWeightedPerformanceDataItems.map(({ date, value }) => {
return { x: parseDate(date).getTime(), y: value };
}),
label: $localize`Portfolio (time-weighted)`
},
{
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,

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

@ -280,4 +280,13 @@ export class HeaderComponent implements OnChanges {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private getFilterType(filterType: string) {
if (filterType === 'ACCOUNT') {
return 'accounts';
} else if (filterType === 'ASSET_CLASS') {
return 'assetClasses';
} else if (filterType === 'TAG') {
return 'tags';
}
}
}

30
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts

@ -100,6 +100,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number;
public stakeRewards: number;
public dividendInBaseCurrencyPrecision = 2;
public dividendYieldPercentWithCurrencyEffect: number;
public feeInBaseCurrency: number;
@ -119,6 +120,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
public netPerformanceWithCurrencyEffectPrecision = 2;
public quantity: number;
public quantityPrecision = 2;
public stakePrecision = 2;
public reportDataGlitchMail: string;
public sectors: {
[name: string]: { name: string; value: number };
@ -205,10 +207,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
dividendYieldPercentWithCurrencyEffect,
feeInBaseCurrency,
firstBuyDate,
historicalData,
stakeRewards,
investment,
marketPrice,
maxPrice,
@ -221,13 +220,18 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
SymbolProfile,
tags,
transactionCount,
value
value,
dividendYieldPercentWithCurrencyEffect,
feeInBaseCurrency,
firstBuyDate,
historicalData
}) => {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.dataProviderInfo = dataProviderInfo;
this.dividendInBaseCurrency = dividendInBaseCurrency;
this.stakeRewards = stakeRewards;
if (
this.data.deviceType === 'mobile' &&
@ -361,7 +365,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}
}
if (isToday(parseISO(this.firstBuyDate))) {
if (this.firstBuyDate && isToday(parseISO(this.firstBuyDate))) {
// Add average price
this.historicalDataItems.push({
date: this.firstBuyDate,
@ -401,6 +405,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
if (
this.benchmarkDataItems[0]?.value === undefined &&
this.firstBuyDate &&
isSameMonth(parseISO(this.firstBuyDate), new Date())
) {
this.benchmarkDataItems[0].value = this.averagePrice;
@ -415,6 +420,19 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
}
);
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) {
this.quantityPrecision = 7;
} else if (this.quantity < 1000) {
this.quantityPrecision = 5;
} else if (this.quantity > 10000000) {
this.quantityPrecision = 0;
}
this.stakePrecision = this.quantityPrecision;
}
this.changeDetectorRef.markForCheck();
}
);

54
apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html

@ -152,30 +152,35 @@
>Investment</gf-value
>
</div>
@if (dividendInBaseCurrency && user?.settings?.isExperimentalFeatures) {
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="data.baseCurrency"
[value]="dividendInBaseCurrency"
>Dividend</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[isPercent]="true"
[locale]="data.locale"
[value]="dividendYieldPercentWithCurrencyEffect"
>Dividend Yield</gf-value
>
</div>
}
<div
*ngIf="dividendInBaseCurrency > 0 || !stakeRewards"
class="col-6 mb-3"
>
<gf-value
i18n
size="medium"
[isCurrency]="true"
[locale]="data.locale"
[precision]="dividendInBaseCurrencyPrecision"
[unit]="data.baseCurrency"
[value]="dividendInBaseCurrency"
>Dividend</gf-value
>
</div>
<div
*ngIf="stakeRewards > 0 && dividendInBaseCurrency == 0"
class="col-6 mb-3"
>
<gf-value
i18n
size="medium"
[locale]="data.locale"
[precision]="stakePrecision"
[value]="stakeRewards"
>Stake Rewards
</gf-value>
</div>
<div class="col-6 mb-3">
<gf-value
i18n
@ -210,7 +215,6 @@
}
</gf-value>
</div>
<div class="col-6 mb-3"></div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
>Asset Class</gf-value

3
apps/client/src/app/components/toggle/toggle.component.ts

@ -19,6 +19,9 @@ import { FormControl } from '@angular/forms';
export class ToggleComponent implements OnChanges {
public static DEFAULT_DATE_RANGE_OPTIONS: ToggleOption[] = [
{ label: $localize`Today`, value: '1d' },
{ label: $localize`1W`, value: '1w' },
{ label: $localize`1M`, value: '1m' },
{ label: $localize`3M`, value: '3m' },
{ label: $localize`YTD`, value: 'ytd' },
{ label: $localize`1Y`, value: '1y' },
{ label: $localize`5Y`, value: '5y' },

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

@ -217,6 +217,10 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.get('quantity').value *
this.activityForm.get('unitPrice').value +
this.activityForm.get('fee').value ?? 0;
} else if (this.activityForm.get('type').value === 'STAKE') {
this.total =
this.activityForm.get('quantity').value * this.currentMarketPrice ??
0;
} else {
this.total =
this.activityForm.get('quantity').value *
@ -270,7 +274,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
if (this.activityForm.get('searchSymbol').invalid) {
this.data.activity.SymbolProfile = null;
} else if (
['BUY', 'DIVIDEND', 'SELL'].includes(
['BUY', 'DIVIDEND', 'SELL', 'STAKE'].includes(
this.activityForm.get('type').value
)
) {

13
apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html

@ -49,6 +49,14 @@
>Revenue for lending out money</small
>
</mat-option>
<mat-option class="line-height-1" value="STAKE">
<span
><b>{{ typesTranslationMap['STAKE'] }}</b></span
><br />
<small class="text-muted text-nowrap" i18n
>Stake rewards, stock dividends, free/gifted stocks</small
>
</mat-option>
<mat-option value="LIABILITY">
<span
><b>{{ typesTranslationMap['LIABILITY'] }}</b></span
@ -193,7 +201,10 @@
class="mb-3"
[ngClass]="{ 'd-none': activityForm.get('type')?.value === 'FEE' }"
>
<div class="align-items-start d-flex">
<div
*ngIf="activityForm.controls['type']?.value !== 'STAKE'"
class="align-items-start d-flex"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label>
@switch (activityForm.get('type')?.value) {

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

@ -201,6 +201,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({
filters: this.userService.getFilters(),
parameters: {
isAllocation: true
},
withMarkets: true
});
}

45
apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts

@ -32,6 +32,13 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmarks: Partial<SymbolProfile>[];
public bottom3: PortfolioPosition[];
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
public timeWeightedPerformanceOptions = [
{ label: $localize`No`, value: 'N' },
{ label: $localize`Both`, value: 'B' },
{ label: $localize`Only`, value: 'O' }
];
public selectedTimeWeightedPerformanceOption: string;
public daysInMarket: number;
public deviceType: string;
public dividendsByGroup: InvestmentItem[];
public dividendTimelineDataLabel = $localize`Dividend`;
@ -52,8 +59,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
public performance: PortfolioPerformance;
public performanceDataItems: HistoricalDataItem[];
public performanceDataItemsInPercentage: HistoricalDataItem[];
public performanceDataItemsTimeWeightedInPercentage: HistoricalDataItem[] =
[];
public portfolioEvolutionDataLabel = $localize`Investment`;
public streaks: PortfolioInvestments['streaks'];
public timeWeightedPerformance: string = 'N';
public top3: PortfolioPosition[];
public unitCurrentStreak: string;
public unitLongestStreak: string;
@ -124,6 +134,30 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
});
}
public onChangeDateRange(dateRange: DateRange) {
this.dataService
.putUserSetting({ dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onTimeWeightedPerformanceChanged(timeWeightedPerformance: string) {
this.timeWeightedPerformance = timeWeightedPerformance;
this.update();
}
public onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode;
this.fetchDividendsAndInvestments();
@ -192,7 +226,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchPortfolioPerformance({
filters: this.userService.getFilters(),
range: this.user?.settings?.dateRange
range: this.user?.settings?.dateRange,
timeWeightedPerformance:
this.timeWeightedPerformance === 'N' ? false : true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart, firstOrderDate, performance }) => {
@ -202,6 +238,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.performance = performance;
this.performanceDataItems = [];
this.performanceDataItemsInPercentage = [];
this.performanceDataItemsTimeWeightedInPercentage = [];
for (const [
index,
@ -230,6 +267,12 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
date,
value: netPerformanceInPercentageWithCurrencyEffect
});
if ((this.timeWeightedPerformance ?? 'N') !== 'N') {
this.performanceDataItemsTimeWeightedInPercentage.push({
date,
value: chart[index].timeWeightedPerformance
});
}
}
this.isLoadingInvestmentChart = false;

30
apps/client/src/app/pages/portfolio/analysis/analysis-page.html

@ -10,10 +10,36 @@
[colorScheme]="user?.settings?.colorScheme"
[isLoading]="isLoadingBenchmarkComparator || isLoadingInvestmentChart"
[locale]="user?.settings?.locale"
[performanceDataItems]="performanceDataItemsInPercentage"
[performanceDataItems]="
timeWeightedPerformance === 'O'
? []
: performanceDataItemsInPercentage
"
[timeWeightedPerformanceDataItems]="
timeWeightedPerformance === 'N'
? []
: performanceDataItemsTimeWeightedInPercentage
"
[user]="user"
(benchmarkChanged)="onChangeBenchmark($event)"
/>
></gf-benchmark-comparator>
<div>
<div class="col-md-6 col-xs-12 d-flex">
<div
class="align-items-center d-flex flex-grow-1 h6 mb-0 py-2 text-truncate"
>
<span i18n>Include time-weighted performance </span>
<gf-toggle
[defaultValue]="timeWeightedPerformance"
[isLoading]="
isLoadingBenchmarkComparator || isLoadingInvestmentChart
"
[options]="timeWeightedPerformanceOptions"
(change)="onTimeWeightedPerformanceChanged($event.value)"
></gf-toggle>
</div>
</div>
</div>
</div>
</div>

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

@ -168,6 +168,10 @@ export class AdminService {
return this.http.post<void>('/api/v1/admin/gather/max', {});
}
public gatherMissingOnly() {
return this.http.post<void>('/api/v1/admin/gather/missing', {});
}
public gatherProfileData() {
return this.http.post<void>('/api/v1/admin/gather/profile-data', {});
}
@ -198,6 +202,22 @@ export class AdminService {
return this.http.post<MarketData | void>(url, {});
}
public gatherSymbolMissingOnly({
dataSource,
date,
symbol
}: AssetProfileIdentifier & {
date?: Date;
}) {
let url = `/api/v1/admin/gatherMissing/${dataSource}/${symbol}`;
if (date) {
url = `${url}/${format(date, DATE_FORMAT)}`;
}
return this.http.post<MarketData | void>(url, {});
}
public fetchSymbolForDate({
dataSource,
dateString,
@ -224,6 +244,7 @@ export class AdminService {
sectors,
symbol,
symbolMapping,
tags,
url
}: AssetProfileIdentifier & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>(
@ -238,6 +259,7 @@ export class AdminService {
scraperConfiguration,
sectors,
symbolMapping,
tags,
url
}
);

11
apps/client/src/app/services/data.service.ts

@ -474,15 +474,18 @@ export class DataService {
public fetchPortfolioDetails({
filters,
parameters,
withMarkets = false
}: {
filters?: Filter[];
parameters?: any;
withMarkets?: boolean;
} = {}): Observable<PortfolioDetails> {
let params = this.buildFiltersAsQueryParams({ filters });
if (withMarkets) {
params = params.append('withMarkets', withMarkets);
params = parameters ? params.appendAll(parameters) : params;
}
return this.http
@ -578,11 +581,13 @@ export class DataService {
filters,
range,
withExcludedAccounts = false,
timeWeightedPerformance = false,
withItems = false
}: {
filters?: Filter[];
range: DateRange;
withExcludedAccounts?: boolean;
timeWeightedPerformance?: boolean;
withItems?: boolean;
}): Observable<PortfolioPerformanceResponse> {
let params = this.buildFiltersAsQueryParams({ filters });
@ -591,6 +596,12 @@ export class DataService {
if (withExcludedAccounts) {
params = params.append('withExcludedAccounts', withExcludedAccounts);
}
if (timeWeightedPerformance) {
params = params.append(
'timeWeightedPerformance',
timeWeightedPerformance
);
}
if (withItems) {
params = params.append('withItems', withItems);

2
apps/client/src/app/services/import-activities.service.ts

@ -348,6 +348,8 @@ export class ImportActivitiesService {
return 'LIABILITY';
case 'sell':
return 'SELL';
case 'stake':
return 'STAKE';
default:
break;
}

6
apps/client/src/app/services/user/user.service.ts

@ -53,14 +53,14 @@ export class UserService extends ObservableStore<UserStoreState> {
if (user?.settings['filters.accounts']) {
filters.push({
id: user.settings['filters.accounts'][0],
id: user.settings['filters.accounts'].join(','),
type: 'ACCOUNT'
});
}
if (user?.settings['filters.assetClasses']) {
filters.push({
id: user.settings['filters.assetClasses'][0],
id: user.settings['filters.assetClasses'].join(','),
type: 'ASSET_CLASS'
});
}
@ -81,7 +81,7 @@ export class UserService extends ObservableStore<UserStoreState> {
if (user?.settings['filters.tags']) {
filters.push({
id: user.settings['filters.tags'][0],
id: user.settings['filters.tags'].join(','),
type: 'TAG'
});
}

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

File diff suppressed because it is too large

6
apps/ui-e2e/project.json

@ -3,6 +3,8 @@
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"tags": [],
"implicitDependencies": ["ui"],
"targets": {
"e2e": {
"executor": "@nx/cypress:cypress",
@ -23,7 +25,5 @@
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
},
"tags": [],
"implicitDependencies": ["ui"]
}
}

46
libs/common/src/lib/chunkhelper.ts

@ -0,0 +1,46 @@
import { Prisma, PrismaClient } from '@prisma/client';
class Chunk<T> implements Iterable<T[] | undefined> {
protected constructor(
private readonly values: readonly T[],
private readonly size: number
) {}
*[Symbol.iterator]() {
const copy = [...this.values];
if (copy.length === 0) yield undefined;
while (copy.length) yield copy.splice(0, this.size);
}
map<U>(mapper: (items?: T[]) => U): U[] {
return Array.from(this).map((items) => mapper(items));
}
static of<U>(values: readonly U[]) {
return {
by: (size: number) => new Chunk(values, size)
};
}
}
export type Queryable<T, Result> = (
p: PrismaClient,
vs?: T[]
) => Prisma.PrismaPromise<Result>;
export class BatchPrismaClient {
constructor(
private readonly prisma: PrismaClient,
private readonly size = 32_000
) {}
over<T>(values: readonly T[]) {
return {
with: <Result>(queryable: Queryable<T, Result>) =>
this.prisma.$transaction(
Chunk.of(values)
.by(this.size)
.map((vs) => queryable(this.prisma, vs))
)
};
}
}

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

@ -98,6 +98,18 @@ export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = {
removeOnComplete: true
};
export const GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME =
'GATHER_MISSING_HISTORICAL_MARKET_DATA';
export const GATHER_MISSING_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions =
{
attempts: 12,
backoff: {
delay: ms('1 minute'),
type: 'exponential'
},
removeOnComplete: true
};
export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_NAME = 'PORTFOLIO';
export const PORTFOLIO_SNAPSHOT_PROCESS_JOB_OPTIONS: JobOptions = {
removeOnComplete: true

3
libs/common/src/lib/interfaces/admin-market-data.interface.ts

@ -1,4 +1,4 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
export interface AdminMarketData {
count: number;
@ -21,4 +21,5 @@ export interface AdminMarketDataItem {
name: string;
sectorsCount: number;
symbol: string;
tags: Tag[];
}

3
libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts

@ -1,4 +1,4 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Tag } from '@prisma/client';
import { Country } from './country.interface';
import { DataProviderInfo } from './data-provider-info.interface';
@ -30,4 +30,5 @@ export interface EnhancedSymbolProfile {
symbolMapping?: { [key: string]: string };
updatedAt: Date;
url?: string;
tags?: Tag[];
}

2
libs/common/src/lib/interfaces/historical-data-item.interface.ts

@ -16,5 +16,7 @@ export interface HistoricalDataItem {
totalInvestmentValueWithCurrencyEffect?: number;
value?: number;
valueInPercentage?: number;
timeWeightedPerformance?: number;
timeWeightedPerformanceWithCurrencyEffect?: number;
valueWithCurrencyEffect?: number;
}

6
libs/common/src/lib/interfaces/symbol-metrics.interface.ts

@ -28,6 +28,9 @@ export interface SymbolMetrics {
};
netPerformance: Big;
netPerformancePercentage: Big;
netPerformanceValuesPercentage: {
[date: string]: Big;
};
netPerformancePercentageWithCurrencyEffectMap: { [key: DateRange]: Big };
netPerformanceValues: {
[date: string]: Big;
@ -49,6 +52,9 @@ export interface SymbolMetrics {
totalInterestInBaseCurrency: Big;
totalInvestment: Big;
totalInvestmentWithCurrencyEffect: Big;
unitPrices: {
[date: string]: Big;
};
totalLiabilities: Big;
totalLiabilitiesInBaseCurrency: Big;
totalValuables: Big;

9
libs/common/src/lib/types/date-range.type.ts

@ -1,9 +1,12 @@
export type DateRange =
| '1d'
| 'wtd'
| '1w'
| 'mtd'
| '1m'
| '3m'
| 'ytd'
| '1y'
| '5y'
| 'max'
| 'mtd'
| 'wtd'
| 'ytd'
| string; // '2024', '2023', '2022', etc.

51
libs/ui/src/lib/activities-table/activities-table.component.scss

@ -1,3 +1,54 @@
:host {
display: block;
.activities {
overflow-x: auto;
.mat-mdc-table {
th {
::ng-deep {
.mat-sort-header-container {
justify-content: inherit;
}
}
}
.mat-mdc-row {
.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);
}
&.stake {
color: var(--blue);
}
&.item {
color: var(--purple);
}
&.liability {
color: var(--red);
}
&.sell {
color: var(--orange);
}
}
}
}
}
}
:host-context(.is-dark-theme) {
.mat-mdc-table {
.type-badge {
background-color: rgba(
var(--palette-foreground-text-dark),
0.1
) !important;
}
}
}

38
libs/ui/src/lib/activity-type/activity-type.component.html

@ -7,21 +7,31 @@
interest: activityType === 'INTEREST',
item: activityType === 'ITEM',
liability: activityType === 'LIABILITY',
sell: activityType === 'SELL'
sell: activityType === 'SELL',
stake: activityType === 'STAKE'
}"
>
@if (activityType === 'BUY') {
<ion-icon name="arrow-up-circle-outline" />
} @else if (activityType === 'DIVIDEND' || activityType === 'INTEREST') {
<ion-icon name="add-circle-outline" />
} @else if (activityType === 'FEE') {
<ion-icon name="hammer-outline" />
} @else if (activityType === 'ITEM') {
<ion-icon name="cube-outline" />
} @else if (activityType === 'LIABILITY') {
<ion-icon name="flame-outline" />
} @else if (activityType === 'SELL') {
<ion-icon name="arrow-down-circle-outline" />
}
<ion-icon
*ngIf="activityType === 'BUY'"
name="arrow-up-circle-outline"
></ion-icon>
<ion-icon
*ngIf="
activityType === 'DIVIDEND' ||
activityType === 'INTEREST' ||
activityType === 'STAKE'
"
name="add-circle-outline"
></ion-icon>
<ion-icon *ngIf="activityType === 'FEE'" name="hammer-outline"></ion-icon>
<ion-icon *ngIf="activityType === 'ITEM'" name="cube-outline"></ion-icon>
<ion-icon
*ngIf="activityType === 'LIABILITY'"
name="flame-outline"
></ion-icon>
<ion-icon
*ngIf="activityType === 'SELL'"
name="arrow-down-circle-outline"
></ion-icon>
<span class="d-none d-lg-block mx-1">{{ activityTypeLabel }}</span>
</div>

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

@ -296,7 +296,7 @@ export class GfAssistantComponent implements OnChanges, OnDestroy, OnInit {
}
}
public hasFilter(aFormValue: { [key: string]: string }) {
public hasFilter(aFormValue: { [key: string]: string[] }) {
return Object.values(aFormValue).some((value) => {
return !!value;
});

6
libs/ui/src/lib/assistant/assistant.html

@ -105,7 +105,7 @@
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Accounts</mat-label>
<mat-select formControlName="account">
<mat-select formControlName="account" multiple>
<mat-option [value]="null" />
@for (account of accounts; track account.id) {
<mat-option [value]="account.id">
@ -153,7 +153,7 @@
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Tags</mat-label>
<mat-select formControlName="tag">
<mat-select formControlName="tag" multiple>
<mat-option [value]="null" />
@for (tag of tags; track tag.id) {
<mat-option [value]="tag.id">{{ tag.label }}</mat-option>
@ -164,7 +164,7 @@
<div class="mb-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Asset Classes</mat-label>
<mat-select formControlName="assetClass">
<mat-select formControlName="assetClass" multiple>
<mat-option [value]="null" />
@for (assetClass of assetClasses; track assetClass.id) {
<mat-option [value]="assetClass.id">{{

1
libs/ui/src/lib/i18n.ts

@ -39,6 +39,7 @@ const locales = {
ITEM: $localize`Valuable`,
LIABILITY: $localize`Liability`,
SELL: $localize`Sell`,
STAKE: $localize`Stake`,
// AssetClass (enum)
CASH: $localize`Cash`,

185
migrations.json

@ -0,0 +1,185 @@
{
"migrations": [
{
"cli": "nx",
"version": "17.3.0-beta.6",
"description": "Updates the nx wrapper.",
"implementation": "./src/migrations/update-17-3-0/update-nxw",
"package": "nx",
"name": "17.3.0-update-nx-wrapper"
},
{
"cli": "nx",
"version": "18.0.0-beta.2",
"description": "Updates nx.json to disabled adding plugins when generating projects in an existing Nx workspace",
"implementation": "./src/migrations/update-18-0-0/disable-crystal-for-existing-workspaces",
"x-repair-skip": true,
"package": "nx",
"name": "18.0.0-disable-adding-plugins-for-existing-workspaces"
},
{
"version": "18.1.0-beta.3",
"description": "Moves affected.defaultBase to defaultBase in `nx.json`",
"implementation": "./src/migrations/update-17-2-0/move-default-base",
"package": "nx",
"name": "move-default-base-to-nx-json-root"
},
{
"cli": "nx",
"version": "18.1.0-beta.3",
"description": "Update to Cypress ^13.6.6 if the workspace is using Cypress v13 to ensure workspaces don't use v13.6.5 which has an issue when verifying Cypress.",
"implementation": "./src/migrations/update-18-1-0/update-cypress-version-13-6-6",
"package": "@nx/cypress",
"name": "update-cypress-version-13-6-6"
},
{
"cli": "nx",
"version": "17.2.6-beta.1",
"description": "Rename workspace rules from @nx/workspace/name to @nx/workspace-name",
"implementation": "./src/migrations/update-17-2-6-rename-workspace-rules/rename-workspace-rules",
"package": "@nx/eslint-plugin",
"name": "update-17-2-6-rename-workspace-rules"
},
{
"version": "17.1.0-beta.2",
"description": "Move jest executor options to nx.json targetDefaults",
"implementation": "./src/migrations/update-17-1-0/move-options-to-target-defaults",
"package": "@nx/jest",
"name": "move-options-to-target-defaults"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Update the @angular/cli package version to ~17.0.0.",
"factory": "./src/migrations/update-17-1-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-0-0"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Rename 'browserTarget' to 'buildTarget'.",
"factory": "./src/migrations/update-17-1-0/browser-target-to-build-target",
"package": "@nx/angular",
"name": "rename-browser-target-to-build-target"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Replace usages of '@nguniversal/builders' with '@angular-devkit/build-angular'.",
"factory": "./src/migrations/update-17-1-0/replace-nguniversal-builders",
"package": "@nx/angular",
"name": "replace-nguniversal-builders"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Replace usages of '@nguniversal/' packages with '@angular/ssr'.",
"factory": "./src/migrations/update-17-1-0/replace-nguniversal-engines",
"package": "@nx/angular",
"name": "replace-nguniversal-engines"
},
{
"cli": "nx",
"version": "17.1.0-beta.5",
"requires": {
"@angular/core": ">=17.0.0"
},
"description": "Replace the deep imports from 'zone.js/dist/zone' and 'zone.js/dist/zone-testing' with 'zone.js' and 'zone.js/testing'.",
"factory": "./src/migrations/update-17-1-0/update-zone-js-deep-import",
"package": "@nx/angular",
"name": "update-zone-js-deep-import"
},
{
"cli": "nx",
"version": "17.2.0-beta.2",
"description": "Rename '@nx/angular:webpack-dev-server' executor to '@nx/angular:dev-server'",
"factory": "./src/migrations/update-17-2-0/rename-webpack-dev-server",
"package": "@nx/angular",
"name": "rename-webpack-dev-server-executor"
},
{
"cli": "nx",
"version": "17.3.0-beta.10",
"requires": {
"@angular/core": ">=17.1.0"
},
"description": "Update the @angular/cli package version to ~17.1.0.",
"factory": "./src/migrations/update-17-3-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-1-0"
},
{
"cli": "nx",
"version": "17.3.0-beta.10",
"requires": {
"@angular/core": ">=17.1.0"
},
"description": "Add 'browser-sync' as dev dependency when '@angular-devkit/build-angular:ssr-dev-server' or '@nx/angular:module-federation-dev-ssr' is used.",
"factory": "./src/migrations/update-17-3-0/add-browser-sync-dependency",
"package": "@nx/angular",
"name": "add-browser-sync-dependency"
},
{
"cli": "nx",
"version": "17.3.0-beta.10",
"requires": {
"@angular/core": ">=17.1.0"
},
"description": "Add 'autoprefixer' as dev dependency when '@nx/angular:ng-packagr-lite' or '@nx/angular:package` is used.",
"factory": "./src/migrations/update-17-3-0/add-autoprefixer-dependency",
"package": "@nx/angular",
"name": "add-autoprefixer-dependency"
},
{
"cli": "nx",
"version": "18.0.0-beta.0",
"description": "Add NX_MF_DEV_SERVER_STATIC_REMOTES to inputs for task hashing when '@nx/angular:webpack-browser' is used for Module Federation.",
"factory": "./src/migrations/update-18-0-0/add-mf-env-var-to-target-defaults",
"package": "@nx/angular",
"name": "add-module-federation-env-var-to-target-defaults"
},
{
"cli": "nx",
"version": "18.1.0-beta.1",
"requires": {
"@angular/core": ">=17.2.0"
},
"description": "Update the @angular/cli package version to ~17.2.0.",
"factory": "./src/migrations/update-18-1-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-2-0"
},
{
"cli": "nx",
"version": "18.1.1-beta.0",
"description": "Ensure targetDefaults inputs for task hashing when '@nx/angular:webpack-browser' is used are correct for Module Federation.",
"factory": "./src/migrations/update-18-1-1/fix-target-defaults-inputs",
"package": "@nx/angular",
"name": "fix-target-defaults-for-webpack-browser"
},
{
"cli": "nx",
"version": "18.2.0-beta.0",
"requires": {
"@angular/core": ">=17.3.0"
},
"description": "Update the @angular/cli package version to ~17.3.0.",
"factory": "./src/migrations/update-18-2-0/update-angular-cli",
"package": "@nx/angular",
"name": "update-angular-cli-version-17-3-0"
}
]
}

6
package-lock.json

@ -45,6 +45,7 @@
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "4.9.0",
"alphavantage": "2.2.0",
"await-lock": "^2.2.2",
"big.js": "6.2.1",
"body-parser": "1.20.2",
"bootstrap": "4.6.0",
@ -12888,6 +12889,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/await-lock": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/await-lock/-/await-lock-2.2.2.tgz",
"integrity": "sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw=="
},
"node_modules/aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",

2
package.json

@ -52,6 +52,7 @@
"ts-node": "ts-node",
"update": "nx migrate latest",
"watch:server": "nx run api:copy-assets && nx run api:build --watch",
"profile:server": "nx run api:profile",
"watch:test": "nx test --watch",
"workspace-generator": "nx workspace-generator"
},
@ -91,6 +92,7 @@
"@simplewebauthn/server": "9.0.3",
"@stripe/stripe-js": "4.9.0",
"alphavantage": "2.2.0",
"await-lock": "^2.2.2",
"big.js": "6.2.1",
"body-parser": "1.20.2",
"bootstrap": "4.6.0",

22
prisma/migrations/20231108082445_added_tags_to_holding/migration.sql

@ -0,0 +1,22 @@
-- AlterEnum
ALTER TYPE "Type" ADD VALUE IF NOT EXISTS 'STAKE';
-- CreateTable
CREATE TABLE IF NOT EXISTS "_SymbolProfileToTag" (
"A" TEXT NOT NULL,
"B" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "_SymbolProfileToTag_AB_unique" ON "_SymbolProfileToTag"("A", "B");
-- CreateIndex
CREATE INDEX IF NOT EXISTS "_SymbolProfileToTag_B_index" ON "_SymbolProfileToTag"("B");
-- AddForeignKey
ALTER TABLE "_SymbolProfileToTag" DROP CONSTRAINT IF EXISTS "_SymbolProfileToTag_A_fkey" ;
ALTER TABLE "_SymbolProfileToTag" ADD CONSTRAINT "_SymbolProfileToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "SymbolProfile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "_SymbolProfileToTag" DROP CONSTRAINT IF EXISTS "_SymbolProfileToTag_B_fkey" ;
ALTER TABLE "_SymbolProfileToTag" ADD CONSTRAINT "_SymbolProfileToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "Tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;

3
prisma/schema.prisma

@ -178,6 +178,7 @@ model SymbolProfile {
symbolMapping Json?
url String?
Order Order[]
tags Tag[]
SymbolProfileOverrides SymbolProfileOverrides?
@@unique([dataSource, symbol])
@ -220,6 +221,7 @@ model Tag {
orders Order[]
userId String?
User User? @relation(fields: [userId], onDelete: Cascade, references: [id])
symbolProfile SymbolProfile[]
@@unique([name, userId])
@@index([name])
@ -313,6 +315,7 @@ enum Type {
ITEM
LIABILITY
SELL
STAKE
}
enum ViewMode {

17248
yarn-error.log

File diff suppressed because it is too large
Loading…
Cancel
Save