Browse Source

Merge branch 'refs/heads/main' into improve-language-localization-for-tr

pull/4467/head
Ömer Faruk Görmel 4 weeks ago
parent
commit
be80a32979
  1. 151
      .eslintrc.json
  2. 4
      .github/ISSUE_TEMPLATE/bug_report.md
  3. 40
      .github/workflows/extract-locales.yml
  4. 238
      CHANGELOG.md
  5. 13
      DEVELOPMENT.md
  6. 2
      README.md
  7. 13
      SECURITY.md
  8. 22
      apps/api/.eslintrc.json
  9. 31
      apps/api/eslint.config.cjs
  10. 20
      apps/api/src/app/account/account.service.ts
  11. 2
      apps/api/src/app/admin/admin.module.ts
  12. 91
      apps/api/src/app/admin/admin.service.ts
  13. 8
      apps/api/src/app/app.module.ts
  14. 33
      apps/api/src/app/auth/web-auth.service.ts
  15. 36
      apps/api/src/app/benchmark/benchmark.module.ts
  16. 34
      apps/api/src/app/endpoints/ai/ai.controller.ts
  17. 2
      apps/api/src/app/endpoints/ai/ai.module.ts
  18. 11
      apps/api/src/app/endpoints/ai/ai.service.ts
  19. 40
      apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts
  20. 63
      apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts
  21. 163
      apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts
  22. 36
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.controller.ts
  23. 43
      apps/api/src/app/endpoints/data-providers/ghostfolio/ghostfolio.service.ts
  24. 5
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  25. 3
      apps/api/src/app/endpoints/public/public.controller.ts
  26. 10
      apps/api/src/app/endpoints/tags/create-tag.dto.ts
  27. 89
      apps/api/src/app/endpoints/tags/tags.controller.ts
  28. 12
      apps/api/src/app/endpoints/tags/tags.module.ts
  29. 13
      apps/api/src/app/endpoints/tags/update-tag.dto.ts
  30. 3
      apps/api/src/app/export/export.controller.ts
  31. 3
      apps/api/src/app/export/export.module.ts
  32. 107
      apps/api/src/app/export/export.service.ts
  33. 20
      apps/api/src/app/health/health.controller.ts
  34. 4
      apps/api/src/app/health/health.module.ts
  35. 27
      apps/api/src/app/health/health.service.ts
  36. 10
      apps/api/src/app/import/create-account-with-balances.dto.ts
  37. 7
      apps/api/src/app/import/import-data.dto.ts
  38. 6
      apps/api/src/app/import/import.service.ts
  39. 2
      apps/api/src/app/info/info.module.ts
  40. 2
      apps/api/src/app/info/info.service.ts
  41. 13
      apps/api/src/app/order/order.service.ts
  42. 2
      apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts
  43. 25
      apps/api/src/app/portfolio/calculator/portfolio-calculator.factory.ts
  44. 6
      apps/api/src/app/portfolio/calculator/portfolio-calculator.ts
  45. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell-in-two-activities.spec.ts
  46. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy-and-sell.spec.ts
  47. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts
  48. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-btcusd-buy-and-sell-partially.spec.ts
  49. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts
  50. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts
  51. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts
  52. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts
  53. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-msft-buy-with-dividend.spec.ts
  54. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts
  55. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell-partially.spec.ts
  56. 2
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-novn-buy-and-sell.spec.ts
  57. 0
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts
  58. 969
      apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.ts
  59. 955
      apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.ts
  60. 2
      apps/api/src/app/portfolio/portfolio.controller.ts
  61. 105
      apps/api/src/app/portfolio/portfolio.service.ts
  62. 21
      apps/api/src/app/redis-cache/redis-cache.service.ts
  63. 2
      apps/api/src/app/symbol/symbol.controller.ts
  64. 6
      apps/api/src/app/tag/create-tag.dto.ts
  65. 14
      apps/api/src/app/tag/tag.module.ts
  66. 81
      apps/api/src/app/tag/tag.service.ts
  67. 9
      apps/api/src/app/tag/update-tag.dto.ts
  68. 50
      apps/api/src/app/user/user.service.ts
  69. 828
      apps/api/src/assets/cryptocurrencies/cryptocurrencies.json
  70. 20
      apps/api/src/assets/sitemap.xml
  71. 2
      apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts
  72. 9
      apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts
  73. 35
      apps/api/src/main.ts
  74. 2
      apps/api/src/middlewares/html-template.middleware.ts
  75. 77
      apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts
  76. 79
      apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts
  77. 77
      apps/api/src/models/rules/regional-market-cluster-risk/europe.ts
  78. 7
      apps/api/src/models/rules/regional-market-cluster-risk/interfaces/rule-settings.interface.ts
  79. 77
      apps/api/src/models/rules/regional-market-cluster-risk/japan.ts
  80. 77
      apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts
  81. 24
      apps/api/src/services/benchmark/benchmark.module.ts
  82. 12
      apps/api/src/services/benchmark/benchmark.service.spec.ts
  83. 162
      apps/api/src/services/benchmark/benchmark.service.ts
  84. 0
      apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts
  85. 8
      apps/api/src/services/cryptocurrency/cryptocurrency.service.ts
  86. 5
      apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts
  87. 15
      apps/api/src/services/data-provider/coingecko/coingecko.service.ts
  88. 94
      apps/api/src/services/data-provider/data-enhancer/trackinsight/trackinsight.service.ts
  89. 6
      apps/api/src/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service.ts
  90. 5
      apps/api/src/services/data-provider/eod-historical-data/eod-historical-data.service.ts
  91. 385
      apps/api/src/services/data-provider/financial-modeling-prep/financial-modeling-prep.service.ts
  92. 68
      apps/api/src/services/data-provider/ghostfolio/ghostfolio.service.ts
  93. 5
      apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts
  94. 10
      apps/api/src/services/data-provider/interfaces/data-provider.interface.ts
  95. 57
      apps/api/src/services/data-provider/manual/manual.service.ts
  96. 5
      apps/api/src/services/data-provider/rapid-api/rapid-api.service.ts
  97. 5
      apps/api/src/services/data-provider/yahoo-finance/yahoo-finance.service.ts
  98. 4
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  99. 3
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  100. 2
      apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

151
.eslintrc.json

@ -1,151 +0,0 @@
{
"root": true,
"ignorePatterns": ["**/*"],
"plugins": ["@nx"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {
"@nx/enforce-module-boundaries": [
"warn",
{
"enforceBuildableLibDependency": true,
"allow": [],
"depConstraints": [
{
"sourceTag": "*",
"onlyDependOnLibsWithTags": ["*"]
}
]
}
],
"@typescript-eslint/no-extra-semi": "error",
"no-extra-semi": "off"
}
},
{
"files": ["*.ts", "*.tsx"],
"extends": ["plugin:@nx/typescript"]
},
{
"files": ["*.js", "*.jsx"],
"extends": ["plugin:@nx/javascript"]
},
{
"files": ["*.ts"],
"plugins": ["eslint-plugin-import", "@typescript-eslint"],
"extends": [
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked"
],
"rules": {
"@typescript-eslint/consistent-indexed-object-style": "off",
"@typescript-eslint/dot-notation": "off",
"@typescript-eslint/explicit-member-accessibility": [
"off",
{
"accessibility": "explicit"
}
],
"@typescript-eslint/member-ordering": "warn",
"@typescript-eslint/naming-convention": [
"off",
{
"selector": "default",
"format": ["camelCase"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": ["variable", "classProperty", "typeProperty"],
"format": ["camelCase", "UPPER_CASE"],
"leadingUnderscore": "allow",
"trailingUnderscore": "allow"
},
{
"selector": "objectLiteralProperty",
"format": null
},
{
"selector": "enumMember",
"format": ["camelCase", "UPPER_CASE", "PascalCase"]
},
{
"selector": "typeLike",
"format": ["PascalCase"]
}
],
"@typescript-eslint/no-empty-interface": "warn",
"@typescript-eslint/no-inferrable-types": [
"warn",
{
"ignoreParameters": true
}
],
"@typescript-eslint/no-non-null-assertion": "warn",
"@typescript-eslint/no-shadow": [
"warn",
{
"hoist": "all"
}
],
"@typescript-eslint/unified-signatures": "error",
"@typescript-eslint/no-loss-of-precision": "warn",
"@typescript-eslint/no-var-requires": "warn",
"@typescript-eslint/ban-types": "warn",
"arrow-body-style": "off",
"constructor-super": "error",
"eqeqeq": ["error", "smart"],
"guard-for-in": "warn",
"id-blacklist": "off",
"id-match": "off",
"import/no-deprecated": "warn",
"no-bitwise": "error",
"no-caller": "error",
"no-debugger": "error",
"no-empty": "off",
"no-eval": "error",
"no-fallthrough": "error",
"no-new-wrappers": "error",
"no-restricted-imports": ["error", "rxjs/Rx"],
"no-undef-init": "error",
"no-underscore-dangle": "off",
"no-var": "error",
"radix": "error",
"no-unsafe-optional-chaining": "warn",
"no-extra-boolean-cast": "warn",
"no-empty-pattern": "warn",
"no-useless-catch": "warn",
"no-unsafe-finally": "warn",
"no-prototype-builtins": "warn",
"no-async-promise-executor": "warn",
"no-constant-condition": "warn",
// The following rules are part of @typescript-eslint/recommended-type-checked
// and can be remove once solved
"@typescript-eslint/await-thenable": "warn",
"@typescript-eslint/ban-ts-comment": "warn",
"@typescript-eslint/no-base-to-string": "warn",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/no-floating-promises": "warn",
"@typescript-eslint/no-misused-promises": "warn",
"@typescript-eslint/no-redundant-type-constituents": "warn",
"@typescript-eslint/no-unnecessary-type-assertion": "warn",
"@typescript-eslint/no-unsafe-argument": "warn",
"@typescript-eslint/no-unsafe-assignment": "warn",
"@typescript-eslint/no-unsafe-enum-comparison": "warn",
"@typescript-eslint/no-unsafe-member-access": "warn",
"@typescript-eslint/no-unsafe-return": "warn",
"@typescript-eslint/no-unsafe-call": "warn",
"@typescript-eslint/require-await": "warn",
"@typescript-eslint/restrict-template-expressions": "warn",
"@typescript-eslint/unbound-method": "warn",
// The following rules are part of @typescript-eslint/stylistic-type-checked
// and can be remove once solved
"@typescript-eslint/prefer-nullish-coalescing": "warn" // TODO: Requires strictNullChecks: true
}
}
],
"extends": ["plugin:storybook/recommended"]
}

4
.github/ISSUE_TEMPLATE/bug_report.md

@ -26,7 +26,7 @@ Thank you for your understanding and cooperation!
2. 2.
3. 3.
**Expected behavior** **Expected Behavior**
<!-- A clear and concise description of what you expected to happen. --> <!-- A clear and concise description of what you expected to happen. -->
@ -48,6 +48,6 @@ Thank you for your understanding and cooperation!
- Browser - Browser
- OS - OS
**Additional context** **Additional Context**
<!-- Add any other context about the problem here. --> <!-- Add any other context about the problem here. -->

40
.github/workflows/extract-locales.yml

@ -0,0 +1,40 @@
name: Extract locales
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
extract_locales:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install dependencies
run: npm ci
- name: Extract locales
run: npm run extract-locales
- name: Check changes
id: verify-changed-files
uses: tj-actions/verify-changed-files@v20
- name: Create pull request
if: steps.verify-changed-files.outputs.files_changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
author: 'github-actions[bot] <github-actions[bot]@users.noreply.github.com>'
branch: 'feature/update-locales'
commit-message: 'Update locales'
delete-branch: true
title: 'Feature/update locales'
token: ${{ secrets.GITHUB_TOKEN }}

238
CHANGELOG.md

