Browse Source

Merge remote-tracking branch 'origin/dockerpush' into mr/Upstream-2025-06-16

pull/5027/head
Dan 2 weeks ago
parent
commit
875a772265
  1. 1
      .admin.cred
  2. 24
      .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. 6
      .husky/pre-commit
  9. 6
      .husky/pre-push
  10. 89
      CHANGELOG.md
  11. 1
      Dockerfile
  12. 11
      apps/api/project.json
  13. 1
      apps/api/src/app/account-balance/account-balance.service.ts
  14. 2
      apps/api/src/app/account/account.service.ts
  15. 46
      apps/api/src/app/admin/admin.controller.ts
  16. 2
      apps/api/src/app/admin/admin.module.ts
  17. 18
      apps/api/src/app/admin/admin.service.ts
  18. 16
      apps/api/src/app/admin/update-asset-profile.dto.ts
  19. 7
      apps/api/src/app/import/import.service.ts
  20. 114
      apps/api/src/app/order/order.service.ts
  21. 23
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  22. 843
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  23. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  24. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  25. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  26. 10
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts
  27. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  28. 14
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts
  29. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  30. 37
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  31. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts
  32. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  33. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  34. 3
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  35. 7
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  36. 520
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  37. 12
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  38. 208
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts
  39. 39
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts
  40. 198
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts
  41. 202
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  42. 259
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts
  43. 894
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts
  44. 261
      apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts
  45. 5
      apps/api/src/app/portfolio/interfaces/portfolio-order-item.interface.ts
  46. 38
      apps/api/src/app/portfolio/portfolio.controller.ts
  47. 618
      apps/api/src/app/portfolio/portfolio.service.ts
  48. 82
      apps/api/src/app/tag/tag.service.ts
  49. 8
      apps/api/src/app/user/update-user-setting.dto.ts
  50. 6
      apps/api/src/app/user/user.controller.ts
  51. 25
      apps/api/src/helper/dateQueryHelper.ts
  52. 1
      apps/api/src/helper/portfolio.helper.ts
  53. 2
      apps/api/src/interceptors/performance-logging/performance-logging.interceptor.ts
  54. 7
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  55. 25
      apps/api/src/services/data-provider/data-provider.service.ts
  56. 32
      apps/api/src/services/data-provider/manual/manual.service.ts
  57. 9
      apps/api/src/services/market-data/market-data.service.ts
  58. 177
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  59. 49
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  60. 12
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.module.ts
  61. 69
      apps/api/src/services/symbol-profile/symbol-profile-overwrite.service.ts
  62. 56
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  63. 7
      apps/api/src/services/tag/tag.service.ts
  64. 11
      apps/client/src/app/components/admin-market-data/admin-market-data.component.ts
  65. 3
      apps/client/src/app/components/admin-market-data/admin-market-data.html
  66. 65
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  67. 44
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  68. 4
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.module.ts
  69. 13
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  70. 8
      apps/client/src/app/components/admin-tag/admin-tag.component.ts
  71. 9
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html
  72. 1
      apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.scss
  73. 10
      apps/client/src/app/components/header/header.component.ts
  74. 30
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.component.ts
  75. 54
      apps/client/src/app/components/holding-detail-dialog/holding-detail-dialog.html
  76. 5
      apps/client/src/app/components/home-holdings/home-holdings.component.ts
  77. 1
      apps/client/src/app/components/home-holdings/home-holdings.html
  78. 12
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.html
  79. 22
      apps/client/src/app/components/portfolio-summary/portfolio-summary.component.ts
  80. 3
      apps/client/src/app/components/toggle/toggle.component.ts
  81. 4
      apps/client/src/app/components/user-account-settings/user-account-settings.html
  82. 6
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.component.ts
  83. 13
      apps/client/src/app/pages/portfolio/activities/create-or-update-activity-dialog/create-or-update-activity-dialog.html
  84. 48
      apps/client/src/app/pages/portfolio/allocations/allocations-page.component.ts
  85. 18
      apps/client/src/app/pages/portfolio/allocations/allocations-page.html
  86. 42
      apps/client/src/app/pages/portfolio/analysis/analysis-page.component.ts
  87. 2
      apps/client/src/app/pages/portfolio/analysis/analysis-page.html
  88. 19
      apps/client/src/app/services/admin.service.ts
  89. 11
      apps/client/src/app/services/data.service.ts
  90. 2
      apps/client/src/app/services/import-activities.service.ts
  91. 6
      apps/client/src/app/services/user/user.service.ts
  92. 2
      apps/client/src/locales/messages.it.xlf
  93. 10
      libs/common/src/lib/calculation-helper.ts
  94. 46
      libs/common/src/lib/chunkhelper.ts
  95. 12
      libs/common/src/lib/config.ts
  96. 3
      libs/common/src/lib/interfaces/admin-market-data.interface.ts
  97. 3
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  98. 2
      libs/common/src/lib/interfaces/historical-data-item.interface.ts
  99. 1
      libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts
  100. 6
      libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts

1
.admin.cred

@ -0,0 +1 @@
14d4daf73eefed7da7c32ec19bc37e678be0244fb46c8f4965bfe9ece7384706ed58222ad9b96323893c1d845bc33a308e7524c2c79636062cbb095e0780cb51

24
.env.dev

@ -1,24 +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

3
.env.example

@ -1,4 +1,4 @@
COMPOSE_PROJECT_NAME=ghostfolio
COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=redis
@ -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}@postgres: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: ${{ vars.DOCKER_REPOSITORY || '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

6
.husky/pre-commit

@ -1,6 +1,2 @@
# Run linting and stop the commit process if any errors are found
# --quiet suppresses warnings (temporary until all warnings are fixed)
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
# Check formatting on modified and uncommitted files, stop the commit if issues are found
npm run format:check --uncommitted || exit 1
npm run format:write --uncommitted || exit 1

6
.husky/pre-push

@ -0,0 +1,6 @@
# Run linting and stop the commit process if any errors are found
# --quiet suppresses warnings (temporary until all warnings are fixed)
npm run affected:lint --base=main --head=HEAD --parallel=2 --quiet || exit 1
# Check formatting on modified and uncommitted files, stop the commit if issues are found
npm run format:check --uncommitted || exit 1

89
CHANGELOG.md

@ -415,6 +415,93 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## 2.149.0 - 2025-03-30
### 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
### Added
- Added support for changing the asset profile identifier (`dataSource` and `symbol`) in the asset profile details dialog of the admin control panel (experimental)
@ -2307,9 +2394,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

@ -62,6 +62,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';
@ -162,6 +163,7 @@ export class AccountService {
});
}
@LogPerformance
public async getCashDetails({
currency,
filters = [],

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

@ -109,6 +109,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.getAllActiveAssetProfileIdentifiers();
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)
@ -162,7 +179,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;
}
@ -279,7 +311,17 @@ export class AdminController {
): Promise<EnhancedSymbolProfile> {
return this.adminService.patchAssetProfileData(
{ dataSource, symbol },
assetProfile
{
...assetProfile,
tags: {
connect: assetProfile.tags?.map(({ id }) => {
return { id };
}),
disconnect: assetProfile.tagsDisconnected?.map(({ id }) => ({
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';
PropertyModule,
QueueModule,
SymbolProfileModule,
SymbolProfileOverwriteModule,
TransformDataSourceInRequestModule
],
controllers: [AdminController],

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

@ -40,11 +40,11 @@ import {
import {
AssetClass,
AssetSubClass,
DataSource,
Prisma,
PrismaClient,
Property,
SymbolProfile
SymbolProfile,
DataSource
} from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -286,7 +286,8 @@ export class AdminService {
scraperConfiguration: true,
sectors: true,
symbol: true,
SymbolProfileOverrides: true
SymbolProfileOverrides: true,
tags: true
}
}),
this.prismaService.symbolProfile.count({ where })
@ -342,7 +343,8 @@ export class AdminService {
name,
sectors,
symbol,
SymbolProfileOverrides
SymbolProfileOverrides,
tags
}) => {
let countriesCount = countries ? Object.keys(countries).length : 0;
@ -405,7 +407,8 @@ export class AdminService {
date: activities?.[0]?.date,
isUsedByUsersWithSubscription:
await isUsedByUsersWithSubscription,
watchedByCount: _count.watchedBy
watchedByCount: _count.watchedBy,
tags
};
}
)
@ -514,6 +517,7 @@ export class AdminService {
holdings,
isActive,
name,
tags,
scraperConfiguration,
sectors,
symbol: newSymbol,
@ -595,6 +599,7 @@ export class AdminService {
sectors,
symbol,
symbolMapping,
tags,
...(dataSource === 'MANUAL'
? { assetClass, assetSubClass, name, url }
: {
@ -783,7 +788,8 @@ export class AdminService {
isActive: true,
name: symbol,
sectorsCount: 0,
watchedByCount: 0
watchedByCount: 0,
tags: []
};
}
);

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

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

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

@ -589,7 +589,12 @@ 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}")`

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

@ -50,33 +50,55 @@ export class OrderService {
public async assignTags({
dataSource,
symbol,
tags,
userId
userId,
tags
}: { tags: Tag[]; userId: string } & AssetProfileIdentifier) {
const orders = await this.prismaService.order.findMany({
where: {
userId,
SymbolProfile: {
dataSource,
symbol
}
const promis = await this.symbolProfileService.getSymbolProfiles([
{
dataSource,
symbol
}
]);
const symbolProfile: EnhancedSymbolProfile = promis[0];
const result = await this.symbolProfileService.updateSymbolProfile(
{ dataSource, symbol },
{
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries.reduce(
(all, v) => [...all, { code: v.code, weight: v.weight }],
[]
),
currency: symbolProfile.currency,
dataSource,
holdings: symbolProfile.holdings.reduce(
(all, v) => [
...all,
{ name: v.name, weight: v.allocationInPercentage }
],
[]
),
name: symbolProfile.name,
sectors: symbolProfile.sectors.reduce(
(all, v) => [...all, { name: v.name, weight: v.weight }],
[]
),
symbol,
tags: {
connectOrCreate: tags.map(({ id, name }) => {
return {
create: {
id,
name
},
where: {
id
}
};
})
},
url: symbolProfile.url
}
});
await Promise.all(
orders.map(({ id }) =>
this.prismaService.order.update({
data: {
tags: {
// The set operation replaces all existing connections with the provided ones
set: tags.map((tag) => {
return { id: tag.id };
})
}
},
where: { id }
})
)
);
this.eventEmitter.emit(
@ -85,6 +107,8 @@ export class OrderService {
userId
})
);
return result;
}
public async createOrder(
@ -302,6 +326,7 @@ export class OrderService {
});
}
@LogPerformance
public async getOrders({
endDate,
filters,
@ -456,13 +481,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) {
@ -494,7 +540,11 @@ export class OrderService {
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true,
SymbolProfile: {
include: {
tags: true
}
},
tags: true
}
}),

23
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';
@ -9,9 +10,11 @@ import { PerformanceCalculationType } from '@ghostfolio/common/types/performance
import { Injectable } from '@nestjs/common';
import { OrderService } from '../../order/order.service';
import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator';
import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
// import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { RoiPortfolioCalculator } from './roi/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
@ -22,9 +25,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,
@ -52,7 +57,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.ROAI:
@ -66,7 +72,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.ROI:
@ -80,7 +87,8 @@ export class PortfolioCalculatorFactory {
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService
});
case PerformanceCalculationType.TWR:
@ -88,13 +96,14 @@ export class PortfolioCalculatorFactory {
accountBalanceItems,
activities,
currency,
filters,
currentRateService: this.currentRateService,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
redisCacheService: this.redisCacheService,
orderService: this.orderService,
filters
});
default:

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

File diff suppressed because it is too large

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

@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -174,9 +175,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') },
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts

@ -77,7 +77,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -161,9 +162,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('-0.0552834149755073478')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('-15.8')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('-15.8') },
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),

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

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

10
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btceur.spec.ts

@ -88,7 +88,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -135,6 +136,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
@ -153,6 +156,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42,
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
@ -172,6 +177,9 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
timeWeightedPerformanceInPercentage: -0.13969735500006986,
timeWeightedPerformanceInPercentageWithCurrencyEffect:
-0.13969735500006986,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,

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

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

14
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd.spec.ts

@ -1,5 +1,6 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import {
activityDummyData,
loadActivityExportFile,
@ -60,6 +61,7 @@ describe('PortfolioCalculator', () => {
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
let orderService: OrderService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
@ -83,12 +85,15 @@ describe('PortfolioCalculator', () => {
redisCacheService = new RedisCacheService(null, null);
orderService = new OrderService(null, null, null, null, null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
orderService
);
});
@ -135,6 +140,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
@ -153,6 +160,8 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,
@ -172,6 +181,9 @@ describe('PortfolioCalculator', () => {
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712,
netPerformanceWithCurrencyEffect: -1463.18,
netWorth: 43099.7,
timeWeightedPerformanceInPercentage: -0.13969735500006986,
timeWeightedPerformanceInPercentageWithCurrencyEffect:
-0.13969735500006986,
totalAccountBalance: 0,
totalInvestment: 44558.42,
totalInvestmentValueWithCurrencyEffect: 44558.42,

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

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

37
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -90,7 +90,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -159,9 +160,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.851974')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') },
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
@ -197,30 +196,12 @@ describe('PortfolioCalculator', () => {
expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: 82.329056 },
{
date: '2023-02-01',
investment: 0
},
{
date: '2023-03-01',
investment: 0
},
{
date: '2023-04-01',
investment: 0
},
{
date: '2023-05-01',
investment: 0
},
{
date: '2023-06-01',
investment: 0
},
{
date: '2023-07-01',
investment: 0
}
{ date: '2023-02-01', investment: 0 },
{ date: '2023-03-01', investment: 0 },
{ date: '2023-04-01', investment: 0 },
{ date: '2023-05-01', investment: 0 },
{ date: '2023-06-01', investment: 0 },
{ date: '2023-07-01', investment: 0 }
]);
});
});

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

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

