Compare commits

..

85 Commits

Author SHA1 Message Date
2729c5651f Release 2.64.0 (#3161) 2024-03-16 19:00:50 +01:00
7e28e42995 Feature/exclude fees from holdings (#3160) 2024-03-16 18:59:23 +01:00
e21563d903 Feature/increase timeout to load benchmarks (#3158)
* Increase request timeout

* Update changelog
2024-03-16 18:15:34 +01:00
3ede69650c Feature/upgrade prisma to version 5.11.0 (#3159)
* Upgrade prisma to version 5.11.0

* Update changelog
2024-03-16 16:51:07 +01:00
c289793c6d Feature/switch between active and closed holdings (#3146)
* Switch between active and closed holdings on the portfolio holdings page

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-03-16 14:20:58 +01:00
a90c067da0 Clean up (#3157) 2024-03-16 13:40:23 +01:00
38c2baf943 Feature/improve exception handling of current investments in various rules (#3156)
* Improve exception handling

* Update changelog
2024-03-16 12:29:21 +01:00
82c78cad6b Format (#3155) 2024-03-16 12:28:58 +01:00
bffe6060bd Pass portfolio calculator to getChart() (#3153) 2024-03-16 10:05:16 +01:00
841bd5c33f Bugfix/fix dividend accumulation in symbol metrics (#3152)
* Fix total dividend calculation

* Update changelog
2024-03-16 10:04:57 +01:00
3b895afc9e Enable account balance update for fee and interest activities (#3145)
* Enable account balance update for fee and interest activities

* Update changelog
2024-03-15 18:56:17 +01:00
00c2ede85e Feature/improve usability of platform and tag management (#3144)
* Improve usability

* Update changelog
2024-03-15 08:37:41 +01:00
8420cb830c Feature/add product roadmap to faq (#3143)
* Add product roadmap

* Update changelog
2024-03-14 14:09:20 +01:00
a0ddd1f9b9 Fix date conversion in import of historical market data (#3117)
* Fix date conversion in import of historical market data

* Update changelog
2024-03-13 20:44:33 +01:00
40d93066ff Introduce .env.dev (#3120) 2024-03-13 20:21:54 +01:00
671e4e316b Release 2.63.2 (#3138) 2024-03-12 20:50:32 +01:00
473136e9aa Release 2.63.1 (#3135)
* Release 2.63.1
2024-03-11 21:35:43 +01:00
9a3db91982 Release 2.63.0 (#3134) 2024-03-11 20:19:23 +01:00
d23cb5f190 Feature/upgrade simplewebauthn dependencies to version 9.0 (#3130)
* Upgrade @simplewebauthn/browser and @simplewebauthn/server to version 9.0

* Update changelog
2024-03-11 20:17:47 +01:00
7a364472c8 Bugfix/fix liability issue in allocations (#3133)
* Remove liabilities from allocations calculation

* Update changelog
2024-03-11 20:16:56 +01:00
59c064e3c8 Feature/upgrade yahoo finance2 to version 2.10.0 (#3127)
* Upgrade yahoo-finance2 to version 2.10.0

* Update changelog
2024-03-11 20:15:55 +01:00
e792924606 Update OSS friends (#3132) 2024-03-11 20:15:32 +01:00
d32dd5e860 Feature/upgrade countries list to version 3.1.0 (#3131)
* Upgrade countries-list to version 3.1.0

* Update changelog
2024-03-11 19:16:20 +01:00
bb86f85203 Feature/add available home server systems to faq (#3126)
* Add available home server systems

* Update changelog

* Add CasaOS to README.md
2024-03-10 09:50:43 +01:00
0bca8897d6 Fix average price calculation by only considering BUY transactions (#3125)
* Fix average price calculation by only considering buy transactions

* Update changelog
2024-03-10 09:35:47 +01:00
ba73f6de2e Release 2.62.0 (#3124) 2024-03-09 19:57:56 +01:00
eb75be8535 Optimize details endpoint (#3123)
* Make summary optional

* Introduce dedicated holdings endpoint

* Update changelog
2024-03-09 19:56:26 +01:00
6d2a897366 Refactor orders with activities (#3122) 2024-03-09 17:17:52 +01:00
d8bfb23f20 Refactor reduce() with getSum() (#3121) 2024-03-09 16:53:59 +01:00
d9d71e7827 Fix issue with removing account from activity (#3112)
* Fix issue with removing account from activity

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-03-09 15:52:05 +01:00
b642ce08e5 Refactor item type (#3119) 2024-03-09 12:32:56 +01:00
bc8d8309d4 Improve handling of future liabilities (#3118)
* Improve handling of future liabilities

* Refactor currentValue to currentValueInBaseCurrency

* Update changelog
2024-03-09 11:07:01 +01:00
1f2f9f22f2 Feature/remove environment variable web auth rp (#3115)
* Remove environment variable WEB_AUTH_RP_ID

* Update changelog
2024-03-08 19:00:21 +01:00
7a3237f1ff Adapt style of inactive users (#3114) 2024-03-08 18:59:23 +01:00
07661d9262 Feature/integrate dividend into transaction point concept (#3092)
* Integrate dividend into transaction point concept

* Update changelog
2024-03-07 20:07:50 +01:00
77358eed65 Feature/Include user role in admin endpoint (#3107)
* Include user role in admin endpoint
2024-03-07 19:38:57 +01:00
c641c28b12 Release 2.61.1 (#3110) 2024-03-06 22:08:00 +01:00
c54392b7bb Bugfix/fix exception in account value calculation (#3109)
* Fix exception in value of account calculation caused by liabilities

* Update changelog
2024-03-06 22:06:27 +01:00
f3a8822a77 Feature/remove-v-from-version-in-admin-endpoint (#3101)
* Remove "v" from version in admin endpoint
2024-03-06 11:11:10 +01:00
f1dc075c36 Update translations (#3093) 2024-03-05 10:16:59 +01:00
144d831954 Release 2.61.0 (#3097) 2024-03-04 20:17:05 +01:00
c37ad9bad4 Bugfix/fix activities import (#3095)
* Fix query parameter handling of booleans

* Update changelog
2024-03-04 20:15:41 +01:00
4ab3f81384 Extract getFactor() (#3089)
* Extract getFactor()

* Refactoring
2024-03-03 20:04:49 +01:00
b932bac9aa Feature/optimize summary calculation (#3088)
* Optimize calculation

* Update changelog
2024-03-03 08:24:51 +01:00
bcdd873222 Add missing title (#3087) 2024-03-02 17:32:32 +01:00
25b3de5828 Release 2.60.0 (#3086) 2024-03-02 14:44:00 +01:00
40b454d2f3 Feature/refresh cryptocurrencies list 20240302 (#3085)
* Update cryptocurrencies.json

* Add UNI7083

* Update changelog
2024-03-02 14:42:40 +01:00
5596e5f03b Feature/integrate wealth items into transaction point concept (#3084)
* Integrate (wealth) items into transaction point concept

* Update changelog
2024-03-02 14:29:03 +01:00
66992ef915 Bugfix/change show condition of button to fetch current market price (#3079)
* Change show condition of button to fetch current market price

* Update changelog
2024-03-02 12:59:54 +01:00
7f67430685 Bugfix/readd value in base currency to activity (#3078)
* Readd valueInBaseCurrency

* Update changelog
2024-03-02 10:03:10 +01:00
8a49a04324 Feature/improve usability of benchmarks in markets overview (#3077)
* Improve icons, localize label

* Update changelog
2024-03-02 09:48:53 +01:00
5d7c19b0ed Fix typo (#3076) 2024-03-02 09:42:41 +01:00
cde74b6c62 Release 2.59.0 (#3069) 2024-02-29 21:06:18 +01:00
633c65e33c Feature/extend self hosting faq (#3068)
* Extend self-hosting FAQ

* Update changelog
2024-02-29 21:04:47 +01:00
d1617f2d87 Feature/add index for is excluded to account database table (#3067)
* Add index for isExcluded to account database table

* Update changelog
2024-02-29 20:50:44 +01:00
68e558f198 Feature/Improve activities import by ISIN number (#3051)
* Improve activities import by ISIN number

* Update changelog
2024-02-29 20:45:40 +01:00
12ca01c862 Update OSS friends (#3066) 2024-02-29 20:26:13 +01:00
2115745471 Bugfix/fix issue with exchange rate calculation of wealth items in accounts (#3065)
* Fix exchange rate calculatio of wealth items in accounts

* Update changelog
2024-02-29 20:14:52 +01:00
2cabd21315 Release 2.58.0 (#3061) 2024-02-27 20:59:44 +01:00
3615e2f057 Feature/improve handling of activities without account (#3060)
* Improve handling of activities without account

* Update changelog
2024-02-27 20:58:28 +01:00
d3679d41b3 Bugfix/fix query to filter activities of excluded accounts (#3059)
* Fix query to filter activities of excluded accounts

* Update changelog
2024-02-27 20:58:04 +01:00
f2d431a6b8 Bugfix/improve asset profile validation in activities import (#3057)
* Improve asset profile validation

* Update changelog
2024-02-27 20:42:23 +01:00
2bc8bebfb8 Clean up dist folders (#3053) 2024-02-26 20:17:18 +01:00
5b20ba3382 Release 2.57.0 (#3054) 2024-02-25 19:14:28 +01:00
15cc294581 Feature/move break down of performance from experimental to general availability (#3047)
* Move break down of performance to general availability

* Update changelog
2024-02-25 19:12:30 +01:00
b060b81204 Fix debugging with VS Code due to missing Source Map (#3050)
Fixes #2801
2024-02-25 19:10:17 +01:00
a8d557eb1b Disable parallel execution of commands causing race condition between mkdir and cp (#3052) 2024-02-25 19:03:28 +01:00
6ae3a47b54 Bugfix/change top and bottom performers to performance with currency effect (#3046)
* Change to performance with currency effect

* Update changelog
2024-02-25 13:43:49 +01:00
88c19eb45e Feature/restructure copy assets nx target (#3045)
* Restructure copy-assets Nx target

* Update changelog
2024-02-25 11:45:00 +01:00
7728706bc8 Release 2.56.0 (#3043) 2024-02-24 20:00:03 +01:00
2e9d40c201 Feature/switch to performance calculations with currency effects (#3039)
* Switch to performance calculations with currency effects

* Improve value redaction in portfolio details endpoint

* Update changelog
2024-02-24 19:58:13 +01:00
c002e37285 Feature/add missing default currency to prepare currencies function (#3042)
* Add missing default currency

* Update changelog
2024-02-24 19:45:51 +01:00
6be38a1c19 Feature/remove is default flag from account database schema (#3041)
* Remove isDefault flag from Account database schema

* Update changelog
2024-02-24 19:44:56 +01:00
a3178fb213 Feature/expose redis database via environment variable (#3036)
* Expose Redis database via environment variable

* Update changelog
2024-02-24 11:56:12 +01:00
e7158f6e16 Feature/upgrade prisma to version 5.10.2 (#3038)
* Upgrade prisma to version 5.10.2

* Update changelog
2024-02-24 11:09:13 +01:00
dbea0456bc Update changelog (#3035) 2024-02-23 19:58:33 +01:00
fefee11301 Release 2.55.0 (#3034) 2024-02-22 20:26:39 +01:00
40836b745b Feature/improve validation for currency in endpoints (#3030)
* Improve validation for currency

* Update changelog
2024-02-22 20:25:22 +01:00
07eabac059 Feature/add missing database indexes part 2 (#3033)
* Add missing database indexes (for orderBy and where clauses)

* Update changelog
2024-02-22 20:21:50 +01:00
48b412cfb8 Feature/harmonize setting of default locale (#3032)
* Harmonize setting of default locale

* Update changelog
2024-02-22 20:10:27 +01:00
b62488628c Prettify markup (#3029) 2024-02-21 09:58:15 +01:00
982c71c728 Feature/set angular parser in prettierrc (#3028)
* Set parser to angular

* Update changelog
2024-02-20 19:54:03 +01:00
5aa16a3779 Release 2.54.0 (#3027) 2024-02-19 19:47:37 +01:00
93de25e5b6 Feature/add missing database indexes (#3026)
* Add missing database indexes

* Update changelog
2024-02-19 19:45:52 +01:00
9acdb41aa2 Refactor params to object (#2987) 2024-02-19 19:32:10 +01:00
203 changed files with 25844 additions and 22974 deletions

25
.env.dev Normal file
View File

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

View File

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

View File

@ -12,6 +12,12 @@
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"], "importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
"importOrderSeparation": true, "importOrderSeparation": true,
"overrides": [ "overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
},
{ {
"files": "*.ts", "files": "*.ts",
"options": { "options": {

View File

@ -5,6 +5,176 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 2.64.0 - 2024-03-16
### Added
- Added a toggle to switch between active and closed holdings on the portfolio holdings page
- Added support to update the cash balance of an account when adding a fee activity
- Added support to update the cash balance of an account when adding an interest activity
- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the usability of the platform management in the admin control panel
- Improved the usability of the tag management in the admin control panel
- Improved the exception handling of various rules in the _X-ray_ section
- Increased the timeout to load benchmarks
- Upgraded `prisma` from version `5.10.2` to `5.11.0`
### Fixed
- Fixed an issue in the dividend calculation of the portfolio holdings
- Fixed the date conversion of the import of historical market data in the admin control panel
## 2.63.2 - 2024-03-12
### Added
- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page
- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`)
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0`
- Upgraded `countries-list` from version `2.6.1` to `3.1.0`
- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0`
### Fixed
- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day
- Fixed an issue in the calculation on the allocations page caused by liabilities
- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_
## 2.62.0 - 2024-03-09
### Changed
- Optimized the calculation of the accounts table
- Optimized the calculation of the portfolio holdings
- Integrated dividend into the transaction point concept in the portfolio service
- Removed the environment variable `WEB_AUTH_RP_ID`
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed an issue with removing a linked account from a (wealth) item activity
## 2.61.1 - 2024-03-06
### Fixed
- Fixed an issue in the account value calculation caused by liabilities
## 2.61.0 - 2024-03-04
### Changed
- Optimized the calculation of the portfolio summary
### Fixed
- Fixed the activities import (query parameter handling)
## 2.60.0 - 2024-03-02
### Added
- Added support for the cryptocurrency _Uniswap_ (`UNI7083-USD`)
### Changed
- Improved the usability of the benchmarks in the markets overview
- Integrated (wealth) items into the transaction point concept in the portfolio service
- Refreshed the cryptocurrencies list
### Fixed
- Fixed a missing value in the activities table on mobile
- Fixed a missing value on the public page
- Displayed the button to fetch the current market price only if the activity is from today
## 2.59.0 - 2024-02-29
### Added
- Added an index for `isExcluded` to the account database table
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the activities import by `isin` in the _Yahoo Finance_ service
### Fixed
- Fixed an issue with the exchange rate calculation of (wealth) items in accounts
## 2.58.0 - 2024-02-27
### Changed
- Improved the handling of activities without account
### Fixed
- Fixed the query to filter activities of excluded accounts
- Improved the asset profile validation in the activities import
## 2.57.0 - 2024-02-25
### Changed
- Moved the break down of the performance into asset and currency on the analysis page from experimental to general availability
- Restructured the `copy-assets` `Nx` target
### Fixed
- Changed the performances of the _Top 3_ and _Bottom 3_ performers on the analysis page to take the currency effects into account
## 2.56.0 - 2024-02-24
### Changed
- Switched the performance calculations to take the currency effects into account
- Removed the `isDefault` flag from the `Account` database schema
- Exposed the database index of _Redis_ as an environment variable (`REDIS_DB`)
- Improved the language localization for German (`de`)
- Upgraded `prisma` from version `5.9.1` to `5.10.2`
### Fixed
- Added the missing default currency to the prepare currencies function in the exchange rate data service
## 2.55.0 - 2024-02-22
### Added
- Added indexes for `alias`, `granteeUserId` and `userId` to the access database table
- Added indexes for `currency`, `name` and `userId` to the account database table
- Added indexes for `accountId`, `date` and `updatedAt` to the account balance database table
- Added an index for `userId` to the auth device database table
- Added indexes for `marketPrice` and `state` to the market data database table
- Added indexes for `date`, `isDraft` and `userId` to the order database table
- Added an index for `name` to the platform database table
- Added indexes for `assetClass`, `currency`, `dataSource`, `isin`, `name` and `symbol` to the symbol profile database table
- Added an index for `userId` to the subscription database table
- Added an index for `name` to the tag database table
- Added indexes for `accessToken`, `createdAt`, `provider`, `role` and `thirdPartyId` to the user database table
### Changed
- Improved the validation for `currency` in various endpoints
- Harmonized the setting of a default locale in various components
- Set the parser to `angular` in the `prettier` options
## 2.54.0 - 2024-02-19
### Added
- Added an index for `id` to the account database table
- Added indexes for `dataSource` and `date` to the market data database table
- Added an index for `accountId` to the order database table
## 2.53.1 - 2024-02-18 ## 2.53.1 - 2024-02-18
### Added ### Added

View File

@ -99,6 +99,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database | | `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database | | `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database | | `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
| `REDIS_DB` | `0` | The database index of _Redis_ |
| `REDIS_HOST` | | The host where _Redis_ is running | | `REDIS_HOST` | | The host where _Redis_ is running |
| `REDIS_PASSWORD` | | The password of _Redis_ | | `REDIS_PASSWORD` | | The password of _Redis_ |
| `REDIS_PORT` | | The port where _Redis_ is running | | `REDIS_PORT` | | The port where _Redis_ is running |
@ -143,7 +144,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
### Home Server Systems (Community) ### Home Server Systems (Community)
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio). Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
## Development ## Development
@ -153,7 +154,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
- [Node.js](https://nodejs.org/en/download) (version 18+) - [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone) - Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`) - Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup ### Setup

View File

@ -13,7 +13,6 @@ export default {
}, },
moduleFileExtensions: ['ts', 'js', 'html'], moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api', coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node', testEnvironment: 'node',
preset: '../../jest.preset.js' preset: '../../jest.preset.js'
}; };

View File

@ -9,12 +9,13 @@
"build": { "build": {
"executor": "@nx/webpack:webpack", "executor": "@nx/webpack:webpack",
"options": { "options": {
"outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts",
"tsConfig": "apps/api/tsconfig.app.json",
"assets": ["apps/api/src/assets"],
"target": "node",
"compiler": "tsc", "compiler": "tsc",
"deleteOutputPath": false,
"main": "apps/api/src/main.ts",
"outputPath": "dist/apps/api",
"sourceMap": true,
"target": "node",
"tsConfig": "apps/api/tsconfig.app.json",
"webpackConfig": "apps/api/webpack.config.js" "webpackConfig": "apps/api/webpack.config.js"
}, },
"configurations": { "configurations": {
@ -33,6 +34,26 @@
}, },
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"]
}, },
"copy-assets": {
"executor": "nx:run-commands",
"options": {
"commands": [
{
"command": "shx rm -rf dist/apps/api"
},
{
"command": "shx mkdir -p dist/apps/api/assets/locales"
},
{
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
},
{
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
}
],
"parallel": false
}
},
"serve": { "serve": {
"executor": "@nx/js:node", "executor": "@nx/js:node",
"options": { "options": {

View File

@ -83,7 +83,7 @@ export class AccessController {
} }
try { try {
return await this.accessService.createAccess({ return this.accessService.createAccess({
alias: data.alias || undefined, alias: data.alias || undefined,
GranteeUser: data.granteeUserId GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } } ? { connect: { id: data.granteeUserId } }

View File

@ -63,7 +63,7 @@ export class AccountController {
{ Order: true } { Order: true }
); );
if (account?.isDefault || account?.Order.length > 0) { if (!account || account?.Order.length > 0) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN), getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN StatusCodes.FORBIDDEN

View File

@ -21,10 +21,8 @@ export class AccountService {
public async account({ public async account({
id_userId id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> { }: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const { id, userId } = id_userId;
const [account] = await this.accounts({ const [account] = await this.accounts({
where: { id, userId } where: id_userId
}); });
return account; return account;

View File

@ -1,6 +1,7 @@
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -19,7 +20,7 @@ export class CreateAccountDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsOptional() @IsOptional()

View File

@ -1,6 +1,7 @@
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsString, IsString,
@ -19,7 +20,7 @@ export class UpdateAccountDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsString() @IsString()

View File

@ -339,6 +339,6 @@ export class AdminController {
@Param('key') key: string, @Param('key') key: string,
@Body() data: PropertyDto @Body() data: PropertyDto
) { ) {
return await this.adminService.putSetting(key, data.value); return this.adminService.putSetting(key, data.value);
} }
} }

View File

@ -71,7 +71,7 @@ export class AdminService {
); );
} }
return await this.symbolProfileService.add( return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
); );
} catch (error) { } catch (error) {
@ -440,13 +440,14 @@ export class AdminService {
}, },
createdAt: true, createdAt: true,
id: true, id: true,
role: true,
Subscription: true Subscription: true
}, },
take: 30 take: 30
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, Analytics, createdAt, id, Subscription }) => { ({ _count, Analytics, createdAt, id, role, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics const engagement = Analytics
@ -466,6 +467,7 @@ export class AdminService {
createdAt, createdAt,
engagement, engagement,
id, id,
role,
subscription, subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
country: Analytics?.country, country: Analytics?.country,

View File

@ -2,6 +2,7 @@ import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
import { import {
IsArray, IsArray,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsObject, IsObject,
IsOptional, IsOptional,
IsString IsString
@ -24,7 +25,7 @@ export class UpdateAssetProfileDto {
@IsOptional() @IsOptional()
countries?: Prisma.InputJsonArray; countries?: Prisma.InputJsonArray;
@IsString() @IsISO4217CurrencyCode()
@IsOptional() @IsOptional()
currency?: string; currency?: string;

View File

@ -53,6 +53,7 @@ import { UserModule } from './user/user.module';
BenchmarkModule, BenchmarkModule,
BullModule.forRoot({ BullModule.forRoot({
redis: { redis: {
db: parseInt(process.env.REDIS_DB ?? '0', 10),
host: process.env.REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT ?? '6379', 10), port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
password: process.env.REDIS_PASSWORD password: process.env.REDIS_PASSWORD

View File

@ -41,7 +41,7 @@ export class WebAuthService {
) {} ) {}
get rpID() { get rpID() {
return this.configurationService.get('WEB_AUTH_RP_ID'); return new URL(this.configurationService.get('ROOT_URL')).hostname;
} }
get expectedOrigin() { get expectedOrigin() {

View File

@ -110,7 +110,9 @@ export class BenchmarkService {
const quotes = await this.dataProviderService.getQuotes({ const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => { items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) }),
requestTimeout: ms('30 seconds'),
useCache: false
}); });
for (const { dataSource, symbol } of benchmarkAssetProfiles) { for (const { dataSource, symbol } of benchmarkAssetProfiles) {
@ -163,7 +165,7 @@ export class BenchmarkService {
await this.redisCacheService.set( await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS, this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks), JSON.stringify(benchmarks),
ms('4 hours') / 1000 ms('2 hours') / 1000
); );
} }

View File

@ -43,8 +43,10 @@ export class ImportController {
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async import( public async import(
@Body() importData: ImportDataDto, @Body() importData: ImportDataDto,
@Query('dryRun') isDryRun?: boolean @Query('dryRun') isDryRunParam = 'false'
): Promise<ImportResponse> { ): Promise<ImportResponse> {
const isDryRun = isDryRunParam === 'true';
if ( if (
!hasPermission(this.request.user.permissions, permissions.createAccount) !hasPermission(this.request.user.permissions, permissions.createAccount)
) { ) {

View File

@ -570,17 +570,10 @@ export class ImportService {
[assetProfileIdentifier: string]: Partial<SymbolProfile>; [assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {}; } = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [ for (const [
index, index,
{ currency, dataSource, symbol, type } { currency, dataSource, symbol, type }
] of uniqueActivitiesDto.entries()) { ] of activitiesDto.entries()) {
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) { if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
throw new Error( throw new Error(
`activities.${index}.dataSource ("${dataSource}") is not valid` `activities.${index}.dataSource ("${dataSource}") is not valid`
@ -602,6 +595,7 @@ export class ImportService {
} }
} }
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
const assetProfile = { const assetProfile = {
currency, currency,
...( ...(
@ -618,15 +612,9 @@ export class ImportService {
); );
} }
if ( if (assetProfile.currency !== currency) {
assetProfile.currency !== currency &&
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error( throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"` `activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
); );
} }
} }
@ -634,6 +622,7 @@ export class ImportService {
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] = assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile; assetProfile;
} }
}
return assetProfiles; return assetProfiles;
} }

View File

@ -10,6 +10,7 @@ import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -38,7 +39,7 @@ export class CreateOrderDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsOptional() @IsOptional()

View File

@ -8,7 +8,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS_OPTIONS GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper'; import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces'; import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -19,7 +19,7 @@ import {
Order, Order,
Prisma, Prisma,
Tag, Tag,
Type as TypeOfOrder Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
@ -70,12 +70,7 @@ export class OrderService {
const updateAccountBalance = data.updateAccountBalance ?? false; const updateAccountBalance = data.updateAccountBalance ?? false;
const userId = data.userId; const userId = data.userId;
if ( if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
const assetClass = data.assetClass; const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass; const assetSubClass = data.assetSubClass;
currency = data.SymbolProfile.connectOrCreate.create.currency; currency = data.SymbolProfile.connectOrCreate.create.currency;
@ -130,11 +125,7 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
const isDraft = const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
? false ? false
: isAfter(data.date as Date, endOfToday()); : isAfter(data.date as Date, endOfToday());
@ -157,7 +148,7 @@ export class OrderService {
.plus(data.fee) .plus(data.fee)
.toNumber(); .toNumber();
if (data.type === 'BUY') { if (['BUY', 'FEE'].includes(data.type)) {
amount = new Big(amount).mul(-1).toNumber(); amount = new Big(amount).mul(-1).toNumber();
} }
@ -180,12 +171,7 @@ export class OrderService {
where where
}); });
if ( if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
order.type === 'FEE' ||
order.type === 'INTEREST' ||
order.type === 'ITEM' ||
order.type === 'LIABILITY'
) {
await this.symbolProfileService.deleteById(order.symbolProfileId); await this.symbolProfileService.deleteById(order.symbolProfileId);
} }
@ -200,6 +186,17 @@ export class OrderService {
return count; return count;
} }
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
},
where: {
SymbolProfile: { dataSource, symbol }
}
});
}
public async getOrders({ public async getOrders({
filters, filters,
includeDrafts = false, includeDrafts = false,
@ -218,7 +215,7 @@ export class OrderService {
sortColumn?: string; sortColumn?: string;
sortDirection?: Prisma.SortOrder; sortDirection?: Prisma.SortOrder;
take?: number; take?: number;
types?: TypeOfOrder[]; types?: ActivityType[];
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
@ -292,19 +289,14 @@ export class OrderService {
} }
if (types) { if (types) {
where.OR = types.map((type) => { where.type = { in: types };
return {
type: {
equals: type
}
};
});
} }
if (withExcludedAccounts === false) { if (withExcludedAccounts === false) {
where.Account = { where.OR = [
NOT: { isExcluded: true } { Account: null },
}; { Account: { NOT: { isExcluded: true } } }
];
} }
const [orders, count] = await Promise.all([ const [orders, count] = await Promise.all([
@ -334,11 +326,13 @@ export class OrderService {
return { return {
...order, ...order,
value, value,
// TODO: Use exchange rate of date
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.SymbolProfile.currency, order.SymbolProfile.currency,
userCurrency userCurrency
), ),
// TODO: Use exchange rate of date
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
order.SymbolProfile.currency, order.SymbolProfile.currency,
@ -369,13 +363,10 @@ export class OrderService {
dataSource?: DataSource; dataSource?: DataSource;
symbol?: string; symbol?: string;
tags?: Tag[]; tags?: Tag[];
type?: ActivityType;
}; };
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
}): Promise<Order> { }): Promise<Order> {
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
if (!data.comment) { if (!data.comment) {
data.comment = null; data.comment = null;
} }
@ -384,13 +375,12 @@ export class OrderService {
let isDraft = false; let isDraft = false;
if ( if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
data.type === 'FEE' ||
data.type === 'INTEREST' ||
data.type === 'ITEM' ||
data.type === 'LIABILITY'
) {
delete data.SymbolProfile.connect; delete data.SymbolProfile.connect;
if (data.Account?.connect?.id_userId?.id === null) {
data.Account = { disconnect: true };
}
} else { } else {
delete data.SymbolProfile.update; delete data.SymbolProfile.update;

View File

@ -9,6 +9,7 @@ import { Transform, TransformFnParams } from 'class-transformer';
import { import {
IsArray, IsArray,
IsEnum, IsEnum,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsNumber, IsNumber,
IsOptional, IsOptional,
@ -37,7 +38,7 @@ export class UpdateOrderDto {
) )
comment?: string; comment?: string;
@IsString() @IsISO4217CurrencyCode()
currency: string; currency: string;
@IsString() @IsString()

View File

@ -43,6 +43,17 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'MSFT':
if (isSameDay(parseDate('2021-09-16'), date)) {
return { marketPrice: 89.12 };
} else if (isSameDay(parseDate('2021-11-16'), date)) {
return { marketPrice: 339.51 };
} else if (isSameDay(parseDate('2023-07-10'), date)) {
return { marketPrice: 331.83 };
}
return { marketPrice: 0 };
case 'NOVN.SW': case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) { if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 }; return { marketPrice: 87.8 };

View File

@ -108,6 +108,7 @@ describe('CurrentRateService', () => {
currentRateService = new CurrentRateService( currentRateService = new CurrentRateService(
dataProviderService, dataProviderService,
marketDataService, marketDataService,
null,
null null
); );
}); });

View File

@ -1,3 +1,4 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
@ -22,6 +23,7 @@ export class CurrentRateService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -121,11 +123,17 @@ export class CurrentRateService {
}); });
if (!value) { if (!value) {
// Fallback to unit price of latest activity
const latestActivity = await this.orderService.getLatestOrder({
dataSource,
symbol
});
value = { value = {
dataSource, dataSource,
symbol, symbol,
date: today, date: today,
marketPrice: 0 marketPrice: latestActivity?.unitPrice ?? 0
}; };
response.values.push(value); response.values.push(value);

View File

@ -3,7 +3,7 @@ import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js'; import Big from 'big.js';
export interface CurrentPositions extends ResponseError { export interface CurrentPositions extends ResponseError {
positions: TimelinePosition[]; currentValueInBaseCurrency: Big;
grossPerformance: Big; grossPerformance: Big;
grossPerformanceWithCurrencyEffect: Big; grossPerformanceWithCurrencyEffect: Big;
grossPerformancePercentage: Big; grossPerformancePercentage: Big;
@ -14,6 +14,6 @@ export interface CurrentPositions extends ResponseError {
netPerformanceWithCurrencyEffect: Big; netPerformanceWithCurrencyEffect: Big;
netPerformancePercentage: Big; netPerformancePercentage: Big;
netPerformancePercentageWithCurrencyEffect: Big; netPerformancePercentageWithCurrencyEffect: Big;
currentValue: Big; positions: TimelinePosition[];
totalInvestment: Big; totalInvestment: Big;
} }

View File

@ -5,7 +5,7 @@ import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder { export interface PortfolioOrderItem extends PortfolioOrder {
feeInBaseCurrency?: Big; feeInBaseCurrency?: Big;
feeInBaseCurrencyWithCurrencyEffect?: Big; feeInBaseCurrencyWithCurrencyEffect?: Big;
itemType?: '' | 'start' | 'end'; itemType?: 'end' | 'start';
unitPriceInBaseCurrency?: Big; unitPriceInBaseCurrency?: Big;
unitPriceInBaseCurrencyWithCurrencyEffect?: Big; unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
} }

View File

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

View File

@ -2,8 +2,10 @@ import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
averagePrice: Big;
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
dividend: Big;
fee: Big; fee: Big;
firstBuyDate: string; firstBuyDate: string;
investment: Big; investment: Big;

View File

@ -0,0 +1,166 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell in two activities', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2021-11-22',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(142.9)
},
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.65),
name: 'Bâloise Holding AG',
quantity: new Big(1),
symbol: 'BALN.SW',
type: 'SELL',
unitPrice: new Big(136.6)
},
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bâloise Holding AG',
quantity: new Big(1),
symbol: 'BALN.SW',
type: 'SELL',
unitPrice: new Big(136.6)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: parseDate('2021-11-22')
});
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-22')
);
const investments = portfolioCalculator.getInvestments();
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
data: chartData,
groupBy: 'month'
});
spy.mockRestore();
expect(currentPositions).toEqual({
currentValueInBaseCurrency: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'),
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.04408677396780965649'),
grossPerformancePercentageWithCurrencyEffect: new Big(
'-0.04408677396780965649'
),
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
investment: new Big('0'),
investmentWithCurrencyEffect: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.05528341497550734703'),
netPerformancePercentageWithCurrencyEffect: new Big(
'-0.05528341497550734703'
),
netPerformanceWithCurrencyEffect: new Big('-15.8'),
marketPrice: 148.9,
marketPriceInBaseCurrency: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
timeWeightedInvestment: new Big('285.80000000000000396627'),
timeWeightedInvestmentWithCurrencyEffect: new Big(
'285.80000000000000396627'
),
transactionCount: 3,
valueInBaseCurrency: new Big('0')
}
],
totalInvestment: new Big('0'),
totalInvestmentWithCurrencyEffect: new Big('0')
});
expect(investments).toEqual([
{ date: '2021-11-22', investment: new Big('285.8') },
{ date: '2021-11-30', investment: new Big('0') }
]);
expect(investmentsByMonth).toEqual([
{ date: '2021-11-01', investment: 0 },
{ date: '2021-12-01', investment: 0 }
]);
});
});
});

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),
@ -107,6 +107,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('3.2'), fee: new Big('3.2'),
firstBuyDate: '2021-11-22', firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
@ -129,7 +131,8 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW', symbol: 'BALN.SW',
timeWeightedInvestment: new Big('285.8'), timeWeightedInvestment: new Big('285.8'),
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'), timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('0')
} }
], ],
totalInvestment: new Big('0'), totalInvestment: new Big('0'),

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
@ -76,7 +76,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('297.8'), currentValueInBaseCurrency: new Big('297.8'),
errors: [], errors: [],
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),
@ -96,6 +96,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('136.6'), averagePrice: new Big('136.6'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1.55'), fee: new Big('1.55'),
firstBuyDate: '2021-11-30', firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
@ -118,7 +120,8 @@ describe('PortfolioCalculator', () => {
symbol: 'BALN.SW', symbol: 'BALN.SW',
timeWeightedInvestment: new Big('273.2'), timeWeightedInvestment: new Big('273.2'),
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'), timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
transactionCount: 1 transactionCount: 1,
valueInBaseCurrency: new Big('297.8')
} }
], ],
totalInvestment: new Big('273.2'), totalInvestment: new Big('273.2'),

View File

@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
@ -100,7 +100,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('13298.425356'), currentValueInBaseCurrency: new Big('13298.425356'),
errors: [], errors: [],
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
grossPerformancePercentage: new Big('42.41978276196153750666'), grossPerformancePercentage: new Big('42.41978276196153750666'),
@ -120,6 +120,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('320.43'), averagePrice: new Big('320.43'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
firstBuyDate: '2015-01-01', firstBuyDate: '2015-01-01',
grossPerformance: new Big('27172.74'), grossPerformance: new Big('27172.74'),
@ -149,7 +151,8 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024' '636.79469348020066587024'
), ),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356')
} }
], ],
totalInvestment: new Big('320.43'), totalInvestment: new Big('320.43'),

View File

@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
@ -89,7 +89,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('103.10483'), currentValueInBaseCurrency: new Big('103.10483'),
errors: [], errors: [],
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33'),
grossPerformancePercentage: new Big('0.3066651705565529623'), grossPerformancePercentage: new Big('0.3066651705565529623'),
@ -109,6 +109,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('89.12'), averagePrice: new Big('89.12'),
currency: 'USD', currency: 'USD',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('1'), fee: new Big('1'),
firstBuyDate: '2023-01-03', firstBuyDate: '2023-01-03',
grossPerformance: new Big('27.33'), grossPerformance: new Big('27.33'),
@ -132,7 +134,8 @@ describe('PortfolioCalculator', () => {
tags: undefined, tags: undefined,
timeWeightedInvestment: new Big('89.12'), timeWeightedInvestment: new Big('89.12'),
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'), timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
transactionCount: 1 transactionCount: 1,
valueInBaseCurrency: new Big('103.10483')
} }
], ],
totalInvestment: new Big('89.12'), totalInvestment: new Big('89.12'),

View File

@ -0,0 +1,118 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
jest.mock(
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
() => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return ExchangeRateDataServiceMock;
})
};
}
);
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
});
describe('get current positions', () => {
it.only('with MSFT buy', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'USD',
orders: [
{
currency: 'USD',
date: '2021-09-16',
dataSource: 'YAHOO',
fee: new Big(19),
name: 'Microsoft Inc.',
quantity: new Big(1),
symbol: 'MSFT',
type: 'BUY',
unitPrice: new Big(298.58)
},
{
currency: 'USD',
date: '2021-11-16',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Microsoft Inc.',
quantity: new Big(1),
symbol: 'MSFT',
type: 'DIVIDEND',
unitPrice: new Big(0.62)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2023-07-10')
);
spy.mockRestore();
expect(currentPositions).toMatchObject({
errors: [],
hasErrors: false,
positions: [
{
averagePrice: new Big('298.58'),
currency: 'USD',
dataSource: 'YAHOO',
dividend: new Big('0.62'),
dividendInBaseCurrency: new Big('0.62'),
fee: new Big('19'),
firstBuyDate: '2021-09-16',
investment: new Big('298.58'),
investmentWithCurrencyEffect: new Big('298.58'),
marketPrice: 331.83,
marketPriceInBaseCurrency: 331.83,
quantity: new Big('1'),
symbol: 'MSFT',
tags: undefined,
transactionCount: 2
}
],
totalInvestment: new Big('298.58'),
totalInvestmentWithCurrencyEffect: new Big('298.58')
});
});
});
});

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
@ -64,7 +64,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big(0), currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0),

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => {
spy.mockRestore(); spy.mockRestore();
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('87.8'), currentValueInBaseCurrency: new Big('87.8'),
errors: [], errors: [],
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.15113417083448194384'), grossPerformancePercentage: new Big('0.15113417083448194384'),
@ -107,6 +107,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('75.80'), averagePrice: new Big('75.80'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('4.25'), fee: new Big('4.25'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'), grossPerformance: new Big('21.93'),
@ -131,7 +133,8 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big( timeWeightedInvestmentWithCurrencyEffect: new Big(
'145.10285714285714285714' '145.10285714285714285714'
), ),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('87.8')
} }
], ],
totalInvestment: new Big('75.80'), totalInvestment: new Big('75.80'),

View File

@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,
@ -113,7 +113,7 @@ describe('PortfolioCalculator', () => {
}); });
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('0'), currentValueInBaseCurrency: new Big('0'),
errors: [], errors: [],
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
grossPerformancePercentage: new Big('0.13100263852242744063'), grossPerformancePercentage: new Big('0.13100263852242744063'),
@ -133,6 +133,8 @@ describe('PortfolioCalculator', () => {
averagePrice: new Big('0'), averagePrice: new Big('0'),
currency: 'CHF', currency: 'CHF',
dataSource: 'YAHOO', dataSource: 'YAHOO',
dividend: new Big('0'),
dividendInBaseCurrency: new Big('0'),
fee: new Big('0'), fee: new Big('0'),
firstBuyDate: '2022-03-07', firstBuyDate: '2022-03-07',
grossPerformance: new Big('19.86'), grossPerformance: new Big('19.86'),
@ -155,7 +157,8 @@ describe('PortfolioCalculator', () => {
symbol: 'NOVN.SW', symbol: 'NOVN.SW',
timeWeightedInvestment: new Big('151.6'), timeWeightedInvestment: new Big('151.6'),
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'), timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
transactionCount: 2 transactionCount: 2,
valueInBaseCurrency: new Big('0')
} }
], ],
totalInvestment: new Big('0'), totalInvestment: new Big('0'),

View File

@ -10,7 +10,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService; let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => { beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null); currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService( exchangeRateDataService = new ExchangeRateDataService(
null, null,

View File

@ -1,3 +1,4 @@
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
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 { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
@ -12,7 +13,6 @@ import {
import { GroupBy } from '@ghostfolio/common/types'; import { GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
addDays, addDays,
@ -22,6 +22,7 @@ import {
format, format,
isBefore, isBefore,
isSameDay, isSameDay,
max,
subDays subDays
} from 'date-fns'; } from 'date-fns';
import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash'; import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash';
@ -70,40 +71,40 @@ export class PortfolioCalculator {
let lastDate: string = null; let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const order of this.orders) { for (const order of this.orders) {
const currentDate = order.date; const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol]; const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type); const factor = getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) { if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment;
const newQuantity = order.quantity const newQuantity = order.quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') { if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus( investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice) order.quantity.mul(order.unitPrice)
); );
} else if (order.type === 'SELL') { } else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus( investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice) order.quantity.mul(oldAccumulatedSymbol.averagePrice)
); );
} }
}
currentTransactionPointItem = { currentTransactionPointItem = {
investment, investment,
averagePrice: newQuantity.gt(0)
? investment.div(newQuantity)
: new Big(0),
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.dataSource,
dividend: new Big(0),
fee: order.fee.plus(oldAccumulatedSymbol.fee), fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
quantity: newQuantity, quantity: newQuantity,
@ -113,11 +114,13 @@ export class PortfolioCalculator {
}; };
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
averagePrice: order.unitPrice,
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.dataSource,
dividend: new Big(0),
fee: order.fee, fee: order.fee,
firstBuyDate: order.date, firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor), investment: order.unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor), quantity: order.quantity.mul(factor),
symbol: order.symbol, symbol: order.symbol,
tags: order.tags, tags: order.tags,
@ -128,22 +131,28 @@ export class PortfolioCalculator {
symbols[order.symbol] = currentTransactionPointItem; symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? []; const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter( const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol (transactionPointItem) => transactionPointItem.symbol !== order.symbol
); );
newItems.push(currentTransactionPointItem); newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => { newItems.sort((a, b) => {
return a.symbol?.localeCompare(b.symbol); return a.symbol?.localeCompare(b.symbol);
}); });
if (lastDate !== currentDate || lastTransactionPoint === null) { if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = { lastTransactionPoint = {
date: currentDate, date: currentDate,
items: newItems items: newItems
}; };
this.transactionPoints.push(lastTransactionPoint); this.transactionPoints.push(lastTransactionPoint);
} else { } else {
lastTransactionPoint.items = newItems; lastTransactionPoint.items = newItems;
} }
lastDate = currentDate; lastDate = currentDate;
} }
} }
@ -441,16 +450,27 @@ export class PortfolioCalculator {
public async getCurrentPositions( public async getCurrentPositions(
start: Date, start: Date,
end = new Date(Date.now()) end?: Date
): Promise<CurrentPositions> { ): Promise<CurrentPositions> {
const transactionPointsBeforeEndDate = const lastTransactionPoint = last(this.transactionPoints);
this.transactionPoints?.filter((transactionPoint) => {
return isBefore(parseDate(transactionPoint.date), end);
}) ?? [];
if (!transactionPointsBeforeEndDate.length) { let endDate = end;
if (!endDate) {
endDate = new Date(Date.now());
if (lastTransactionPoint) {
endDate = max([endDate, parseDate(lastTransactionPoint.date)]);
}
}
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
return isBefore(parseDate(date), endDate);
});
if (!transactionPoints.length) {
return { return {
currentValue: new Big(0), currentValueInBaseCurrency: new Big(0),
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
grossPerformancePercentageWithCurrencyEffect: new Big(0), grossPerformancePercentageWithCurrencyEffect: new Big(0),
@ -465,41 +485,40 @@ export class PortfolioCalculator {
}; };
} }
const lastTransactionPoint =
transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
const currencies: { [symbol: string]: string } = {}; const currencies: { [symbol: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = []; const dataGatheringItems: IDataGatheringItem[] = [];
let dates: Date[] = []; let dates: Date[] = [];
let firstIndex = transactionPointsBeforeEndDate.length; let firstIndex = transactionPoints.length;
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
dates.push(resetHours(start)); dates.push(resetHours(start));
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
for (const { currency, dataSource, symbol } of transactionPoints[
firstIndex - 1
].items) {
dataGatheringItems.push({ dataGatheringItems.push({
dataSource: item.dataSource, dataSource,
symbol: item.symbol symbol
}); });
currencies[item.symbol] = item.currency; currencies[symbol] = currency;
} }
for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) { for (let i = 0; i < transactionPoints.length; i++) {
if ( if (
!isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) && !isBefore(parseDate(transactionPoints[i].date), start) &&
firstTransactionPoint === null firstTransactionPoint === null
) { ) {
firstTransactionPoint = transactionPointsBeforeEndDate[i]; firstTransactionPoint = transactionPoints[i];
firstIndex = i; firstIndex = i;
} }
if (firstTransactionPoint !== null) { if (firstTransactionPoint !== null) {
dates.push( dates.push(resetHours(parseDate(transactionPoints[i].date)));
resetHours(parseDate(transactionPointsBeforeEndDate[i].date))
);
} }
} }
dates.push(resetHours(end)); dates.push(resetHours(endDate));
// Add dates of last week for fallback // Add dates of last week for fallback
dates.push(subDays(resetHours(new Date()), 7)); dates.push(subDays(resetHours(new Date()), 7));
@ -526,7 +545,7 @@ export class PortfolioCalculator {
let exchangeRatesByCurrency = let exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({ await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)), currencies: uniq(Object.values(currencies)),
endDate: endOfDay(end), endDate: endOfDay(endDate),
startDate: parseDate(this.transactionPoints?.[0]?.date), startDate: parseDate(this.transactionPoints?.[0]?.date),
targetCurrency: this.currency targetCurrency: this.currency
}); });
@ -562,7 +581,7 @@ export class PortfolioCalculator {
} }
} }
const endDateString = format(end, DATE_FORMAT); const endDateString = format(endDate, DATE_FORMAT);
if (firstIndex > 0) { if (firstIndex > 0) {
firstIndex--; firstIndex--;
@ -574,9 +593,9 @@ export class PortfolioCalculator {
const errors: ResponseError['errors'] = []; const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[ const marketPriceInBaseCurrency = (
item.symbol marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
]?.mul( ).mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[ exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
endDateString endDateString
] ]
@ -594,12 +613,14 @@ export class PortfolioCalculator {
netPerformanceWithCurrencyEffect, netPerformanceWithCurrencyEffect,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
totalDividend,
totalDividendInBaseCurrency,
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect totalInvestmentWithCurrencyEffect
} = this.getSymbolMetrics({ } = this.getSymbolMetrics({
end,
marketSymbolMap, marketSymbolMap,
start, start,
end: endDate,
exchangeRates: exchangeRates:
exchangeRatesByCurrency[`${item.currency}${this.currency}`], exchangeRatesByCurrency[`${item.currency}${this.currency}`],
symbol: item.symbol symbol: item.symbol
@ -608,11 +629,11 @@ export class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors; hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
positions.push({ positions.push({
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
timeWeightedInvestment, timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect, timeWeightedInvestmentWithCurrencyEffect,
averagePrice: item.quantity.eq(0) averagePrice: item.averagePrice,
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,
fee: item.fee, fee: item.fee,
@ -646,7 +667,10 @@ export class PortfolioCalculator {
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
tags: item.tags, tags: item.tags,
transactionCount: item.transactionCount transactionCount: item.transactionCount,
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
item.quantity
)
}); });
if ( if (
@ -715,7 +739,7 @@ export class PortfolioCalculator {
} }
private calculateOverallPerformance(positions: TimelinePosition[]) { private calculateOverallPerformance(positions: TimelinePosition[]) {
let currentValue = new Big(0); let currentValueInBaseCurrency = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
let grossPerformanceWithCurrencyEffect = new Big(0); let grossPerformanceWithCurrencyEffect = new Big(0);
let hasErrors = false; let hasErrors = false;
@ -727,14 +751,9 @@ export class PortfolioCalculator {
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0); let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
for (const currentPosition of positions) { for (const currentPosition of positions) {
if ( if (currentPosition.valueInBaseCurrency) {
currentPosition.investment && currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
currentPosition.marketPriceInBaseCurrency currentPosition.valueInBaseCurrency
) {
currentValue = currentValue.plus(
new Big(currentPosition.marketPriceInBaseCurrency).mul(
currentPosition.quantity
)
); );
} else { } else {
hasErrors = true; hasErrors = true;
@ -791,7 +810,7 @@ export class PortfolioCalculator {
} }
return { return {
currentValue, currentValueInBaseCurrency,
grossPerformance, grossPerformance,
grossPerformanceWithCurrencyEffect, grossPerformanceWithCurrencyEffect,
hasErrors, hasErrors,
@ -820,24 +839,6 @@ export class PortfolioCalculator {
}; };
} }
private getFactor(type: TypeOfOrder) {
let factor: number;
switch (type) {
case 'BUY':
factor = 1;
break;
case 'SELL':
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}
private getSymbolMetrics({ private getSymbolMetrics({
end, end,
exchangeRates, exchangeRates,
@ -889,14 +890,13 @@ export class PortfolioCalculator {
[date: string]: Big; [date: string]: Big;
} = {}; } = {};
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0); let totalInvestmentWithCurrencyEffect = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0); let totalQuantityFromBuyTransactions = new Big(0);
let totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = new Big(
0
);
let totalUnits = new Big(0); let totalUnits = new Big(0);
let valueAtStartDate: Big; let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big; let valueAtStartDateWithCurrencyEffect: Big;
@ -932,6 +932,8 @@ export class PortfolioCalculator {
timeWeightedInvestmentValues: {}, timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {}, timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0), timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0) totalInvestmentWithCurrencyEffect: new Big(0)
}; };
@ -972,6 +974,8 @@ export class PortfolioCalculator {
timeWeightedInvestmentValues: {}, timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {}, timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0), timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInvestment: new Big(0), totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0) totalInvestmentWithCurrencyEffect: new Big(0)
}; };
@ -988,7 +992,7 @@ export class PortfolioCalculator {
itemType: 'start', itemType: 'start',
name: '', name: '',
quantity: new Big(0), quantity: new Big(0),
type: TypeOfOrder.BUY, type: 'BUY',
unitPrice: unitPriceAtStartDate unitPrice: unitPriceAtStartDate
}); });
@ -1002,7 +1006,7 @@ export class PortfolioCalculator {
itemType: 'end', itemType: 'end',
name: '', name: '',
quantity: new Big(0), quantity: new Big(0),
type: TypeOfOrder.BUY, type: 'BUY',
unitPrice: unitPriceAtEndDate unitPrice: unitPriceAtEndDate
}); });
@ -1029,7 +1033,7 @@ export class PortfolioCalculator {
feeInBaseCurrency: new Big(0), feeInBaseCurrency: new Big(0),
name: '', name: '',
quantity: new Big(0), quantity: new Big(0),
type: TypeOfOrder.BUY, type: 'BUY',
unitPrice: unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ?? marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice lastUnitPrice
@ -1042,28 +1046,26 @@ export class PortfolioCalculator {
} }
} }
// Sort orders so that the start and end placeholder order are at the right // Sort orders so that the start and end placeholder order are at the correct
// position // position
orders = sortBy(orders, (order) => { orders = sortBy(orders, ({ date, itemType }) => {
let sortIndex = new Date(order.date); let sortIndex = new Date(date);
if (order.itemType === 'start') { if (itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1); sortIndex = addMilliseconds(sortIndex, 1);
} else if (itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
} }
return sortIndex.getTime(); return sortIndex.getTime();
}); });
const indexOfStartOrder = orders.findIndex((order) => { const indexOfStartOrder = orders.findIndex(({ itemType }) => {
return order.itemType === 'start'; return itemType === 'start';
}); });
const indexOfEndOrder = orders.findIndex((order) => { const indexOfEndOrder = orders.findIndex(({ itemType }) => {
return order.itemType === 'end'; return itemType === 'end';
}); });
let totalInvestmentDays = 0; let totalInvestmentDays = 0;
@ -1126,29 +1128,41 @@ export class PortfolioCalculator {
valueOfInvestmentBeforeTransactionWithCurrencyEffect; valueOfInvestmentBeforeTransactionWithCurrencyEffect;
} }
const transactionInvestment = let transactionInvestment = new Big(0);
order.type === 'BUY' let transactionInvestmentWithCurrencyEffect = new Big(0);
? order.quantity
.mul(order.unitPriceInBaseCurrency)
.mul(this.getFactor(order.type))
: totalUnits.gt(0)
? totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(this.getFactor(order.type))
: new Big(0);
const transactionInvestmentWithCurrencyEffect = if (order.type === 'BUY') {
order.type === 'BUY' transactionInvestment = order.quantity
? order.quantity .mul(order.unitPriceInBaseCurrency)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect = order.quantity
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect) .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.mul(this.getFactor(order.type)) .mul(getFactor(order.type));
: totalUnits.gt(0)
? totalInvestmentWithCurrencyEffect 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) .div(totalUnits)
.mul(order.quantity) .mul(order.quantity)
.mul(this.getFactor(order.type)) .mul(getFactor(order.type));
: new Big(0); transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log('totalInvestment', totalInvestment.toNumber()); console.log('totalInvestment', totalInvestment.toNumber());
@ -1202,9 +1216,16 @@ export class PortfolioCalculator {
order.feeInBaseCurrencyWithCurrencyEffect ?? 0 order.feeInBaseCurrencyWithCurrencyEffect ?? 0
); );
totalUnits = totalUnits.plus( totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
order.quantity.mul(this.getFactor(order.type))
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
); );
}
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency); const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
@ -1213,14 +1234,14 @@ export class PortfolioCalculator {
); );
const grossPerformanceFromSell = const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL order.type === 'SELL'
? order.unitPriceInBaseCurrency ? order.unitPriceInBaseCurrency
.minus(lastAveragePrice) .minus(lastAveragePrice)
.mul(order.quantity) .mul(order.quantity)
: new Big(0); : new Big(0);
const grossPerformanceFromSellWithCurrencyEffect = const grossPerformanceFromSellWithCurrencyEffect =
order.type === TypeOfOrder.SELL order.type === 'SELL'
? order.unitPriceInBaseCurrencyWithCurrencyEffect ? order.unitPriceInBaseCurrencyWithCurrencyEffect
.minus(lastAveragePriceWithCurrencyEffect) .minus(lastAveragePriceWithCurrencyEffect)
.mul(order.quantity) .mul(order.quantity)
@ -1235,35 +1256,21 @@ export class PortfolioCalculator {
grossPerformanceFromSellWithCurrencyEffect grossPerformanceFromSellWithCurrencyEffect
); );
totalInvestmentWithGrossPerformanceFromSell = lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
totalInvestmentWithGrossPerformanceFromSell
.plus(transactionInvestment)
.plus(grossPerformanceFromSell);
totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect =
totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect
.plus(transactionInvestmentWithCurrencyEffect)
.plus(grossPerformanceFromSellWithCurrencyEffect);
lastAveragePrice = totalUnits.eq(0)
? new Big(0) ? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits); : totalInvestmentFromBuyTransactions.div(
totalQuantityFromBuyTransactions
);
lastAveragePriceWithCurrencyEffect = totalUnits.eq(0) lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
0
)
? new Big(0) ? new Big(0)
: totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.div( : totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
totalUnits totalQuantityFromBuyTransactions
); );
if (PortfolioCalculator.ENABLE_LOGGING) { if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
'totalInvestmentWithGrossPerformanceFromSell',
totalInvestmentWithGrossPerformanceFromSell.toNumber()
);
console.log(
'totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect',
totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.toNumber()
);
console.log( console.log(
'grossPerformanceFromSells', 'grossPerformanceFromSells',
grossPerformanceFromSells.toNumber() grossPerformanceFromSells.toNumber()
@ -1297,7 +1304,7 @@ export class PortfolioCalculator {
grossPerformanceWithCurrencyEffect; grossPerformanceWithCurrencyEffect;
} }
if (i > indexOfStartOrder) { if (i > indexOfStartOrder && ['BUY', 'SELL'].includes(order.type)) {
// Only consider periods with an investment for the calculation of // Only consider periods with an investment for the calculation of
// the time weighted investment // the time weighted investment
if (valueOfInvestmentBeforeTransaction.gt(0)) { if (valueOfInvestmentBeforeTransaction.gt(0)) {
@ -1309,11 +1316,10 @@ export class PortfolioCalculator {
orderDate, orderDate,
previousOrderDate previousOrderDate
); );
// Set to at least 1 day, otherwise the transactions on the same day
// would not be considered in the time weighted calculation
if (daysSinceLastOrder <= 0) { if (daysSinceLastOrder <= 0) {
daysSinceLastOrder = 1; // 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 // Sum up the total investment days since the start date to calculate
@ -1491,6 +1497,7 @@ export class PortfolioCalculator {
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed( Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2 2
)} )}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed( Gross performance: ${totalGrossPerformance.toFixed(
2 2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}% )} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
@ -1528,6 +1535,8 @@ export class PortfolioCalculator {
netPerformanceValuesWithCurrencyEffect, netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues, timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect, timeWeightedInvestmentValuesWithCurrencyEffect,
totalDividend,
totalDividendInBaseCurrency,
totalInvestment, totalInvestment,
totalInvestmentWithCurrencyEffect, totalInvestmentWithCurrencyEffect,
grossPerformance: totalGrossPerformance, grossPerformance: totalGrossPerformance,

View File

@ -1,4 +1,5 @@
import { AccessService } from '@ghostfolio/api/app/access/access.service'; import { AccessService } from '@ghostfolio/api/app/access/access.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
import { import {
@ -11,6 +12,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
import { ApiService } from '@ghostfolio/api/services/api/api.service'; import { ApiService } from '@ghostfolio/api/services/api/api.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
HEADER_KEY_IMPERSONATION HEADER_KEY_IMPERSONATION
@ -18,6 +20,7 @@ import {
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioDividends, PortfolioDividends,
PortfolioHoldingsResponse,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
@ -57,6 +60,8 @@ export class PortfolioController {
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly portfolioService: PortfolioService, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
@ -71,8 +76,11 @@ export class PortfolioController {
@Query('accounts') filterByAccounts?: string, @Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string @Query('tags') filterByTags?: string,
@Query('withLiabilities') withLiabilitiesParam = 'false'
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
const withLiabilities = withLiabilitiesParam === 'true';
let hasDetails = true; let hasDetails = true;
let hasError = false; let hasError = false;
const hasReadRestrictedAccessPermission = const hasReadRestrictedAccessPermission =
@ -91,20 +99,14 @@ export class PortfolioController {
filterByTags filterByTags
}); });
const { const { accounts, hasErrors, holdings, platforms, summary } =
accounts, await this.portfolioService.getDetails({
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasErrors,
holdings,
platforms,
summary,
totalValueInBaseCurrency
} = await this.portfolioService.getDetails({
dateRange, dateRange,
filters, filters,
impersonationId, impersonationId,
userId: this.request.user.id withLiabilities,
userId: this.request.user.id,
withSummary: true
}); });
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -118,27 +120,23 @@ export class PortfolioController {
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
const totalInvestment = Object.values(holdings) const totalInvestment = Object.values(holdings)
.map((portfolioPosition) => { .map(({ investment }) => {
return portfolioPosition.investment; return investment;
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const totalValue = Object.values(holdings) const totalValue = Object.values(holdings)
.map((portfolioPosition) => { .filter(({ assetClass, assetSubClass }) => {
return this.exchangeRateDataService.toCurrency( return assetClass !== 'CASH' && assetSubClass !== 'CASH';
portfolioPosition.quantity * portfolioPosition.marketPrice, })
portfolioPosition.currency, .map(({ valueInBaseCurrency }) => {
this.request.user.Settings.settings.baseCurrency return valueInBaseCurrency;
);
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(holdings)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.grossPerformance = null;
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.valueInPercentage = portfolioPosition.valueInPercentage =
portfolioPosition.valueInBaseCurrency / totalValue; portfolioPosition.valueInBaseCurrency / totalValue;
} }
@ -164,19 +162,21 @@ export class PortfolioController {
'currentGrossPerformanceWithCurrencyEffect', 'currentGrossPerformanceWithCurrencyEffect',
'currentNetPerformance', 'currentNetPerformance',
'currentNetPerformanceWithCurrencyEffect', 'currentNetPerformanceWithCurrencyEffect',
'currentNetWorth',
'currentValue', 'currentValue',
'dividend', 'dividendInBaseCurrency',
'emergencyFund', 'emergencyFund',
'excludedAccountsAndActivities', 'excludedAccountsAndActivities',
'fees', 'fees',
'filteredValueInBaseCurrency',
'fireWealth', 'fireWealth',
'interest', 'interest',
'items', 'items',
'liabilities', 'liabilities',
'netWorth',
'totalBuy', 'totalBuy',
'totalInvestment', 'totalInvestment',
'totalSell' 'totalSell',
'totalValueInBaseCurrency'
]); ]);
} }
@ -203,12 +203,9 @@ export class PortfolioController {
return { return {
accounts, accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError, hasError,
holdings, holdings,
platforms, platforms,
totalValueInBaseCurrency,
summary: portfolioSummary summary: portfolioSummary
}; };
} }
@ -235,11 +232,21 @@ export class PortfolioController {
filterByTags filterByTags
}); });
let dividends = await this.portfolioService.getDividends({ const impersonationUserId =
dateRange, await this.impersonationService.validateImpersonationId(impersonationId);
const userCurrency = this.request.user.Settings.settings.baseCurrency;
const { activities } = await this.orderService.getOrders({
filters, filters,
groupBy, userCurrency,
impersonationId userId: impersonationUserId || this.request.user.id,
types: ['DIVIDEND']
});
let dividends = await this.portfolioService.getDividends({
activities,
dateRange,
groupBy
}); });
if ( if (
@ -269,6 +276,35 @@ export class PortfolioController {
return { dividends }; return { dividends };
} }
@Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByHoldingType,
filterBySearchQuery,
filterByTags
});
const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId,
userId: this.request.user.id
});
return { holdings: Object.values(holdings) };
}
@Get('investments') @Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments( public async getInvestments(
@ -346,8 +382,12 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string, @Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max', @Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string, @Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false @Query('withExcludedAccounts') withExcludedAccountsParam = 'false',
@Query('withItems') withItemsParam = 'false'
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const withItems = withItemsParam === 'true';
const hasReadRestrictedAccessPermission = const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({ this.userService.hasReadRestrictedAccessPermission({
impersonationId, impersonationId,
@ -365,6 +405,7 @@ export class PortfolioController {
filters, filters,
impersonationId, impersonationId,
withExcludedAccounts, withExcludedAccounts,
withItems,
userId: this.request.user.id userId: this.request.user.id
}); });
@ -429,6 +470,10 @@ export class PortfolioController {
return nullifyValuesInObject(item, ['totalInvestment', 'value']); return nullifyValuesInObject(item, ['totalInvestment', 'value']);
} }
); );
performanceInformation.performance = nullifyValuesInObject(
performanceInformation.performance,
['currentNetPerformance', 'currentNetPerformancePercent']
);
} }
return performanceInformation; return performanceInformation;
@ -483,7 +528,6 @@ export class PortfolioController {
} }
const { holdings } = await this.portfolioService.getDetails({ const { holdings } = await this.portfolioService.getDetails({
dateRange: 'max',
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }], filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
impersonationId: access.userId, impersonationId: access.userId,
userId: user.id userId: user.id
@ -515,7 +559,8 @@ export class PortfolioController {
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity, dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
markets: hasDetails ? portfolioPosition.markets : undefined, markets: hasDetails ? portfolioPosition.markets : undefined,
name: portfolioPosition.name, name: portfolioPosition.name,
netPerformancePercent: portfolioPosition.netPerformancePercent, netPerformancePercentWithCurrencyEffect:
portfolioPosition.netPerformancePercentWithCurrencyEffect,
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol, symbol: portfolioPosition.symbol,
url: portfolioPosition.url, url: portfolioPosition.url,

View File

@ -7,6 +7,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account'; import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment'; import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
@ -23,7 +24,12 @@ import {
MAX_CHART_ITEMS, MAX_CHART_ITEMS,
UNKNOWN_KEY UNKNOWN_KEY
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper'; import {
DATE_FORMAT,
getAllActivityTypes,
getSum,
parseDate
} from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
EnhancedSymbolProfile, EnhancedSymbolProfile,
@ -62,6 +68,7 @@ import {
Tag Tag
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { isUUID } from 'class-validator';
import { import {
differenceInDays, differenceInDays,
format, format,
@ -119,7 +126,7 @@ export class PortfolioService {
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<AccountWithValue[]> { }): Promise<AccountWithValue[]> {
const where: Prisma.AccountWhereInput = { userId: userId }; const where: Prisma.AccountWhereInput = { userId };
const accountFilter = filters?.find(({ type }) => { const accountFilter = filters?.find(({ type }) => {
return type === 'ACCOUNT'; return type === 'ACCOUNT';
@ -216,29 +223,18 @@ export class PortfolioService {
} }
public async getDividends({ public async getDividends({
dateRange, activities,
filters, dateRange = 'max',
groupBy, groupBy
impersonationId
}: { }: {
dateRange: DateRange; activities: Activity[];
filters?: Filter[]; dateRange?: DateRange;
groupBy?: GroupBy; groupBy?: GroupBy;
impersonationId: string;
}): Promise<InvestmentItem[]> { }): Promise<InvestmentItem[]> {
const userId = await this.getUserId(impersonationId, this.request.user.id); let dividends = activities.map(({ date, valueInBaseCurrency }) => {
const { activities } = await this.orderService.getOrders({
filters,
userId,
types: ['DIVIDEND'],
userCurrency: this.request.user.Settings.settings.baseCurrency
});
let dividends = activities.map((dividend) => {
return { return {
date: format(dividend.date, DATE_FORMAT), date: format(date, DATE_FORMAT),
investment: dividend.valueInBaseCurrency investment: valueInBaseCurrency
}; };
}); });
@ -275,7 +271,8 @@ export class PortfolioService {
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId, userId,
includeDrafts: true includeDrafts: true,
types: ['BUY', 'SELL']
}); });
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
@ -297,10 +294,9 @@ export class PortfolioService {
const { items } = await this.getChart({ const { items } = await this.getChart({
dateRange, dateRange,
impersonationId, impersonationId,
portfolioOrders, portfolioCalculator,
transactionPoints, transactionPoints,
userId, userId,
userCurrency: this.request.user.Settings.settings.baseCurrency,
withDataDecimation: false withDataDecimation: false
}); });
@ -340,13 +336,17 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false,
withLiabilities = false,
withSummary = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withLiabilities?: boolean;
withSummary?: boolean;
}): Promise<PortfolioDetails & { hasErrors: boolean }> { }): Promise<PortfolioDetails & { hasErrors: boolean }> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -356,9 +356,20 @@ export class PortfolioService {
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
); );
const { orders, portfolioOrders, transactionPoints } = let types = getAllActivityTypes().filter((activityType) => {
return activityType !== 'FEE';
});
if (withLiabilities === false) {
types = types.filter((activityType) => {
return activityType !== 'LIABILITY';
});
}
const { activities, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
types,
userId, userId,
withExcludedAccounts withExcludedAccounts
}); });
@ -386,18 +397,29 @@ export class PortfolioService {
}); });
const holdings: PortfolioDetails['holdings'] = {}; const holdings: PortfolioDetails['holdings'] = {};
const totalValueInBaseCurrency = currentPositions.currentValue.plus(
const totalValueInBaseCurrency =
currentPositions.currentValueInBaseCurrency.plus(
cashDetails.balanceInBaseCurrency cashDetails.balanceInBaseCurrency
); );
const isFilteredByAccount = const isFilteredByAccount =
filters?.some((filter) => { filters?.some(({ type }) => {
return filter.type === 'ACCOUNT'; return type === 'ACCOUNT';
}) ?? false;
const isFilteredByCash = filters?.some(({ id, type }) => {
return id === 'CASH' && type === 'ASSET_CLASS';
});
const isFilteredByClosedHoldings =
filters?.some(({ id, type }) => {
return id === 'CLOSED' && type === 'HOLDING_TYPE';
}) ?? false; }) ?? false;
let filteredValueInBaseCurrency = isFilteredByAccount let filteredValueInBaseCurrency = isFilteredByAccount
? totalValueInBaseCurrency ? totalValueInBaseCurrency
: currentPositions.currentValue; : currentPositions.currentValueInBaseCurrency;
if ( if (
filters?.length === 0 || filters?.length === 0 ||
@ -434,15 +456,40 @@ export class PortfolioService {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
for (const item of currentPositions.positions) { for (const {
if (item.quantity.lte(0)) { currency,
dividend,
firstBuyDate,
grossPerformance,
grossPerformanceWithCurrencyEffect,
grossPerformancePercentage,
grossPerformancePercentageWithCurrencyEffect,
investment,
marketPrice,
netPerformance,
netPerformancePercentage,
netPerformancePercentageWithCurrencyEffect,
netPerformanceWithCurrencyEffect,
quantity,
symbol,
tags,
transactionCount,
valueInBaseCurrency
} of currentPositions.positions) {
if (isFilteredByClosedHoldings === true) {
if (!quantity.eq(0)) {
// Ignore positions with a quantity
continue;
}
} else {
if (quantity.eq(0)) {
// Ignore positions without any quantity // Ignore positions without any quantity
continue; continue;
} }
}
const value = item.quantity.mul(item.marketPriceInBaseCurrency ?? 0); const symbolProfile = symbolProfileMap[symbol];
const symbolProfile = symbolProfileMap[item.symbol]; const dataProviderResponse = dataProviderResponses[symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
const markets: PortfolioPosition['markets'] = { const markets: PortfolioPosition['markets'] = {
[UNKNOWN_KEY]: 0, [UNKNOWN_KEY]: 0,
@ -506,49 +553,53 @@ export class PortfolioService {
} }
} else { } else {
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY]) markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
.plus(value) .plus(valueInBaseCurrency)
.toNumber(); .toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY]) marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
.plus(value) .plus(valueInBaseCurrency)
.toNumber(); .toNumber();
} }
holdings[item.symbol] = { holdings[symbol] = {
currency,
markets, markets,
marketsAdvanced, marketsAdvanced,
marketPrice,
symbol,
tags,
transactionCount,
allocationInPercentage: filteredValueInBaseCurrency.eq(0) allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0 ? 0
: value.div(filteredValueInBaseCurrency).toNumber(), : valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass, assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
dateOfFirstActivity: parseDate(item.firstBuyDate), dateOfFirstActivity: parseDate(firstBuyDate),
grossPerformance: item.grossPerformance?.toNumber() ?? 0, dividend: dividend?.toNumber() ?? 0,
grossPerformancePercent: grossPerformance: grossPerformance?.toNumber() ?? 0,
item.grossPerformancePercentage?.toNumber() ?? 0, grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0,
investment: item.investment.toNumber(), grossPerformancePercentWithCurrencyEffect:
marketPrice: item.marketPrice, grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
grossPerformanceWithCurrencyEffect:
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
investment: investment.toNumber(),
marketState: dataProviderResponse?.marketState ?? 'delayed', marketState: dataProviderResponse?.marketState ?? 'delayed',
name: symbolProfile.name, name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0, netPerformance: netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0, netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
quantity: item.quantity.toNumber(), netPerformancePercentWithCurrencyEffect:
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
netPerformanceWithCurrencyEffect:
netPerformanceWithCurrencyEffect?.toNumber() ?? 0,
quantity: quantity.toNumber(),
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount,
url: symbolProfile.url, url: symbolProfile.url,
valueInBaseCurrency: value.toNumber() valueInBaseCurrency: valueInBaseCurrency.toNumber()
}; };
} }
const isFilteredByCash = filters?.some((filter) => {
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
});
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) { if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
const cashPositions = await this.getCashPositions({ const cashPositions = await this.getCashPositions({
cashDetails, cashDetails,
@ -562,8 +613,8 @@ export class PortfolioService {
} }
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({ const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
activities,
filters, filters,
orders,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
userId, userId,
@ -605,7 +656,12 @@ export class PortfolioService {
}; };
} }
const summary = await this.getSummary({ let summary: PortfolioSummary;
if (withSummary) {
summary = await this.getSummary({
filteredValueInBaseCurrency,
holdings,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId, userId,
@ -615,18 +671,14 @@ export class PortfolioService {
holdings holdings
}) })
}); });
}
return { return {
accounts, accounts,
holdings, holdings,
platforms, platforms,
summary, summary,
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(), hasErrors: currentPositions.hasErrors
filteredValueInPercentage: summary.netWorth
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
: 0,
hasErrors: currentPositions.hasErrors,
totalValueInBaseCurrency: summary.netWorth
}; };
} }
@ -692,7 +744,7 @@ export class PortfolioService {
.filter((order) => { .filter((order) => {
tags = tags.concat(order.tags); tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL'; return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
}) })
.map((order) => ({ .map((order) => ({
currency: order.SymbolProfile.currency, currency: order.SymbolProfile.currency,
@ -733,6 +785,7 @@ export class PortfolioService {
averagePrice, averagePrice,
currency, currency,
dataSource, dataSource,
dividendInBaseCurrency,
fee, fee,
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
@ -741,22 +794,14 @@ export class PortfolioService {
} = position; } = position;
const accounts: PortfolioPositionDetail['accounts'] = uniqBy( const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
orders, orders.filter(({ Account }) => {
return Account;
}),
'Account.id' 'Account.id'
).map(({ Account }) => { ).map(({ Account }) => {
return Account; return Account;
}); });
const dividendInBaseCurrency = getSum(
orders
.filter(({ type }) => {
return type === 'DIVIDEND';
})
.map(({ valueInBaseCurrency }) => {
return new Big(valueInBaseCurrency);
})
);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[{ dataSource, symbol: aSymbol }], [{ dataSource, symbol: aSymbol }],
'day', 'day',
@ -790,9 +835,7 @@ export class PortfolioService {
); );
if (currentSymbol) { if (currentSymbol) {
currentAveragePrice = currentSymbol.quantity.eq(0) currentAveragePrice = currentSymbol.averagePrice.toNumber();
? 0
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
currentQuantity = currentSymbol.quantity.toNumber(); currentQuantity = currentSymbol.quantity.toNumber();
} }
@ -945,7 +988,8 @@ export class PortfolioService {
const { portfolioOrders, transactionPoints } = const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId userId,
types: ['BUY', 'SELL']
}); });
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
@ -1075,13 +1119,15 @@ export class PortfolioService {
filters, filters,
impersonationId, impersonationId,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false,
withItems = false
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
filters?: Filter[]; filters?: Filter[];
impersonationId: string; impersonationId: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
withItems?: boolean;
}): Promise<PortfolioPerformanceResponse> { }): Promise<PortfolioPerformanceResponse> {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const user = await this.userService.user({ id: userId }); const user = await this.userService.user({ id: userId });
@ -1116,7 +1162,8 @@ export class PortfolioService {
await this.getTransactionPoints({ await this.getTransactionPoints({
filters, filters,
userId, userId,
withExcludedAccounts withExcludedAccounts,
types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL']
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1160,7 +1207,7 @@ export class PortfolioService {
const startDate = this.getStartDate(dateRange, portfolioStart); const startDate = this.getStartDate(dateRange, portfolioStart);
const { const {
currentValue, currentValueInBaseCurrency,
errors, errors,
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
@ -1187,9 +1234,8 @@ export class PortfolioService {
const { items } = await this.getChart({ const { items } = await this.getChart({
dateRange, dateRange,
impersonationId, impersonationId,
portfolioOrders, portfolioCalculator,
transactionPoints, transactionPoints,
userCurrency,
userId userId
}); });
@ -1255,7 +1301,7 @@ export class PortfolioService {
currentNetPerformancePercentWithCurrencyEffect.toNumber(), currentNetPerformancePercentWithCurrencyEffect.toNumber(),
currentNetPerformanceWithCurrencyEffect: currentNetPerformanceWithCurrencyEffect:
currentNetPerformanceWithCurrencyEffect.toNumber(), currentNetPerformanceWithCurrencyEffect.toNumber(),
currentValue: currentValue.toNumber(), currentValue: currentValueInBaseCurrency.toNumber(),
totalInvestment: totalInvestment.toNumber() totalInvestment: totalInvestment.toNumber()
} }
}; };
@ -1266,9 +1312,10 @@ 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 { orders, portfolioOrders, transactionPoints } = const { activities, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({ await this.getTransactionPoints({
userId userId,
types: ['BUY', 'SELL']
}); });
const portfolioCalculator = new PortfolioCalculator({ const portfolioCalculator = new PortfolioCalculator({
@ -1297,7 +1344,7 @@ export class PortfolioService {
} }
const { accounts } = await this.getValueOfAccountsAndPlatforms({ const { accounts } = await this.getValueOfAccountsAndPlatforms({
orders, activities,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
userId userId
@ -1307,7 +1354,7 @@ export class PortfolioService {
return { return {
rules: { rules: {
accountClusterRisk: isEmpty(orders) accountClusterRisk: isEmpty(activities)
? undefined ? undefined
: await this.rulesService.evaluate( : await this.rulesService.evaluate(
[ [
@ -1322,7 +1369,7 @@ export class PortfolioService {
], ],
userSettings userSettings
), ),
currencyClusterRisk: isEmpty(orders) currencyClusterRisk: isEmpty(activities)
? undefined ? undefined
: await this.rulesService.evaluate( : await this.rulesService.evaluate(
[ [
@ -1351,7 +1398,7 @@ export class PortfolioService {
new FeeRatioInitialInvestment( new FeeRatioInitialInvestment(
this.exchangeRateDataService, this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(), currentPositions.totalInvestment.toNumber(),
this.getFees({ userCurrency, activities: orders }).toNumber() this.getFees({ activities, userCurrency }).toNumber()
) )
], ],
userSettings userSettings
@ -1413,17 +1460,15 @@ export class PortfolioService {
private async getChart({ private async getChart({
dateRange = 'max', dateRange = 'max',
impersonationId, impersonationId,
portfolioOrders, portfolioCalculator,
transactionPoints, transactionPoints,
userCurrency,
userId, userId,
withDataDecimation = true withDataDecimation = true
}: { }: {
dateRange?: DateRange; dateRange?: DateRange;
impersonationId: string; impersonationId: string;
portfolioOrders: PortfolioOrder[]; portfolioCalculator: PortfolioCalculator;
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
userCurrency: string;
userId: string; userId: string;
withDataDecimation?: boolean; withDataDecimation?: boolean;
}): Promise<HistoricalDataContainer> { }): Promise<HistoricalDataContainer> {
@ -1437,15 +1482,6 @@ export class PortfolioService {
userId = await this.getUserId(impersonationId, userId); userId = await this.getUserId(impersonationId, userId);
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
const endDate = new Date(); const endDate = new Date();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
@ -1558,28 +1594,25 @@ export class PortfolioService {
private getFees({ private getFees({
activities, activities,
date = new Date(0),
userCurrency userCurrency
}: { }: {
activities: OrderWithAccount[]; activities: OrderWithAccount[];
date?: Date;
userCurrency: string; userCurrency: string;
}) { }) {
return activities return getSum(
.filter((activity) => { activities
// Filter out all activities before given date (drafts) .filter(({ isDraft }) => {
return isBefore(date, new Date(activity.date)); return isDraft === false;
}) })
.map(({ fee, SymbolProfile }) => { .map(({ fee, SymbolProfile }) => {
return this.exchangeRateDataService.toCurrency( return new Big(
this.exchangeRateDataService.toCurrency(
fee, fee,
SymbolProfile.currency, SymbolProfile.currency,
userCurrency userCurrency
)
); );
}) })
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
); );
} }
@ -1598,14 +1631,19 @@ export class PortfolioService {
countries: [], countries: [],
dataSource: undefined, dataSource: undefined,
dateOfFirstActivity: undefined, dateOfFirstActivity: undefined,
dividend: 0,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
grossPerformancePercentWithCurrencyEffect: 0,
grossPerformanceWithCurrencyEffect: 0,
investment: balance, investment: balance,
marketPrice: 0, marketPrice: 0,
marketState: 'open', marketState: 'open',
name: currency, name: currency,
netPerformance: 0, netPerformance: 0,
netPerformancePercent: 0, netPerformancePercent: 0,
netPerformancePercentWithCurrencyEffect: 0,
netPerformanceWithCurrencyEffect: 0,
quantity: 0, quantity: 0,
sectors: [], sectors: [],
symbol: currency, symbol: currency,
@ -1686,12 +1724,16 @@ export class PortfolioService {
private async getSummary({ private async getSummary({
balanceInBaseCurrency, balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
filteredValueInBaseCurrency,
holdings,
impersonationId, impersonationId,
userCurrency, userCurrency,
userId userId
}: { }: {
balanceInBaseCurrency: number; balanceInBaseCurrency: number;
emergencyFundPositionsValueInBaseCurrency: number; emergencyFundPositionsValueInBaseCurrency: number;
filteredValueInBaseCurrency: Big;
holdings: PortfolioDetails['holdings'];
impersonationId: string; impersonationId: string;
userCurrency: string; userCurrency: string;
userId: string; userId: string;
@ -1705,57 +1747,87 @@ export class PortfolioService {
}); });
const { activities } = await this.orderService.getOrders({ const { activities } = await this.orderService.getOrders({
userCurrency,
userId
});
let { activities: excludedActivities } = await this.orderService.getOrders({
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts: true withExcludedAccounts: true
}); });
excludedActivities = excludedActivities.filter(({ Account: account }) => { const excludedActivities: Activity[] = [];
return account?.isExcluded ?? false; const nonExcludedActivities: Activity[] = [];
});
for (const activity of activities) {
if (activity.Account?.isExcluded) {
excludedActivities.push(activity);
} else {
nonExcludedActivities.push(activity);
}
}
const dividendInBaseCurrency = getSum(
(
await this.getDividends({
activities: activities.filter(({ type }) => {
return type === 'DIVIDEND';
})
})
).map(({ investment }) => {
return new Big(investment);
})
);
const dividend = this.getSumOfActivityType({
activities,
userCurrency,
activityType: 'DIVIDEND'
}).toNumber();
const emergencyFund = new Big( const emergencyFund = new Big(
Math.max( Math.max(
emergencyFundPositionsValueInBaseCurrency, emergencyFundPositionsValueInBaseCurrency,
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0 (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
) )
); );
const fees = this.getFees({ activities, userCurrency }).toNumber(); const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date; const firstOrderDate = activities[0]?.date;
const interest = this.getSumOfActivityType({ const interest = this.getSumOfActivityType({
activities, activities,
userCurrency, userCurrency,
activityType: 'INTEREST' activityType: 'INTEREST'
}).toNumber(); }).toNumber();
const items = this.getSumOfActivityType({
activities, const items = getSum(
userCurrency, Object.keys(holdings)
activityType: 'ITEM' .filter((symbol) => {
}).toNumber(); return (
const liabilities = this.getSumOfActivityType({ isUUID(symbol) &&
activities, holdings[symbol].dataSource === 'MANUAL' &&
userCurrency, holdings[symbol].valueInBaseCurrency > 0
activityType: 'LIABILITY' );
}).toNumber(); })
.map((symbol) => {
return new Big(holdings[symbol].valueInBaseCurrency).abs();
})
).toNumber();
const liabilities = getSum(
Object.keys(holdings)
.filter((symbol) => {
return (
isUUID(symbol) &&
holdings[symbol].dataSource === 'MANUAL' &&
holdings[symbol].valueInBaseCurrency < 0
);
})
.map((symbol) => {
return new Big(holdings[symbol].valueInBaseCurrency).abs();
})
).toNumber();
const totalBuy = this.getSumOfActivityType({ const totalBuy = this.getSumOfActivityType({
activities,
userCurrency, userCurrency,
activities: nonExcludedActivities,
activityType: 'BUY' activityType: 'BUY'
}).toNumber(); }).toNumber();
const totalSell = this.getSumOfActivityType({ const totalSell = this.getSumOfActivityType({
activities,
userCurrency, userCurrency,
activities: nonExcludedActivities,
activityType: 'SELL' activityType: 'SELL'
}).toNumber(); }).toNumber();
@ -1763,7 +1835,9 @@ export class PortfolioService {
.minus(emergencyFund) .minus(emergencyFund)
.plus(emergencyFundPositionsValueInBaseCurrency) .plus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(); .toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell); const committedFunds = new Big(totalBuy).minus(totalSell);
const totalOfExcludedActivities = this.getSumOfActivityType({ const totalOfExcludedActivities = this.getSumOfActivityType({
userCurrency, userCurrency,
activities: excludedActivities, activities: excludedActivities,
@ -1814,21 +1888,36 @@ export class PortfolioService {
}) })
?.toNumber(); ?.toNumber();
const annualizedPerformancePercentWithCurrencyEffect =
new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
exchangeRateDataService: this.exchangeRateDataService,
orders: []
})
.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
)
})
?.toNumber();
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
annualizedPerformancePercent, annualizedPerformancePercent,
annualizedPerformancePercentWithCurrencyEffect,
cash, cash,
dividend,
excludedAccountsAndActivities, excludedAccountsAndActivities,
fees, fees,
firstOrderDate, firstOrderDate,
interest, interest,
items, items,
liabilities, liabilities,
netWorth,
totalBuy, totalBuy,
totalSell, totalSell,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
emergencyFund: { emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency, assets: emergencyFundPositionsValueInBaseCurrency,
cash: emergencyFund cash: emergencyFund
@ -1836,61 +1925,61 @@ export class PortfolioService {
.toNumber(), .toNumber(),
total: emergencyFund.toNumber() total: emergencyFund.toNumber()
}, },
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
filteredValueInPercentage: netWorth
? filteredValueInBaseCurrency.div(netWorth).toNumber()
: undefined,
fireWealth: new Big(performanceInformation.performance.currentValue) fireWealth: new Big(performanceInformation.performance.currentValue)
.minus(emergencyFundPositionsValueInBaseCurrency) .minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(), .toNumber(),
ordersCount: activities.filter(({ type }) => { ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL'; return type === 'BUY' || type === 'SELL';
}).length }).length,
totalValueInBaseCurrency: netWorth
}; };
} }
private getSumOfActivityType({ private getSumOfActivityType({
activities, activities,
activityType, activityType,
date = new Date(0),
userCurrency userCurrency
}: { }: {
activities: OrderWithAccount[]; activities: OrderWithAccount[];
activityType: ActivityType; activityType: ActivityType;
date?: Date;
userCurrency: string; userCurrency: string;
}) { }) {
return activities return getSum(
.filter((activity) => { activities
// Filter out all activities before given date (drafts) and .filter(({ isDraft, type }) => {
// activity type return isDraft === false && type === activityType;
return (
isBefore(date, new Date(activity.date)) &&
activity.type === activityType
);
}) })
.map(({ quantity, SymbolProfile, unitPrice }) => { .map(({ quantity, SymbolProfile, unitPrice }) => {
return this.exchangeRateDataService.toCurrency( return new Big(
this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(), new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency, SymbolProfile.currency,
userCurrency userCurrency
)
); );
}) })
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
); );
} }
private async getTransactionPoints({ private async getTransactionPoints({
filters, filters,
includeDrafts = false, includeDrafts = false,
types = getAllActivityTypes(),
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
filters?: Filter[]; filters?: Filter[];
includeDrafts?: boolean; includeDrafts?: boolean;
types?: ActivityType[];
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}): Promise<{ }): Promise<{
activities: Activity[];
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: Activity[];
portfolioOrders: PortfolioOrder[]; portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = const userCurrency =
@ -1899,14 +1988,14 @@ export class PortfolioService {
const { activities, count } = await this.orderService.getOrders({ const { activities, count } = await this.orderService.getOrders({
filters, filters,
includeDrafts, includeDrafts,
types,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts, withExcludedAccounts
types: ['BUY', 'SELL']
}); });
if (count <= 0) { if (count <= 0) {
return { transactionPoints: [], orders: [], portfolioOrders: [] }; return { activities: [], transactionPoints: [], portfolioOrders: [] };
} }
const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({ const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({
@ -1932,8 +2021,8 @@ export class PortfolioService {
portfolioCalculator.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
return { return {
activities,
portfolioOrders, portfolioOrders,
orders: activities,
transactionPoints: portfolioCalculator.getTransactionPoints() transactionPoints: portfolioCalculator.getTransactionPoints()
}; };
} }
@ -1954,29 +2043,20 @@ export class PortfolioService {
} }
private async getValueOfAccountsAndPlatforms({ private async getValueOfAccountsAndPlatforms({
activities,
filters = [], filters = [],
orders,
portfolioItemsNow, portfolioItemsNow,
userCurrency, userCurrency,
userId, userId,
withExcludedAccounts = false withExcludedAccounts = false
}: { }: {
activities: Activity[];
filters?: Filter[]; filters?: Filter[];
orders: OrderWithAccount[];
portfolioItemsNow: { [p: string]: TimelinePosition }; portfolioItemsNow: { [p: string]: TimelinePosition };
userCurrency: string; userCurrency: string;
userId: string; userId: string;
withExcludedAccounts?: boolean; withExcludedAccounts?: boolean;
}) { }) {
const { activities: ordersOfTypeItemOrLiability } =
await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM', 'LIABILITY']
});
const accounts: PortfolioDetails['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
const platforms: PortfolioDetails['platforms'] = {}; const platforms: PortfolioDetails['platforms'] = {};
@ -1994,7 +2074,7 @@ export class PortfolioService {
}); });
} else { } else {
const accountIds = uniq( const accountIds = uniq(
orders activities
.filter(({ accountId }) => { .filter(({ accountId }) => {
return accountId; return accountId;
}) })
@ -2014,19 +2094,10 @@ export class PortfolioService {
}); });
for (const account of currentAccounts) { for (const account of currentAccounts) {
let ordersByAccount = orders.filter(({ accountId }) => { const ordersByAccount = activities.filter(({ accountId }) => {
return accountId === account.id; return accountId === account.id;
}); });
const ordersOfTypeItemOrLiabilityByAccount =
ordersOfTypeItemOrLiability.filter(({ accountId }) => {
return accountId === account.id;
});
ordersByAccount = ordersByAccount.concat(
ordersOfTypeItemOrLiabilityByAccount
);
accounts[account.id] = { accounts[account.id] = {
balance: account.balance, balance: account.balance,
currency: account.currency, currency: account.currency,
@ -2058,41 +2129,39 @@ export class PortfolioService {
}; };
} }
for (const order of ordersByAccount) { for (const {
Account,
quantity,
SymbolProfile,
type
} of ordersByAccount) {
let currentValueOfSymbolInBaseCurrency = let currentValueOfSymbolInBaseCurrency =
order.quantity * getFactor(type) *
(portfolioItemsNow[order.SymbolProfile.symbol] quantity *
?.marketPriceInBaseCurrency ?? (portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ??
order.unitPrice ??
0); 0);
if (order.type === 'LIABILITY' || order.type === 'SELL') { if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
currentValueOfSymbolInBaseCurrency *= -1; accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
}
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
currentValueOfSymbolInBaseCurrency; currentValueOfSymbolInBaseCurrency;
} else { } else {
accounts[order.Account?.id || UNKNOWN_KEY] = { accounts[Account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: Account?.currency,
name: account.name, name: account.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };
} }
if ( if (
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] platforms[Account?.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency
?.valueInBaseCurrency
) { ) {
platforms[ platforms[Account?.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
order.Account?.Platform?.id || UNKNOWN_KEY currentValueOfSymbolInBaseCurrency;
].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency;
} else { } else {
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = { platforms[Account?.Platform?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: Account?.currency,
name: account.Platform?.name, name: account.Platform?.name,
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
}; };

View File

@ -15,6 +15,7 @@ import { RedisCacheService } from './redis-cache.service';
inject: [ConfigurationService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => { useFactory: async (configurationService: ConfigurationService) => {
return <RedisClientOptions>{ return <RedisClientOptions>{
db: configurationService.get('REDIS_DB'),
host: configurationService.get('REDIS_HOST'), host: configurationService.get('REDIS_HOST'),
max: configurationService.get('MAX_ITEM_IN_CACHE'), max: configurationService.get('MAX_ITEM_IN_CACHE'),
password: configurationService.get('REDIS_PASSWORD'), password: configurationService.get('REDIS_PASSWORD'),

View File

@ -21,7 +21,7 @@ export class RedisCacheService {
} }
public async get(key: string): Promise<string> { public async get(key: string): Promise<string> {
return await this.cache.get(key); return this.cache.get(key);
} }
public getQuoteKey({ dataSource, symbol }: UniqueAsset) { public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
@ -29,15 +29,15 @@ export class RedisCacheService {
} }
public async remove(key: string) { public async remove(key: string) {
await this.cache.del(key); return this.cache.del(key);
} }
public async reset() { public async reset() {
await this.cache.reset(); return this.cache.reset();
} }
public async set(key: string, value: string, ttlInSeconds?: number) { public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set( return this.cache.set(
key, key,
value, value,
ttlInSeconds ?? this.configurationService.get('CACHE_TTL') ttlInSeconds ?? this.configurationService.get('CACHE_TTL')

View File

@ -116,7 +116,7 @@ export class SubscriptionController {
@Body() { couponId, priceId }: { couponId: string; priceId: string } @Body() { couponId, priceId }: { couponId: string; priceId: string }
) { ) {
try { try {
return await this.subscriptionService.createCheckoutSession({ return this.subscriptionService.createCheckoutSession({
couponId, couponId,
priceId, priceId,
user: this.request.user user: this.request.user

View File

@ -39,9 +39,11 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) @UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(TransformDataSourceInResponseInterceptor) @UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol( public async lookupSymbol(
@Query('includeIndices') includeIndices: boolean = false, @Query('includeIndices') includeIndicesParam = 'false',
@Query('query') query = '' @Query('query') query = ''
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
const includeIndices = includeIndicesParam === 'true';
try { try {
return this.symbolService.lookup({ return this.symbolService.lookup({
includeIndices, includeIndices,

View File

@ -7,6 +7,7 @@ import type {
import { import {
IsArray, IsArray,
IsBoolean, IsBoolean,
IsISO4217CurrencyCode,
IsISO8601, IsISO8601,
IsIn, IsIn,
IsNumber, IsNumber,
@ -19,8 +20,8 @@ export class UpdateUserSettingDto {
@IsOptional() @IsOptional()
annualInterestRate?: number; annualInterestRate?: number;
@IsISO4217CurrencyCode()
@IsOptional() @IsOptional()
@IsString()
baseCurrency?: string; baseCurrency?: string;
@IsString() @IsString()

View File

@ -135,7 +135,7 @@ export class UserController {
} }
} }
return await this.userService.updateUserSetting({ return this.userService.updateUserSetting({
userSettings, userSettings,
userId: this.request.user.id userId: this.request.user.id
}); });

View File

@ -1,11 +1,13 @@
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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service'; import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { import {
DEFAULT_CURRENCY, DEFAULT_CURRENCY,
DEFAULT_LANGUAGE_CODE,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SYSTEM_MESSAGE, PROPERTY_SYSTEM_MESSAGE,
locale locale
@ -31,6 +33,8 @@ const crypto = require('crypto');
@Injectable() @Injectable()
export class UserService { export class UserService {
private i18nService = new I18nService();
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
@ -325,8 +329,10 @@ export class UserService {
Account: { Account: {
create: { create: {
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
isDefault: true, name: this.i18nService.getTranslation({
name: 'Default Account' id: 'myAccount',
languageCode: DEFAULT_LANGUAGE_CODE // TODO
})
} }
}, },
Settings: { Settings: {
@ -438,7 +444,7 @@ export class UserService {
settings settings
}, },
where: { where: {
userId: userId userId
} }
}); });

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@
"LUNA1": "Terra", "LUNA1": "Terra",
"LUNA2": "Terra", "LUNA2": "Terra",
"SGB1": "Songbird", "SGB1": "Songbird",
"SMURFCAT": "Real Smurf Cat",
"UNI1": "Uniswap", "UNI1": "Uniswap",
"UNI7083": "Uniswap",
"UST": "TerraUSD" "UST": "TerraUSD"
} }

View File

@ -1,4 +1,4 @@
export const environment = { export const environment = {
production: true, production: true,
version: `v${require('../../../../package.json').version}` version: `${require('../../../../package.json').version}`
}; };

View File

@ -0,0 +1,21 @@
import { Type as ActivityType } from '@prisma/client';
export function getFactor(activityType: ActivityType) {
let factor: number;
switch (activityType) {
case 'BUY':
case 'ITEM':
factor = 1;
break;
case 'LIABILITY':
case 'SELL':
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}

View File

@ -49,14 +49,14 @@ export class RedactValuesInResponseInterceptor<T>
'dividendInBaseCurrency', 'dividendInBaseCurrency',
'fee', 'fee',
'feeInBaseCurrency', 'feeInBaseCurrency',
'filteredValueInBaseCurrency',
'grossPerformance', 'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'investment', 'investment',
'netPerformance', 'netPerformance',
'netPerformanceWithCurrencyEffect',
'quantity', 'quantity',
'symbolMapping', 'symbolMapping',
'totalBalanceInBaseCurrency', 'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice', 'unitPrice',
'value', 'value',
'valueInBaseCurrency' 'valueInBaseCurrency'

View File

@ -1,6 +1,6 @@
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client'; import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
export class Order { export class Order {
@ -14,7 +14,7 @@ export class Order {
private symbol: string; private symbol: string;
private symbolProfile: SymbolProfile; private symbolProfile: SymbolProfile;
private total: number; private total: number;
private type: TypeOfOrder; private type: ActivityType;
private unitPrice: number; private unitPrice: number;
public constructor(data: IOrder) { public constructor(data: IOrder) {

View File

@ -35,7 +35,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} }
let maxItem; let maxItem: (typeof accounts)[0];
let totalInvestment = 0; let totalInvestment = 0;
for (const account of Object.values(accounts)) { for (const account of Object.values(accounts)) {
@ -52,7 +52,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
} }
} }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.threshold) { if (maxInvestmentRatio > ruleSettings.threshold) {
return { return {

View File

@ -43,7 +43,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0; const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
if (maxItem.groupKey !== ruleSettings.baseCurrency) { if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
return { return {
evaluation: `The major part of your current investment is not in your base currency (${( evaluation: `The major part of your current investment is not in your base currency (${(
baseCurrencyValueRatio * 100 baseCurrencyValueRatio * 100

View File

@ -37,7 +37,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
} }
}); });
const maxValueRatio = maxItem.value / totalValue; const maxValueRatio = maxItem?.value / totalValue || 0;
if (maxValueRatio > ruleSettings.threshold) { if (maxValueRatio > ruleSettings.threshold) {
return { return {
@ -52,7 +52,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
return { return {
evaluation: `The major part of your current investment is in ${ evaluation: `The major part of your current investment is in ${
maxItem.groupKey maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${ } (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100 ruleSettings.threshold * 100
}%`, }%`,

View File

@ -10,18 +10,21 @@ export class ApiService {
filterByAccounts, filterByAccounts,
filterByAssetClasses, filterByAssetClasses,
filterByAssetSubClasses, filterByAssetSubClasses,
filterByHoldingType,
filterBySearchQuery, filterBySearchQuery,
filterByTags filterByTags
}: { }: {
filterByAccounts?: string; filterByAccounts?: string;
filterByAssetClasses?: string; filterByAssetClasses?: string;
filterByAssetSubClasses?: string; filterByAssetSubClasses?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string; filterBySearchQuery?: string;
filterByTags?: string; filterByTags?: string;
}): Filter[] { }): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? []; const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? []; const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? []; const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase(); const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? []; const tagIds = filterByTags?.split(',') ?? [];
@ -52,6 +55,13 @@ export class ApiService {
}) })
]; ];
if (holdingType) {
filters.push({
id: holdingType,
type: 'HOLDING_TYPE'
});
}
if (searchQuery) { if (searchQuery) {
filters.push({ filters.push({
id: searchQuery, id: searchQuery,

View File

@ -3,7 +3,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid';
@Injectable() @Injectable()
export class ConfigurationService { export class ConfigurationService {
@ -43,18 +43,18 @@ export class ConfigurationService {
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
PORT: port({ default: 3333 }), PORT: port({ default: 3333 }),
REDIS_DB: num({ default: 0 }),
REDIS_HOST: str({ default: 'localhost' }), REDIS_HOST: str({ default: 'localhost' }),
REDIS_PASSWORD: str({ default: '' }), REDIS_PASSWORD: str({ default: '' }),
REDIS_PORT: port({ default: 6379 }), REDIS_PORT: port({ default: 6379 }),
REQUEST_TIMEOUT: num({ default: 2000 }), REQUEST_TIMEOUT: num({ default: 2000 }),
ROOT_URL: str({ default: DEFAULT_ROOT_URL }), ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
STRIPE_PUBLIC_KEY: str({ default: '' }), STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }), TWITTER_API_SECRET: str({ default: 'dummyApiSecret' })
WEB_AUTH_RP_ID: host({ default: 'localhost' })
}); });
} }

View File

@ -37,12 +37,14 @@ export class AlphaVantageService implements DataProviderInterface {
return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE'); return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE');
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -52,15 +52,17 @@ export class CoinGeckoService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = { const response: Partial<SymbolProfile> = {
symbol,
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetSubClass.CRYPTOCURRENCY, assetSubClass: AssetSubClass.CRYPTOCURRENCY,
currency: DEFAULT_CURRENCY, currency: DEFAULT_CURRENCY,
dataSource: this.getName(), dataSource: this.getName()
symbol: aSymbol
}; };
try { try {
@ -70,7 +72,7 @@ export class CoinGeckoService implements DataProviderInterface {
abortController.abort(); abortController.abort();
}, this.configurationService.get('REQUEST_TIMEOUT')); }, this.configurationService.get('REQUEST_TIMEOUT'));
const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, { const { name } = await got(`${this.apiUrl}/coins/${symbol}`, {
headers: this.headers, headers: this.headers,
// @ts-ignore // @ts-ignore
signal: abortController.signal signal: abortController.signal
@ -81,7 +83,7 @@ export class CoinGeckoService implements DataProviderInterface {
let message = error; let message = error;
if (error?.code === 'ABORT_ERR') { if (error?.code === 'ABORT_ERR') {
message = `RequestError: The operation to get the asset profile for ${aSymbol} was aborted because the request to the data provider took more than ${this.configurationService.get( message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
'REQUEST_TIMEOUT' 'REQUEST_TIMEOUT'
)}ms`; )}ms`;
} }

View File

@ -5,12 +5,12 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client'; import { SymbolProfile } from '@prisma/client';
import { countries } from 'countries-list';
import got from 'got'; import got from 'got';
@Injectable() @Injectable()
export class TrackinsightDataEnhancerService implements DataEnhancerInterface { export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
private static baseUrl = 'https://www.trackinsight.com/data-api'; private static baseUrl = 'https://www.trackinsight.com/data-api';
private static countries = require('countries-list/dist/countries.json');
private static countriesMapping = { private static countriesMapping = {
'Russian Federation': 'Russia' 'Russian Federation': 'Russia'
}; };
@ -131,20 +131,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.countries as unknown as Country[]).length === 0 (response.countries as unknown as Country[]).length === 0
) { ) {
response.countries = []; response.countries = [];
for (const [name, value] of Object.entries<any>( for (const [name, value] of Object.entries<any>(
holdings?.countries ?? {} holdings?.countries ?? {}
)) { )) {
let countryCode: string; let countryCode: string;
for (const [key, country] of Object.entries<any>( for (const [code, country] of Object.entries(countries)) {
TrackinsightDataEnhancerService.countries
)) {
if ( if (
country.name === name || country.name === name ||
country.name === country.name ===
TrackinsightDataEnhancerService.countriesMapping[name] TrackinsightDataEnhancerService.countriesMapping[name]
) { ) {
countryCode = key; countryCode = code;
break; break;
} }
} }

View File

@ -196,7 +196,9 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
shortName: assetProfile.price.shortName, shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol symbol: assetProfile.price.symbol
}); });
response.symbol = assetProfile.price.symbol; response.symbol = this.convertFromYahooFinanceSymbol(
assetProfile.price.symbol
);
if (assetSubClass === AssetSubClass.MUTUALFUND) { if (assetSubClass === AssetSubClass.MUTUALFUND) {
response.sectors = []; response.sectors = [];

View File

@ -92,7 +92,9 @@ export class DataProviderService {
for (const symbol of symbols) { for (const symbol of symbols) {
const promise = Promise.resolve( const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol) this.getDataProvider(DataSource[dataSource]).getAssetProfile({
symbol
})
); );
promises.push( promises.push(
@ -202,7 +204,8 @@ export class DataProviderService {
}); });
try { try {
const queryRaw = `SELECT * const queryRaw = `
SELECT *
FROM "MarketData" FROM "MarketData"
WHERE "dataSource" IN ('${dataSources.join(`','`)}') WHERE "dataSource" IN ('${dataSources.join(`','`)}')
AND "symbol" IN ('${symbols.join( AND "symbol" IN ('${symbols.join(

View File

@ -46,19 +46,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
const [searchResult] = await this.getSearchResult(aSymbol); symbol: string;
}): Promise<Partial<SymbolProfile>> {
const [searchResult] = await this.getSearchResult(symbol);
return { return {
symbol,
assetClass: searchResult?.assetClass, assetClass: searchResult?.assetClass,
assetSubClass: searchResult?.assetSubClass, assetSubClass: searchResult?.assetSubClass,
currency: this.convertCurrency(searchResult?.currency), currency: this.convertCurrency(searchResult?.currency),
dataSource: this.getName(), dataSource: this.getName(),
isin: searchResult?.isin, isin: searchResult?.isin,
name: searchResult?.name, name: searchResult?.name
symbol: aSymbol
}; };
} }
@ -239,25 +241,36 @@ export class EodHistoricalDataService implements DataProviderInterface {
}) })
); );
response = quotes.reduce( for (const { close, code, timestamp } of quotes) {
( let currency: string;
result: { [symbol: string]: IDataProviderResponse },
{ close, code, timestamp } if (code.endsWith('.FOREX')) {
) => { currency = this.convertFromEodSymbol(code)?.replace(
const currency = symbolProfiles.find(({ symbol }) => { DEFAULT_CURRENCY,
''
);
}
if (!currency) {
currency = symbolProfiles.find(({ symbol }) => {
return symbol === code; return symbol === code;
})?.currency; })?.currency;
}
if (!currency) {
const { items } = await this.search({ query: code });
if (items.length === 1) {
currency = items[0].currency;
}
}
if (isNumber(close)) { if (isNumber(close)) {
result[this.convertFromEodSymbol(code)] = { response[this.convertFromEodSymbol(code)] = {
currency: currency,
currency ??
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: close, marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
? 'open'
: 'closed'
}; };
} else { } else {
Logger.error( Logger.error(
@ -265,11 +278,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
'EodHistoricalDataService' 'EodHistoricalDataService'
); );
} }
}
return result;
},
{}
);
return response; return response;
} catch (error) { } catch (error) {

View File

@ -37,12 +37,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -33,12 +33,14 @@ export class GoogleSheetsService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -11,7 +11,11 @@ import { DataSource, SymbolProfile } from '@prisma/client';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>; getAssetProfile({
symbol
}: {
symbol: string;
}): Promise<Partial<SymbolProfile>>;
getDataProviderInfo(): DataProviderInfo; getDataProviderInfo(): DataProviderInfo;

View File

@ -43,16 +43,18 @@ export class ManualService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
const assetProfile: Partial<SymbolProfile> = { const assetProfile: Partial<SymbolProfile> = {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{ dataSource: this.getName(), symbol: aSymbol } { symbol, dataSource: this.getName() }
]); ]);
if (symbolProfile) { if (symbolProfile) {
@ -164,13 +166,15 @@ export class ManualService implements DataProviderInterface {
} }
}); });
for (const symbolProfile of symbolProfiles) { for (const { currency, symbol } of symbolProfiles) {
response[symbolProfile.symbol] = { let marketPrice = marketData.find((marketDataItem) => {
currency: symbolProfile.currency, return marketDataItem.symbol === symbol;
})?.marketPrice;
response[symbol] = {
currency,
marketPrice,
dataSource: this.getName(), dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
})?.marketPrice,
marketState: 'delayed' marketState: 'delayed'
}; };
} }

View File

@ -30,12 +30,14 @@ export class RapidApiService implements DataProviderInterface {
return !!this.configurationService.get('API_KEY_RAPID_API'); return !!this.configurationService.get('API_KEY_RAPID_API');
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
symbol: string;
}): Promise<Partial<SymbolProfile>> {
return { return {
dataSource: this.getName(), symbol,
symbol: aSymbol dataSource: this.getName()
}; };
} }

View File

@ -33,20 +33,12 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
} }
public async getAssetProfile( public async getAssetProfile({
aSymbol: string symbol
): Promise<Partial<SymbolProfile>> { }: {
const { assetClass, assetSubClass, currency, name, symbol } = symbol: string;
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol); }): Promise<Partial<SymbolProfile>> {
return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
return {
assetClass,
assetSubClass,
currency,
name,
symbol,
dataSource: this.getName()
};
} }
public getDataProviderInfo(): DataProviderInfo { public getDataProviderInfo(): DataProviderInfo {

View File

@ -22,6 +22,14 @@ export const ExchangeRateDataServiceMock = {
'2023-07-10': 0.8854 '2023-07-10': 0.8854
} }
}); });
} else if (targetCurrency === 'USD') {
return Promise.resolve({
USDUSD: {
'2018-01-01': 1,
'2021-11-16': 1,
'2023-07-10': 1
}
});
} }
return Promise.resolve({}); return Promise.resolve({});

View File

@ -73,7 +73,17 @@ export class ExchangeRateDataService {
currencyTo: targetCurrency currencyTo: targetCurrency
}); });
let previousExchangeRate = 1; const dateStrings = Object.keys(
exchangeRatesByCurrency[`${currency}${targetCurrency}`]
);
const lastDateString = dateStrings.reduce((a, b) => {
return a > b ? a : b;
});
let previousExchangeRate =
exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[
lastDateString
] ?? 1;
// Start from the most recent date and fill in missing exchange rates // Start from the most recent date and fill in missing exchange rates
// using the latest available rate // using the latest available rate
@ -94,7 +104,7 @@ export class ExchangeRateDataService {
exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] = exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] =
previousExchangeRate; previousExchangeRate;
if (currency === DEFAULT_CURRENCY) { if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
Logger.error( Logger.error(
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`, `No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`,
'ExchangeRateDataService' 'ExchangeRateDataService'
@ -433,13 +443,17 @@ export class ExchangeRateDataService {
]) * ]) *
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)]; marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
if (isNaN(factor)) {
throw new Error('Exchange rate is not a number');
} else {
factors[format(date, DATE_FORMAT)] = factor; factors[format(date, DATE_FORMAT)] = factor;
}
} catch { } catch {
Logger.error( Logger.error(
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format( `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
date, date,
DATE_FORMAT DATE_FORMAT
)}`, )}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`,
'ExchangeRateDataService' 'ExchangeRateDataService'
); );
} }
@ -451,7 +465,7 @@ export class ExchangeRateDataService {
} }
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {
let currencies: string[] = []; let currencies: string[] = [DEFAULT_CURRENCY];
( (
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({

View File

@ -30,6 +30,7 @@ export interface Environment extends CleanedEnvAccessors {
MAX_ACTIVITIES_TO_IMPORT: number; MAX_ACTIVITIES_TO_IMPORT: number;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
PORT: number; PORT: number;
REDIS_DB: number;
REDIS_HOST: string; REDIS_HOST: string;
REDIS_PASSWORD: string; REDIS_PASSWORD: string;
REDIS_PORT: number; REDIS_PORT: number;
@ -41,5 +42,4 @@ export interface Environment extends CleanedEnvAccessors {
TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_ACCESS_TOKEN_SECRET: string;
TWITTER_API_KEY: string; TWITTER_API_KEY: string;
TWITTER_API_SECRET: string; TWITTER_API_SECRET: string;
WEB_AUTH_RP_ID: string;
} }

View File

@ -5,7 +5,7 @@ import {
Account, Account,
DataSource, DataSource,
SymbolProfile, SymbolProfile,
Type as TypeOfOrder Type as ActivityType
} from '@prisma/client'; } from '@prisma/client';
export interface IOrder { export interface IOrder {
@ -18,7 +18,7 @@ export interface IOrder {
quantity: number; quantity: number;
symbol: string; symbol: string;
symbolProfile: SymbolProfile; symbolProfile: SymbolProfile;
type: TypeOfOrder; type: ActivityType;
unitPrice: number; unitPrice: number;
} }

View File

@ -189,9 +189,8 @@ export class SymbolProfileService {
return { return {
code, code,
weight, weight,
continent: continent: continents[countries[code]?.continent] ?? UNKNOWN_KEY,
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY, name: countries[code]?.name ?? UNKNOWN_KEY
name: countries[code as string]?.name ?? UNKNOWN_KEY
}; };
}); });
} }

View File

@ -13,13 +13,13 @@
"build": { "build": {
"executor": "@nx/angular:webpack-browser", "executor": "@nx/angular:webpack-browser",
"options": { "options": {
"deleteOutputPath": false,
"localize": true, "localize": true,
"outputPath": "dist/apps/client", "outputPath": "dist/apps/client",
"index": "apps/client/src/index.html", "index": "apps/client/src/index.html",
"main": "apps/client/src/main.ts", "main": "apps/client/src/main.ts",
"polyfills": "apps/client/src/polyfills.ts", "polyfills": "apps/client/src/polyfills.ts",
"tsConfig": "apps/client/tsconfig.app.json", "tsConfig": "apps/client/tsconfig.app.json",
"assets": [],
"styles": [ "styles": [
"apps/client/src/assets/fonts/inter.css", "apps/client/src/assets/fonts/inter.css",
"apps/client/src/styles/theme.scss", "apps/client/src/styles/theme.scss",
@ -108,13 +108,22 @@
"options": { "options": {
"commands": [ "commands": [
{ {
"command": "shx mkdir -p dist/apps/client" "command": "shx rm -rf dist/apps/client"
}, },
{ {
"command": "shx cp -r apps/client/src/assets dist/apps/client" "command": "shx mkdir -p dist/apps/client/.well-known"
}, },
{ {
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client" "command": "shx mkdir -p dist/apps/client/assets"
},
{
"command": "shx mkdir -p dist/apps/client/ionicons"
},
{
"command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets"
},
{
"command": "shx cp -r apps/client/src/assets/.well-known/* dist/apps/client/.well-known"
}, },
{ {
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client" "command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
@ -128,9 +137,6 @@
{ {
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client" "command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
}, },
{
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
},
{ {
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client" "command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
}, },
@ -138,7 +144,7 @@
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client" "command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
}, },
{ {
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons" "command": "shx cp -r node_modules/ionicons/dist/ionicons/* dist/apps/client/ionicons"
}, },
{ {
"command": "shx cp CHANGELOG.md dist/apps/client/assets" "command": "shx cp CHANGELOG.md dist/apps/client/assets"
@ -146,7 +152,8 @@
{ {
"command": "shx cp LICENSE dist/apps/client/assets" "command": "shx cp LICENSE dist/apps/client/assets"
} }
] ],
"parallel": false
} }
}, },
"serve": { "serve": {

View File

@ -115,7 +115,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
); );
this.dataService this.dataService
.fetchPortfolioDetails({ .fetchPortfolioHoldings({
filters: [ filters: [
{ {
type: 'ACCOUNT', type: 'ACCOUNT',
@ -125,11 +125,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ holdings }) => { .subscribe(({ holdings }) => {
this.holdings = []; this.holdings = holdings;
for (const [symbol, holding] of Object.entries(holdings)) {
this.holdings.push(holding);
}
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
@ -227,7 +223,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
} }
], ],
range: 'max', range: 'max',
withExcludedAccounts: true withExcludedAccounts: true,
withItems: true
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => { .subscribe(({ chart }) => {

View File

@ -25,7 +25,9 @@
class="h-100" class="h-100"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[isInPercent]="data.hasImpersonationId || user.settings.isRestrictedView" [isInPercent]="
data.hasImpersonationId || user.settings.isRestrictedView
"
[isLoading]="isLoadingChart" [isLoading]="isLoadingChart"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
/> />
@ -92,7 +94,9 @@
[dataSource]="dataSource" [dataSource]="dataSource"
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false" [hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView" [hasPermissionToExportActivities]="
!data.hasImpersonationId && !user.settings.isRestrictedView
"
[hasPermissionToFilter]="false" [hasPermissionToFilter]="false"
[hasPermissionToOpenDetails]="false" [hasPermissionToOpenDetails]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
@ -113,7 +117,11 @@
[accountBalances]="accountBalances" [accountBalances]="accountBalances"
[accountId]="data.accountId" [accountId]="data.accountId"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[showActions]="!data.hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView" [showActions]="
!data.hasImpersonationId &&
hasPermissionToDeleteAccountBalance &&
!user.settings.isRestrictedView
"
(accountBalanceDeleted)="onDeleteAccountBalance($event)" (accountBalanceDeleted)="onDeleteAccountBalance($event)"
/> />
</mat-tab> </mat-tab>

View File

@ -41,11 +41,6 @@
[url]="element.Platform?.url" [url]="element.Platform?.url"
/> />
<span>{{ element.name }}</span> <span>{{ element.name }}</span>
<span
*ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted"
>(Default)</span
>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td> <td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container> </ng-container>
@ -261,7 +256,7 @@
</button> </button>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.transactionCount > 0" [disabled]="element.transactionCount > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">

View File

@ -1,3 +1,5 @@
import { getLocale } from '@ghostfolio/common/helper';
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
@ -27,7 +29,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToOpenDetails = true; @Input() hasPermissionToOpenDetails = true;
@Input() locale: string; @Input() locale = getLocale();
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() showBalance = true; @Input() showBalance = true;
@Input() showFooter = true; @Input() showFooter = true;

View File

@ -155,15 +155,14 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string; day: string;
yearMonth: string; yearMonth: string;
}) { }) {
const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{ data: <MarketDataDetailDialogParams>{
date,
marketPrice, marketPrice,
currency: this.currency, currency: this.currency,
dataSource: this.dataSource, dataSource: this.dataSource,
dateString: `${yearMonth}-${day}`,
symbol: this.symbol, symbol: this.symbol,
user: this.user user: this.user
}, },

View File

@ -5,7 +5,7 @@ import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams { export interface MarketDataDetailDialogParams {
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
date: Date; dateString: string;
marketPrice: number; marketPrice: number;
symbol: string; symbol: string;
user: User; user: User;

View File

@ -45,7 +45,7 @@ export class MarketDataDetailDialog implements OnDestroy {
this.adminService this.adminService
.fetchSymbolForDate({ .fetchSymbolForDate({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
date: this.data.date, dateString: this.data.dateString,
symbol: this.data.symbol symbol: this.data.symbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -63,7 +63,7 @@ export class MarketDataDetailDialog implements OnDestroy {
marketData: { marketData: {
marketData: [ marketData: [
{ {
date: this.data.date.toISOString(), date: this.data.dateString,
marketPrice: this.data.marketPrice marketPrice: this.data.marketPrice
} }
] ]

View File

@ -9,7 +9,7 @@
matInput matInput
name="date" name="date"
[matDatepicker]="date" [matDatepicker]="date"
[(ngModel)]="data.date" [(ngModel)]="data.dateString"
/> />
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date"> <mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
<ion-icon <ion-icon

View File

@ -163,7 +163,12 @@
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button <button
mat-menu-item mat-menu-item
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })" (click)="
onOpenAssetProfileDialog({
dataSource: element.dataSource,
symbol: element.symbol
})
"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" /> <ion-icon class="mr-2" name="create-outline" />
@ -173,7 +178,12 @@
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})" (click)="
onDeleteProfileData({
dataSource: element.dataSource,
symbol: element.symbol
})
"
> >
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
@ -189,16 +199,19 @@
*matRowDef="let row; columns: displayedColumns" *matRowDef="let row; columns: displayedColumns"
class="cursor-pointer" class="cursor-pointer"
mat-row mat-row
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })" (click)="
onOpenAssetProfileDialog({
dataSource: row.dataSource,
symbol: row.symbol
})
"
></tr> ></tr>
</table> </table>
<mat-paginator <mat-paginator
[length]="totalItems" [length]="totalItems"
[ngClass]="{ [ngClass]="{
'd-none': 'd-none': (isLoading && totalItems === 0) || totalItems <= pageSize
(isLoading && totalItems === 0) ||
totalItems <= pageSize
}" }"
[pageSize]="pageSize" [pageSize]="pageSize"
[showFirstLastButtons]="true" [showFirstLastButtons]="true"

View File

@ -1,4 +1,5 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto'; import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
@ -195,15 +196,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
header: true, header: true,
skipEmptyLines: true skipEmptyLines: true
} }
).data; ).data as UpdateMarketDataDto[];
this.adminService this.adminService
.postMarketData({ .postMarketData({
dataSource: this.data.dataSource, dataSource: this.data.dataSource,
marketData: { marketData: {
marketData: marketData.map(({ date, marketPrice }) => { marketData
return { marketPrice, date: parseDate(date).toISOString() };
})
}, },
symbol: this.data.symbol symbol: this.data.symbol
}) })

View File

@ -25,7 +25,9 @@
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]="assetProfileForm.dirty" [disabled]="assetProfileForm.dirty"
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})" (click)="
onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol })
"
> >
<ng-container i18n>Gather Historical Data</ng-container> <ng-container i18n>Gather Historical Data</ng-container>
</button> </button>
@ -33,7 +35,12 @@
mat-menu-item mat-menu-item
type="button" type="button"
[disabled]="assetProfileForm.dirty" [disabled]="assetProfileForm.dirty"
(click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})" (click)="
onGatherProfileDataBySymbol({
dataSource: data.dataSource,
symbol: data.symbol
})
"
> >
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
@ -73,7 +80,12 @@
color="accent" color="accent"
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''" [disabled]="
!assetProfileForm.controls['historicalData']?.controls['csvString']
.touched ||
assetProfileForm.controls['historicalData']?.controls['csvString']
?.value === ''
"
(click)="onImportHistoricalData()" (click)="onImportHistoricalData()"
> >
<ng-container i18n>Import</ng-container> <ng-container i18n>Import</ng-container>
@ -129,10 +141,15 @@
> >
</div> </div>
<ng-container <ng-container
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0" *ngIf="
assetProfile?.countries?.length > 0 ||
assetProfile?.sectors?.length > 0
"
> >
@if (assetProfile?.countries?.length === 1 && @if (
assetProfile?.sectors?.length === 1 ) { assetProfile?.countries?.length === 1 &&
assetProfile?.sectors?.length === 1
) {
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3"> <div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
@ -222,7 +239,17 @@
color="primary" color="primary"
i18n i18n
[checked]="isBenchmark" [checked]="isBenchmark"
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})" (change)="
isBenchmark
? onUnsetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
: onSetBenchmark({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>Benchmark</mat-checkbox >Benchmark</mat-checkbox
> >
</div> </div>
@ -253,7 +280,9 @@
color="accent" color="accent"
mat-flat-button mat-flat-button
type="button" type="button"
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'" [disabled]="
assetProfileForm.controls['scraperConfiguration'].value === '{}'
"
(click)="onTestMarketData()" (click)="onTestMarketData()"
> >
<ng-container i18n>Test</ng-container> <ng-container i18n>Test</ng-container>

View File

@ -112,7 +112,9 @@
<mat-slide-toggle <mat-slide-toggle
color="primary" color="primary"
hideIcon="true" hideIcon="true"
[checked]="info.globalPermissions.includes(permissions.createUserAccount)" [checked]="
info.globalPermissions.includes(permissions.createUserAccount)
"
(change)="onEnableUserSignupModeChange($event)" (change)="onEnableUserSignupModeChange($event)"
/> />
</div> </div>
@ -143,7 +145,7 @@
<div class="w-50" i18n>System Message</div> <div class="w-50" i18n>System Message</div>
<div class="w-50"> <div class="w-50">
<div *ngIf="systemMessage" class="align-items-center d-flex"> <div *ngIf="systemMessage" class="align-items-center d-flex">
<div class="text-truncate">{{ systemMessage | json }}</div> <div class="text-truncate">{{ systemMessage | json }}</div>
<button <button
class="h-100 mx-1 no-min-width px-2" class="h-100 mx-1 no-min-width px-2"
mat-button mat-button

View File

@ -91,7 +91,11 @@
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
<button mat-menu-item (click)="onDeletePlatform(element.id)"> <button
mat-menu-item
[disabled]="element.accountCount > 0"
(click)="onDeletePlatform(element.id)"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span> <span i18n>Delete</span>

View File

@ -71,7 +71,11 @@
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </button>
<button mat-menu-item (click)="onDeleteTag(element.id)"> <button
mat-menu-item
[disabled]="element.activityCount > 0"
(click)="onDeleteTag(element.id)"
>
<span class="align-items-center d-flex"> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" /> <ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span> <span i18n>Delete</span>

View File

@ -35,17 +35,31 @@
mat-cell mat-cell
> >
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block text-monospace" <span
>{{ element.id }}</span class="d-none d-sm-inline-block text-monospace"
[ngClass]="{
'text-line-through': element.role === 'INACTIVE'
}"
>{{ element.id }}</span
> >
<span class="d-inline-block d-sm-none text-monospace" <span
>{{ (element.id | slice:0:5) + '...' }}</span class="d-inline-block d-sm-none text-monospace"
[ngClass]="{
'text-line-through': element.role === 'INACTIVE'
}"
>{{ (element.id | slice: 0 : 5) + '...' }}</span
> >
<gf-premium-indicator <gf-premium-indicator
*ngIf="element?.subscription?.type === 'Premium'" *ngIf="element?.subscription?.type === 'Premium'"
class="ml-1" class="ml-1"
[enableLink]="false" [enableLink]="false"
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'" [title]="
'Expires ' +
formatDistanceToNow(element.subscription.expiresAt) +
' (' +
(element.subscription.expiresAt | date: defaultDateFormat) +
')'
"
/> />
</div> </div>
</td> </td>
@ -67,9 +81,9 @@
class="mat-mdc-cell px-1 py-2" class="mat-mdc-cell px-1 py-2"
mat-cell mat-cell
> >
<span class="h5" [title]="element.country" <span class="h5" [title]="element.country">{{
>{{ getEmojiFlag(element.country) }}</span getEmojiFlag(element.country)
> }}</span>
</td> </td>
</ng-container> </ng-container>

View File

@ -7,6 +7,7 @@ import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
import { import {
getBackgroundColor, getBackgroundColor,
getDateFormatString, getDateFormatString,
getLocale,
getTextColor, getTextColor,
parseDate parseDate
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
@ -51,7 +52,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() colorScheme: ColorScheme; @Input() colorScheme: ColorScheme;
@Input() daysInMarket: number; @Input() daysInMarket: number;
@Input() isLoading: boolean; @Input() isLoading: boolean;
@Input() locale: string; @Input() locale = getLocale();
@Input() performanceDataItems: LineChartItem[]; @Input() performanceDataItems: LineChartItem[];
@Input() user: User; @Input() user: User;

View File

@ -154,8 +154,8 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchPositions({ range: this.user?.settings?.dateRange }) .fetchPositions({ range: this.user?.settings?.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe(({ positions }) => {
this.positions = response.positions; this.positions = positions;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });

View File

@ -127,10 +127,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
this.isLoadingPerformance = false; this.isLoadingPerformance = false;
this.historicalDataItems = chart.map( this.historicalDataItems = chart.map(
({ date, netPerformanceInPercentage }) => { ({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
return { return {
date, date,
value: netPerformanceInPercentage value: netPerformanceInPercentageWithCurrencyEffect
}; };
} }
); );

View File

@ -32,8 +32,8 @@
<a class="d-block" [routerLink]="['/portfolio']"> <a class="d-block" [routerLink]="['/portfolio']">
<span i18n>Monitor and analyze your portfolio</span><br /> <span i18n>Monitor and analyze your portfolio</span><br />
<span class="font-weight-normal" i18n <span class="font-weight-normal" i18n
>Track your progress in real-time with comprehensive analysis and >Track your progress in real-time with comprehensive analysis
insights.</span and insights.</span
> >
</a> </a>
</li> </li>

View File

@ -102,7 +102,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
this.isLoading = true; this.isLoading = true;
this.dataService this.dataService
.fetchPortfolioDetails() .fetchPortfolioDetails({ withLiabilities: true })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ summary }) => { .subscribe(({ summary }) => {
this.summary = summary; this.summary = summary;

View File

@ -6,7 +6,9 @@
<mat-card-content> <mat-card-content>
<gf-portfolio-summary <gf-portfolio-summary
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings" [hasPermissionToUpdateUserSettings]="
!hasImpersonationId && hasPermissionToUpdateUserSettings
"
[isLoading]="isLoading" [isLoading]="isLoading"
[language]="user?.settings?.language" [language]="user?.settings?.language"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

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