@ -7,14 +7,252 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Added support for filtering in the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
- Added the _Storybook_ path to the `sitemap.xml` file
### Changed
- Improved the export functionality by applying filters on accounts and tags
- Improved the symbol validation in the _Yahoo Finance_ service (get asset profiles)
- Refactored `lodash.uniq` with `Array.from(new Set(...))`
- Refreshed the cryptocurrencies list
### Fixed
- Fixed an issue in the activities import functionality related to the account balances
- Changed client-side dates to be sent in UTC format to ensure date consistency
- Benchmark endpoint
- Exchange rate endpoint
## 2.146.0 - 2025-03-15
### Changed
- Improved the usability of the user account registration
- Improved the usability of the _Copy AI prompt to clipboard_ actions on the analysis page (experimental)
- Formatted the name in the _Financial Modeling Prep_ service
- Removed the exchange rates from the overview of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `angular` from version `19.0.5` to `19.2.1`
- Upgraded `Nx` from version `20.3.2` to `20.5.0`
- Upgraded `prettier` from version `3.5.1` to `3.5.3`
- Upgraded `prisma` from version `6.4.1` to `6.5.0`
### Fixed
- Fixed an issue with serving _Storybook_ related to the `contentSecurityPolicy`
## 2.145.1 - 2025-03-10
### Added
- Extended the export functionality by the account balances
- Added a _Copy portfolio data to clipboard for AI prompt_ action to the analysis page (experimental)
### Changed
- Improved the style of the summary on the _X-ray_ page
- Improved the language localization for German (`de`)
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `9.0` to `13.1`
### Fixed
- Fixed an issue to get dividends in the _Financial Modeling Prep_ service
- Fixed an issue to get historical market data in the _Financial Modeling Prep_ service
- Fixed an issue with serving _Storybook_
## 2.144.0 - 2025-03-06
### Fixed
- Fixed the missing import functionality on the non-empty activities page
- Fixed the functionality to delete an asset profile of a custom currency in the admin control panel
## 2.143.0 - 2025-03-02
### Added
- Added the Ghostfolio _LinkedIn_ page to the about page
- Added the Ghostfolio _LinkedIn_ page to the footer
### Changed
- Optimized the asynchronous operations using `Promise.all()` in the portfolio service (`getPerformance`)
- Improved the symbol lookup in the _Trackinsight_ data enhancer for asset profile data
- Removed the no transactions info component from the holdings table on the home page
- Refactored the show condition of the step by step introduction for new users using the activities count
- Upgraded `color` from version `4.2.3` to `5.0.0`
- Upgraded `prisma` from version `6.3.0` to `6.4.1`
### Fixed
- Handled an exception in the export functionality related to platforms
- Handled an exception in the benchmark service related to unnamed asset profiles
## 2.142.0 - 2025-02-28
### Added
- Extended the export functionality by the platforms
- Extended the portfolio snapshot in the portfolio calculator by the `createdAt` timestamp
- Extended the _Trackinsight_ data enhancer for asset profile data by `cusip`
- Added _Storybook_ to the build process
### Changed
- Upgraded `eslint` dependencies
## 2.141.0 - 2025-02-25
### Added
- Extended the export functionality by the tags
- Extended the portfolio snapshot in the portfolio calculator by the activities count
- Extended the user endpoint `GET api/v1/user` by the activities count
- Added `cusip` to the asset profile model
### Changed
- Upgraded `prettier` from version `3.4.2` to `3.5.1`
### Fixed
- Improved the numeric comparison of strings in the value component
## 2.140.0 - 2025-02-20
### Changed ### Changed
- Reloaded the available tags after creating a custom tag in the holding detail dialog (experimental)
- Improved the validation of the currency management in the admin control panel
- Migrated the `@ghostfolio/client` components to control flow
- Migrated the `@ghostfolio/ui` components to control flow
- Improved the language localization for German (`de`)
### Fixed
- Improved the error handling in the `HttpResponseInterceptor`
- Fixed an issue while using symbol profile overrides in the historical market data table of the admin control panel
- Added missing assets in _Storybook_ setup
## 2.139.1 - 2025-02-15
### Added
- Extended the tooltip in the chart of the holdings tab on the home page by the allocation, change and performance
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Asia-Pacific Markets)
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Japan)
- Added support to create custom tags in the holding detail dialog (experimental)
- Extended the tags selector component by a `readonly` attribute
- Extended the tags selector component to support creating custom tags
- Extended the holding detail dialog by the historical market data editor (experimental)
- Added global styles to the _Storybook_ setup
### Changed
- Improved the symbol lookup in the _Trackinsight_ data enhancer for asset profile data
- Improved the language localization for German (`de`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `5.2.1` to `5.2.2`
### Fixed
- Fixed the gaps in the chart of the benchmark comparator
## 2.138.0 - 2025-02-08
### Added
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Emerging Markets)
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (Europe)
- Added a link to _Duck.ai_ to the _Copy AI prompt to clipboard_ action on the analysis page (experimental)
- Extracted the tags selector to a reusable component used in the create or update activity dialog and holding detail dialog
- Added stories for the tags selector component
### Changed
- Improved the caching of the portfolio snapshot in the portfolio calculator by expiring cache entries when a user changes tags in the holding detail dialog
- Improved the error handling in the _CoinGecko_ service
- Improved the language localization for German (`de`)
- Upgraded `svgmap` from version `2.6.0` to `2.12.2`
## 2.137.1 - 2025-02-01
### Added
- Added a new static portfolio analysis rule: _Regional Market Cluster Risk_ (North America)
- Added support for ETF sector data in the _Yahoo Finance_ data enhancer
### Changed
- Extracted the scraper configuration to a sub form in the asset profile details dialog of the admin control
- Migrated the database seeding to _TypeScript_
- Improved the language localization for German (`de`)
- Upgraded `@trivago/prettier-plugin-sort-imports` from version `4.3.0` to `5.2.1`
- Upgraded `bull` from version `4.16.4` to `4.16.5`
- Upgraded `ng-extract-i18n-merge` from version `2.13.1` to `2.14.1`
- Upgraded `prisma` from version `6.2.1` to `6.3.0`
### Fixed
- Fixed the dynamic numerical precision for cryptocurrencies in the holding detail dialog
## 2.136.0 - 2025-01-24
### Added
- Set up a _GitHub Action_ to automatically extract locales when the `main` branch changes
### Changed
- Extended the _Financial Modeling Prep_ service
- Improved the language localization for Ukrainian (`uk`)
- Refreshed the cryptocurrencies list
- Upgraded `date-fns` from version `3.6.0` to `4.1.0`
- Upgraded `rxjs` from version `7.5.6` to `7.8.1`
### Fixed
- Fixed an issue with the detection of the thousand separator by locale
- Fixed an issue with holdings and sectors while using symbol profile overrides
- Fixed an issue with the MIME type detection in the scraper configuration
## 2.135.0 - 2025-01-19
### Changed
- Moved the language localization for Polski (`pl`) from experimental to general availability
- Extended the _Financial Modeling Prep_ service
- Switched to _ESLint_’s flat config format
- Upgraded `bull` from version `4.16.2` to `4.16.4`
- Upgraded `chart.js` from version `4.2.0` to `4.4.7`
- Upgraded `chartjs-chart-treemap` from version `2.3.1` to `3.1.0`
- Upgraded `chartjs-plugin-annotation` from version `2.1.2` to `3.1.0`
- Upgraded `eslint` dependencies
- Upgraded `nestjs` from version `10.1.3` to `10.4.15`
- Upgraded `Nx` from version `20.3.0` to `20.3.2`
- Upgraded `reflect-metadata` from version `0.1.13` to `0.2.2`
- Upgraded `uuid` from version `11.0.2` to `11.0.5`
## 2.134.0 - 2025-01-15
### Added
- Set up the language localization for Українська (`uk`)
### Changed
- Extended the health check endpoint to include database and cache operations (experimental)
- Refactored various `lodash` functions with native JavaScript equivalents - Refactored various `lodash` functions with native JavaScript equivalents
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `6.1.0` to `6.2.1` - Upgraded `prisma` from version `6.1.0` to `6.2.1`
### Fixed ### Fixed
- Fixed an issue with the import of activities with type `FEE` (where unit price is `0`)
- Fixed an issue with the renaming of activities with type `FEE`, `INTEREST`, `ITEM` or `LIABILITY` - Fixed an issue with the renaming of activities with type `FEE`, `INTEREST`, `ITEM` or `LIABILITY`
- Handled an exception in the scraper configuration introduced by the migration from `got` to `fetch`
## 2.133.1 - 2025-01-09 ## 2.133.1 - 2025-01-09

13
DEVELOPMENT.md

@ -60,6 +60,10 @@ Remove permission in `UserService` using `without()`
Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template Use `@if (user?.settings?.isExperimentalFeatures) {}` in HTML template
## Component Library (_Storybook_)
https://ghostfol.io/development/storybook
## Git ## Git
### Rebase ### Rebase
@ -101,3 +105,12 @@ https://www.prisma.io/docs/concepts/components/prisma-migrate/db-push
Run `npm run prisma migrate dev --name added_job_title` Run `npm run prisma migrate dev --name added_job_title`
https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate https://www.prisma.io/docs/concepts/components/prisma-migrate#getting-started-with-prisma-migrate
## SSL
Generate `localhost.cert` and `localhost.pem` files.
```
openssl req -x509 -newkey rsa:2048 -nodes -keyout apps/client/localhost.pem -out apps/client/localhost.cert -days 365 \
-subj "/C=CH/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
```

2
README.md