7
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -1,4 +1,5 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import {
activityDummyData,
symbolProfileDummyData,
@ -55,6 +56,7 @@ describe('PortfolioCalculator', () => {
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
let orderServiceMock: OrderService;
beforeEach(() => {
configurationService = new ConfigurationService();
@ -72,12 +74,15 @@ describe('PortfolioCalculator', () => {
redisCacheService = new RedisCacheService(null, null);
orderServiceMock = new OrderService(null, null, null, null, null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
orderServiceMock
);
});

3
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts

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

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

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

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

@ -91,7 +91,8 @@ describe('PortfolioCalculator', () => {
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
redisCacheService,
null
);
});
@ -157,9 +158,7 @@ describe('PortfolioCalculator', () => {
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.12348284960422163588')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('17.68')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') },
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'),

520
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -1,256 +1,264 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 24, // 2 * (87.8 - 75.8) = 24
netPerformanceInPercentage: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438, // 24 ÷ 151.6 = 0.158311345646438
netPerformanceWithCurrencyEffect: 24,
netWorth: 175.6, // 2 * 87.8 = 175.6
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
});
});
});
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
/**
* Closing price on 2022-03-07 is unknown,
* hence it uses the last unit price (2022-04-11): 87.8
*/
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 24,
netPerformanceInPercentage: 0.158311345646438,
netPerformanceInPercentageWithCurrencyEffect: 0.158311345646438,
netPerformanceWithCurrencyEffect: 24,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
netWorth: 175.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 175.6, // 2 * 87.8 = 175.6
valueWithCurrencyEffect: 175.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
timeWeightedPerformanceInPercentage: -0.02357630979498861,
timeWeightedPerformanceInPercentageWithCurrencyEffect:
-0.02357630979498861,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
});
});
});

12
apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts

@ -226,7 +226,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
totalValuablesInBaseCurrency: new Big(0),
unitPrices: {}
};
}
@ -276,7 +277,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
totalValuablesInBaseCurrency: new Big(0),
unitPrices: {}
};
}
@ -821,6 +823,9 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
for (const dateRange of [
'1d',
'1w',
'1m',
'3m',
'1y',
'5y',
'max',
@ -981,7 +986,8 @@ export class RoaiPortfolioCalculator extends PortfolioCalculator {
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect,
unitPrices: marketSymbolMap[endDateString]
};
}
}

208
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-googl-buy.spec.ts

@ -0,0 +1,208 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with GOOGL buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2023-01-03'),
feeInAssetProfileCurrency: 1,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 89.12
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('103.10483'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('89.12'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'),
feeInBaseCurrency: new Big('0.9238'),
firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33').mul(0.8854),
grossPerformancePercentage: new Big('0.3066651705565529623'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.25235044599563974109'
),
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
investment: new Big('89.12').mul(0.8854),
investmentWithCurrencyEffect: new Big('82.329056'),
netPerformance: new Big('26.33').mul(0.8854),
netPerformancePercentage: new Big('0.29544434470377019749'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.24112962014285697628')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('19.851974') },
marketPrice: 116.45,
marketPriceInBaseCurrency: 103.10483,
quantity: new Big('1'),
symbol: 'GOOGL',
tags: [],
timeWeightedInvestment: new Big('89.12').mul(0.8854),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
}
],
totalFeesWithCurrencyEffect: new Big('0.9238'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('89.12').mul(0.8854),
totalInvestmentWithCurrencyEffect: new Big('82.329056'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: new Big('26.33').mul(0.8854).toNumber(),
netPerformanceInPercentage: 0.29544434470377019749,
netPerformanceInPercentageWithCurrencyEffect: 0.24112962014285697628,
netPerformanceWithCurrencyEffect: 19.851974,
totalInvestmentValueWithCurrencyEffect: 82.329056
})
);
expect(investments).toEqual([
{ date: '2023-01-03', investment: new Big('89.12') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2023-01-01', investment: 82.329056 },
{ date: '2023-02-01', investment: 0 },
{ date: '2023-03-01', investment: 0 },
{ date: '2023-04-01', investment: 0 },
{ date: '2023-05-01', investment: 0 },
{ date: '2023-06-01', investment: 0 },
{ date: '2023-07-01', investment: 0 }
]);
});
});
});

39
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-helper-object.ts

@ -0,0 +1,39 @@
import { SymbolMetrics } from '@ghostfolio/common/interfaces';
import { Big } from 'big.js';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
export class PortfolioCalculatorSymbolMetricsHelperObject {
currentExchangeRate: number;
endDateString: string;
exchangeRateAtOrderDate: number;
fees: Big = new Big(0);
feesWithCurrencyEffect: Big = new Big(0);
feesAtStartDate: Big = new Big(0);
feesAtStartDateWithCurrencyEffect: Big = new Big(0);
grossPerformanceAtStartDate: Big = new Big(0);
grossPerformanceAtStartDateWithCurrencyEffect: Big = new Big(0);
indexOfEndOrder: number;
indexOfStartOrder: number;
initialValue: Big;
initialValueWithCurrencyEffect: Big;
investmentAtStartDate: Big;
investmentAtStartDateWithCurrencyEffect: Big;
investmentValueBeforeTransaction: Big = new Big(0);
investmentValueBeforeTransactionWithCurrencyEffect: Big = new Big(0);
ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
startDateString: string;
symbolMetrics: SymbolMetrics;
totalUnits: Big = new Big(0);
totalInvestmentFromBuyTransactions: Big = new Big(0);
totalInvestmentFromBuyTransactionsWithCurrencyEffect: Big = new Big(0);
totalQuantityFromBuyTransactions: Big = new Big(0);
totalValueOfPositionsSold: Big = new Big(0);
totalValueOfPositionsSoldWithCurrencyEffect: Big = new Big(0);
unitPrice: Big;
unitPriceAtEndDate: Big = new Big(0);
unitPriceAtStartDate: Big = new Big(0);
valueAtStartDate: Big = new Big(0);
valueAtStartDateWithCurrencyEffect: Big = new Big(0);
}

198
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-msft-buy-with-dividend.spec.ts

@ -0,0 +1,198 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2023-07-10').getTime());
const activities: Activity[] = [
{
...activityDummyData,
date: new Date('2021-09-16'),
feeInAssetProfileCurrency: 19,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPriceInAssetProfileCurrency: 298.58
},
{
...activityDummyData,
date: new Date('2021-11-16'),
feeInAssetProfileCurrency: 0,
quantity: 1,
SymbolProfile: {
...symbolProfileDummyData,
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPriceInAssetProfileCurrency: 0.62
}
];
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'USD',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
expect(portfolioSnapshot).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
grossPerformance: new Big('33.87'),
grossPerformancePercentage: new Big('0.11343693482483756447'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.11343693482483756447'
),
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.87'),
netPerformancePercentage: new Big('0.04980239801728180052'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.04980239801728180052')
},
netPerformanceWithCurrencyEffectMap: {
'1d': new Big('-5.39'),
'5y': new Big('14.87'),
max: new Big('14.87'),
wtd: new Big('-5.39')
},
quantity: new Big('1'),
symbol: 'MSFT',
tags: [],
transactionCount: 2
}
],
totalFeesWithCurrencyEffect: new Big('19'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
totalInvestmentValueWithCurrencyEffect: 298.58
})
);
});
});
});

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

@ -0,0 +1,202 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell-partially.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell partially', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('87.8'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'),
feeInBaseCurrency: new Big('4.25'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.14465699208443271768'
),
grossPerformanceWithCurrencyEffect: new Big('21.93'),
investment: new Big('75.80'),
investmentWithCurrencyEffect: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.11662269129287598945')
},
netPerformanceWithCurrencyEffectMap: { max: new Big('17.68') },
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('87.8')
}
],
totalFeesWithCurrencyEffect: new Big('4.25'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('75.80'),
totalInvestmentWithCurrencyEffect: new Big('75.80'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 17.68,
netPerformanceInPercentage: 0.11662269129287598945,
netPerformanceInPercentageWithCurrencyEffect: 0.11662269129287598945,
netPerformanceWithCurrencyEffect: 17.68,
totalInvestmentValueWithCurrencyEffect: 75.8
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('75.8') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -75.8 }
]);
});
});
});

259
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-novn-buy-and-sell.spec.ts

@ -0,0 +1,259 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
activityDummyData,
loadActivityExportFile,
symbolProfileDummyData,
userDummyData
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service';
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Big } from 'big.js';
import { join } from 'path';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
PortfolioSnapshotService: jest.fn().mockImplementation(() => {
return PortfolioSnapshotServiceMock;
})
};
}
);
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
RedisCacheService: jest.fn().mockImplementation(() => {
return RedisCacheServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let activityDtos: CreateOrderDto[];
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
let portfolioSnapshotService: PortfolioSnapshotService;
let redisCacheService: RedisCacheService;
beforeAll(() => {
activityDtos = loadActivityExportFile(
join(
__dirname,
'../../../../../../../test/import/ok-novn-buy-and-sell.json'
)
);
});
beforeEach(() => {
configurationService = new ConfigurationService();
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
portfolioSnapshotService = new PortfolioSnapshotService(null);
redisCacheService = new RedisCacheService(null, null);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
currentRateService,
exchangeRateDataService,
portfolioSnapshotService,
redisCacheService,
null
);
});
describe('get current positions', () => {
it.only('with NOVN.SW buy and sell', async () => {
jest.useFakeTimers().setSystemTime(parseDate('2022-04-11').getTime());
const activities: Activity[] = activityDtos.map((activity) => ({
...activityDummyData,
...activity,
date: parseDate(activity.date),
feeInAssetProfileCurrency: activity.fee,
SymbolProfile: {
...symbolProfileDummyData,
currency: activity.currency,
dataSource: activity.dataSource,
name: 'Novartis AG',
symbol: activity.symbol
},
unitPriceInAssetProfileCurrency: activity.unitPrice
}));
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities,
calculationType: PerformanceCalculationType.ROI,
currency: 'CHF',
userId: userDummyData.id
});
const portfolioSnapshot = await portfolioCalculator.computeSnapshot();
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: portfolioSnapshot.historicalData,
groupBy: 'month'
});
expect(portfolioSnapshot.historicalData[0]).toEqual({
date: '2022-03-06',
investmentValueWithCurrencyEffect: 0,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot.historicalData[1]).toEqual({
date: '2022-03-07',
investmentValueWithCurrencyEffect: 151.6,
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
timeWeightedPerformanceInPercentage: 0,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0,
netWorth: 151.6,
totalAccountBalance: 0,
totalInvestment: 151.6,
totalInvestmentValueWithCurrencyEffect: 151.6,
value: 151.6,
valueWithCurrencyEffect: 151.6
});
expect(
portfolioSnapshot.historicalData[
portfolioSnapshot.historicalData.length - 1
]
).toEqual({
date: '2022-04-11',
investmentValueWithCurrencyEffect: 0,
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
timeWeightedPerformanceInPercentage: 0.13100263852242744,
timeWeightedPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744,
netPerformanceWithCurrencyEffect: 19.86,
netWorth: 0,
totalAccountBalance: 0,
totalInvestment: 0,
totalInvestmentValueWithCurrencyEffect: 0,
value: 0,
valueWithCurrencyEffect: 0
});
expect(portfolioSnapshot).toMatchObject({
currentValueInBaseCurrency: new Big('0'),
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'),
feeInBaseCurrency: new Big('0'),
firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'0.13100263852242744063'
),
grossPerformanceWithCurrencyEffect: new Big('19.86'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('19.86'),
netPerformancePercentage: new Big('0.13100263852242744063'),
netPerformancePercentageWithCurrencyEffectMap: {
max: new Big('0.13100263852242744063')
},
netPerformanceWithCurrencyEffectMap: {
max: new Big('19.86')
},
marketPrice: 87.8,
marketPriceInBaseCurrency: 87.8,
quantity: new Big('0'),
symbol: 'NOVN.SW',
tags: [],
timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2,
valueInBaseCurrency: new Big('0')
}
],
totalFeesWithCurrencyEffect: new Big('0'),
totalInterestWithCurrencyEffect: new Big('0'),
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0'),
totalLiabilitiesWithCurrencyEffect: new Big('0'),
totalValuablesWithCurrencyEffect: new Big('0')
});
expect(portfolioSnapshot.historicalData.at(-1)).toMatchObject(
expect.objectContaining({
netPerformance: 19.86,
netPerformanceInPercentage: 0.13100263852242744063,
netPerformanceInPercentageWithCurrencyEffect: 0.13100263852242744063,
netPerformanceWithCurrencyEffect: 19.86,
totalInvestmentValueWithCurrencyEffect: 0
})
);
expect(investments).toEqual([
{ date: '2022-03-07', investment: new Big('151.6') },
{ date: '2022-04-08', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2022-03-01', investment: 151.6 },
{ date: '2022-04-01', investment: -151.6 }
]);
});
});
});

894
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator-symbolmetrics-helper.ts