@ -47,7 +47,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max` - ✅ Portfolio performance: Return on Average Investment (ROAI) for `Today`, `WTD`, `MTD`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Import and export transactions - ✅ Import and export transactions

13
SECURITY.md

@ -0,0 +1,13 @@
# Security Policy
## Reporting Security Issues
If you discover a security vulnerability in this repository, please report it to security[at]ghostfol.io. We will acknowledge your report and provide guidance on the next steps.
To help us resolve the issue, please include the following details:
- A description of the vulnerability
- Steps to reproduce the vulnerability
- Affected versions of the software
We appreciate your responsible disclosure and will work to address the issue promptly.

22
apps/api/.eslintrc.json

@ -1,22 +0,0 @@
{
"extends": "../../.eslintrc.json",
"ignorePatterns": ["!**/*"],
"rules": {},
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"parserOptions": {
"project": ["apps/api/tsconfig.*?.json"]
},
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

31
apps/api/eslint.config.cjs

@ -0,0 +1,31 @@
const baseConfig = require('../../eslint.config.cjs');
module.exports = [
{
ignores: ['**/dist']
},
...baseConfig,
{
rules: {}
},
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {},
languageOptions: {
parserOptions: {
project: ['apps/api/tsconfig.*?.json']
}
}
},
{
files: ['**/*.ts', '**/*.tsx'],
// Override or add rules here
rules: {}
},
{
files: ['**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {}
}
];

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

@ -7,7 +7,13 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import {
Account,
AccountBalance,
Order,
Platform,
Prisma
} from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { groupBy } from 'lodash'; import { groupBy } from 'lodash';
@ -56,13 +62,19 @@ export class AccountService {
orderBy?: Prisma.AccountOrderByWithRelationInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
balances?: AccountBalance[];
Order?: Order[]; Order?: Order[];
Platform?: Platform; Platform?: Platform;
})[] })[]
> { > {
const { include = {}, skip, take, cursor, where, orderBy } = params; const { include = {}, skip, take, cursor, where, orderBy } = params;
include.balances = { orderBy: { date: 'desc' }, take: 1 }; const isBalancesIncluded = !!include.balances;
include.balances = {
orderBy: { date: 'desc' },
...(isBalancesIncluded ? {} : { take: 1 })
};
const accounts = await this.prismaService.account.findMany({ const accounts = await this.prismaService.account.findMany({
cursor, cursor,
@ -76,7 +88,9 @@ export class AccountService {
return accounts.map((account) => { return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 }; account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances; if (!isBalancesIncluded) {
delete account.balances;
}
return account; return account;
}); });

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

@ -1,8 +1,8 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';

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

@ -1,7 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service'; import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
@ -10,7 +10,6 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { import {
DEFAULT_CURRENCY,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_IS_USER_SIGNUP_ENABLED PROPERTY_IS_USER_SIGNUP_ENABLED
@ -30,13 +29,13 @@ import {
EnhancedSymbolProfile, EnhancedSymbolProfile,
Filter Filter
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { MarketDataPreset } from '@ghostfolio/common/types'; import { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { import {
AssetClass, AssetClass,
AssetSubClass, AssetSubClass,
DataSource,
Prisma, Prisma,
PrismaClient, PrismaClient,
Property, Property,
@ -108,35 +107,29 @@ export class AdminService {
symbol symbol
}: AssetProfileIdentifier) { }: AssetProfileIdentifier) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
public async get(): Promise<AdminData> { const currency = getCurrencyFromSymbol(symbol);
const exchangeRates = this.exchangeRateDataService const customCurrencies = (await this.propertyService.getByKey(
.getCurrencies() PROPERTY_CURRENCIES
.filter((currency) => { )) as string[];
return currency !== DEFAULT_CURRENCY;
})
.map((currency) => {
const label1 = DEFAULT_CURRENCY;
const label2 = currency;
return { if (customCurrencies.includes(currency)) {
label1, const updatedCustomCurrencies = customCurrencies.filter(
label2, (customCurrency) => {
dataSource: return customCurrency !== currency;
DataSource[ }
this.configurationService.get('DATA_SOURCE_EXCHANGE_RATES') );
],
symbol: `${label1}${label2}`,
value: this.exchangeRateDataService.toCurrency(
1,
DEFAULT_CURRENCY,
currency
)
};
});
await this.putSetting(
PROPERTY_CURRENCIES,
JSON.stringify(updatedCustomCurrencies)
);
} else {
await this.symbolProfileService.delete({ dataSource, symbol });
}
}
public async get(): Promise<AdminData> {
const [settings, transactionCount, userCount] = await Promise.all([ const [settings, transactionCount, userCount] = await Promise.all([
this.propertyService.get(), this.propertyService.get(),
this.prismaService.order.count(), this.prismaService.order.count(),
@ -144,7 +137,6 @@ export class AdminService {
]); ]);
return { return {
exchangeRates,
settings, settings,
transactionCount, transactionCount,
userCount, userCount,
@ -259,7 +251,8 @@ export class AdminService {
}, },
scraperConfiguration: true, scraperConfiguration: true,
sectors: true, sectors: true,
symbol: true symbol: true,
SymbolProfileOverrides: true
} }
}), }),
this.prismaService.symbolProfile.count({ where }) this.prismaService.symbolProfile.count({ where })
@ -313,11 +306,10 @@ export class AdminService {
name, name,
Order, Order,
sectors, sectors,
symbol symbol,
SymbolProfileOverrides
}) => { }) => {
const countriesCount = countries let countriesCount = countries ? Object.keys(countries).length : 0;
? Object.keys(countries).length
: 0;
const lastMarketPrice = lastMarketPriceMap.get( const lastMarketPrice = lastMarketPriceMap.get(
getAssetProfileIdentifier({ dataSource, symbol }) getAssetProfileIdentifier({ dataSource, symbol })
@ -331,7 +323,34 @@ export class AdminService {
); );
})?._count ?? 0; })?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0; let sectorsCount = sectors ? Object.keys(sectors).length : 0;
if (SymbolProfileOverrides) {
assetClass = SymbolProfileOverrides.assetClass ?? assetClass;
assetSubClass =
SymbolProfileOverrides.assetSubClass ?? assetSubClass;
if (
(
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
)?.length > 0
) {
countriesCount = (
SymbolProfileOverrides.countries as unknown as Prisma.JsonArray
).length;
}
name = SymbolProfileOverrides.name ?? name;
if (
(SymbolProfileOverrides.sectors as unknown as Sector[])
?.length > 0
) {
sectorsCount = (
SymbolProfileOverrides.sectors as unknown as Prisma.JsonArray
).length;
}
}
return { return {
assetClass, assetClass,

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

@ -29,13 +29,14 @@ import { AppController } from './app.controller';
import { AssetModule } from './asset/asset.module'; import { AssetModule } from './asset/asset.module';
import { AuthDeviceModule } from './auth-device/auth-device.module'; import { AuthDeviceModule } from './auth-device/auth-device.module';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { AiModule } from './endpoints/ai/ai.module'; import { AiModule } from './endpoints/ai/ai.module';
import { ApiKeysModule } from './endpoints/api-keys/api-keys.module'; import { ApiKeysModule } from './endpoints/api-keys/api-keys.module';
import { BenchmarksModule } from './endpoints/benchmarks/benchmarks.module';
import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module'; import { GhostfolioModule } from './endpoints/data-providers/ghostfolio/ghostfolio.module';
import { MarketDataModule } from './endpoints/market-data/market-data.module'; import { MarketDataModule } from './endpoints/market-data/market-data.module';
import { PublicModule } from './endpoints/public/public.module'; import { PublicModule } from './endpoints/public/public.module';
import { TagsModule } from './endpoints/tags/tags.module';
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; import { ExchangeRateModule } from './exchange-rate/exchange-rate.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
@ -49,7 +50,6 @@ import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SitemapModule } from './sitemap/sitemap.module'; import { SitemapModule } from './sitemap/sitemap.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { TagModule } from './tag/tag.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@Module({ @Module({
@ -63,7 +63,7 @@ import { UserModule } from './user/user.module';
AssetModule, AssetModule,
AuthDeviceModule, AuthDeviceModule,
AuthModule, AuthModule,
BenchmarkModule, BenchmarksModule,
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10), db: parseInt(process.env.REDIS_DB ?? '0', 10),
@ -124,7 +124,7 @@ import { UserModule } from './user/user.module';
SitemapModule, SitemapModule,
SubscriptionModule, SubscriptionModule,
SymbolModule, SymbolModule,
TagModule, TagsModule,
TwitterBotModule, TwitterBotModule,
UserModule UserModule
], ],

33
apps/api/src/app/auth/web-auth.service.ts

@ -24,6 +24,7 @@ import {
verifyRegistrationResponse, verifyRegistrationResponse,
VerifyRegistrationResponseOpts VerifyRegistrationResponseOpts
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { isoBase64URL, isoUint8Array } from '@simplewebauthn/server/helpers';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
@ -54,10 +55,9 @@ export class WebAuthService {
const opts: GenerateRegistrationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: isoUint8Array.fromUTF8String(user.id),
userName: '', userName: '',
timeout: 60000, timeout: 60000,
attestationType: 'indirect',
authenticatorSelection: { authenticatorSelection: {
authenticatorAttachment: 'platform', authenticatorAttachment: 'platform',
requireResidentKey: false, requireResidentKey: false,
@ -111,11 +111,17 @@ export class WebAuthService {
where: { userId: user.id } where: { userId: user.id }
}); });
if (registrationInfo && verified) { if (registrationInfo && verified) {
const { counter, credentialID, credentialPublicKey } = registrationInfo; const {
credential: {
counter,
id: credentialId,
publicKey: credentialPublicKey
}
} = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find((device) => {
(device) => device.credentialId === credentialID return isoBase64URL.fromBuffer(device.credentialId) === credentialId;
); });
if (!existingDevice) { if (!existingDevice) {
/** /**
@ -123,7 +129,7 @@ export class WebAuthService {
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter, counter,
credentialId: Buffer.from(credentialID), credentialId: Buffer.from(credentialId),
credentialPublicKey: Buffer.from(credentialPublicKey), credentialPublicKey: Buffer.from(credentialPublicKey),
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
@ -148,9 +154,8 @@ export class WebAuthService {
const opts: GenerateAuthenticationOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
allowCredentials: [ allowCredentials: [
{ {
id: device.credentialId, id: isoBase64URL.fromBuffer(device.credentialId),
transports: ['internal'], transports: ['internal']
type: 'public-key'
} }
], ],
rpID: this.rpID, rpID: this.rpID,
@ -187,10 +192,10 @@ export class WebAuthService {
let verification: VerifiedAuthenticationResponse; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAuthenticationResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
authenticator: { credential: {
credentialID: device.credentialId, counter: device.counter,
credentialPublicKey: device.credentialPublicKey, id: isoBase64URL.fromBuffer(device.credentialId),
counter: device.counter publicKey: device.credentialPublicKey
}, },
expectedChallenge: `${user.authChallenge}`, expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,

36
apps/api/src/app/benchmark/benchmark.module.ts

@ -1,36 +0,0 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
@Module({
controllers: [BenchmarkController],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

34
apps/api/src/app/endpoints/ai/ai.controller.ts

@ -1,14 +1,22 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE DEFAULT_LANGUAGE_CODE
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { AiPromptResponse } from '@ghostfolio/common/interfaces'; import { AiPromptResponse } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { AiPromptMode, RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import {
Controller,
Get,
Inject,
Param,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
@ -18,14 +26,32 @@ import { AiService } from './ai.service';
export class AiController { export class AiController {
public constructor( public constructor(
private readonly aiService: AiService, private readonly aiService: AiService,
private readonly apiService: ApiService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get('prompt') @Get('prompt/:mode')
@HasPermission(permissions.readAiPrompt) @HasPermission(permissions.readAiPrompt)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getPrompt(): Promise<AiPromptResponse> { public async getPrompt(
@Param('mode') mode: AiPromptMode,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string
): Promise<AiPromptResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const prompt = await this.aiService.getPrompt({ const prompt = await this.aiService.getPrompt({
filters,
mode,
impersonationId: undefined, impersonationId: undefined,
languageCode: languageCode:
this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE, this.request.user.Settings.settings.language ?? DEFAULT_LANGUAGE_CODE,

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

@ -7,6 +7,7 @@ import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.servic
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service'; import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -25,6 +26,7 @@ import { AiService } from './ai.service';
@Module({ @Module({
controllers: [AiController], controllers: [AiController],
imports: [ imports: [
ApiModule,
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,

11
apps/api/src/app/endpoints/ai/ai.service.ts

@ -1,4 +1,6 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { Filter } from '@ghostfolio/common/interfaces';
import type { AiPromptMode } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -7,17 +9,22 @@ export class AiService {
public constructor(private readonly portfolioService: PortfolioService) {} public constructor(private readonly portfolioService: PortfolioService) {}
public async getPrompt({ public async getPrompt({
filters,
impersonationId, impersonationId,
languageCode, languageCode,
mode,
userCurrency, userCurrency,
userId userId
}: { }: {
filters?: Filter[];
impersonationId: string; impersonationId: string;
languageCode: string; languageCode: string;
mode: AiPromptMode;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}) { }) {
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId, impersonationId,
userId userId
}); });
@ -43,6 +50,10 @@ export class AiService {
) )
]; ];
if (mode === 'portfolio') {
return holdingsTable.join('\n');
}
return [ return [
`You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`, `You are a neutral financial assistant. Please analyze the following investment portfolio (base currency being ${userCurrency}) in simple words.`,
...holdingsTable, ...holdingsTable,

40
apps/api/src/app/benchmark/benchmark.controller.ts → apps/api/src/app/endpoints/benchmarks/benchmarks.controller.ts

@ -2,7 +2,10 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper'; import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import type { import type {
AssetProfileIdentifier, AssetProfileIdentifier,
BenchmarkMarketDataDetails, BenchmarkMarketDataDetails,
@ -16,6 +19,7 @@ import {
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -29,12 +33,14 @@ import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { BenchmarkService } from './benchmark.service'; import { BenchmarksService } from './benchmarks.service';
@Controller('benchmark') @Controller('benchmarks')
export class BenchmarkController { export class BenchmarksController {
public constructor( public constructor(
private readonly apiService: ApiService,
private readonly benchmarkService: BenchmarkService, private readonly benchmarkService: BenchmarkService,
private readonly benchmarksService: BenchmarksService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -108,23 +114,43 @@ export class BenchmarkController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInRequestInterceptor) @UseInterceptors(TransformDataSourceInRequestInterceptor)
public async getBenchmarkMarketDataForUser( public async getBenchmarkMarketDataForUser(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('startDateString') startDateString: string, @Param('startDateString') startDateString: string,
@Param('symbol') symbol: string, @Param('symbol') symbol: string,
@Query('range') dateRange: DateRange = 'max' @Query('range') dateRange: DateRange = 'max',
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('dataSource') filterByDataSource?: string,
@Query('symbol') filterBySymbol?: string,
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false'
): Promise<BenchmarkMarketDataDetails> { ): Promise<BenchmarkMarketDataDetails> {
const { endDate, startDate } = getIntervalFromDateRange( const { endDate, startDate } = getIntervalFromDateRange(
dateRange, dateRange,
new Date(startDateString) new Date(startDateString)
); );
const userCurrency = this.request.user.Settings.settings.baseCurrency;
return this.benchmarkService.getMarketDataForUser({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByDataSource,
filterBySymbol,
filterByTags
});
const withExcludedAccounts = withExcludedAccountsParam === 'true';
return this.benchmarksService.getMarketDataForUser({
dataSource, dataSource,
dateRange,
endDate, endDate,
filters,
impersonationId,
startDate, startDate,
symbol, symbol,
userCurrency withExcludedAccounts,
user: this.request.user
}); });
} }
} }

63
apps/api/src/app/endpoints/benchmarks/benchmarks.module.ts

@ -0,0 +1,63 @@
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { RulesService } from '@ghostfolio/api/app/portfolio/rules.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { PortfolioSnapshotQueueModule } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarksController } from './benchmarks.controller';
import { BenchmarksService } from './benchmarks.service';
@Module({
controllers: [BenchmarksController],
imports: [
ApiModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
MarketDataModule,
OrderModule,
PortfolioSnapshotQueueModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolModule,
SymbolProfileModule,
TransformDataSourceInRequestModule,
TransformDataSourceInResponseModule,
UserModule
],
providers: [
AccountBalanceService,
AccountService,
BenchmarkService,
BenchmarksService,
CurrentRateService,
MarketDataService,
PortfolioCalculatorFactory,
PortfolioService,
RulesService
]
})
export class BenchmarksModule {}

163
apps/api/src/app/endpoints/benchmarks/benchmarks.service.ts

@ -0,0 +1,163 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
BenchmarkMarketDataDetails,
Filter
} from '@ghostfolio/common/interfaces';
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { format, isSameDay } from 'date-fns';
import { isNumber } from 'lodash';
@Injectable()
export class BenchmarksService {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly portfolioService: PortfolioService,
private readonly symbolService: SymbolService
) {}
public async getMarketDataForUser({
dataSource,
dateRange,
endDate = new Date(),
filters,
impersonationId,
startDate,
symbol,
user,
withExcludedAccounts
}: {
dateRange: DateRange;
endDate?: Date;
filters?: Filter[];
impersonationId: string;
startDate: Date;
user: UserWithSettings;
withExcludedAccounts?: boolean;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const userCurrency = user.Settings.settings.baseCurrency;
const userId = user.id;
const { chart } = await this.portfolioService.getPerformance({
dateRange,
filters,
impersonationId,
userId,
withExcludedAccounts
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: chart.map(({ date }) => {
return resetHours(parseDate(date));
})
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.benchmarkService.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
});
}
return {
marketData
};
}
}

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

@ -2,6 +2,7 @@ import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorat
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { parseDate } from '@ghostfolio/common/helper'; import { parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderGhostfolioStatusResponse, DataProviderGhostfolioStatusResponse,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -37,6 +38,41 @@ export class GhostfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@Get('asset-profile/:symbol')
@HasPermission(permissions.enableDataProviderGhostfolio)
@UseGuards(AuthGuard('api-key'), HasPermissionGuard)
public async getAssetProfile(
@Param('symbol') symbol: string
): Promise<DataProviderGhostfolioAssetProfileResponse> {
const maxDailyRequests = await this.ghostfolioService.getMaxDailyRequests();
if (
this.request.user.dataProviderGhostfolioDailyRequests > maxDailyRequests
) {
throw new HttpException(
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
StatusCodes.TOO_MANY_REQUESTS
);
}
try {
const assetProfile = await this.ghostfolioService.getAssetProfile({
symbol
});
await this.ghostfolioService.incrementDailyRequests({
userId: this.request.user.id
});
return assetProfile;
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
StatusCodes.INTERNAL_SERVER_ERROR
);
}
}
/** /**
* @deprecated * @deprecated
*/ */

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { import {
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -15,6 +16,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config'; import { PROPERTY_DATA_SOURCES_GHOSTFOLIO_DATA_PROVIDER_MAX_REQUESTS } from '@ghostfolio/common/config';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -25,7 +27,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types'; import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
@Injectable() @Injectable()
@ -37,6 +39,44 @@ export class GhostfolioService {
private readonly propertyService: PropertyService private readonly propertyService: PropertyService
) {} ) {}
public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol
}: GetAssetProfileParams) {
let result: DataProviderGhostfolioAssetProfileResponse = {};
try {
const promises: Promise<Partial<SymbolProfile>>[] = [];
for (const dataProviderService of this.getDataProviderServices()) {
promises.push(
dataProviderService
.getAssetProfile({
requestTimeout,
symbol
})
.then((assetProfile) => {
result = {
...result,
...assetProfile,
dataSource: DataSource.GHOSTFOLIO
};
return assetProfile;
})
);
}
await Promise.all(promises);
return result;
} catch (error) {
Logger.error(error, 'GhostfolioService');
throw error;
}
}
public async getDividends({ public async getDividends({
from, from,
granularity, granularity,
@ -277,6 +317,7 @@ export class GhostfolioService {
}); });
results.items = filteredItems; results.items = filteredItems;
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error, 'GhostfolioService'); Logger.error(error, 'GhostfolioService');

5
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -1,6 +1,7 @@
import { AdminService } from '@ghostfolio/api/app/admin/admin.service'; import { AdminService } from '@ghostfolio/api/app/admin/admin.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getCurrencyFromSymbol, isCurrency } from '@ghostfolio/common/helper';
import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces'; import { MarketDataDetailsResponse } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
@ -42,7 +43,7 @@ export class MarketDataController {
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (!assetProfile) { if (!assetProfile && !isCurrency(getCurrencyFromSymbol(symbol))) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND), getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND StatusCodes.NOT_FOUND
@ -55,7 +56,7 @@ export class MarketDataController {
); );
const canReadOwnAssetProfile = const canReadOwnAssetProfile =
assetProfile.userId === this.request.user.id && assetProfile?.userId === this.request.user.id &&
hasPermission( hasPermission(
this.request.user.permissions, this.request.user.permissions,
permissions.readMarketDataOfOwnAssetProfile permissions.readMarketDataOfOwnAssetProfile

3
apps/api/src/app/endpoints/public/public.controller.ts

@ -57,7 +57,7 @@ export class PublicController {
} }
const [ const [
{ holdings, markets }, { createdAt, holdings, markets },
{ performance: performance1d }, { performance: performance1d },
{ performance: performanceMax }, { performance: performanceMax },
{ performance: performanceYtd } { performance: performanceYtd }
@ -81,6 +81,7 @@ export class PublicController {
}); });
const publicPortfolioResponse: PublicPortfolioResponse = { const publicPortfolioResponse: PublicPortfolioResponse = {
createdAt,
hasDetails, hasDetails,
markets, markets,
alias: access.alias, alias: access.alias,

10
apps/api/src/app/endpoints/tags/create-tag.dto.ts

@ -0,0 +1,10 @@
import { IsOptional, IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

89
apps/api/src/app/tag/tag.controller.ts → apps/api/src/app/endpoints/tags/tags.controller.ts

@ -1,6 +1,8 @@
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { permissions } from '@ghostfolio/common/permissions'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
@ -8,41 +10,63 @@ import {
Delete, Delete,
Get, Get,
HttpException, HttpException,
Inject,
Param, Param,
Post, Post,
Put, Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Tag } from '@prisma/client'; import { Tag } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateTagDto } from './create-tag.dto'; import { CreateTagDto } from './create-tag.dto';
import { TagService } from './tag.service';
import { UpdateTagDto } from './update-tag.dto'; import { UpdateTagDto } from './update-tag.dto';
@Controller('tag') @Controller('tags')
export class TagController { export class TagsController {
public constructor(private readonly tagService: TagService) {} public constructor(
@Inject(REQUEST) private readonly request: RequestWithUser,
@Get() private readonly tagService: TagService
@HasPermission(permissions.readTags) ) {}
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@Post() @Post()
@HasPermission(permissions.createTag) @UseGuards(AuthGuard('jwt'))
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async createTag(@Body() data: CreateTagDto): Promise<Tag> { public async createTag(@Body() data: CreateTagDto): Promise<Tag> {
const canCreateOwnTag = hasPermission(
this.request.user.permissions,
permissions.createOwnTag
);
const canCreateTag = hasPermission(
this.request.user.permissions,
permissions.createTag
);
if (!canCreateOwnTag && !canCreateTag) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
if (canCreateOwnTag && !canCreateTag) {
if (data.userId !== this.request.user.id) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
return this.tagService.createTag(data); return this.tagService.createTag(data);
} }
@HasPermission(permissions.updateTag) @Delete(':id')
@Put(':id') @HasPermission(permissions.deleteTag)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) { public async deleteTag(@Param('id') id: string) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -54,20 +78,20 @@ export class TagController {
); );
} }
return this.tagService.updateTag({ return this.tagService.deleteTag({ id });
data: {
...data
},
where: {
id
}
});
} }
@Delete(':id') @Get()
@HasPermission(permissions.deleteTag) @HasPermission(permissions.readTags)
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async deleteTag(@Param('id') id: string) { public async getTags() {
return this.tagService.getTagsWithActivityCount();
}
@HasPermission(permissions.updateTag)
@Put(':id')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async updateTag(@Param('id') id: string, @Body() data: UpdateTagDto) {
const originalTag = await this.tagService.getTag({ const originalTag = await this.tagService.getTag({
id id
}); });
@ -79,6 +103,13 @@ export class TagController {
); );
} }
return this.tagService.deleteTag({ id }); return this.tagService.updateTag({
data: {
...data
},
where: {
id
}
});
} }
} }

12
apps/api/src/app/endpoints/tags/tags.module.ts

@ -0,0 +1,12 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
@Module({
controllers: [TagsController],
imports: [PrismaModule, TagModule]
})
export class TagsModule {}

13
apps/api/src/app/endpoints/tags/update-tag.dto.ts

@ -0,0 +1,13 @@
import { IsOptional, IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
@IsOptional()
@IsString()
userId?: string;
}

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

@ -21,10 +21,11 @@ export class ExportController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async export( public async export(
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('activityIds') activityIds?: string[], @Query('activityIds') filterByActivityIds?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string
): Promise<Export> { ): Promise<Export> {
const activityIds = filterByActivityIds?.split(',') ?? [];
const filters = this.apiService.buildFiltersFromQueryParams({ const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,

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

@ -1,6 +1,7 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { ApiModule } from '@ghostfolio/api/services/api/api.module'; import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -8,7 +9,7 @@ import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [AccountModule, ApiModule, OrderModule], imports: [AccountModule, ApiModule, OrderModule, TagModule],
controllers: [ExportController], controllers: [ExportController],
providers: [ExportService] providers: [ExportService]
}) })

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

@ -1,15 +1,18 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment'; import { environment } from '@ghostfolio/api/environments/environment';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { Filter, Export } from '@ghostfolio/common/interfaces'; import { Filter, Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Platform } from '@prisma/client';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly orderService: OrderService private readonly orderService: OrderService,
private readonly tagService: TagService
) {} ) {}
public async export({ public async export({
@ -23,46 +26,96 @@ export class ExportService {
userCurrency: string; userCurrency: string;
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
const platformsMap: { [platformId: string]: Platform } = {};
let { activities } = await this.orderService.getOrders({
filters,
userCurrency,
userId,
includeDrafts: true,
sortColumn: 'date',
sortDirection: 'asc',
withExcludedAccounts: true
});
if (activityIds?.length > 0) {
activities = activities.filter(({ id }) => {
return activityIds.includes(id);
});
}
const accounts = ( const accounts = (
await this.accountService.accounts({ await this.accountService.accounts({
include: {
balances: true,
Platform: true
},
orderBy: { orderBy: {
name: 'asc' name: 'asc'
}, },
where: { userId } where: { userId }
}) })
).map( )
({ balance, comment, currency, id, isExcluded, name, platformId }) => { .filter(({ id }) => {
return { return activities.length > 0
? activities.some(({ accountId }) => {
return accountId === id;
})
: true;
})
.map(
({
balance, balance,
balances,
comment, comment,
currency, currency,
id, id,
isExcluded, isExcluded,
name, name,
Platform: platform,
platformId platformId
}; }) => {
} if (platformId) {
); platformsMap[platformId] = platform;
}
let { activities } = await this.orderService.getOrders({ return {
filters, balance,
userCurrency, balances: balances.map(({ date, value }) => {
userId, return { date: date.toISOString(), value };
includeDrafts: true, }),
sortColumn: 'date', comment,
sortDirection: 'asc', currency,
withExcludedAccounts: true id,
}); isExcluded,
name,
platformId
};
}
);
if (activityIds) { const tags = (await this.tagService.getTagsForUser(userId))
activities = activities.filter((activity) => { .filter(
return activityIds.includes(activity.id); ({ id, isUsed }) =>
isUsed &&
activities.some((activity) => {
return activity.tags.some(({ id: tagId }) => {
return tagId === id;
});
})
)
.map(({ id, name }) => {
return {
id,
name
};
}); });
}
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
accounts, accounts,
platforms: Object.values(platformsMap),
tags,
activities: activities.map( activities: activities.map(
({ ({
accountId, accountId,
@ -72,6 +125,7 @@ export class ExportService {
id, id,
quantity, quantity,
SymbolProfile, SymbolProfile,
tags: currentTags,
type, type,
unitPrice unitPrice
}) => { }) => {
@ -86,13 +140,12 @@ export class ExportService {
currency: SymbolProfile.currency, currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(), date: date.toISOString(),
symbol: symbol: ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(type)
type === 'FEE' || ? SymbolProfile.name
type === 'INTEREST' || : SymbolProfile.symbol,
type === 'ITEM' || tags: currentTags.map(({ id: tagId }) => {
type === 'LIABILITY' return tagId;
? SymbolProfile.name })
: SymbolProfile.symbol
}; };
} }
), ),

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

@ -3,13 +3,14 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { import {
Controller, Controller,
Get, Get,
HttpCode,
HttpException, HttpException,
HttpStatus, HttpStatus,
Param, Param,
Res,
UseInterceptors UseInterceptors
} from '@nestjs/common'; } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
@ -19,9 +20,20 @@ export class HealthController {
public constructor(private readonly healthService: HealthService) {} public constructor(private readonly healthService: HealthService) {}
@Get() @Get()
@HttpCode(HttpStatus.OK) public async getHealth(@Res() response: Response) {
public getHealth() { const databaseServiceHealthy = await this.healthService.isDatabaseHealthy();
return { status: getReasonPhrase(StatusCodes.OK) }; const redisCacheServiceHealthy =
await this.healthService.isRedisCacheHealthy();
if (databaseServiceHealthy && redisCacheServiceHealthy) {
return response
.status(HttpStatus.OK)
.json({ status: getReasonPhrase(StatusCodes.OK) });
} else {
return response
.status(HttpStatus.SERVICE_UNAVAILABLE)
.json({ status: getReasonPhrase(StatusCodes.SERVICE_UNAVAILABLE) });
}
} }
@Get('data-enhancer/:name') @Get('data-enhancer/:name')

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

@ -1,6 +1,8 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module'; import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -12,6 +14,8 @@ import { HealthService } from './health.service';
imports: [ imports: [
DataEnhancerModule, DataEnhancerModule,
DataProviderModule, DataProviderModule,
PropertyModule,
RedisCacheModule,
TransformDataSourceInRequestModule TransformDataSourceInRequestModule
], ],
providers: [HealthService] providers: [HealthService]

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

@ -1,5 +1,8 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service'; import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -8,7 +11,9 @@ import { DataSource } from '@prisma/client';
export class HealthService { export class HealthService {
public constructor( public constructor(
private readonly dataEnhancerService: DataEnhancerService, private readonly dataEnhancerService: DataEnhancerService,
private readonly dataProviderService: DataProviderService private readonly dataProviderService: DataProviderService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) {} ) {}
public async hasResponseFromDataEnhancer(aName: string) { public async hasResponseFromDataEnhancer(aName: string) {
@ -18,4 +23,24 @@ export class HealthService {
public async hasResponseFromDataProvider(aDataSource: DataSource) { public async hasResponseFromDataProvider(aDataSource: DataSource) {
return this.dataProviderService.checkQuote(aDataSource); return this.dataProviderService.checkQuote(aDataSource);
} }
public async isDatabaseHealthy() {
try {
await this.propertyService.getByKey(PROPERTY_CURRENCIES);
return true;
} catch {
return false;
}
}
public async isRedisCacheHealthy() {
try {
const isHealthy = await this.redisCacheService.isHealthy();
return isHealthy;
} catch {
return false;
}
}
} }

10
apps/api/src/app/import/create-account-with-balances.dto.ts

@ -0,0 +1,10 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { AccountBalance } from '@ghostfolio/common/interfaces';
import { IsArray, IsOptional } from 'class-validator';
export class CreateAccountWithBalancesDto extends CreateAccountDto {
@IsArray()
@IsOptional()
balances?: AccountBalance;
}

7
apps/api/src/app/import/import-data.dto.ts

@ -1,15 +1,16 @@
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, IsOptional, ValidateNested } from 'class-validator'; import { IsArray, IsOptional, ValidateNested } from 'class-validator';
import { CreateAccountWithBalancesDto } from './create-account-with-balances.dto';
export class ImportDataDto { export class ImportDataDto {
@IsOptional() @IsOptional()
@IsArray() @IsArray()
@Type(() => CreateAccountDto) @Type(() => CreateAccountWithBalancesDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
accounts: CreateAccountDto[]; accounts: CreateAccountWithBalancesDto[];
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)

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

@ -30,7 +30,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns'; import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash'; import { isNumber, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
@Injectable() @Injectable()
@ -293,6 +293,7 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -328,7 +329,7 @@ export class ImportService {
date date
); );
if (!unitPrice) { if (!isNumber(unitPrice)) {
throw new Error( throw new Error(
`activities.${index} historical exchange rate at ${format( `activities.${index} historical exchange rate at ${format(
date, date,
@ -367,6 +368,7 @@ export class ImportService {
assetSubClass, assetSubClass,
countries, countries,
createdAt, createdAt,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,

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

@ -1,8 +1,8 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module'; import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
import { BenchmarkModule } from '@ghostfolio/api/services/benchmark/benchmark.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';

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

@ -1,7 +1,7 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service'; import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { BenchmarkService } from '@ghostfolio/api/services/benchmark/benchmark.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';

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

@ -63,14 +63,14 @@ export class OrderService {
} }
}); });
return Promise.all( await Promise.all(
orders.map(({ id }) => orders.map(({ id }) =>
this.prismaService.order.update({ this.prismaService.order.update({
data: { data: {
tags: { tags: {
// The set operation replaces all existing connections with the provided ones // The set operation replaces all existing connections with the provided ones
set: tags.map(({ id }) => { set: tags.map((tag) => {
return { id }; return { id: tag.id };
}) })
} }
}, },
@ -78,6 +78,13 @@ export class OrderService {
}) })
) )
); );
this.eventEmitter.emit(
PortfolioChangedEvent.getName(),
new PortfolioChangedEvent({
userId
})
);
} }
public async createOrder( public async createOrder(

2
apps/api/src/app/portfolio/calculator/mwr/portfolio-calculator.ts

@ -5,7 +5,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
export class MWRPortfolioCalculator extends PortfolioCalculator { export class MwrPortfolioCalculator extends PortfolioCalculator {
protected calculateOverallPerformance(): PortfolioSnapshot { protected calculateOverallPerformance(): PortfolioSnapshot {
throw new Error('Method not implemented.'); throw new Error('Method not implemented.');
} }

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

@ -8,12 +8,14 @@ import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { MWRPortfolioCalculator } from './mwr/portfolio-calculator'; import { MwrPortfolioCalculator } from './mwr/portfolio-calculator';
import { PortfolioCalculator } from './portfolio-calculator'; import { PortfolioCalculator } from './portfolio-calculator';
import { TWRPortfolioCalculator } from './twr/portfolio-calculator'; import { RoaiPortfolioCalculator } from './roai/portfolio-calculator';
import { TwrPortfolioCalculator } from './twr/portfolio-calculator';
export enum PerformanceCalculationType { export enum PerformanceCalculationType {
MWR = 'MWR', // Money-Weighted Rate of Return MWR = 'MWR', // Money-Weighted Rate of Return
ROAI = 'ROAI', // Return on Average Investment
TWR = 'TWR' // Time-Weighted Rate of Return TWR = 'TWR' // Time-Weighted Rate of Return
} }
@ -44,7 +46,7 @@ export class PortfolioCalculatorFactory {
}): PortfolioCalculator { }): PortfolioCalculator {
switch (calculationType) { switch (calculationType) {
case PerformanceCalculationType.MWR: case PerformanceCalculationType.MWR:
return new MWRPortfolioCalculator({ return new MwrPortfolioCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
currency, currency,
@ -56,15 +58,28 @@ export class PortfolioCalculatorFactory {
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService
}); });
case PerformanceCalculationType.TWR: case PerformanceCalculationType.ROAI:
return new TWRPortfolioCalculator({ return new RoaiPortfolioCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
currency, currency,
filters,
userId,
configurationService: this.configurationService,
currentRateService: this.currentRateService, currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService
});
case PerformanceCalculationType.TWR:
return new TwrPortfolioCalculator({
accountBalanceItems,
activities,
currency,
filters, filters,
userId, userId,
configurationService: this.configurationService, configurationService: this.configurationService,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService, exchangeRateDataService: this.exchangeRateDataService,
portfolioSnapshotService: this.portfolioSnapshotService, portfolioSnapshotService: this.portfolioSnapshotService,
redisCacheService: this.redisCacheService redisCacheService: this.redisCacheService

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

@ -49,7 +49,7 @@ import {
min, min,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, sortBy, sum, uniq, uniqBy } from 'lodash'; import { isNumber, sortBy, sum, uniqBy } from 'lodash';
export abstract class PortfolioCalculator { export abstract class PortfolioCalculator {
protected static readonly ENABLE_LOGGING = false; protected static readonly ENABLE_LOGGING = false;
@ -175,6 +175,8 @@ export abstract class PortfolioCalculator {
if (!transactionPoints.length) { if (!transactionPoints.length) {
return { return {
activitiesCount: 0,
createdAt: new Date(),
currentValueInBaseCurrency: new Big(0), currentValueInBaseCurrency: new Big(0),
errors: [], errors: [],
hasErrors: false, hasErrors: false,
@ -220,7 +222,7 @@ export abstract class PortfolioCalculator {
const exchangeRatesByCurrency = const exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)), currencies: Array.from(new Set(Object.values(currencies))),
endDate: endOfDay(this.endDate), endDate: endOfDay(this.endDate),
startDate: this.startDate, startDate: this.startDate,
targetCurrency: this.currency targetCurrency: this.currency

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

@ -137,7 +137,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

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

@ -122,7 +122,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-baln-buy.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-baln-buy.spec.ts

@ -107,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

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

@ -136,7 +136,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-fee.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-fee.spec.ts

@ -107,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-googl-buy.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-googl-buy.spec.ts

@ -120,7 +120,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-item.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-item.spec.ts

@ -107,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-liability.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-liability.spec.ts

@ -107,7 +107,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });

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

@ -135,7 +135,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'USD', currency: 'USD',
userId: userDummyData.id userId: userDummyData.id
}); });

2
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator-no-orders.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator-no-orders.spec.ts

@ -84,7 +84,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities: [], activities: [],
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

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

@ -116,7 +116,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

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

@ -116,7 +116,7 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ const portfolioCalculator = portfolioCalculatorFactory.createCalculator({
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: 'CHF', currency: 'CHF',
userId: userDummyData.id userId: userDummyData.id
}); });

0
apps/api/src/app/portfolio/calculator/twr/portfolio-calculator.spec.ts → apps/api/src/app/portfolio/calculator/roai/portfolio-calculator.spec.ts

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

@ -0,0 +1,969 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
SymbolMetrics
} from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Big } from 'big.js';
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns';
import { cloneDeep, sortBy } from 'lodash';
export class RoaiPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
activitiesCount: this.activities.filter(({ type }) => {
return ['BUY', 'SELL'].includes(type);
}).length,
createdAt: new Date(),
errors: [],
historicalData: [],
totalLiabilitiesWithCurrencyEffect: new Big(0),
totalValuablesWithCurrencyEffect: new Big(0)
};
}
protected getSymbolMetrics({
chartDateMap,
dataSource,
end,
exchangeRates,
marketSymbolMap,
start,
symbol
}: {
chartDateMap?: { [date: string]: boolean };
end: Date;
exchangeRates: { [dateString: string]: number };
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
} & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
const totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let lastUnitPrice: Big;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
if (date > rangeEndDateString) {
continue;
} else if (date < rangeStartDateString) {
break;
}
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
}
}

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

@ -1,965 +1,24 @@
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { getIntervalFromDateRange } from '@ghostfolio/common/calculation-helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
SymbolMetrics SymbolMetrics
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; import { PortfolioSnapshot } from '@ghostfolio/common/models';
import { DateRange } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common'; export class TwrPortfolioCalculator extends PortfolioCalculator {
import { Big } from 'big.js'; protected calculateOverallPerformance(): PortfolioSnapshot {
import { addMilliseconds, differenceInDays, format, isBefore } from 'date-fns'; throw new Error('Method not implemented.');
import { cloneDeep, sortBy } from 'lodash';
export class TWRPortfolioCalculator extends PortfolioCalculator {
private chartDates: string[];
protected calculateOverallPerformance(
positions: TimelinePosition[]
): PortfolioSnapshot {
let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let totalFeesWithCurrencyEffect = new Big(0);
const totalInterestWithCurrencyEffect = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalTimeWeightedInvestment = new Big(0);
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.feeInBaseCurrency) {
totalFeesWithCurrencyEffect = totalFeesWithCurrencyEffect.plus(
currentPosition.feeInBaseCurrency
);
}
if (currentPosition.valueInBaseCurrency) {
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.valueInBaseCurrency
);
} else {
hasErrors = true;
}
if (currentPosition.investment) {
totalInvestment = totalInvestment.plus(currentPosition.investment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
currentPosition.investmentWithCurrencyEffect
);
} else {
hasErrors = true;
}
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
grossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.plus(
currentPosition.grossPerformanceWithCurrencyEffect
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.timeWeightedInvestment) {
totalTimeWeightedInvestment = totalTimeWeightedInvestment.plus(
currentPosition.timeWeightedInvestment
);
totalTimeWeightedInvestmentWithCurrencyEffect =
totalTimeWeightedInvestmentWithCurrencyEffect.plus(
currentPosition.timeWeightedInvestmentWithCurrencyEffect
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing historical market data for ${currentPosition.symbol} (${currentPosition.dataSource})`,
'PortfolioCalculator'
);
hasErrors = true;
}
}
return {
currentValueInBaseCurrency,
hasErrors,
positions,
totalFeesWithCurrencyEffect,
totalInterestWithCurrencyEffect,
totalInvestment,
totalInvestmentWithCurrencyEffect,
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; end: Date;
exchangeRates: { [dateString: string]: number }; exchangeRates: { [dateString: string]: number };
marketSymbolMap: { marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
}; };
start: Date; start: Date;
step?: number;
} & AssetProfileIdentifier): SymbolMetrics { } & AssetProfileIdentifier): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)]; throw new Error('Method not implemented.');
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let fees = new Big(0);
let feesAtStartDate = new Big(0);
let feesAtStartDateWithCurrencyEffect = new Big(0);
let feesWithCurrencyEffect = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceAtStartDateWithCurrencyEffect = new Big(0);
let grossPerformanceFromSells = new Big(0);
let grossPerformanceFromSellsWithCurrencyEffect = new Big(0);
let initialValue: Big;
let initialValueWithCurrencyEffect: Big;
let investmentAtStartDate: Big;
let investmentAtStartDateWithCurrencyEffect: Big;
const investmentValuesAccumulated: { [date: string]: Big } = {};
const investmentValuesAccumulatedWithCurrencyEffect: {
[date: string]: Big;
} = {};
const investmentValuesWithCurrencyEffect: { [date: string]: Big } = {};
let lastAveragePrice = new Big(0);
let lastAveragePriceWithCurrencyEffect = new Big(0);
const netPerformanceValues: { [date: string]: Big } = {};
const netPerformanceValuesWithCurrencyEffect: { [date: string]: Big } = {};
const timeWeightedInvestmentValues: { [date: string]: Big } = {};
const timeWeightedInvestmentValuesWithCurrencyEffect: {
[date: string]: Big;
} = {};
const totalAccountBalanceInBaseCurrency = new Big(0);
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInterest = new Big(0);
let totalInterestInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalLiabilities = new Big(0);
let totalLiabilitiesInBaseCurrency = new Big(0);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let totalValuables = new Big(0);
let totalValuablesInBaseCurrency = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(
this.activities.filter(({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
})
);
if (orders.length <= 0) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: false,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
netPerformanceWithCurrencyEffectMap: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
const dateOfFirstTransaction = new Date(orders[0].date);
const endDateString = format(end, DATE_FORMAT);
const startDateString = format(start, DATE_FORMAT);
const unitPriceAtStartDate = marketSymbolMap[startDateString]?.[symbol];
const unitPriceAtEndDate = marketSymbolMap[endDateString]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
currentValues: {},
currentValuesWithCurrencyEffect: {},
feesWithCurrencyEffect: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0),
grossPerformanceWithCurrencyEffect: new Big(0),
hasErrors: true,
initialValue: new Big(0),
initialValueWithCurrencyEffect: new Big(0),
investmentValuesAccumulated: {},
investmentValuesAccumulatedWithCurrencyEffect: {},
investmentValuesWithCurrencyEffect: {},
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
netPerformancePercentageWithCurrencyEffectMap: {},
netPerformanceWithCurrencyEffectMap: {},
netPerformanceValues: {},
netPerformanceValuesWithCurrencyEffect: {},
timeWeightedInvestment: new Big(0),
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalAccountBalanceInBaseCurrency: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInterest: new Big(0),
totalInterestInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0),
totalLiabilities: new Big(0),
totalLiabilitiesInBaseCurrency: new Big(0),
totalValuables: new Big(0),
totalValuablesInBaseCurrency: new Big(0)
};
}
// Add a synthetic order at the start and the end date
orders.push({
date: startDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
date: endDateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
let lastUnitPrice: Big;
const ordersByDate: { [date: string]: PortfolioOrderItem[] } = {};
for (const order of orders) {
ordersByDate[order.date] = ordersByDate[order.date] ?? [];
ordersByDate[order.date].push(order);
}
if (!this.chartDates) {
this.chartDates = Object.keys(chartDateMap).sort();
}
for (const dateString of this.chartDates) {
if (dateString < startDateString) {
continue;
} else if (dateString > endDateString) {
break;
}
if (ordersByDate[dateString]?.length > 0) {
for (const order of ordersByDate[dateString]) {
order.unitPriceFromMarketData =
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice;
}
} else {
orders.push({
date: dateString,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
quantity: new Big(0),
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice,
unitPriceFromMarketData:
marketSymbolMap[dateString]?.[symbol] ?? lastUnitPrice
});
}
const lastOrder = orders.at(-1);
lastUnitPrice = lastOrder.unitPriceFromMarketData ?? lastOrder.unitPrice;
}
// Sort orders so that the start and end placeholder order are at the correct
// position
orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(date);
if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return itemType === 'start';
});
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return itemType === 'end';
});
let totalInvestmentDays = 0;
let sumOfTimeWeightedInvestments = new Big(0);
let sumOfTimeWeightedInvestmentsWithCurrencyEffect = new Big(0);
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log();
console.log();
console.log(
i + 1,
order.date,
order.type,
order.itemType ? `(${order.itemType})` : ''
);
}
const exchangeRateAtOrderDate = exchangeRates[order.date];
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'INTEREST') {
const interest = order.quantity.mul(order.unitPrice);
totalInterest = totalInterest.plus(interest);
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
interest.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'ITEM') {
const valuables = order.quantity.mul(order.unitPrice);
totalValuables = totalValuables.plus(valuables);
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
valuables.mul(exchangeRateAtOrderDate ?? 1)
);
} else if (order.type === 'LIABILITY') {
const liabilities = order.quantity.mul(order.unitPrice);
totalLiabilities = totalLiabilities.plus(liabilities);
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
liabilities.mul(exchangeRateAtOrderDate ?? 1)
);
}
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
if (order.fee) {
order.feeInBaseCurrency = order.fee.mul(currentExchangeRate ?? 1);
order.feeInBaseCurrencyWithCurrencyEffect = order.fee.mul(
exchangeRateAtOrderDate ?? 1
);
}
const unitPrice = ['BUY', 'SELL'].includes(order.type)
? order.unitPrice
: order.unitPriceFromMarketData;
if (unitPrice) {
order.unitPriceInBaseCurrency = unitPrice.mul(currentExchangeRate ?? 1);
order.unitPriceInBaseCurrencyWithCurrencyEffect = unitPrice.mul(
exchangeRateAtOrderDate ?? 1
);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPriceInBaseCurrency
);
const valueOfInvestmentBeforeTransactionWithCurrencyEffect =
totalUnits.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
investmentAtStartDateWithCurrencyEffect =
totalInvestmentWithCurrencyEffect ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
valueAtStartDateWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
}
let transactionInvestment = new Big(0);
let transactionInvestmentWithCurrencyEffect = new Big(0);
if (order.type === 'BUY') {
transactionInvestment = order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(getFactor(order.type));
totalQuantityFromBuyTransactions =
totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('order.quantity', order.quantity.toNumber());
console.log('transactionInvestment', transactionInvestment.toNumber());
console.log(
'transactionInvestmentWithCurrencyEffect',
transactionInvestmentWithCurrencyEffect.toNumber()
);
}
const totalInvestmentBeforeTransaction = totalInvestment;
const totalInvestmentBeforeTransactionWithCurrencyEffect =
totalInvestmentWithCurrencyEffect;
totalInvestment = totalInvestment.plus(transactionInvestment);
totalInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
initialValueWithCurrencyEffect =
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
initialValueWithCurrencyEffect =
transactionInvestmentWithCurrencyEffect;
}
}
fees = fees.plus(order.feeInBaseCurrency ?? 0);
feesWithCurrencyEffect = feesWithCurrencyEffect.plus(
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
order.unitPriceInBaseCurrencyWithCurrencyEffect
);
const grossPerformanceFromSell =
order.type === 'SELL'
? order.unitPriceInBaseCurrency
.minus(lastAveragePrice)
.mul(order.quantity)
: new Big(0);
const grossPerformanceFromSellWithCurrencyEffect =
order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
grossPerformanceFromSellsWithCurrencyEffect =
grossPerformanceFromSellsWithCurrencyEffect.plus(
grossPerformanceFromSellWithCurrencyEffect
);
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
? new Big(0)
: totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0)
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalQuantityFromBuyTransactions
);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber()
);
console.log(
'grossPerformanceFromSellWithCurrencyEffect',
grossPerformanceFromSellWithCurrencyEffect.toNumber()
);
}
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
const newGrossPerformanceWithCurrencyEffect =
valueOfInvestmentWithCurrencyEffect
.minus(totalInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellsWithCurrencyEffect);
grossPerformance = newGrossPerformance;
grossPerformanceWithCurrencyEffect =
newGrossPerformanceWithCurrencyEffect;
if (order.itemType === 'start') {
feesAtStartDate = fees;
feesAtStartDateWithCurrencyEffect = feesWithCurrencyEffect;
grossPerformanceAtStartDate = grossPerformance;
grossPerformanceAtStartDateWithCurrencyEffect =
grossPerformanceWithCurrencyEffect;
}
if (i > indexOfStartOrder) {
// Only consider periods with an investment for the calculation of
// the time weighted investment
if (
valueOfInvestmentBeforeTransaction.gt(0) &&
['BUY', 'SELL'].includes(order.type)
) {
// Calculate the number of days since the previous order
const orderDate = new Date(order.date);
const previousOrderDate = new Date(orders[i - 1].date);
let daysSinceLastOrder = differenceInDays(
orderDate,
previousOrderDate
);
if (daysSinceLastOrder <= 0) {
// The time between two activities on the same day is unknown
// -> Set it to the smallest floating point number greater than 0
daysSinceLastOrder = Number.EPSILON;
}
// Sum up the total investment days since the start date to calculate
// the time weighted investment
totalInvestmentDays += daysSinceLastOrder;
sumOfTimeWeightedInvestments = sumOfTimeWeightedInvestments.add(
valueAtStartDate
.minus(investmentAtStartDate)
.plus(totalInvestmentBeforeTransaction)
.mul(daysSinceLastOrder)
);
sumOfTimeWeightedInvestmentsWithCurrencyEffect =
sumOfTimeWeightedInvestmentsWithCurrencyEffect.add(
valueAtStartDateWithCurrencyEffect
.minus(investmentAtStartDateWithCurrencyEffect)
.plus(totalInvestmentBeforeTransactionWithCurrencyEffect)
.mul(daysSinceLastOrder)
);
}
currentValues[order.date] = valueOfInvestment;
currentValuesWithCurrencyEffect[order.date] =
valueOfInvestmentWithCurrencyEffect;
netPerformanceValues[order.date] = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
netPerformanceValuesWithCurrencyEffect[order.date] =
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.minus(
feesWithCurrencyEffect.minus(feesAtStartDateWithCurrencyEffect)
);
investmentValuesAccumulated[order.date] = totalInvestment;
investmentValuesAccumulatedWithCurrencyEffect[order.date] =
totalInvestmentWithCurrencyEffect;
investmentValuesWithCurrencyEffect[order.date] = (
investmentValuesWithCurrencyEffect[order.date] ?? new Big(0)
).add(transactionInvestmentWithCurrencyEffect);
timeWeightedInvestmentValues[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
timeWeightedInvestmentValuesWithCurrencyEffect[order.date] =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber());
console.log(
'totalInvestmentWithCurrencyEffect',
totalInvestmentWithCurrencyEffect.toNumber()
);
console.log(
'totalGrossPerformance',
grossPerformance.minus(grossPerformanceAtStartDate).toNumber()
);
console.log(
'totalGrossPerformanceWithCurrencyEffect',
grossPerformanceWithCurrencyEffect
.minus(grossPerformanceAtStartDateWithCurrencyEffect)
.toNumber()
);
}
if (i === indexOfEndOrder) {
break;
}
}
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalGrossPerformanceWithCurrencyEffect =
grossPerformanceWithCurrencyEffect.minus(
grossPerformanceAtStartDateWithCurrencyEffect
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const timeWeightedAverageInvestmentBetweenStartAndEndDate =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestments.div(totalInvestmentDays)
: new Big(0);
const timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect =
totalInvestmentDays > 0
? sumOfTimeWeightedInvestmentsWithCurrencyEffect.div(
totalInvestmentDays
)
: new Big(0);
const grossPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const grossPerformancePercentageWithCurrencyEffect =
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.gt(
0
)
? totalGrossPerformanceWithCurrencyEffect.div(
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
)
: new Big(0);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const feesPerUnitWithCurrencyEffect = totalUnits.gt(0)
? feesWithCurrencyEffect
.minus(feesAtStartDateWithCurrencyEffect)
.div(totalUnits)
: new Big(0);
const netPerformancePercentage =
timeWeightedAverageInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(
timeWeightedAverageInvestmentBetweenStartAndEndDate
)
: new Big(0);
const netPerformancePercentageWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
const netPerformanceWithCurrencyEffectMap: {
[key: DateRange]: Big;
} = {};
for (const dateRange of [
'1d',
'1y',
'5y',
'max',
'mtd',
'wtd',
'ytd'
// TODO:
// ...eachYearOfInterval({ end, start })
// .filter((date) => {
// return !isThisYear(date);
// })
// .map((date) => {
// return format(date, 'yyyy');
// })
] as DateRange[]) {
const dateInterval = getIntervalFromDateRange(dateRange);
const endDate = dateInterval.endDate;
let startDate = dateInterval.startDate;
if (isBefore(startDate, start)) {
startDate = start;
}
const rangeEndDateString = format(endDate, DATE_FORMAT);
const rangeStartDateString = format(startDate, DATE_FORMAT);
const currentValuesAtDateRangeStartWithCurrencyEffect =
currentValuesWithCurrencyEffect[rangeStartDateString] ?? new Big(0);
const investmentValuesAccumulatedAtStartDateWithCurrencyEffect =
investmentValuesAccumulatedWithCurrencyEffect[rangeStartDateString] ??
new Big(0);
const grossPerformanceAtDateRangeStartWithCurrencyEffect =
currentValuesAtDateRangeStartWithCurrencyEffect.minus(
investmentValuesAccumulatedAtStartDateWithCurrencyEffect
);
let average = new Big(0);
let dayCount = 0;
for (let i = this.chartDates.length - 1; i >= 0; i -= 1) {
const date = this.chartDates[i];
if (date > rangeEndDateString) {
continue;
} else if (date < rangeStartDateString) {
break;
}
if (
investmentValuesAccumulatedWithCurrencyEffect[date] instanceof Big &&
investmentValuesAccumulatedWithCurrencyEffect[date].gt(0)
) {
average = average.add(
investmentValuesAccumulatedWithCurrencyEffect[date].add(
grossPerformanceAtDateRangeStartWithCurrencyEffect
)
);
dayCount++;
}
}
if (dayCount > 0) {
average = average.div(dayCount);
}
netPerformanceWithCurrencyEffectMap[dateRange] =
netPerformanceValuesWithCurrencyEffect[rangeEndDateString]?.minus(
// If the date range is 'max', take 0 as a start value. Otherwise,
// the value of the end of the day of the start date is taken which
// differs from the buying price.
dateRange === 'max'
? new Big(0)
: (netPerformanceValuesWithCurrencyEffect[rangeStartDateString] ??
new Big(0))
) ?? new Big(0);
netPerformancePercentageWithCurrencyEffectMap[dateRange] = average.gt(0)
? netPerformanceWithCurrencyEffectMap[dateRange].div(average)
: new Big(0);
}
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Total investment: ${totalInvestment.toFixed(2)}
Total investment with currency effect: ${totalInvestmentWithCurrencyEffect.toFixed(
2
)}
Time weighted investment: ${timeWeightedAverageInvestmentBetweenStartAndEndDate.toFixed(
2
)}
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Gross performance with currency effect: ${totalGrossPerformanceWithCurrencyEffect.toFixed(
2
)} / ${grossPerformancePercentageWithCurrencyEffect
.mul(100)
.toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Fees per unit with currency effect: ${feesPerUnitWithCurrencyEffect.toFixed(
2
)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%
Net performance with currency effect: ${netPerformancePercentageWithCurrencyEffectMap[
'max'
].toFixed(2)}%`
);
}
return {
currentValues,
currentValuesWithCurrencyEffect,
feesWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
initialValue,
initialValueWithCurrencyEffect,
investmentValuesAccumulated,
investmentValuesAccumulatedWithCurrencyEffect,
investmentValuesWithCurrencyEffect,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffectMap,
netPerformanceValues,
netPerformanceValuesWithCurrencyEffect,
netPerformanceWithCurrencyEffectMap,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalAccountBalanceInBaseCurrency,
totalDividend,
totalDividendInBaseCurrency,
totalInterest,
totalInterestInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
totalLiabilities,
totalLiabilitiesInBaseCurrency,
totalValuables,
totalValuablesInBaseCurrency,
grossPerformance: totalGrossPerformance,
grossPerformanceWithCurrencyEffect:
totalGrossPerformanceWithCurrencyEffect,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
timeWeightedInvestment:
timeWeightedAverageInvestmentBetweenStartAndEndDate,
timeWeightedInvestmentWithCurrencyEffect:
timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect
};
} }
} }

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

@ -105,6 +105,7 @@ export class PortfolioController {
const { const {
accounts, accounts,
createdAt,
hasErrors, hasErrors,
holdings, holdings,
markets, markets,
@ -233,6 +234,7 @@ export class PortfolioController {
return { return {
accounts, accounts,
createdAt,
hasError, hasError,
holdings, holdings,
platforms, platforms,

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

@ -15,6 +15,11 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
@ -77,7 +82,7 @@ import {
parseISO, parseISO,
set set
} from 'date-fns'; } from 'date-fns';
import { isEmpty, uniq } from 'lodash'; import { isEmpty } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator'; import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { import {
@ -290,7 +295,7 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.Settings.settings.baseCurrency
}); });
@ -367,11 +372,11 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency currency: userCurrency
}); });
const { currentValueInBaseCurrency, hasErrors, positions } = const { createdAt, currentValueInBaseCurrency, hasErrors, positions } =
await portfolioCalculator.getSnapshot(); await portfolioCalculator.getSnapshot();
const cashDetails = await this.accountService.getCashDetails({ const cashDetails = await this.accountService.getCashDetails({
@ -612,6 +617,7 @@ export class PortfolioService {
return { return {
accounts, accounts,
createdAt,
hasErrors, hasErrors,
holdings, holdings,
markets, markets,
@ -674,7 +680,7 @@ export class PortfolioService {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
activities, activities,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency currency: userCurrency
}); });
@ -944,7 +950,7 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: this.request.user.Settings.settings.baseCurrency currency: this.request.user.Settings.settings.baseCurrency
}); });
@ -1075,19 +1081,18 @@ export class PortfolioService {
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
const userCurrency = this.getUserCurrency(user); const userCurrency = this.getUserCurrency(user);
const accountBalanceItems = const [accountBalanceItems, { activities }] = await Promise.all([
await this.accountBalanceService.getAccountBalanceItems({ this.accountBalanceService.getAccountBalanceItems({
filters, filters,
userId, userId,
userCurrency userCurrency
}); }),
this.orderService.getOrdersForPortfolioCalculator({
const { activities } =
await this.orderService.getOrdersForPortfolioCalculator({
filters, filters,
userCurrency, userCurrency,
userId userId
}); })
]);
if (accountBalanceItems.length === 0 && activities.length === 0) { if (accountBalanceItems.length === 0 && activities.length === 0) {
return { return {
@ -1111,7 +1116,7 @@ export class PortfolioService {
activities, activities,
filters, filters,
userId, userId,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: userCurrency currency: userCurrency
}); });
@ -1167,12 +1172,19 @@ export class PortfolioService {
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const userSettings = this.request.user.Settings.settings as UserSettings; const userSettings = this.request.user.Settings.settings as UserSettings;
const { accounts, holdings, markets, summary } = await this.getDetails({ const { accounts, holdings, markets, marketsAdvanced, summary } =
impersonationId, await this.getDetails({
userId, impersonationId,
withMarkets: true, userId,
withSummary: true withMarkets: true,
}); withSummary: true
});
const marketsAdvancedTotalInBaseCurrency = getSum(
Object.values(marketsAdvanced).map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
).toNumber();
const marketsTotalInBaseCurrency = getSum( const marketsTotalInBaseCurrency = getSum(
Object.values(markets).map(({ valueInBaseCurrency }) => { Object.values(markets).map(({ valueInBaseCurrency }) => {
@ -1265,7 +1277,40 @@ export class PortfolioService {
) )
], ],
userSettings userSettings
) ),
regionalMarketClusterRisk:
summary.ordersCount > 0
? await this.rulesService.evaluate(
[
new RegionalMarketClusterRiskAsiaPacific(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.asiaPacific.valueInBaseCurrency
),
new RegionalMarketClusterRiskEmergingMarkets(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.emergingMarkets.valueInBaseCurrency
),
new RegionalMarketClusterRiskEurope(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.europe.valueInBaseCurrency
),
new RegionalMarketClusterRiskJapan(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.japan.valueInBaseCurrency
),
new RegionalMarketClusterRiskNorthAmerica(
this.exchangeRateDataService,
marketsAdvancedTotalInBaseCurrency,
marketsAdvanced.northAmerica.valueInBaseCurrency
)
],
userSettings
)
: undefined
}; };
return { rules, statistics: this.getReportStatistics(rules) }; return { rules, statistics: this.getReportStatistics(rules) };
@ -1987,14 +2032,16 @@ export class PortfolioService {
where: { id: filters[0].id } where: { id: filters[0].id }
}); });
} else { } else {
const accountIds = uniq( const accountIds = Array.from(
activities new Set(
.filter(({ accountId }) => { activities
return accountId; .filter(({ accountId }) => {
}) return accountId;
.map(({ accountId }) => { })
return accountId; .map(({ accountId }) => {
}) return accountId;
})
)
); );
currentAccounts = await this.accountService.accounts({ currentAccounts = await this.accountService.accounts({

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

@ -7,6 +7,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common';
import { Milliseconds } from 'cache-manager'; import { Milliseconds } from 'cache-manager';
import { RedisCache } from 'cache-manager-redis-yet'; import { RedisCache } from 'cache-manager-redis-yet';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import ms from 'ms';
@Injectable() @Injectable()
export class RedisCacheService { export class RedisCacheService {
@ -59,6 +60,26 @@ export class RedisCacheService {
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`; return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
} }
public async isHealthy() {
try {
const client = this.cache.store.client;
const isHealthy = await Promise.race([
client.ping(),
new Promise((_, reject) =>
setTimeout(
() => reject(new Error('Redis health check timeout')),
ms('2 seconds')
)
)
]);
return isHealthy === 'PONG';
} catch (error) {
return false;
}
}
public async remove(key: string) { public async remove(key: string) {
return this.cache.del(key); return this.cache.del(key);
} }

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

@ -47,7 +47,7 @@ export class SymbolController {
try { try {
return this.symbolService.lookup({ return this.symbolService.lookup({
includeIndices, includeIndices,
query: query.toLowerCase(), query,
user: this.request.user user: this.request.user
}); });
} catch { } catch {

6
apps/api/src/app/tag/create-tag.dto.ts

@ -1,6 +0,0 @@
import { IsString } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
}

14
apps/api/src/app/tag/tag.module.ts

@ -1,14 +0,0 @@
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
exports: [TagService],
imports: [PrismaModule],
providers: [TagService]
})
export class TagModule {}

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

@ -1,81 +0,0 @@
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 }
}
}
});
return tagsWithOrderCount.map(({ _count, id, name, userId }) => {
return {
id,
name,
userId,
activityCount: _count.orders
};
});
}
public async updateTag({
data,
where
}: {
data: Prisma.TagUpdateInput;
where: Prisma.TagWhereUniqueInput;
}): Promise<Tag> {
return this.prismaService.tag.update({
data,
where
});
}
}