@ -0,0 +1,894 @@
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { SymbolMetrics } from '@ghostfolio/common/interfaces';
import { DateRangeTypes } from '@ghostfolio/common/types/date-range.type';
import { DataSource } from '@prisma/client';
import { Big } from 'big.js';
import { isBefore, addMilliseconds, format } from 'date-fns';
import { sortBy } from 'lodash';
import { getFactor } from '../../../../helper/portfolio.helper';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
import { PortfolioCalculatorSymbolMetricsHelperObject } from './portfolio-calculator-helper-object';
export class RoiPortfolioCalculatorSymbolMetricsHelper {
private ENABLE_LOGGING: boolean;
private baseCurrencySuffix = 'InBaseCurrency';
private chartDates: string[];
private marketSymbolMap: { [date: string]: { [symbol: string]: Big } };
public constructor(
ENABLE_LOGGING: boolean,
marketSymbolMap: { [date: string]: { [symbol: string]: Big } },
chartDates: string[]
) {
this.ENABLE_LOGGING = ENABLE_LOGGING;
this.marketSymbolMap = marketSymbolMap;
this.chartDates = chartDates;
}
public calculateNetPerformanceByDateRange(
start: Date,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
for (const dateRange of DateRangeTypes) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[
dateRange
] =
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[
rangeEndDateString
]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (symbolMetricsHelper.symbolMetrics
.netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
const investmentBasis = this.calculateInvestmentBasis(
symbolMetricsHelper,
rangeStartDateString,
rangeEndDateString
);
symbolMetricsHelper.symbolMetrics.netPerformancePercentageWithCurrencyEffectMap[
dateRange
] = investmentBasis.gt(0)
? symbolMetricsHelper.symbolMetrics.netPerformanceWithCurrencyEffectMap[
dateRange
].div(investmentBasis)
: new Big(0);
}
}
public handleOverallPerformanceCalculation(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
symbolMetricsHelper.symbolMetrics.grossPerformance =
symbolMetricsHelper.symbolMetrics.grossPerformance.minus(
symbolMetricsHelper.grossPerformanceAtStartDate
);
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.minus(
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect
);
symbolMetricsHelper.symbolMetrics.netPerformance =
symbolMetricsHelper.symbolMetrics.grossPerformance.minus(
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate)
);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment = new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactions
);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentWithCurrencyEffect =
new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
);
if (symbolMetricsHelper.symbolMetrics.timeWeightedInvestment.gt(0)) {
symbolMetricsHelper.symbolMetrics.netPerformancePercentage =
symbolMetricsHelper.symbolMetrics.netPerformance.div(
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment
);
symbolMetricsHelper.symbolMetrics.grossPerformancePercentage =
symbolMetricsHelper.symbolMetrics.grossPerformance.div(
symbolMetricsHelper.symbolMetrics.timeWeightedInvestment
);
symbolMetricsHelper.symbolMetrics.grossPerformancePercentageWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect.div(
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentWithCurrencyEffect
);
}
}
public processOrderMetrics(
orders: PortfolioOrderItem[],
i: number,
exchangeRates: { [dateString: string]: number },
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const order = orders[i];
this.writeOrderToLogIfNecessary(i, order);
symbolMetricsHelper.exchangeRateAtOrderDate = exchangeRates[order.date];
const value = order.quantity.gt(0)
? order.quantity.mul(order.unitPrice)
: new Big(0);
this.handleNoneBuyAndSellOrders(order, value, symbolMetricsHelper);
this.handleStartOrder(
order,
i,
orders,
symbolMetricsHelper.unitPriceAtStartDate
);
this.handleOrderFee(order, symbolMetricsHelper);
symbolMetricsHelper.unitPrice = this.getUnitPriceAndFillCurrencyDeviations(
order,
symbolMetricsHelper
);
if (order.unitPriceInBaseCurrency) {
symbolMetricsHelper.investmentValueBeforeTransaction =
symbolMetricsHelper.totalUnits.mul(order.unitPriceInBaseCurrency);
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect =
symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
}
this.handleInitialInvestmentValues(symbolMetricsHelper, i, order);
const { transactionInvestment, transactionInvestmentWithCurrencyEffect } =
this.handleBuyAndSellTranscation(order, symbolMetricsHelper);
this.logTransactionValuesIfRequested(
order,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.updateTotalInvestments(
symbolMetricsHelper,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.setInitialValueIfNecessary(
symbolMetricsHelper,
transactionInvestment,
transactionInvestmentWithCurrencyEffect
);
this.accumulateFees(symbolMetricsHelper, order);
symbolMetricsHelper.totalUnits = symbolMetricsHelper.totalUnits.plus(
order.quantity.mul(getFactor(order.type))
);
this.fillOrderUnitPricesIfMissing(order, symbolMetricsHelper);
const valueOfInvestment = symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentWithCurrencyEffect =
symbolMetricsHelper.totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const valueOfPositionsSold =
order.type === 'SELL'
? order.unitPriceInBaseCurrency.mul(order.quantity)
: new Big(0);
const valueOfPositionsSoldWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect.mul(order.quantity)
: new Big(0);
symbolMetricsHelper.totalValueOfPositionsSold =
symbolMetricsHelper.totalValueOfPositionsSold.plus(valueOfPositionsSold);
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect =
symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect.plus(
valueOfPositionsSoldWithCurrencyEffect
);
this.handlePerformanceCalculation(
valueOfInvestment,
symbolMetricsHelper,
valueOfInvestmentWithCurrencyEffect,
order
);
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulated[order.date] =
new Big(symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber());
symbolMetricsHelper.symbolMetrics.investmentValuesAccumulatedWithCurrencyEffect[
order.date
] = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[
order.date
] = (
symbolMetricsHelper.symbolMetrics.investmentValuesWithCurrencyEffect[
order.date
] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
}
public handlePerformanceCalculation(
valueOfInvestment: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
valueOfInvestmentWithCurrencyEffect: Big,
order: PortfolioOrderItem
) {
this.calculateGrossPerformance(
valueOfInvestment,
symbolMetricsHelper,
valueOfInvestmentWithCurrencyEffect
);
this.calculateNetPerformance(
symbolMetricsHelper,
order,
valueOfInvestment,
valueOfInvestmentWithCurrencyEffect
);
}
public calculateNetPerformance(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem,
valueOfInvestment: Big,
valueOfInvestmentWithCurrencyEffect: Big
) {
symbolMetricsHelper.symbolMetrics.currentValues[order.date] = new Big(
valueOfInvestment
);
symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[
order.date
] = new Big(valueOfInvestmentWithCurrencyEffect);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValues[order.date] =
new Big(symbolMetricsHelper.totalInvestmentFromBuyTransactions);
symbolMetricsHelper.symbolMetrics.timeWeightedInvestmentValuesWithCurrencyEffect[
order.date
] = new Big(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
);
symbolMetricsHelper.symbolMetrics.netPerformanceValues[order.date] =
symbolMetricsHelper.symbolMetrics.grossPerformance
.minus(symbolMetricsHelper.grossPerformanceAtStartDate)
.minus(
symbolMetricsHelper.fees.minus(symbolMetricsHelper.feesAtStartDate)
);
symbolMetricsHelper.symbolMetrics.netPerformanceValuesWithCurrencyEffect[
order.date
] = symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect
.minus(symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
symbolMetricsHelper.feesWithCurrencyEffect.minus(
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect
)
);
}
public calculateGrossPerformance(
valueOfInvestment: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
valueOfInvestmentWithCurrencyEffect: Big
) {
const newGrossPerformance = valueOfInvestment
.minus(symbolMetricsHelper.totalInvestmentFromBuyTransactions)
.plus(symbolMetricsHelper.totalValueOfPositionsSold)
.plus(
symbolMetricsHelper.symbolMetrics.totalDividend.mul(
symbolMetricsHelper.currentExchangeRate
)
)
.plus(
symbolMetricsHelper.symbolMetrics.totalInterest.mul(
symbolMetricsHelper.currentExchangeRate
)
);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect
)
.plus(symbolMetricsHelper.totalValueOfPositionsSoldWithCurrencyEffect)
.plus(symbolMetricsHelper.symbolMetrics.totalDividendInBaseCurrency)
.plus(symbolMetricsHelper.symbolMetrics.totalInterestInBaseCurrency);
symbolMetricsHelper.symbolMetrics.grossPerformance = newGrossPerformance;
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
}
public accumulateFees(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem
) {
symbolMetricsHelper.fees = symbolMetricsHelper.fees.plus(
order.feeInBaseCurrency ?? 0
);
symbolMetricsHelper.feesWithCurrencyEffect =
symbolMetricsHelper.feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
}
public updateTotalInvestments(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
symbolMetricsHelper.symbolMetrics.totalInvestment =
symbolMetricsHelper.symbolMetrics.totalInvestment.plus(
transactionInvestment
);
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
}
public setInitialValueIfNecessary(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
if (!symbolMetricsHelper.initialValue && transactionInvestment.gt(0)) {
symbolMetricsHelper.initialValue = transactionInvestment;
symbolMetricsHelper.initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
public logTransactionValuesIfRequested(
order: PortfolioOrderItem,
transactionInvestment: Big,
transactionInvestmentWithCurrencyEffect: Big
) {
if (this.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
}
public handleBuyAndSellTranscation(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
switch (order.type) {
case 'BUY':
return this.handleBuyTransaction(order, symbolMetricsHelper);
case 'SELL':
return this.handleSellTransaction(symbolMetricsHelper, order);
default:
return {
transactionInvestment: new Big(0),
transactionInvestmentWithCurrencyEffect: new Big(0)
};
}
}
public handleSellTransaction(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
order: PortfolioOrderItem
) {
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (symbolMetricsHelper.totalUnits.gt(0)) {
transactionInvestment = symbolMetricsHelper.symbolMetrics.totalInvestment
.div(symbolMetricsHelper.totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect
.div(symbolMetricsHelper.totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
return { transactionInvestment, transactionInvestmentWithCurrencyEffect };
}
public handleBuyTransaction(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
const transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
symbolMetricsHelper.totalQuantityFromBuyTransactions =
symbolMetricsHelper.totalQuantityFromBuyTransactions.plus(order.quantity);
symbolMetricsHelper.totalInvestmentFromBuyTransactions =
symbolMetricsHelper.totalInvestmentFromBuyTransactions.plus(
transactionInvestment
);
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect =
symbolMetricsHelper.totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
return { transactionInvestment, transactionInvestmentWithCurrencyEffect };
}
public handleInitialInvestmentValues(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
i: number,
order: PortfolioOrderItem
) {
if (
!symbolMetricsHelper.investmentAtStartDate &&
i >= symbolMetricsHelper.indexOfStartOrder
) {
symbolMetricsHelper.investmentAtStartDate = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()
);
symbolMetricsHelper.investmentAtStartDateWithCurrencyEffect = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
symbolMetricsHelper.valueAtStartDate = new Big(
symbolMetricsHelper.investmentValueBeforeTransaction.toNumber()
);
symbolMetricsHelper.valueAtStartDateWithCurrencyEffect = new Big(
symbolMetricsHelper.investmentValueBeforeTransactionWithCurrencyEffect.toNumber()
);
}
if (order.itemType === 'start') {
symbolMetricsHelper.feesAtStartDate = symbolMetricsHelper.fees;
symbolMetricsHelper.feesAtStartDateWithCurrencyEffect =
symbolMetricsHelper.feesWithCurrencyEffect;
symbolMetricsHelper.grossPerformanceAtStartDate =
symbolMetricsHelper.symbolMetrics.grossPerformance;
symbolMetricsHelper.grossPerformanceAtStartDateWithCurrencyEffect =
symbolMetricsHelper.symbolMetrics.grossPerformanceWithCurrencyEffect;
}
if (
i >= symbolMetricsHelper.indexOfStartOrder &&
!symbolMetricsHelper.initialValue
) {
if (
i === symbolMetricsHelper.indexOfStartOrder &&
!symbolMetricsHelper.symbolMetrics.totalInvestment.eq(0)
) {
symbolMetricsHelper.initialValue = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestment.toNumber()
);
symbolMetricsHelper.initialValueWithCurrencyEffect = new Big(
symbolMetricsHelper.symbolMetrics.totalInvestmentWithCurrencyEffect.toNumber()
);
}
}
}
public getSymbolMetricHelperObject(
exchangeRates: { [dateString: string]: number },
start: Date,
end: Date,
marketSymbolMap: { [date: string]: { [symbol: string]: Big } },
symbol: string
): PortfolioCalculatorSymbolMetricsHelperObject {
const symbolMetricsHelper =
new PortfolioCalculatorSymbolMetricsHelperObject();
symbolMetricsHelper.symbolMetrics = this.createEmptySymbolMetrics();
symbolMetricsHelper.currentExchangeRate =
exchangeRates[format(new Date(), DATE_FORMAT)];
symbolMetricsHelper.startDateString = format(start, DATE_FORMAT);
symbolMetricsHelper.endDateString = format(end, DATE_FORMAT);
symbolMetricsHelper.unitPriceAtStartDate =
marketSymbolMap[symbolMetricsHelper.startDateString]?.[symbol];
symbolMetricsHelper.unitPriceAtEndDate =
marketSymbolMap[symbolMetricsHelper.endDateString]?.[symbol];
symbolMetricsHelper.totalUnits = new Big(0);
return symbolMetricsHelper;
}
public getUnitPriceAndFillCurrencyDeviations(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const unitprice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitprice) {
order.unitPriceInBaseCurrency = unitprice.mul(
symbolMetricsHelper.currentExchangeRate ?? 1
);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitprice.mul(
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1
);
}
return unitprice;
}
public handleOrderFee(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(
symbolMetricsHelper.currentExchangeRate ?? 1
);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
symbolMetricsHelper.exchangeRateAtOrderDate ?? 1
);
}
}
public handleStartOrder(
order: PortfolioOrderItem,
i: number,
orders: PortfolioOrderItem[],
unitPriceAtStartDate: Big.Big
) {
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
i === 0 ? orders[i + 1]?.unitPrice : unitPriceAtStartDate;
}
}
public handleNoneBuyAndSellOrders(
order: PortfolioOrderItem,
value: Big.Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
const symbolMetricsKey = this.getSymbolMetricsKeyFromOrderType(order.type);
if (symbolMetricsKey) {
this.calculateMetrics(value, symbolMetricsHelper, symbolMetricsKey);
}
}
public getSymbolMetricsKeyFromOrderType(
orderType: PortfolioOrderItem['type']
): keyof SymbolMetrics {
switch (orderType) {
case 'DIVIDEND':
return 'totalDividend';
case 'INTEREST':
return 'totalInterest';
case 'ITEM':
return 'totalValuables';
case 'LIABILITY':
return 'totalLiabilities';
default:
return undefined;
}
}
public calculateMetrics(
value: Big,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
key: keyof SymbolMetrics
) {
const stringKey = key.toString();
symbolMetricsHelper.symbolMetrics[stringKey] = (
symbolMetricsHelper.symbolMetrics[stringKey] as Big
).plus(value);
if (
Object.keys(symbolMetricsHelper.symbolMetrics).includes(
stringKey + this.baseCurrencySuffix
)
) {
symbolMetricsHelper.symbolMetrics[stringKey + this.baseCurrencySuffix] = (
symbolMetricsHelper.symbolMetrics[
stringKey + this.baseCurrencySuffix
] as Big
).plus(value.mul(symbolMetricsHelper.exchangeRateAtOrderDate ?? 1));
} else {
throw new Error(
`Key ${stringKey + this.baseCurrencySuffix} not found in symbolMetrics`
);
}
}
public writeOrderToLogIfNecessary(i: number, order: PortfolioOrderItem) {
if (this.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
}
public fillOrdersAndSortByTime(
orders: PortfolioOrderItem[],
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
chartDateMap: { [date: string]: boolean },
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
symbol: string,
dataSource: DataSource
) {
this.fillOrdersByDate(orders, symbolMetricsHelper.ordersByDate);
this.chartDates ??= Object.keys(chartDateMap).sort();
this.fillOrdersWithDatesFromChartDate(
symbolMetricsHelper,
marketSymbolMap,
symbol,
orders,
dataSource
);
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = this.sortOrdersByTime(orders);
return orders;
}
public sortOrdersByTime(orders: PortfolioOrderItem[]) {
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
return orders;
}
public fillOrdersWithDatesFromChartDate(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
symbol: string,
orders: PortfolioOrderItem[],
dataSource: DataSource
) {
let lastUnitPrice: Big;
for (const dateString of this.chartDates) {
if (dateString < symbolMetricsHelper.startDateString) {
continue;
} else if (dateString > symbolMetricsHelper.endDateString) {
break;
}
if (symbolMetricsHelper.ordersByDate[dateString]?.length > 0) {
for (const order of symbolMetricsHelper.ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push(
this.getFakeOrder(
dateString,
dataSource,
symbol,
marketSymbolMap,
lastUnitPrice
)
);
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
return lastUnitPrice;
}
public getFakeOrder(
dateString: string,
dataSource: DataSource,
symbol: string,
marketSymbolMap: { [date: string]: { [symbol: string]: Big.Big } },
lastUnitPrice: Big.Big
): PortfolioOrderItem {
return {
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
};
}
public fillOrdersByDate(
orders: PortfolioOrderItem[],
ordersByDate: { [date: string]: PortfolioOrderItem[] }
) {
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
}
public addSyntheticStartAndEndOrder(
orders: PortfolioOrderItem[],
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
dataSource: DataSource,
symbol: string
) {
orders.push({
date: symbolMetricsHelper.startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: symbolMetricsHelper.unitPriceAtStartDate
});
orders.push({
date: symbolMetricsHelper.endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: symbolMetricsHelper.unitPriceAtEndDate
});
}
public hasNoUnitPriceAtEndOrStartDate(
unitPriceAtEndDate: Big.Big,
unitPriceAtStartDate: Big.Big,
orders: PortfolioOrderItem[],
start: Date
) {
return (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(new Date(orders[0].date), start))
);
}
public createEmptySymbolMetrics(): SymbolMetrics {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
unitPrices: {},
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
private fillOrderUnitPricesIfMissing(
order: PortfolioOrderItem,
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject
) {
order.unitPriceInBaseCurrency ??= this.marketSymbolMap[order.date]?.[
order.SymbolProfile.symbol
].mul(symbolMetricsHelper.currentExchangeRate);
order.unitPriceInBaseCurrencyWithCurrencyEffect ??= this.marketSymbolMap[
order.date
]?.[order.SymbolProfile.symbol].mul(
symbolMetricsHelper.exchangeRateAtOrderDate
);
}
private calculateInvestmentBasis(
symbolMetricsHelper: PortfolioCalculatorSymbolMetricsHelperObject,
rangeStartDateString: string,
rangeEndDateString: string
) {
let investmentBasis = this.getValueOrZero(
symbolMetricsHelper.symbolMetrics.currentValuesWithCurrencyEffect[
rangeStartDateString
]
).plus(
this.getValueOrZero(
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString]
)?.minus(
this.getValueOrZero(
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentValuesWithCurrencyEffect[
rangeStartDateString
]
)
)
);
if (!investmentBasis.gt(0)) {
investmentBasis =
symbolMetricsHelper.symbolMetrics
.timeWeightedInvestmentValuesWithCurrencyEffect[rangeEndDateString];
}
return investmentBasis;
}
private getValueOrZero(value: Big | undefined) {
return value ?? new Big(0);
}
}

261
apps/api/src/app/portfolio/calculator/roi/portfolio-calculator.ts

@ -1,29 +1,272 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { cloneDeep } from 'lodash';
import { PortfolioOrderItem } from '../../interfaces/portfolio-order-item.interface';
import { RoiPortfolioCalculatorSymbolMetricsHelper } from './portfolio-calculator-symbolmetrics-helper';
export class RoiPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.');
}
private chartDates: string[];
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
@LogPerformance
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
({
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
} = this.calculatePositionMetrics(
currentPosition,
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
));
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL', 'STAKE'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getSymbolMetrics({}: {
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics {
throw new Error('Method not implemented.');
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
const symbolMetricsHelperClass =
new RoiPortfolioCalculatorSymbolMetricsHelper(
PortfolioCalculator.ENABLE_LOGGING,
marketSymbolMap,
this.chartDates
);
const symbolMetricsHelper =
symbolMetricsHelperClass.getSymbolMetricHelperObject(
exchangeRates,
start,
end,
marketSymbolMap,
symbol
);
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (!orders.length) {
return symbolMetricsHelper.symbolMetrics;
}
if (
symbolMetricsHelperClass.hasNoUnitPriceAtEndOrStartDate(
symbolMetricsHelper.unitPriceAtEndDate,
symbolMetricsHelper.unitPriceAtStartDate,
orders,
start
)
) {
symbolMetricsHelper.symbolMetrics.hasErrors = true;
return symbolMetricsHelper.symbolMetrics;
}
symbolMetricsHelperClass.addSyntheticStartAndEndOrder(
orders,
symbolMetricsHelper,
dataSource,
symbol
);
orders = symbolMetricsHelperClass.fillOrdersAndSortByTime(
orders,
symbolMetricsHelper,
chartDateMap,
marketSymbolMap,
symbol,
dataSource
);
symbolMetricsHelper.indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
symbolMetricsHelper.indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
for (let i = 0; i < orders.length; i++) {
symbolMetricsHelperClass.processOrderMetrics(
orders,
i,
exchangeRates,
symbolMetricsHelper
);
if (i === symbolMetricsHelper.indexOfEndOrder) {
break;
}
}
symbolMetricsHelperClass.handleOverallPerformanceCalculation(
symbolMetricsHelper
);
symbolMetricsHelperClass.calculateNetPerformanceByDateRange(
start,
symbolMetricsHelper
);
return symbolMetricsHelper.symbolMetrics;
}
protected getPerformanceCalculationType() {
return PerformanceCalculationType.ROI;
}
private calculatePositionMetrics(
currentPosition: TimelinePosition,
totalFeesWithCurrencyEffect: Big,
currentValueInBaseCurrency: Big,
hasErrors: boolean,
totalInvestment: Big,
totalInvestmentWithCurrencyEffect: Big,
grossPerformance: Big,
grossPerformanceWithCurrencyEffect: Big,
netPerformance: Big,
totalTimeWeightedInvestment: Big,
totalTimeWeightedInvestmentWithCurrencyEffect: Big
) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
return {
totalFeesWithCurrencyEffect,
currentValueInBaseCurrency,
hasErrors,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance,
grossPerformanceWithCurrencyEffect,
netPerformance,
totalTimeWeightedInvestment,
totalTimeWeightedInvestmentWithCurrencyEffect
};
}
}

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;
}

38
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';
@ -159,6 +162,23 @@ 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 : [];
portfolioPosition.tags = hasDetails ? portfolioPosition.tags : [];
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -225,6 +245,7 @@ export class PortfolioController {
currency: hasDetails ? portfolioPosition.currency : undefined,
holdings: hasDetails ? portfolioPosition.holdings : [],
markets: hasDetails ? portfolioPosition.markets : undefined,
tags: hasDetails ? portfolioPosition.tags : [],
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
: undefined,
@ -417,6 +438,14 @@ export class PortfolioController {
filterByTags
});
const { performance } = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
withExcludedAccounts: false,
userId: this.request.user.id
});
const { holdings } = await this.portfolioService.getDetails({
dateRange,
filters,
@ -424,7 +453,7 @@ export class PortfolioController {
userId: this.request.user.id
});
return { holdings: Object.values(holdings) };
return { holdings: Object.values(holdings), performance };
}
@Get('investments')
@ -502,6 +531,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,
@ -510,10 +540,8 @@ export class PortfolioController {
@Query('range') dateRange: DateRange = 'max',
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
@Query('withExcludedAccounts') withExcludedAccounts = false
): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,

618
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 { AssetClassClusterRiskEquity } from '@ghostfolio/api/models/rules/asset-class-cluster-risk/equity';
@ -86,7 +87,7 @@ import {
parseISO,
set
} from 'date-fns';
import { isEmpty } from 'lodash';
import { isEmpty, uniqBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
@ -115,6 +116,7 @@ export class PortfolioService {
private readonly userService: UserService
) {}
@LogPerformance
public async getAccounts({
filters,
userId,
@ -205,6 +207,7 @@ export class PortfolioService {
});
}
@LogPerformance
public async getAccountsWithAggregations({
filters,
userId,
@ -241,6 +244,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getDividends({
activities,
groupBy
@ -248,16 +252,18 @@ export class PortfolioService {
activities: Activity[];
groupBy?: GroupBy;
}): Promise<InvestmentItem[]> {
let dividends = activities.map(({ currency, date, value }) => {
return {
date: format(date, DATE_FORMAT),
investment: this.exchangeRateDataService.toCurrency(
value,
currency,
this.getUserCurrency()
)
};
});
let dividends = activities.map(
({ currency, date, value, SymbolProfile }) => {
return {
date: format(date, DATE_FORMAT),
investment: this.exchangeRateDataService.toCurrency(
value,
currency ?? SymbolProfile.currency,
this.getUserCurrency()
)
};
}
);
if (groupBy) {
dividends = this.getDividendsByGroup({ dividends, groupBy });
@ -266,6 +272,7 @@ export class PortfolioService {
return dividends;
}
@LogPerformance
public async getInvestments({
dateRange,
filters,
@ -344,6 +351,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getDetails({
dateRange = 'max',
filters,
@ -492,13 +500,17 @@ export class PortfolioService {
}));
}
const tagsInternal = tags.concat(
symbolProfiles.find((sp) => sp.symbol === symbol)?.tags ?? []
);
holdings[symbol] = {
currency,
markets,
marketsAdvanced,
marketPrice,
symbol,
tags,
tags: uniqBy(tagsInternal, 'id'),
transactionCount,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
@ -635,6 +647,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getHolding(
aDataSource: DataSource,
aImpersonationId: string,
@ -655,6 +668,7 @@ export class PortfolioService {
activities: [],
averagePrice: undefined,
dataProviderInfo: undefined,
stakeRewards: undefined,
dividendInBaseCurrency: undefined,
dividendYieldPercent: undefined,
dividendYieldPercentWithCurrencyEffect: undefined,
@ -745,6 +759,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',
@ -838,6 +862,7 @@ export class PortfolioService {
activities: activitiesOfHolding,
averagePrice: averagePrice.toNumber(),
dataProviderInfo: portfolioCalculator.getDataProviderInfos()?.[0],
stakeRewards: stakeRewards.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
dividendYieldPercent: dividendYieldPercent.toNumber(),
dividendYieldPercentWithCurrencyEffect:
@ -945,6 +970,7 @@ export class PortfolioService {
activities: [],
averagePrice: 0,
dataProviderInfo: undefined,
stakeRewards: 0,
dividendInBaseCurrency: 0,
dividendYieldPercent: 0,
dividendYieldPercentWithCurrencyEffect: 0,
@ -974,6 +1000,7 @@ export class PortfolioService {
}
}
@LogPerformance
public async getHoldings({
dateRange = 'max',
filters,
@ -1104,6 +1131,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:
@ -1123,6 +1151,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getPerformance({
dateRange = 'max',
filters,
@ -1178,17 +1207,11 @@ export class PortfolioService {
currency: userCurrency
});
const { errors, hasErrors, historicalData } =
await portfolioCalculator.getSnapshot();
const { endDate, startDate } = getIntervalFromDateRange(dateRange);
const { chart } = await portfolioCalculator.getPerformance({
end: endDate,
start: startDate
});
const range = { end: endDate, start: startDate };
const {
chart,
netPerformance,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
@ -1196,21 +1219,12 @@ export class PortfolioService {
netWorth,
totalInvestment,
valueWithCurrencyEffect
} = chart?.at(-1) ?? {
netPerformance: 0,
netPerformanceInPercentage: 0,
netPerformanceInPercentageWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
netWorth: 0,
totalInvestment: 0,
valueWithCurrencyEffect: 0
};
} = await portfolioCalculator.getPerformance(range);
return {
chart,
errors,
hasErrors,
firstOrderDate: parseDate(historicalData[0]?.date),
hasErrors: false,
firstOrderDate: parseDate(chart[0]?.date),
performance: {
netPerformance,
netPerformanceWithCurrencyEffect,
@ -1224,6 +1238,7 @@ export class PortfolioService {
};
}
@LogPerformance
public async getReport(
impersonationId: string
): Promise<PortfolioReportResponse> {
@ -1382,6 +1397,7 @@ export class PortfolioService {
return { rules, statistics: this.getReportStatistics(rules) };
}
@LogPerformance
public async updateTags({
dataSource,
impersonationId,
@ -1396,7 +1412,6 @@ export class PortfolioService {
userId: string;
}) {
userId = await this.getUserId(impersonationId, userId);
await this.orderService.assignTags({ dataSource, symbol, tags, userId });
}
@ -1589,68 +1604,7 @@ export class PortfolioService {
return cashPositions;
}
private getDividendsByGroup({
dividends,
groupBy
}: {
dividends: InvestmentItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
if (dividends.length === 0) {
return [];
}
const dividendsByGroup: InvestmentItem[] = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
for (const [index, dividend] of dividends.entries()) {
if (
isSameYear(parseDate(dividend.date), currentDate) &&
(groupBy === 'year' ||
isSameMonth(parseDate(dividend.date), currentDate))
) {
// Same group: Add up dividends
investmentByGroup = investmentByGroup.plus(dividend.investment);
} else {
// New group: Store previous group and reset
if (currentDate) {
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
currentDate = parseDate(dividend.date);
investmentByGroup = new Big(dividend.investment);
}
if (index === dividends.length - 1) {
// Store current month (latest order)
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
}
return dividendsByGroup;
}
@LogPerformance
private getEmergencyFundHoldingsValueInBaseCurrency({
holdings
}: {
@ -1676,128 +1630,7 @@ export class PortfolioService {
return valueInBaseCurrencyOfEmergencyFundHoldings.toNumber();
}
private getInitialCashPosition({
balance,
currency
}: {
balance: number;
currency: string;
}): PortfolioPosition {
return {
currency,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
dividend: 0,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
valueInBaseCurrency: balance
};
}
private getMarkets({
assetProfile
}: {
assetProfile: EnhancedSymbolProfile;
}) {
const markets = {
[UNKNOWN_KEY]: 0,
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
const marketsAdvanced = {
[UNKNOWN_KEY]: 0,
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
};
if (assetProfile.countries.length > 0) {
for (const country of assetProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
}
markets[UNKNOWN_KEY] = new Big(1)
.minus(markets.developedMarkets)
.minus(markets.emergingMarkets)
.minus(markets.otherMarkets)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(1)
.minus(marketsAdvanced.asiaPacific)
.minus(marketsAdvanced.emergingMarkets)
.minus(marketsAdvanced.europe)
.minus(marketsAdvanced.japan)
.minus(marketsAdvanced.northAmerica)
.minus(marketsAdvanced.otherMarkets)
.toNumber();
return { markets, marketsAdvanced };
}
@LogPerformance
private getReportStatistics(
evaluatedRules: PortfolioReportResponse['rules']
): PortfolioReportResponse['statistics'] {
@ -1816,6 +1649,7 @@ export class PortfolioService {
return { rulesActiveCount, rulesFulfilledCount };
}
@LogPerformance
private getStreaks({
investments,
savingsRate
@ -1838,6 +1672,7 @@ export class PortfolioService {
return { currentStreak, longestStreak };
}
@LogPerformance
private async getSummary({
balanceInBaseCurrency,
emergencyFundHoldingsValueInBaseCurrency,
@ -1863,7 +1698,6 @@ export class PortfolioService {
userId,
withExcludedAccounts: true
});
const excludedActivities: Activity[] = [];
const nonExcludedActivities: Activity[] = [];
@ -1926,7 +1760,9 @@ export class PortfolioService {
.plus(emergencyFundHoldingsValueInBaseCurrency)
.toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell);
const committedFunds = new Big(totalBuy)
.minus(totalSell)
.minus(dividendInBaseCurrency);
const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency,
@ -1946,7 +1782,6 @@ export class PortfolioService {
currency: userCurrency,
withExcludedAccounts: true
});
const excludedBalanceInBaseCurrency = new Big(
cashDetailsWithExcludedAccounts.balanceInBaseCurrency
).minus(balanceInBaseCurrency);
@ -1955,12 +1790,9 @@ export class PortfolioService {
.plus(totalOfExcludedActivities)
.toNumber();
const netWorth = new Big(balanceInBaseCurrency)
.plus(currentValueInBaseCurrency)
.plus(valuables)
.plus(excludedAccountsAndActivities)
.minus(liabilities)
.toNumber();
const netWorth = await portfolioCalculator
.getUnfilteredNetWorth(this.getUserCurrency())
.then((value) => value.toNumber());
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
@ -2023,85 +1855,24 @@ export class PortfolioService {
};
}
private getSumOfActivityType({
@LogPerformance
private async getValueOfAccountsAndPlatforms({
activities,
activityType,
userCurrency
filters = [],
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts = false
}: {
activities: Activity[];
activityType: ActivityType;
filters?: Filter[];
portfolioItemsNow: Record<string, TimelinePosition>;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}) {
return getSum(
activities
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
)
);
})
);
}
private getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings
}: {
emergencyFundHoldingsValueInBaseCurrency: number;
userSettings: UserSettings;
}) {
return new Big(
Math.max(
emergencyFundHoldingsValueInBaseCurrency,
userSettings?.emergencyFund ?? 0
)
);
}
private getUserCurrency(aUser?: UserWithSettings) {
return (
aUser?.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getUserPerformanceCalculationType(
aUser: UserWithSettings
): PerformanceCalculationType {
return aUser?.Settings?.settings.performanceCalculationType;
}
private async getValueOfAccountsAndPlatforms({
activities,
filters = [],
portfolioItemsNow,
userCurrency,
userId,
withExcludedAccounts = false
}: {
activities: Activity[];
filters?: Filter[];
portfolioItemsNow: Record<string, TimelinePosition>;
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
}) {
const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {};
const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {};
let currentAccounts: (Account & {
Order?: Order[];
@ -2216,4 +1987,251 @@ export class PortfolioService {
return { accounts, platforms };
}
@LogPerformance
private getSumOfActivityType({
activities,
activityType,
userCurrency
}: {
activities: Activity[];
activityType: ActivityType;
userCurrency: string;
}) {
return getSum(
activities
.filter(({ isDraft, type }) => {
return isDraft === false && type === activityType;
})
.map(({ quantity, SymbolProfile, unitPrice }) => {
return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
userCurrency
)
);
})
);
}
private getInitialCashPosition({
balance,
currency
}: {
balance: number;
currency: string;
}): PortfolioPosition {
return {
currency,
allocationInPercentage: 0,
assetClass: AssetClass.LIQUIDITY,
assetSubClass: AssetSubClass.CASH,
countries: [],
dataSource: undefined,
dateOfFirstActivity: undefined,
dividend: 0,
grossPerformance: 0,
grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
holdings: [],
investment: balance,
marketPrice: 0,
name: currency,
netPerformance: 0,
netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
valueInBaseCurrency: balance
};
}
private getDividendsByGroup({
dividends,
groupBy
}: {
dividends: InvestmentItem[];
groupBy: GroupBy;
}): InvestmentItem[] {
if (dividends.length === 0) {
return [];
}
const dividendsByGroup: InvestmentItem[] = [];
let currentDate: Date;
let investmentByGroup = new Big(0);
for (const [index, dividend] of dividends.entries()) {
if (
isSameYear(parseDate(dividend.date), currentDate) &&
(groupBy === 'year' ||
isSameMonth(parseDate(dividend.date), currentDate))
) {
// Same group: Add up dividends
investmentByGroup = investmentByGroup.plus(dividend.investment);
} else {
// New group: Store previous group and reset
if (currentDate) {
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
currentDate = parseDate(dividend.date);
investmentByGroup = new Big(dividend.investment);
}
if (index === dividends.length - 1) {
// Store current month (latest order)
dividendsByGroup.push({
date: format(
set(currentDate, {
date: 1,
month: groupBy === 'year' ? 0 : currentDate.getMonth()
}),
DATE_FORMAT
),
investment: investmentByGroup.toNumber()
});
}
}
return dividendsByGroup;
}
private getMarkets({
assetProfile
}: {
assetProfile: EnhancedSymbolProfile;
}) {
const markets = {
[UNKNOWN_KEY]: 0,
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
const marketsAdvanced = {
[UNKNOWN_KEY]: 0,
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
};
if (assetProfile.countries.length > 0) {
for (const country of assetProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
}
markets[UNKNOWN_KEY] = new Big(1)
.minus(markets.developedMarkets)
.minus(markets.emergingMarkets)
.minus(markets.otherMarkets)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(1)
.minus(marketsAdvanced.asiaPacific)
.minus(marketsAdvanced.emergingMarkets)
.minus(marketsAdvanced.europe)
.minus(marketsAdvanced.japan)
.minus(marketsAdvanced.northAmerica)
.minus(marketsAdvanced.otherMarkets)
.toNumber();
return { markets, marketsAdvanced };
}
private getTotalEmergencyFund({
emergencyFundHoldingsValueInBaseCurrency,
userSettings
}: {
emergencyFundHoldingsValueInBaseCurrency: number;
userSettings: UserSettings;
}) {
return new Big(
Math.max(
emergencyFundHoldingsValueInBaseCurrency,
userSettings?.emergencyFund ?? 0
)
);
}
private getUserCurrency(aUser?: UserWithSettings) {
return (
aUser?.Settings?.settings.baseCurrency ??
this.request.user?.Settings?.settings.baseCurrency ??
DEFAULT_CURRENCY
);
}
private async getUserId(aImpersonationId: string, aUserId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(aImpersonationId);
return impersonationUserId || aUserId;
}
private getUserPerformanceCalculationType(
aUser: UserWithSettings
): PerformanceCalculationType {
return aUser?.Settings?.settings.performanceCalculationType;
}
}

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

@ -0,0 +1,82 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { Prisma, Tag } from '@prisma/client';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async createTag(data: Prisma.TagCreateInput) {
return this.prismaService.tag.create({
data
});
}
public async deleteTag(where: Prisma.TagWhereUniqueInput): Promise<Tag> {
return this.prismaService.tag.delete({ where });
}
public async getTag(
tagWhereUniqueInput: Prisma.TagWhereUniqueInput
): Promise<Tag> {
return this.prismaService.tag.findUnique({
where: tagWhereUniqueInput
});
}
public async getTags({
cursor,
orderBy,
skip,
take,
where
}: {
cursor?: Prisma.TagWhereUniqueInput;
orderBy?: Prisma.TagOrderByWithRelationInput;
skip?: number;
take?: number;
where?: Prisma.TagWhereInput;
} = {}) {
return this.prismaService.tag.findMany({
cursor,
orderBy,
skip,
take,
where
});
}
public async getTagsWithActivityCount() {
const tagsWithOrderCount = await this.prismaService.tag.findMany({
include: {
_count: {
select: { orders: true, symbolProfile: true }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
activityCount: _count.orders,
holdingCount: _count.symbolProfile
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

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

@ -6,6 +6,7 @@ import type {
HoldingsViewMode,
ViewMode
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import {
IsArray,
@ -37,6 +38,9 @@ export class UpdateUserSettingDto {
@IsIn([
'1d',
'1w',
'1m',
'3m',
'1y',
'5y',
'max',
@ -114,4 +118,8 @@ export class UpdateUserSettingDto {
@IsOptional()
xRayRules?: XRayRulesSettings;
@IsIn(['TWR', 'ROI', 'ROAI', 'MWR'] as PerformanceCalculationType[])
@IsOptional()
performanceCalculationType?: PerformanceCalculationType;
}

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

@ -183,6 +183,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

@ -14,7 +14,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',
@ -94,10 +93,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return {};
});
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 {
@ -397,6 +398,7 @@ export class DataProviderService {
return result;
}
@LogPerformance
public async getQuotes({
items,
requestTimeout,
@ -530,6 +532,8 @@ export class DataProviderService {
}
response[symbol] = dataProviderResponse;
const quotesCacheTTL =
this.getAppropriateCacheTTL(dataProviderResponse);
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
@ -537,7 +541,7 @@ export class DataProviderService {
dataSource: DataSource[dataSource]
}),
JSON.stringify(response[symbol]),
this.configurationService.get('CACHE_QUOTES_TTL')
quotesCacheTTL
);
for (const {
@ -620,6 +624,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

@ -13,6 +13,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,
@ -147,18 +148,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: {
@ -155,7 +158,6 @@ export class MarketDataService {
where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> {
const { data, where } = params;
return this.prismaService.marketData.upsert({
where,
create: {
@ -179,7 +181,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,
@ -202,7 +204,6 @@ export class MarketDataService {
});
}
);
return this.prismaService.$transaction(upsertPromises);
return await Promise.all(upsertPromises);
}
}

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

@ -1,6 +1,9 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { AssetProfileDelistedError } from '@ghostfolio/api/services/data-provider/errors/asset-profile-delisted.error';
import { 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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import {
@ -8,15 +11,17 @@ import {
DEFAULT_PROCESSOR_GATHER_ASSET_PROFILE_CONCURRENCY,
DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_CONCURRENCY,
GATHER_ASSET_PROFILE_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME
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,
@ -24,7 +29,9 @@ import {
getMonth,
getYear,
isBefore,
parseISO
parseISO,
eachDayOfInterval,
isEqual
} from 'date-fns';
import { DataGatheringService } from './data-gathering.service';
@ -194,4 +201,166 @@ export class DataGatheringProcessor {
throw 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>) {
const { dataSource, date, symbol } = job.data;
try {
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({
assetProfileIdentifiers: [{ 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) {
if (error instanceof AssetProfileDelistedError) {
await this.symbolProfileService.updateSymbolProfile(
{
dataSource,
symbol
},
{
isActive: false
}
);
Logger.log(
`Historical market data gathering has been discarded for ${symbol} (${dataSource})`,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
return job.discard();
}
Logger.error(
error,
`DataGatheringProcessor (${GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME})`
);
throw error;
}
}
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,
@ -297,6 +317,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 getAllActiveAssetProfileIdentifiers(): 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;
}
}

56
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,31 +11,19 @@ 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()
export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {}
public async add(
assetProfile: Prisma.SymbolProfileCreateInput
): Promise<SymbolProfile | never> {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
}
public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({
where: { id }
});
}
@LogPerformance
public async getActiveSymbolProfilesByUserSubscription({
withUserSubscription = false
}: {
@ -70,6 +59,7 @@ export class SymbolProfileService {
});
}
@LogPerformance
public async getSymbolProfiles(
aAssetProfileIdentifiers: AssetProfileIdentifier[]
): Promise<EnhancedSymbolProfile[]> {
@ -86,6 +76,7 @@ export class SymbolProfileService {
select: { date: true },
take: 1
},
tags: true,
SymbolProfileOverrides: true
},
where: {
@ -102,6 +93,24 @@ export class SymbolProfileService {
});
}
public async add(
assetProfile: Prisma.SymbolProfileCreateInput
): Promise<SymbolProfile | never> {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
}
public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({
where: { id }
});
}
public async getSymbolProfilesByIds(
symbolProfileIds: string[]
): Promise<EnhancedSymbolProfile[]> {
@ -111,7 +120,8 @@ export class SymbolProfileService {
_count: {
select: { activities: true }
},
SymbolProfileOverrides: true
SymbolProfileOverrides: true,
tags: true
},
where: {
id: {
@ -155,6 +165,7 @@ export class SymbolProfileService {
holdings,
isActive,
name,
tags,
scraperConfiguration,
sectors,
symbolMapping,
@ -172,6 +183,7 @@ export class SymbolProfileService {
holdings,
isActive,
name,
tags,
scraperConfiguration,
sectors,
symbolMapping,
@ -188,6 +200,7 @@ export class SymbolProfileService {
activities?: {
date: Date;
}[];
tags?: Tag[];
SymbolProfileOverrides: SymbolProfileOverrides;
})[]
): EnhancedSymbolProfile[] {
@ -206,7 +219,8 @@ export class SymbolProfileService {
sectors: this.getSectors(
symbolProfile?.sectors as unknown as Prisma.JsonArray
),
symbolMapping: this.getSymbolMapping(symbolProfile)
symbolMapping: this.getSymbolMapping(symbolProfile),
tags: symbolProfile?.tags
};
item.activitiesCount = symbolProfile._count.activities;

7
apps/api/src/services/tag/tag.service.ts

@ -56,7 +56,8 @@ export class TagService {
where: {
userId
}
}
},
symbolProfile: {}
}
}
},
@ -75,11 +76,11 @@ export class TagService {
}
});
return tags.map(({ _count, id, name, userId }) => ({
return tags.map(({ id, name, userId }) => ({
id,
name,
userId,
isUsed: _count.activities > 0
isUsed: true
}));
}

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

@ -261,6 +261,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

@ -231,6 +231,9 @@
>Gather All Historical Market 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>

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

@ -15,6 +15,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
import { COMMA, ENTER } from '@angular/cdk/keycodes';
import { HttpErrorResponse } from '@angular/common/http';
import {
ChangeDetectionStrategy,
@ -34,6 +35,7 @@ import {
ValidationErrors,
Validators
} from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
@ -41,7 +43,8 @@ import {
AssetClass,
AssetSubClass,
MarketData,
SymbolProfile
SymbolProfile,
Tag
} from '@prisma/client';
import { format } from 'date-fns';
import { StatusCodes } from 'http-status-codes';
@ -60,6 +63,8 @@ import { AssetProfileDialogParams } from './interfaces/interfaces';
standalone: false
})
export class AssetProfileDialog implements OnDestroy, OnInit {
@ViewChild('tagInput') tagInput: ElementRef<HTMLInputElement>;
public separatorKeysCodes: number[] = [ENTER, COMMA];
private static readonly HISTORICAL_DATA_TEMPLATE = `date;marketPrice\n${format(
new Date(),
DATE_FORMAT
@ -91,6 +96,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}),
isActive: [true],
name: ['', Validators.required],
tags: new FormControl<Tag[]>(undefined),
tagsDisconnected: new FormControl<Tag[]>(undefined),
scraperConfiguration: this.formBuilder.group({
defaultMarketPrice: null,
headers: JSON.stringify({}),
@ -149,6 +156,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
[name: string]: { name: string; value: number };
};
public HoldingTags: { id: string; name: string; userId: string }[];
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -188,6 +197,18 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public initialize() {
this.dataService
.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.historicalDataItems = undefined;
this.userService.stateChanged
@ -247,6 +268,8 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
assetClass: this.assetProfile.assetClass ?? null,
assetSubClass: this.assetProfile.assetSubClass ?? null,
comment: this.assetProfile?.comment ?? '',
tags: this.assetProfile?.tags ?? [],
tagsDisconnected: [],
countries: JSON.stringify(
this.assetProfile?.countries?.map(({ code, weight }) => {
return { code, weight };
@ -272,7 +295,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();
@ -316,6 +339,16 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
.subscribe();
}
public onGatherSymbolMissingOnly({
dataSource,
symbol
}: AssetProfileIdentifier) {
this.adminService
.gatherSymbolMissingOnly({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe();
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.initialize();
@ -397,10 +430,12 @@ 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,
tagsDisconnected: this.assetProfileForm.get('tagsDisconnected').value,
currency: this.assetProfileForm.get('currency').value,
isActive: this.assetProfileForm.get('isActive').value,
name: this.assetProfileForm.get('name').value,
url: this.assetProfileForm.get('url').value || null
url: this.assetProfileForm.get('url').value
};
try {
@ -569,6 +604,30 @@ 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.controls['tagsDisconnected'].setValue([
...(this.assetProfileForm.controls['tagsDisconnected'].value ?? []),
aTag
]);
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

@ -34,6 +34,19 @@
[disabled]="
assetProfileForm.dirty || !assetProfileForm.controls.isActive.value
"
(click)="
onGatherSymbolMissingOnly({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>
<ng-container i18n>Gather Missing Historical Data</ng-container>
</button>
<button
mat-menu-item
type="button"
[disabled]="assetProfileForm.dirty"
(click)="
onGatherProfileDataBySymbol({
dataSource: data.dataSource,
@ -325,6 +338,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

@ -11,8 +11,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 { MatExpansionModule } from '@angular/material/expansion';
import { MatInputModule } from '@angular/material/input';
@ -32,6 +34,8 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
GfHistoricalMarketDataEditorComponent,
GfLineChartComponent,
GfPortfolioProportionChartComponent,
MatAutocompleteModule,
MatChipsModule,
GfSymbolAutocompleteComponent,
GfValueComponent,
MatButtonModule,

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

@ -48,6 +48,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 *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></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>();

9
apps/client/src/app/components/benchmark-comparator/benchmark-comparator.component.html

@ -3,7 +3,14 @@
<div
class="align-items-center d-flex flex-grow-1 h5 mb-0 py-2 text-truncate"
>
<span i18n>Performance</span>
<span i18n
>Performance
{{
user?.settings?.performanceCalculationType === 'ROI'
? '(Time-Weighted)'
: ''
}}</span
>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}

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;
}
}

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

@ -188,15 +188,19 @@ export class HeaderComponent implements OnChanges {
for (const filter of filters) {
if (filter.type === 'ACCOUNT') {
userSetting['filters.accounts'] = filter.id ? [filter.id] : null;
userSetting['filters.accounts'] = filter.id?.length
? [filter.id]
: null;
} else if (filter.type === 'ASSET_CLASS') {
userSetting['filters.assetClasses'] = filter.id ? [filter.id] : null;
userSetting['filters.assetClasses'] = filter.id?.length
? [filter.id]
: null;
} else if (filter.type === 'DATA_SOURCE') {
userSetting['filters.dataSource'] = filter.id ? filter.id : null;
} else if (filter.type === 'SYMBOL') {
userSetting['filters.symbol'] = filter.id ? filter.id : null;
} else if (filter.type === 'TAG') {
userSetting['filters.tags'] = filter.id ? [filter.id] : null;
userSetting['filters.tags'] = filter.id?.length ? [filter.id] : null;
}
}

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

@ -95,6 +95,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;
@ -116,6 +117,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 };
@ -229,10 +231,7 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
averagePrice,
dataProviderInfo,
dividendInBaseCurrency,
dividendYieldPercentWithCurrencyEffect,
feeInBaseCurrency,
firstBuyDate,
historicalData,
stakeRewards,
investmentInBaseCurrencyWithCurrencyEffect,
marketPrice,
marketPriceMax,
@ -245,13 +244,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' &&
@ -384,7 +388,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,
@ -424,6 +428,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;
@ -442,6 +447,19 @@ export class GfHoldingDetailDialogComponent implements OnDestroy, OnInit {
this.fetchMarketData();
}
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

5
apps/client/src/app/components/home-holdings/home-holdings.component.ts

@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
AssetProfileIdentifier,
PortfolioPosition,
PortfolioPerformance,
ToggleOption,
User
} from '@ghostfolio/common/interfaces';
@ -32,6 +33,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public hasPermissionToAccessHoldingsChart: boolean;
public hasPermissionToCreateOrder: boolean;
public holdings: PortfolioPosition[];
public performance: PortfolioPerformance;
public holdingType: HoldingType = 'ACTIVE';
public holdingTypeOptions: ToggleOption[] = [
{ label: $localize`Active`, value: 'ACTIVE' },
@ -165,8 +167,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.fetchHoldings()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => {
.subscribe(({ holdings, performance }) => {
this.holdings = holdings;
this.performance = performance;
this.changeDetectorRef.markForCheck();
});

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

@ -50,6 +50,7 @@
[deviceType]="deviceType"
[holdings]="holdings"
[locale]="user?.settings?.locale"
[performance]="performance"
(holdingClicked)="onHoldingClicked($event)"
/>
@if (hasPermissionToCreateOrder && holdings?.length > 0) {

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

@ -115,11 +115,13 @@
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 text-truncate ml-3">
<ng-container i18n>Net Performance</ng-container>
<abbr
class="initialism ml-2 text-muted"
title="Return on Average Investment"
>(ROAI)</abbr
>
<ng-container *ngIf="this.calculationType">
<abbr
class="initialism ml-2 text-muted"
title="{{ this.calculationType.title }}"
>({{ this.calculationType.value }})</abbr
>
</ng-container>
</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value

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

@ -1,6 +1,7 @@
import { NotificationService } from '@ghostfolio/client/core/notification/notification.service';
import { getDateFnsLocale, getLocale } from '@ghostfolio/common/helper';
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { translate } from '@ghostfolio/ui/i18n';
import {
@ -36,6 +37,8 @@ export class PortfolioSummaryComponent implements OnChanges {
);
public timeInMarket: string;
protected calculationType: { title: string; value: string };
public constructor(private notificationService: NotificationService) {}
public ngOnChanges() {
@ -50,6 +53,7 @@ export class PortfolioSummaryComponent implements OnChanges {
} else {
this.timeInMarket = undefined;
}
this.calculationType = this.getCalulationType();
}
public onEditEmergencyFund() {
@ -64,4 +68,22 @@ export class PortfolioSummaryComponent implements OnChanges {
title: $localize`Please set the amount of your emergency fund.`
});
}
private getCalulationType(): { title: string; value: string } {
switch (this.user?.settings?.performanceCalculationType) {
case PerformanceCalculationType.ROAI:
return {
title: 'Return on Average Investment',
value: PerformanceCalculationType.ROAI
};
case PerformanceCalculationType.ROI:
return {
title: 'Return on Investment',
value: PerformanceCalculationType.ROI
};
default:
return undefined;
}
}
}

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

@ -20,6 +20,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' },

4
apps/client/src/app/components/user-account-settings/user-account-settings.html

@ -34,7 +34,6 @@
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-select
name="performanceCalculationType"
[disabled]="true"
[value]="user.settings.performanceCalculationType"
(selectionChange)="
onChangeUserSetting(
@ -46,6 +45,9 @@
<mat-option value="ROAI"
>Return on Average Investment (ROAI)</mat-option
>
<mat-option value="ROI"
>Return on Investment (ROI)</mat-option
>
</mat-select>
</mat-form-field>
</div>

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

@ -202,6 +202,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 *
@ -255,7 +259,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
@ -194,7 +202,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) {

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

@ -7,6 +7,7 @@ import { MAX_TOP_HOLDINGS, UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Holding,
HoldingWithParents,
PortfolioDetails,
PortfolioPosition,
@ -91,6 +92,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public topHoldingsMap: {
[name: string]: { name: string; value: number };
};
public tagHoldings: Holding[];
public tagHoldingsMap: {
[name: string]: { name: string; value: number };
};
public totalValueInEtf = 0;
public UNKNOWN_KEY = UNKNOWN_KEY;
public user: User;
@ -202,6 +207,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
private fetchPortfolioDetails() {
return this.dataService.fetchPortfolioDetails({
filters: this.userService.getFilters(),
parameters: {
isAllocation: true
},
withMarkets: true
});
}
@ -280,6 +288,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}
};
this.topHoldingsMap = {};
this.tagHoldingsMap = {};
}
private initializeAllocationsData() {
@ -338,6 +347,22 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
name: position.name
};
if (position.tags.length > 0) {
for (const tag of position.tags) {
const { name } = tag;
if (this.tagHoldingsMap[name]?.value) {
this.tagHoldingsMap[name].value +=
position.valueInBaseCurrency ?? 0;
} else {
this.tagHoldingsMap[name] = {
name,
value: position.valueInBaseCurrency ?? 0
};
}
}
}
if (position.assetClass !== AssetClass.LIQUIDITY) {
// Prepare analysis data by continents, countries, holdings and sectors except for liquidity
@ -478,6 +503,29 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
};
}
this.tagHoldings = Object.values(this.tagHoldingsMap)
.map(({ name, value }) => {
if (this.hasImpersonationId || this.user.settings.isRestrictedView) {
return {
name,
allocationInPercentage: value,
valueInBaseCurrency: null
};
}
return {
name,
allocationInPercentage:
this.portfolioDetails.summary.currentValueInBaseCurrency > 0
? value / this.portfolioDetails.summary.currentValueInBaseCurrency
: 0,
valueInBaseCurrency: value
};
})
.sort((a, b) => {
return b.allocationInPercentage - a.allocationInPercentage;
});
this.topHoldings = Object.values(this.topHoldingsMap)
.map(({ name, value }) => {
if (this.hasImpersonationId || this.user.settings.isRestrictedView) {

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

@ -327,6 +327,24 @@
'd-none': !user?.settings?.isExperimentalFeatures
}"
>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate">
<span i18n>By Tag Holding</span>
@if (user?.subscription?.type === 'Basic') {
<gf-premium-indicator class="ml-1" />
}
</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-top-holdings
[baseCurrency]="user?.settings?.baseCurrency"
[locale]="user?.settings?.locale"
[pageSize]="10"
[topHoldings]="tagHoldings"
/>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate">

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

@ -12,7 +12,12 @@ import {
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { AiPromptMode, GroupBy } from '@ghostfolio/common/types';
import type {
AiPromptMode,
DateRange,
GroupBy
} from '@ghostfolio/common/types';
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type';
import { translate } from '@ghostfolio/ui/i18n';
import { Clipboard } from '@angular/cdk/clipboard';
@ -46,6 +51,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`;
@ -69,6 +81,8 @@ 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 top3: PortfolioPosition[];
@ -148,6 +162,24 @@ 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 onChangeGroupBy(aMode: GroupBy) {
this.mode = aMode;
this.fetchDividendsAndInvestments();
@ -267,12 +299,14 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.performance = performance;
this.performanceDataItems = [];
this.performanceDataItemsInPercentage = [];
this.performanceDataItemsTimeWeightedInPercentage = [];
for (const [
index,
{
date,
netPerformanceInPercentageWithCurrencyEffect,
timeWeightedPerformanceInPercentageWithCurrencyEffect,
totalInvestmentValueWithCurrencyEffect,
valueInPercentage,
valueWithCurrencyEffect
@ -293,7 +327,11 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
}
this.performanceDataItemsInPercentage.push({
date,
value: netPerformanceInPercentageWithCurrencyEffect
value:
this.user?.settings?.performanceCalculationType ===
PerformanceCalculationType.ROI
? timeWeightedPerformanceInPercentageWithCurrencyEffect
: netPerformanceInPercentageWithCurrencyEffect
});
}

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

@ -87,7 +87,7 @@
[performanceDataItems]="performanceDataItemsInPercentage"
[user]="user"
(benchmarkChanged)="onChangeBenchmark($event)"
/>
></gf-benchmark-comparator>
</div>
</div>

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

@ -164,6 +164,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', {});
}
@ -183,6 +187,17 @@ export class AdminService {
return this.http.post<MarketData | void>(url, {});
}
public gatherSymbolMissingOnly({
dataSource,
symbol
}: AssetProfileIdentifier & {
date?: Date;
}) {
const url = `/api/v1/admin/gatherMissing/${dataSource}/${symbol}`;
return this.http.post<MarketData | void>(url, {});
}
public fetchSymbolForDate({
dataSource,
dateString,
@ -212,6 +227,8 @@ export class AdminService {
sectors,
symbol: newSymbol,
symbolMapping,
tags,
tagsDisconnected,
url
}: UpdateAssetProfileDto
) {
@ -230,6 +247,8 @@ export class AdminService {
sectors,
symbol: newSymbol,
symbolMapping,
tags,
tagsDisconnected,
url
}
);

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

@ -519,15 +519,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
@ -617,11 +620,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 });
@ -630,6 +635,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

@ -349,6 +349,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'
});
}

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

@ -7739,4 +7739,4 @@
</trans-unit>
</body>
</file>
</xliff>
</xliff>

10
libs/common/src/lib/calculation-helper.ts

@ -7,6 +7,7 @@ import {
startOfWeek,
startOfYear,
subDays,
subMonths,
subYears
} from 'date-fns';
import { isNumber } from 'lodash';
@ -59,12 +60,21 @@ export function getIntervalFromDateRange(
subDays(startOfWeek(resetHours(new Date()), { weekStartsOn: 1 }), 1)
]);
break;
case '1w':
startDate = max([startDate, subDays(resetHours(new Date()), 7)]);
break;
case 'ytd':
startDate = max([
startDate,
subDays(startOfYear(resetHours(new Date())), 1)
]);
break;
case '1m':
startDate = max([startDate, subMonths(resetHours(new Date()), 1)]);
break;
case '3m':
startDate = max([startDate, subMonths(resetHours(new Date()), 3)]);
break;
case '1y':
startDate = max([startDate, subYears(resetHours(new Date()), 1)]);
break;

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

@ -97,6 +97,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;
@ -23,4 +23,5 @@ export interface AdminMarketDataItem {
sectorsCount: number;
symbol: string;
watchedByCount: number;
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';
@ -31,5 +31,6 @@ export interface EnhancedSymbolProfile {
symbolMapping?: { [key: string]: string };
updatedAt: Date;
url?: string;
tags?: Tag[];
userId?: string;
}

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

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

1
libs/common/src/lib/interfaces/responses/portfolio-holding-response.interface.ts

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

6
libs/common/src/lib/interfaces/responses/portfolio-holdings-response.interface.ts

@ -1,5 +1,9 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import {
PortfolioPosition,
PortfolioPerformance
} from '@ghostfolio/common/interfaces';
export interface PortfolioHoldingsResponse {
holdings: PortfolioPosition[];
performance: PortfolioPerformance;
}

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

Loading…
Cancel
Save