9
apps/api/src/app/tag/update-tag.dto.ts

@ -1,9 +0,0 @@
import { IsString } from 'class-validator';
export class UpdateTagDto {
@IsString()
id: string;
@IsString()
name: string;
}

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

@ -13,6 +13,11 @@ import { EconomicMarketClusterRiskDevelopedMarkets } from '@ghostfolio/api/model
import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets'; import { EconomicMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/economic-market-cluster-risk/emerging-markets';
import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup'; import { EmergencyFundSetup } from '@ghostfolio/api/models/rules/emergency-fund/emergency-fund-setup';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { RegionalMarketClusterRiskAsiaPacific } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/asia-pacific';
import { RegionalMarketClusterRiskEmergingMarkets } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/emerging-markets';
import { RegionalMarketClusterRiskEurope } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/europe';
import { RegionalMarketClusterRiskJapan } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/japan';
import { RegionalMarketClusterRiskNorthAmerica } from '@ghostfolio/api/models/rules/regional-market-cluster-risk/north-america';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service'; import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
@ -81,6 +86,9 @@ export class UserService {
orderBy: { alias: 'asc' }, orderBy: { alias: 'asc' },
where: { GranteeUser: { id } } where: { GranteeUser: { id } }
}), }),
this.prismaService.order.count({
where: { userId: id }
}),
this.prismaService.order.findFirst({ this.prismaService.order.findFirst({
orderBy: { orderBy: {
date: 'asc' date: 'asc'
@ -91,8 +99,9 @@ export class UserService {
]); ]);
const access = userData[0]; const access = userData[0];
const firstActivity = userData[1]; const activitiesCount = userData[1];
let tags = userData[2]; const firstActivity = userData[2];
let tags = userData[3];
let systemMessage: SystemMessage; let systemMessage: SystemMessage;
@ -112,6 +121,7 @@ export class UserService {
} }
return { return {
activitiesCount,
id, id,
permissions, permissions,
subscription, subscription,
@ -268,7 +278,35 @@ export class UserService {
undefined, undefined,
undefined, undefined,
undefined undefined
).getSettings(user.Settings.settings) ).getSettings(user.Settings.settings),
RegionalMarketClusterRiskAsiaPacific:
new RegionalMarketClusterRiskAsiaPacific(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskEmergingMarkets:
new RegionalMarketClusterRiskEmergingMarkets(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskEurope: new RegionalMarketClusterRiskEurope(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskJapan: new RegionalMarketClusterRiskJapan(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings),
RegionalMarketClusterRiskNorthAmerica:
new RegionalMarketClusterRiskNorthAmerica(
undefined,
undefined,
undefined
).getSettings(user.Settings.settings)
}; };
let currentPermissions = getPermissions(user.role); let currentPermissions = getPermissions(user.role);
@ -313,7 +351,11 @@ export class UserService {
currentPermissions, currentPermissions,
permissions.accessHoldingsChart, permissions.accessHoldingsChart,
permissions.createAccess, permissions.createAccess,
permissions.readAiPrompt permissions.createMarketDataOfOwnAssetProfile,
permissions.createOwnTag,
permissions.readAiPrompt,
permissions.readMarketDataOfOwnAssetProfile,
permissions.updateMarketDataOfOwnAssetProfile
); );
// Reset benchmark // Reset benchmark

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

File diff suppressed because it is too large

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

@ -92,6 +92,10 @@
<loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc> <loc>https://ghostfol.io/de/ueber-uns/oss-friends</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<url>
<loc>https://ghostfol.io/development/storybook</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url> <url>
<loc>https://ghostfol.io/en</loc> <loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -504,12 +508,10 @@
<loc>https://ghostfol.io/pl/o-ghostfolio</loc> <loc>https://ghostfol.io/pl/o-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!-- <url>
<url> <loc>https://ghostfol.io/pl/open</loc>
<loc>https://ghostfol.io/pl/open</loc> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> </url>
</url>
-->
<url> <url>
<loc>https://ghostfol.io/pl/rynki</loc> <loc>https://ghostfol.io/pl/rynki</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
@ -583,6 +585,12 @@
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url> </url>
<!-- <!--
<url>
<loc>https://ghostfol.io/uk</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
-->
<!--
<url> <url>
<loc>https://ghostfol.io/zh</loc> <loc>https://ghostfol.io/zh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod> <lastmod>${currentDate}T00:00:00+00:00</lastmod>

2
apps/api/src/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor.ts

@ -26,7 +26,7 @@ export class TransformDataSourceInRequestInterceptor<T>
const request = http.getRequest(); const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (request.body.activities) { if (request.body?.activities) {
request.body.activities = request.body.activities.map((activity) => { request.body.activities = request.body.activities.map((activity) => {
if (DataSource[activity.dataSource]) { if (DataSource[activity.dataSource]) {
return activity; return activity;

9
apps/api/src/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor.ts

@ -33,9 +33,12 @@ export class TransformDataSourceInResponseInterceptor<T>
attribute: 'dataSource', attribute: 'dataSource',
valueMap: Object.keys(DataSource).reduce( valueMap: Object.keys(DataSource).reduce(
(valueMap, dataSource) => { (valueMap, dataSource) => {
valueMap[dataSource] = encodeDataSource( if (!['MANUAL'].includes(dataSource)) {
DataSource[dataSource] valueMap[dataSource] = encodeDataSource(
); DataSource[dataSource]
);
}
return valueMap; return valueMap;
}, },
{} {}

35
apps/api/src/main.ts

@ -1,3 +1,5 @@
import { STORYBOOK_PATH } from '@ghostfolio/common/config';
import { import {
Logger, Logger,
LogLevel, LogLevel,
@ -7,6 +9,7 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express'; import type { NestExpressApplication } from '@nestjs/platform-express';
import { NextFunction, Request, Response } from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
@ -50,20 +53,24 @@ async function bootstrap() {
app.useBodyParser('json', { limit: '10mb' }); app.useBodyParser('json', { limit: '10mb' });
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use( app.use((req: Request, res: Response, next: NextFunction) => {
helmet({ if (req.path.startsWith(STORYBOOK_PATH)) {
contentSecurityPolicy: { next();
directives: { } else {
connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe helmet({
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe contentSecurityPolicy: {
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe directives: {
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
} scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
}, scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity) styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}) }
); },
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})(req, res, next);
}
});
} }
app.use(HtmlTemplateMiddleware); app.use(HtmlTemplateMiddleware);

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

@ -3,6 +3,7 @@ import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
import { import {
DEFAULT_LANGUAGE_CODE, DEFAULT_LANGUAGE_CODE,
DEFAULT_ROOT_URL, DEFAULT_ROOT_URL,
STORYBOOK_PATH,
SUPPORTED_LANGUAGE_CODES SUPPORTED_LANGUAGE_CODES
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, interpolate } from '@ghostfolio/common/helper';
@ -129,6 +130,7 @@ export const HtmlTemplateMiddleware = async (
if ( if (
path.startsWith('/api/') || path.startsWith('/api/') ||
path.startsWith(STORYBOOK_PATH) ||
isFileRequest(path) || isFileRequest(path) ||
!environment.production !environment.production
) { ) {

77
apps/api/src/models/rules/regional-market-cluster-risk/asia-pacific.ts

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskAsiaPacific extends Rule<Settings> {
private asiaPacificValueInBaseCurrency: number;
private currentValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
asiaPacificValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskAsiaPacific.name,
name: 'Asia-Pacific'
});
this.asiaPacificValueInBaseCurrency = asiaPacificValueInBaseCurrency;
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const asiaPacificMarketValueRatio = this.currentValueInBaseCurrency
? this.asiaPacificValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (asiaPacificMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (asiaPacificMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Asia-Pacific market contribution of your current investment (${(asiaPacificMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.03,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.02
};
}
}

79
apps/api/src/models/rules/regional-market-cluster-risk/emerging-markets.ts

@ -0,0 +1,79 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskEmergingMarkets extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private emergingMarketsValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
emergingMarketsValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEmergingMarkets.name,
name: 'Emerging Markets'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.emergingMarketsValueInBaseCurrency =
emergingMarketsValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const emergingMarketsValueRatio = this.currentValueInBaseCurrency
? this.emergingMarketsValueInBaseCurrency /
this.currentValueInBaseCurrency
: 0;
if (emergingMarketsValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Emerging Markets contribution of your current investment (${(emergingMarketsValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (emergingMarketsValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Emerging Markets contribution of your current investment (${(emergingMarketsValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Emerging Markets contribution of your current investment (${(emergingMarketsValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.12,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.08
};
}
}

77
apps/api/src/models/rules/regional-market-cluster-risk/europe.ts

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskEurope extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private europeValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
europeValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskEurope.name,
name: 'Europe'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.europeValueInBaseCurrency = europeValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const europeMarketValueRatio = this.currentValueInBaseCurrency
? this.europeValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (europeMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Europe market contribution of your current investment (${(europeMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (europeMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Europe market contribution of your current investment (${(europeMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Europe market contribution of your current investment (${(europeMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.15,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.11
};
}
}

7
apps/api/src/models/rules/regional-market-cluster-risk/interfaces/rule-settings.interface.ts

@ -0,0 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
export interface Settings extends RuleSettings {
baseCurrency: string;
thresholdMin: number;
thresholdMax: number;
}

77
apps/api/src/models/rules/regional-market-cluster-risk/japan.ts

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskJapan extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private japanValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
japanValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskJapan.name,
name: 'Japan'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.japanValueInBaseCurrency = japanValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const japanMarketValueRatio = this.currentValueInBaseCurrency
? this.japanValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (japanMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (japanMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The Japan market contribution of your current investment (${(japanMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.06,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.04
};
}
}

77
apps/api/src/models/rules/regional-market-cluster-risk/north-america.ts

@ -0,0 +1,77 @@
import { Rule } from '@ghostfolio/api/models/rule';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { UserSettings } from '@ghostfolio/common/interfaces';
import { Settings } from './interfaces/rule-settings.interface';
export class RegionalMarketClusterRiskNorthAmerica extends Rule<Settings> {
private currentValueInBaseCurrency: number;
private northAmericaValueInBaseCurrency: number;
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
currentValueInBaseCurrency: number,
northAmericaValueInBaseCurrency: number
) {
super(exchangeRateDataService, {
key: RegionalMarketClusterRiskNorthAmerica.name,
name: 'North America'
});
this.currentValueInBaseCurrency = currentValueInBaseCurrency;
this.northAmericaValueInBaseCurrency = northAmericaValueInBaseCurrency;
}
public evaluate(ruleSettings: Settings) {
const northAmericaMarketValueRatio = this.currentValueInBaseCurrency
? this.northAmericaValueInBaseCurrency / this.currentValueInBaseCurrency
: 0;
if (northAmericaMarketValueRatio > ruleSettings.thresholdMax) {
return {
evaluation: `The North America market contribution of your current investment (${(northAmericaMarketValueRatio * 100).toPrecision(3)}%) exceeds ${(
ruleSettings.thresholdMax * 100
).toPrecision(3)}%`,
value: false
};
} else if (northAmericaMarketValueRatio < ruleSettings.thresholdMin) {
return {
evaluation: `The North America market contribution of your current investment (${(northAmericaMarketValueRatio * 100).toPrecision(3)}%) is below ${(
ruleSettings.thresholdMin * 100
).toPrecision(3)}%`,
value: false
};
}
return {
evaluation: `The North America market contribution of your current investment (${(northAmericaMarketValueRatio * 100).toPrecision(3)}%) is within the range of ${(
ruleSettings.thresholdMin * 100
).toPrecision(
3
)}% and ${(ruleSettings.thresholdMax * 100).toPrecision(3)}%`,
value: true
};
}
public getConfiguration() {
return {
threshold: {
max: 1,
min: 0,
step: 0.01,
unit: '%'
},
thresholdMax: true,
thresholdMin: true
};
}
public getSettings({ baseCurrency, xRayRules }: UserSettings): Settings {
return {
baseCurrency,
isActive: xRayRules?.[this.getKey()]?.isActive ?? true,
thresholdMax: xRayRules?.[this.getKey()]?.thresholdMax ?? 0.69,
thresholdMin: xRayRules?.[this.getKey()]?.thresholdMin ?? 0.65
};
}
}

24
apps/api/src/services/benchmark/benchmark.module.ts

@ -0,0 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkService } from './benchmark.service';
@Module({
exports: [BenchmarkService],
imports: [
DataProviderModule,
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

12
apps/api/src/app/benchmark/benchmark.service.spec.ts → apps/api/src/services/benchmark/benchmark.service.spec.ts

@ -4,17 +4,7 @@ describe('BenchmarkService', () => {
let benchmarkService: BenchmarkService; let benchmarkService: BenchmarkService;
beforeAll(async () => { beforeAll(async () => {
benchmarkService = new BenchmarkService( benchmarkService = new BenchmarkService(null, null, null, null, null, null);
null,
null,
null,
null,
null,
null,
null,
null,
null
);
}); });
it('calculateChangeInPercentage', async () => { it('calculateChangeInPercentage', async () => {

162
apps/api/src/app/benchmark/benchmark.service.ts → apps/api/src/services/benchmark/benchmark.service.ts

@ -1,8 +1,5 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -11,16 +8,10 @@ import {
CACHE_TTL_INFINITE, CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { import { calculateBenchmarkTrend } from '@ghostfolio/common/helper';
DATE_FORMAT,
calculateBenchmarkTrend,
parseDate,
resetHours
} from '@ghostfolio/common/helper';
import { import {
AssetProfileIdentifier, AssetProfileIdentifier,
Benchmark, Benchmark,
BenchmarkMarketDataDetails,
BenchmarkProperty, BenchmarkProperty,
BenchmarkResponse BenchmarkResponse
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -29,16 +20,8 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { Big } from 'big.js'; import { Big } from 'big.js';
import { import { addHours, isAfter, subDays } from 'date-fns';
addHours, import { uniqBy } from 'lodash';
differenceInDays,
eachDayOfInterval,
format,
isAfter,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, uniqBy } from 'lodash';
import ms from 'ms'; import ms from 'ms';
import { BenchmarkValue } from './interfaces/benchmark-value.interface'; import { BenchmarkValue } from './interfaces/benchmark-value.interface';
@ -48,15 +31,12 @@ export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS'; private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService, private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService, private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService, private readonly symbolProfileService: SymbolProfileService
private readonly symbolService: SymbolService
) {} ) {}
public calculateChangeInPercentage(baseValue: number, currentValue: number) { public calculateChangeInPercentage(baseValue: number, currentValue: number) {
@ -153,139 +133,9 @@ export class BenchmarkService {
symbol symbol
}; };
}) })
.sort((a, b) => a.name.localeCompare(b.name)); .sort((a, b) => {
} return a.name?.localeCompare(b?.name) ?? 0;
public async getMarketDataForUser({
dataSource,
endDate = new Date(),
startDate,
symbol,
userCurrency
}: {
endDate?: Date;
startDate: Date;
userCurrency: string;
} & AssetProfileIdentifier): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
const dates = eachDayOfInterval(
{
start: startDate,
end: endDate
},
{
step: Math.round(
days /
Math.min(days, this.configurationService.get('MAX_CHART_ITEMS'))
)
}
).map((date) => {
return resetHours(date);
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
dataSource,
symbol
}
}),
this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
dataSource,
symbol,
date: {
in: dates
}
}
})
]);
const exchangeRates =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
startDate,
currencies: [currentSymbolItem.currency],
targetCurrency: userCurrency
});
const exchangeRateAtStartDate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(startDate, DATE_FORMAT)
];
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
return isSameDay(date, startDate);
})?.marketPrice;
if (!marketPriceAtStartDate) {
Logger.error(
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
startDate,
DATE_FORMAT
)}`,
'BenchmarkService'
);
return { marketData };
}
for (const marketDataItem of marketDataItems) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(marketDataItem.date, DATE_FORMAT),
value:
marketPriceAtStartDate === 0
? 0
: this.calculateChangeInPercentage(
marketPriceAtStartDate,
marketDataItem.marketPrice * exchangeRateFactor
) * 100
});
}
const includesEndDate = isSameDay(
parseDate(marketData.at(-1).date),
endDate
);
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
? exchangeRate / exchangeRateAtStartDate
: 1;
marketData.push({
date: format(endDate, DATE_FORMAT),
value:
this.calculateChangeInPercentage(
marketPriceAtStartDate,
currentSymbolItem.marketPrice * exchangeRateFactor
) * 100
}); });
}
return {
marketData
};
} }
public async addBenchmark({ public async addBenchmark({

0
apps/api/src/app/benchmark/interfaces/benchmark-value.interface.ts → apps/api/src/services/benchmark/interfaces/benchmark-value.interface.ts

8
apps/api/src/services/cryptocurrency/cryptocurrency.service.ts

@ -1,3 +1,5 @@
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json'); const cryptocurrencies = require('../../assets/cryptocurrencies/cryptocurrencies.json');
@ -9,7 +11,11 @@ export class CryptocurrencyService {
public isCryptocurrency(aSymbol = '') { public isCryptocurrency(aSymbol = '') {
const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3); const cryptocurrencySymbol = aSymbol.substring(0, aSymbol.length - 3);
return this.getCryptocurrencies().includes(cryptocurrencySymbol);
return (
aSymbol.endsWith(DEFAULT_CURRENCY) &&
this.getCryptocurrencies().includes(cryptocurrencySymbol)
);
} }
private getCryptocurrencies() { private getCryptocurrencies() {

5
apps/api/src/services/data-provider/alpha-vantage/alpha-vantage.service.ts

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -41,9 +42,7 @@ export class AlphaVantageService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -56,9 +57,7 @@ export class CoinGeckoService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol, symbol,
assetClass: AssetClass.LIQUIDITY, assetClass: AssetClass.LIQUIDITY,
@ -112,7 +111,7 @@ export class CoinGeckoService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
try { try {
const { prices } = await fetch( const { error, prices, status } = await fetch(
`${ `${
this.apiUrl this.apiUrl
}/coins/${symbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime( }/coins/${symbol}/market_chart/range?vs_currency=${DEFAULT_CURRENCY.toLowerCase()}&from=${getUnixTime(
@ -124,6 +123,14 @@ export class CoinGeckoService implements DataProviderInterface {
} }
).then((res) => res.json()); ).then((res) => res.json());
if (error?.status) {
throw new Error(error.status.error_message);
}
if (status) {
throw new Error(status.error_message);
}
const result: { const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = { } = {

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

@ -44,60 +44,54 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response; return response;
} }
let trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol
});
if (!trackinsightSymbol) {
trackinsightSymbol = await this.searchTrackinsightSymbol({
requestTimeout,
symbol: symbol.split('.')?.[0]
});
}
if (!trackinsightSymbol) {
return response;
}
const profile = await fetch( const profile = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/funds/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/funds/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout(requestTimeout) signal: AbortSignal.timeout(requestTimeout)
} }
) )
.then((res) => res.json()) .then((res) => res.json())
.catch(() => { .catch(() => {
return fetch( return {};
`${TrackinsightDataEnhancerService.baseUrl}/funds/${
symbol.split('.')?.[0]
}.json`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
)
.then((res) => res.json())
.catch(() => {
return {};
});
}); });
const isin = profile?.isin?.split(';')?.[0]; const cusip = profile?.cusip;
if (cusip) {
response.cusip = cusip;
}
const isin = profile?.isins?.[0];
if (isin) { if (isin) {
response.isin = isin; response.isin = isin;
} }
const holdings = await fetch( const holdings = await fetch(
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${symbol}.json`, `${TrackinsightDataEnhancerService.baseUrl}/holdings/${trackinsightSymbol}.json`,
{ {
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
) )
.then((res) => res.json()) .then((res) => res.json())
.catch(() => { .catch(() => {
return fetch( return {};
`${TrackinsightDataEnhancerService.baseUrl}/holdings/${
symbol.split('.')?.[0]
}.json`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
)
.then((res) => res.json())
.catch(() => {
return {};
});
}); });
if ( if (
@ -180,4 +174,36 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
public getTestSymbol() { public getTestSymbol() {
return 'QQQ'; return 'QQQ';
} }
private async searchTrackinsightSymbol({
requestTimeout,
symbol
}: {
requestTimeout: number;
symbol: string;
}) {
return fetch(
`https://www.trackinsight.com/search-api/search_v2/${symbol}/_/ticker/default/0/3`,
{
signal: AbortSignal.timeout(requestTimeout)
}
)
.then((res) => res.json())
.then((jsonRes) => {
if (
jsonRes['results']?.['count'] === 1 ||
// Allow exact match
jsonRes['results']?.['docs']?.[0]?.['ticker'] === symbol ||
// Allow EXCHANGE:SYMBOL
jsonRes['results']?.['docs']?.[0]?.['ticker']?.endsWith(`:${symbol}`)
) {
return jsonRes['results']['docs'][0]['ticker'];
}
return undefined;
})
.catch(() => {
return undefined;
});
}
} }

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

@ -170,6 +170,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
symbol = quotes[0].symbol; symbol = quotes[0].symbol;
} }
} catch {} } catch {}
} else if (symbol?.includes('-')) {
throw new Error(`${symbol} is not valid`);
} else { } else {
symbol = this.convertToYahooFinanceSymbol(symbol); symbol = this.convertToYahooFinanceSymbol(symbol);
} }
@ -197,7 +199,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
assetProfile.price.symbol assetProfile.price.symbol
); );
if (assetSubClass === AssetSubClass.MUTUALFUND) { if (['ETF', 'MUTUALFUND'].includes(assetSubClass)) {
response.sectors = []; response.sectors = [];
for (const sectorWeighting of assetProfile.topHoldings for (const sectorWeighting of assetProfile.topHoldings
@ -207,7 +209,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
} }
} }
} else if ( } else if (
assetSubClass === AssetSubClass.STOCK && assetSubClass === 'STOCK' &&
assetProfile.summaryProfile?.country assetProfile.summaryProfile?.country
) { ) {
// Add country if asset is stock and country available // Add country if asset is stock and country available

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -51,9 +52,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(symbol); const [searchResult] = await this.getSearchResult(symbol);
return { return {

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

@ -1,6 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -10,7 +12,7 @@ import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DEFAULT_CURRENCY } from '@ghostfolio/common/config'; import { REPLACE_NAME_PARTS } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
DataProviderInfo, DataProviderInfo,
@ -19,16 +21,32 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client'; import {
import { format, isAfter, isBefore, isSameDay } from 'date-fns'; AssetClass,
AssetSubClass,
DataSource,
SymbolProfile
} from '@prisma/client';
import { isISIN } from 'class-validator';
import { countries } from 'countries-list';
import {
addDays,
addYears,
format,
isAfter,
isBefore,
isSameDay,
parseISO
} from 'date-fns';
@Injectable() @Injectable()
export class FinancialModelingPrepService implements DataProviderInterface { export class FinancialModelingPrepService implements DataProviderInterface {
private apiKey: string; private apiKey: string;
private readonly URL = 'https://financialmodelingprep.com/api/v3'; private readonly URL = this.getUrl({ version: 3 });
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) { ) {
this.apiKey = this.configurationService.get( this.apiKey = this.configurationService.get(
'API_KEY_FINANCIAL_MODELING_PREP' 'API_KEY_FINANCIAL_MODELING_PREP'
@ -40,14 +58,154 @@ export class FinancialModelingPrepService implements DataProviderInterface {
} }
public async getAssetProfile({ public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string; const response: Partial<SymbolProfile> = {
}): Promise<Partial<SymbolProfile>> {
return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
}; };
try {
if (this.cryptocurrencyService.isCryptocurrency(symbol)) {
const [quote] = await fetch(
`${this.URL}/quote/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
response.assetClass = AssetClass.LIQUIDITY;
response.assetSubClass = AssetSubClass.CRYPTOCURRENCY;
response.currency = symbol.substring(symbol.length - 3);
response.name = quote.name;
} else {
const [assetProfile] = await fetch(
`${this.URL}/profile/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
const { assetClass, assetSubClass } =
this.parseAssetClass(assetProfile);
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
if (assetSubClass === AssetSubClass.ETF) {
const etfCountryWeightings = await fetch(
`${this.URL}/etf-country-weightings/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
response.countries = etfCountryWeightings.map(
({ country: countryName, weightPercentage }) => {
let countryCode: string;
for (const [code, country] of Object.entries(countries)) {
if (country.name === countryName) {
countryCode = code;
break;
}
}
return {
code: countryCode,
weight: parseFloat(weightPercentage.slice(0, -1)) / 100
};
}
);
const [etfInformation] = await fetch(
`${this.getUrl({ version: 4 })}/etf-info?symbol=${symbol}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
if (etfInformation.website) {
response.url = etfInformation.website;
}
const [portfolioDate] = await fetch(
`${this.getUrl({ version: 4 })}/etf-holdings/portfolio-date?symbol=${symbol}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
if (portfolioDate) {
const etfHoldings = await fetch(
`${this.getUrl({ version: 4 })}/etf-holdings?date=${portfolioDate.date}&symbol=${symbol}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
const sortedTopHoldings = etfHoldings
.sort((a, b) => {
return b.pctVal - a.pctVal;
})
.slice(0, 10);
response.holdings = sortedTopHoldings.map(({ name, pctVal }) => {
return { name, weight: pctVal / 100 };
});
}
const etfSectorWeightings = await fetch(
`${this.URL}/etf-sector-weightings/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
response.sectors = etfSectorWeightings.map(
({ sector, weightPercentage }) => {
return {
name: sector,
weight: parseFloat(weightPercentage.slice(0, -1)) / 100
};
}
);
} else if (assetSubClass === AssetSubClass.STOCK) {
if (assetProfile.country) {
response.countries = [{ code: assetProfile.country, weight: 1 }];
}
if (assetProfile.sector) {
response.sectors = [{ name: assetProfile.sector, weight: 1 }];
}
}
response.currency = assetProfile.currency;
if (assetProfile.isin) {
response.isin = assetProfile.isin;
}
response.name = this.formatName({ name: assetProfile.companyName });
if (assetProfile.website) {
response.url = assetProfile.website;
}
}
} catch (error) {
let message = error;
if (error?.name === 'AbortError') {
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${(
requestTimeout / 1000
).toFixed(3)} seconds`;
}
Logger.error(message, 'FinancialModelingPrepService');
}
return response;
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
@ -58,8 +216,54 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}; };
} }
public async getDividends({}: GetDividendsParams) { public async getDividends({
return {}; from,
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol,
to
}: GetDividendsParams) {
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
try {
const response: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
const { historical = [] } = await fetch(
`${this.URL}/historical-price-full/stock_dividend/${symbol}?apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(requestTimeout)
}
).then((res) => res.json());
historical
.filter(({ date }) => {
return (
(isSameDay(parseISO(date), from) ||
isAfter(parseISO(date), from)) &&
isBefore(parseISO(date), to)
);
})
.forEach(({ adjDividend, date }) => {
response[date] = {
marketPrice: adjDividend
};
});
return response;
} catch (error) {
Logger.error(
`Could not get dividends for ${symbol} (${this.getName()}) from ${format(
from,
DATE_FORMAT
)} to ${format(to, DATE_FORMAT)}: [${error.name}] ${error.message}`,
'FinancialModelingPrepService'
);
return {};
}
} }
public async getHistorical({ public async getHistorical({
@ -70,30 +274,44 @@ export class FinancialModelingPrepService implements DataProviderInterface {
}: GetHistoricalParams): Promise<{ }: GetHistoricalParams): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
const MAX_YEARS_PER_REQUEST = 5;
const result: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let currentFrom = from;
try { try {
const { historical } = await fetch( while (isBefore(currentFrom, to) || isSameDay(currentFrom, to)) {
`${this.URL}/historical-price-full/${symbol}?apikey=${this.apiKey}`, const currentTo = isBefore(
{ addYears(currentFrom, MAX_YEARS_PER_REQUEST),
signal: AbortSignal.timeout(requestTimeout) to
} )
).then((res) => res.json()); ? addYears(currentFrom, MAX_YEARS_PER_REQUEST)
: to;
const result: { const { historical = [] } = await fetch(
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; `${this.URL}/historical-price-full/${symbol}?apikey=${this.apiKey}&from=${format(currentFrom, DATE_FORMAT)}&to=${format(currentTo, DATE_FORMAT)}`,
} = { {
[symbol]: {} signal: AbortSignal.timeout(requestTimeout)
}; }
).then((res) => res.json());
for (const { close, date } of historical) {
if ( for (const { adjClose, date } of historical) {
(isSameDay(parseDate(date), from) || if (
isAfter(parseDate(date), from)) && (isSameDay(parseDate(date), currentFrom) ||
isBefore(parseDate(date), to) isAfter(parseDate(date), currentFrom)) &&
) { isBefore(parseDate(date), currentTo)
result[symbol][date] = { ) {
marketPrice: close result[symbol][date] = {
}; marketPrice: adjClose
};
}
} }
currentFrom = addYears(currentFrom, MAX_YEARS_PER_REQUEST);
} }
return result; return result;
@ -130,8 +348,10 @@ export class FinancialModelingPrepService implements DataProviderInterface {
).then((res) => res.json()); ).then((res) => res.json());
for (const { price, symbol } of quotes) { for (const { price, symbol } of quotes) {
const { currency } = await this.getAssetProfile({ symbol });
response[symbol] = { response[symbol] = {
currency: DEFAULT_CURRENCY, currency,
dataProviderInfo: this.getDataProviderInfo(), dataProviderInfo: this.getDataProviderInfo(),
dataSource: DataSource.FINANCIAL_MODELING_PREP, dataSource: DataSource.FINANCIAL_MODELING_PREP,
marketPrice: price, marketPrice: price,
@ -143,7 +363,7 @@ export class FinancialModelingPrepService implements DataProviderInterface {
if (error?.name === 'AbortError') { if (error?.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} }
@ -161,25 +381,49 @@ export class FinancialModelingPrepService implements DataProviderInterface {
let items: LookupItem[] = []; let items: LookupItem[] = [];
try { try {
const result = await fetch( if (isISIN(query)) {
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`, const result = await fetch(
{ `${this.getUrl({ version: 4 })}/search/isin?isin=${query}&apikey=${this.apiKey}`,
signal: AbortSignal.timeout( {
this.configurationService.get('REQUEST_TIMEOUT') signal: AbortSignal.timeout(
) this.configurationService.get('REQUEST_TIMEOUT')
} )
).then((res) => res.json()); }
).then((res) => res.json());
items = result.map(({ currency, name, symbol }) => { items = result.map(({ companyName, currency, symbol }) => {
return { return {
// TODO: Add assetClass currency,
// TODO: Add assetSubClass symbol,
currency, assetClass: undefined, // TODO
name, assetSubClass: undefined, // TODO
symbol, dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName() dataSource: this.getName(),
}; name: this.formatName({ name: companyName })
}); };
});
} else {
const result = await fetch(
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
{
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
}
).then((res) => res.json());
items = result.map(({ currency, name, symbol }) => {
return {
currency,
symbol,
assetClass: undefined, // TODO
assetSubClass: undefined, // TODO
dataProviderInfo: this.getDataProviderInfo(),
dataSource: this.getName(),
name: this.formatName({ name })
};
});
}
} catch (error) { } catch (error) {
let message = error; let message = error;
@ -194,4 +438,41 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return { items }; return { items };
} }
private formatName({ name }: { name: string }) {
if (name) {
for (const part of REPLACE_NAME_PARTS) {
name = name.replace(part, '');
}
name = name.trim();
}
return name;
}
private getUrl({ version }: { version: number }) {
return `https://financialmodelingprep.com/api/v${version}`;
}
private parseAssetClass(profile: any): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
if (profile.isEtf) {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
} else if (profile.isFund) {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
} else {
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
}
return { assetClass, assetSubClass };
}
} }

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

@ -2,6 +2,7 @@ import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -18,6 +19,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { import {
DataProviderGhostfolioAssetProfileResponse,
DataProviderInfo, DataProviderInfo,
DividendsResponse, DividendsResponse,
HistoricalResponse, HistoricalResponse,
@ -46,21 +48,46 @@ export class GhostfolioService implements DataProviderInterface {
} }
public async getAssetProfile({ public async getAssetProfile({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string; let response: DataProviderGhostfolioAssetProfileResponse = {};
}): Promise<Partial<SymbolProfile>> {
const { items } = await this.search({ query: symbol });
const searchResult = items?.[0];
return { try {
symbol, const assetProfile = (await fetch(
assetClass: searchResult?.assetClass, `${this.URL}/v1/data-providers/ghostfolio/asset-profile/${symbol}`,
assetSubClass: searchResult?.assetSubClass, {
currency: searchResult?.currency, headers: await this.getRequestHeaders(),
dataSource: this.getName(), signal: AbortSignal.timeout(requestTimeout)
name: searchResult?.name }
}; ).then((res) =>
res.json()
)) as DataProviderGhostfolioAssetProfileResponse;
response = assetProfile;
} catch (error) {
let message = error;
if (error.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
requestTimeout / 1000
).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded';
} else if (error.response?.statusCode === StatusCodes.UNAUTHORIZED) {
if (!error.request?.options?.headers?.authorization?.includes('-')) {
message =
'RequestError: The provided API key is invalid. Please update it in the Settings section of the Admin Control panel.';
} else {
message =
'RequestError: The provided API key has expired. Please request a new one and update it in the Settings section of the Admin Control panel.';
}
}
Logger.error(message, 'GhostfolioService');
}
return response;
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {
@ -203,7 +230,7 @@ export class GhostfolioService implements DataProviderInterface {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to get the quotes was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';
@ -224,10 +251,13 @@ export class GhostfolioService implements DataProviderInterface {
} }
public getTestSymbol() { public getTestSymbol() {
return 'AAPL.US'; return 'AAPL';
} }
public async search({ query }: GetSearchParams): Promise<LookupResponse> { public async search({
requestTimeout = this.configurationService.get('REQUEST_TIMEOUT'),
query
}: GetSearchParams): Promise<LookupResponse> {
let searchResult: LookupResponse = { items: [] }; let searchResult: LookupResponse = { items: [] };
try { try {
@ -235,9 +265,7 @@ export class GhostfolioService implements DataProviderInterface {
`${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`, `${this.URL}/v2/data-providers/ghostfolio/lookup?query=${query}`,
{ {
headers: await this.getRequestHeaders(), headers: await this.getRequestHeaders(),
signal: AbortSignal.timeout( signal: AbortSignal.timeout(requestTimeout)
this.configurationService.get('REQUEST_TIMEOUT')
)
} }
).then((res) => res.json())) as LookupResponse; ).then((res) => res.json())) as LookupResponse;
} catch (error) { } catch (error) {
@ -245,7 +273,7 @@ export class GhostfolioService implements DataProviderInterface {
if (error.name === 'AbortError') { if (error.name === 'AbortError') {
message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${( message = `RequestError: The operation to search for ${query} was aborted because the request to the data provider took more than ${(
this.configurationService.get('REQUEST_TIMEOUT') / 1000 requestTimeout / 1000
).toFixed(3)} seconds`; ).toFixed(3)} seconds`;
} else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) { } else if (error.response?.statusCode === StatusCodes.TOO_MANY_REQUESTS) {
message = 'RequestError: The daily request limit has been exceeded'; message = 'RequestError: The daily request limit has been exceeded';

5
apps/api/src/services/data-provider/google-sheets/google-sheets.service.ts

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -37,9 +38,7 @@ export class GoogleSheetsService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()

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

@ -15,9 +15,7 @@ export interface DataProviderInterface {
getAssetProfile({ getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>>;
symbol: string;
}): Promise<Partial<SymbolProfile>>;
getDataProviderInfo(): DataProviderInfo; getDataProviderInfo(): DataProviderInfo;
@ -55,6 +53,11 @@ export interface DataProviderInterface {
search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>; search({ includeIndices, query }: GetSearchParams): Promise<LookupResponse>;
} }
export interface GetAssetProfileParams {
requestTimeout?: number;
symbol: string;
}
export interface GetDividendsParams { export interface GetDividendsParams {
from: Date; from: Date;
granularity?: Granularity; granularity?: Granularity;
@ -79,5 +82,6 @@ export interface GetQuotesParams {
export interface GetSearchParams { export interface GetSearchParams {
includeIndices?: boolean; includeIndices?: boolean;
query: string; query: string;
requestTimeout?: number;
userId?: string; userId?: string;
} }

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -43,9 +44,7 @@ export class ManualService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const assetProfile: Partial<SymbolProfile> = { const assetProfile: Partial<SymbolProfile> = {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()
@ -273,38 +272,36 @@ export class ManualService implements DataProviderInterface {
private async scrape( private async scrape(
scraperConfiguration: ScraperConfiguration scraperConfiguration: ScraperConfiguration
): Promise<number> { ): Promise<number> {
try { let locale = scraperConfiguration.locale;
let locale = scraperConfiguration.locale;
const response = await fetch(scraperConfiguration.url, {
headers: scraperConfiguration.headers as HeadersInit,
signal: AbortSignal.timeout(
this.configurationService.get('REQUEST_TIMEOUT')
)
});
if (response.headers['content-type'].includes('application/json')) { const response = await fetch(scraperConfiguration.url, {
const data = await response.json(); headers: scraperConfiguration.headers as HeadersInit,
const value = String( signal: AbortSignal.timeout(
jsonpath.query(data, scraperConfiguration.selector)[0] this.configurationService.get('REQUEST_TIMEOUT')
); )
});
return extractNumberFromString({ locale, value }); if (response.headers.get('content-type')?.includes('application/json')) {
} else { const data = await response.json();
const $ = cheerio.load(await response.text());
if (!locale) { const value = String(
try { jsonpath.query(data, scraperConfiguration.selector)[0]
locale = $('html').attr('lang'); );
} catch {}
}
return extractNumberFromString({ return extractNumberFromString({ locale, value });
locale, } else {
value: $(scraperConfiguration.selector).first().text() const $ = cheerio.load(await response.text());
});
if (!locale) {
try {
locale = $('html').attr('lang');
} catch {}
} }
} catch (error) {
throw error; return extractNumberFromString({
locale,
value: $(scraperConfiguration.selector).first().text()
});
} }
} }
} }

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

@ -1,6 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -33,9 +34,7 @@ export class RapidApiService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
symbol, symbol,
dataSource: this.getName() dataSource: this.getName()

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

@ -2,6 +2,7 @@ import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/c
import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service'; import { YahooFinanceDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/yahoo-finance/yahoo-finance.service';
import { import {
DataProviderInterface, DataProviderInterface,
GetAssetProfileParams,
GetDividendsParams, GetDividendsParams,
GetHistoricalParams, GetHistoricalParams,
GetQuotesParams, GetQuotesParams,
@ -43,9 +44,7 @@ export class YahooFinanceService implements DataProviderInterface {
public async getAssetProfile({ public async getAssetProfile({
symbol symbol
}: { }: GetAssetProfileParams): Promise<Partial<SymbolProfile>> {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol); return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
} }

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

@ -23,7 +23,7 @@ import {
isToday, isToday,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { isNumber, uniq } from 'lodash'; import { isNumber } from 'lodash';
import ms from 'ms'; import ms from 'ms';
@Injectable() @Injectable()
@ -515,7 +515,7 @@ export class ExchangeRateDataService {
} }
} }
return uniq(currencies).filter(Boolean).sort(); return Array.from(new Set(currencies)).filter(Boolean).sort();
} }
private prepareCurrencyPairs(aCurrencies: string[]) { private prepareCurrencyPairs(aCurrencies: string[]) {

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

@ -200,6 +200,7 @@ export class DataGatheringService {
assetSubClass, assetSubClass,
countries, countries,
currency, currency,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -218,6 +219,7 @@ export class DataGatheringService {
assetSubClass, assetSubClass,
countries, countries,
currency, currency,
cusip,
dataSource, dataSource,
figi, figi,
figiComposite, figiComposite,
@ -234,6 +236,7 @@ export class DataGatheringService {
assetSubClass, assetSubClass,
countries, countries,
currency, currency,
cusip,
figi, figi,
figiComposite, figiComposite,
figiShareClass, figiShareClass,

2
apps/api/src/services/queues/portfolio-snapshot/portfolio-snapshot.processor.ts

@ -68,7 +68,7 @@ export class PortfolioSnapshotProcessor {
const portfolioCalculator = this.calculatorFactory.createCalculator({ const portfolioCalculator = this.calculatorFactory.createCalculator({
accountBalanceItems, accountBalanceItems,
activities, activities,
calculationType: PerformanceCalculationType.TWR, calculationType: PerformanceCalculationType.ROAI,
currency: job.data.userCurrency, currency: job.data.userCurrency,
filters: job.data.filters, filters: job.data.filters,
userId: job.data.userId userId: job.data.userId

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

Loading…
Cancel
Save