Compare commits

..

82 Commits

Author SHA1 Message Date
f1d2a52cba Release 2.65.0 (#3170) 2024-03-19 20:13:22 +01:00
87cc887865 Feature/Set meta theme color dynamically to respect appearance (#3129)
* Set meta theme color dynamically to respect appearance (dark mode)

* Update changelog
2024-03-19 20:11:34 +01:00
61ecd15f1d Feature/change edit button to a in admin market data page (#3164)
* Change edit button to a

* Update changelog
2024-03-19 19:55:08 +01:00
eb853f05ae Feature/add support to delete asset profile from dialog (#3165)
* Add support to delete asset profile from dialog

* Update changelog
2024-03-19 19:27:16 +01:00
6285417903 Feature/change grant private access with permissions to general availability (#3169)
* Change grant private access with permissions from experimental to general availability

* Update changelog
2024-03-19 19:25:43 +01:00
ca674a654e Feature/add symbol and isin to position detail dialog (#3163)
* Add symbol and ISIN

* Update translations

* Update changelog
2024-03-18 13:58:46 +01:00
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
150 changed files with 25044 additions and 22389 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

@ -5,15 +5,168 @@ 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.65.0 - 2024-03-19
### Added
- Added the symbol and ISIN number to the position detail dialog
- Added support to delete an asset profile in the asset profile details dialog of the admin control
### Changed
- Moved the support to grant private access with permissions from experimental to general availability
- Set the meta theme color dynamically to respect the appearance (dark mode)
- Improved the usability to edit market data in the admin control panel
## 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 ## 2.55.0 - 2024-02-22
### Added ### Added
- Added indexes for `alias`, `granteeUserId` and `userId` to the access database table - 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 `currency`, `name` and `userId` to the account database table
- Added an index for `accountId`, `date` and `updatedAt` to the account balance 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 an index for `userId` to the auth device database table
- Added an index for `marketPrice` and `state` to the market data 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 indexes for `date`, `isDraft` and `userId` to the order database table
- Added an index for `name` to the platform 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 indexes for `assetClass`, `currency`, `dataSource`, `isin`, `name` and `symbol` to the symbol profile database table

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

@ -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

@ -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,37 +595,33 @@ export class ImportService {
} }
} }
const assetProfile = { if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
currency, const assetProfile = {
...( currency,
await this.dataProviderService.getAssetProfiles([ ...(
{ dataSource, symbol } await this.dataProviderService.getAssetProfiles([
]) { dataSource, symbol }
)?.[symbol] ])
}; )?.[symbol]
};
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') { if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
if (!assetProfile?.name) { if (!assetProfile?.name) {
throw new Error( throw new Error(
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
}
if (assetProfile.currency !== currency) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
);
}
} }
if ( assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile.currency !== currency && assetProfile;
!this.exchangeRateDataService.hasCurrencyPair(
currency,
assetProfile.currency
)
) {
throw new Error(
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
);
}
} }
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
} }
return assetProfiles; return assetProfiles;

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,13 +125,9 @@ 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' || ? false
data.type === 'INTEREST' || : isAfter(data.date as Date, endOfToday());
data.type === 'ITEM' ||
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({ const order = await this.prismaService.order.create({
data: { data: {
@ -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

@ -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 (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
if (newQuantity.gt(0)) { order.quantity.mul(order.unitPrice)
if (order.type === 'BUY') { );
investment = oldAccumulatedSymbol.investment.plus( } else if (order.type === 'SELL') {
order.quantity.mul(unitPrice) investment = oldAccumulatedSymbol.investment.minus(
); order.quantity.mul(oldAccumulatedSymbol.averagePrice)
} else if (order.type === 'SELL') { );
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(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(order.unitPriceInBaseCurrencyWithCurrencyEffect) .mul(getFactor(order.type));
.mul(this.getFactor(order.type))
: totalUnits.gt(0) transactionInvestmentWithCurrencyEffect = order.quantity
? totalInvestmentWithCurrencyEffect .mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
.div(totalUnits) .mul(getFactor(order.type));
.mul(order.quantity)
.mul(this.getFactor(order.type)) totalQuantityFromBuyTransactions =
: new Big(0); totalQuantityFromBuyTransactions.plus(order.quantity);
totalInvestmentFromBuyTransactions =
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
transactionInvestmentWithCurrencyEffect
);
} else if (order.type === 'SELL') {
if (totalUnits.gt(0)) {
transactionInvestment = totalInvestment
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
transactionInvestmentWithCurrencyEffect =
totalInvestmentWithCurrencyEffect
.div(totalUnits)
.mul(order.quantity)
.mul(getFactor(order.type));
}
}
if (PortfolioCalculator.ENABLE_LOGGING) { 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,21 +99,15 @@ export class PortfolioController {
filterByTags filterByTags
}); });
const { const { accounts, hasErrors, holdings, platforms, summary } =
accounts, await this.portfolioService.getDetails({
filteredValueInBaseCurrency, dateRange,
filteredValueInPercentage, filters,
hasErrors, impersonationId,
holdings, withLiabilities,
platforms, userId: this.request.user.id,
summary, withSummary: true
totalValueInBaseCurrency });
} = await this.portfolioService.getDetails({
dateRange,
filters,
impersonationId,
userId: this.request.user.id
});
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true; hasError = true;
@ -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(
cashDetails.balanceInBaseCurrency const totalValueInBaseCurrency =
); currentPositions.currentValueInBaseCurrency.plus(
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,
// Ignore positions without any quantity dividend,
continue; 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
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,28 +656,29 @@ export class PortfolioService {
}; };
} }
const summary = await this.getSummary({ let summary: PortfolioSummary;
impersonationId,
userCurrency, if (withSummary) {
userId, summary = await this.getSummary({
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency, filteredValueInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency: holdings,
this.getEmergencyFundPositionsValueInBaseCurrency({ impersonationId,
holdings userCurrency,
}) userId,
}); balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
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,29 +1594,26 @@ 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(
fee, this.exchangeRateDataService.toCurrency(
SymbolProfile.currency, fee,
userCurrency SymbolProfile.currency,
); userCurrency
}) )
.reduce( );
(previous, current) => new Big(previous).plus(current), })
new Big(0) );
);
} }
private getInitialCashPosition({ private getInitialCashPosition({
@ -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)) && .map(({ quantity, SymbolProfile, unitPrice }) => {
activity.type === activityType return new Big(
); this.exchangeRateDataService.toCurrency(
}) new Big(quantity).mul(unitPrice).toNumber(),
.map(({ quantity, SymbolProfile, unitPrice }) => { SymbolProfile.currency,
return this.exchangeRateDataService.toCurrency( userCurrency
new Big(quantity).mul(unitPrice).toNumber(), )
SymbolProfile.currency, );
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

@ -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: {

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

@ -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

@ -204,13 +204,14 @@ export class DataProviderService {
}); });
try { try {
const queryRaw = `SELECT * const queryRaw = `
FROM "MarketData" SELECT *
WHERE "dataSource" IN ('${dataSources.join(`','`)}') FROM "MarketData"
AND "symbol" IN ('${symbols.join( WHERE "dataSource" IN ('${dataSources.join(`','`)}')
`','` AND "symbol" IN ('${symbols.join(
)}') ${granularityQuery} ${rangeQuery} `','`
ORDER BY date;`; )}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] = const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRawUnsafe(queryRaw); await this.prismaService.$queryRawUnsafe(queryRaw);

View File

@ -241,37 +241,44 @@ 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 (isNumber(close)) { if (!currency) {
result[this.convertFromEodSymbol(code)] = { const { items } = await this.search({ query: code });
currency:
currency ?? if (items.length === 1) {
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''), currency = items[0].currency;
dataSource: this.getName(),
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000))
? 'open'
: 'closed'
};
} else {
Logger.error(
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
'EodHistoricalDataService'
);
} }
}
return result; if (isNumber(close)) {
}, response[this.convertFromEodSymbol(code)] = {
{} currency,
); dataSource: this.getName(),
marketPrice: close,
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
};
} else {
Logger.error(
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
'EodHistoricalDataService'
);
}
}
return response; return response;
} catch (error) { } catch (error) {

View File

@ -166,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

@ -38,17 +38,7 @@ export class YahooFinanceService implements DataProviderInterface {
}: { }: {
symbol: string; symbol: string;
}): Promise<Partial<SymbolProfile>> { }): Promise<Partial<SymbolProfile>> {
const { assetClass, assetSubClass, currency, name } = return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
await 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)];
factors[format(date, DATE_FORMAT)] = factor; if (isNaN(factor)) {
throw new Error('Exchange rate is not a number');
} else {
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

@ -1,3 +1,4 @@
import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces'; import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types'; import { ColorScheme } from '@ghostfolio/common/types';
@ -187,20 +188,28 @@ export class AppComponent implements OnDestroy, OnInit {
? userPreferredColorScheme === 'DARK' ? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches; : window.matchMedia('(prefers-color-scheme: dark)').matches;
this.toggleThemeStyleClass(isDarkTheme); this.toggleTheme(isDarkTheme);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => { window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
if (!this.user?.settings.colorScheme) { if (!this.user?.settings.colorScheme) {
this.toggleThemeStyleClass(event.matches); this.toggleTheme(event.matches);
} }
}); });
} }
private toggleThemeStyleClass(isDarkTheme: boolean) { private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background'
);
if (isDarkTheme) { if (isDarkTheme) {
this.document.body.classList.add('is-dark-theme'); this.document.body.classList.add('is-dark-theme');
} else { } else {
this.document.body.classList.remove('is-dark-theme'); this.document.body.classList.remove('is-dark-theme');
} }
this.document
.querySelector('meta[name="theme-color"]')
.setAttribute('content', themeColor);
} }
} }

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

@ -40,12 +40,7 @@
[tooltip]="element.Platform?.name" [tooltip]="element.Platform?.name"
[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

@ -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

@ -26,6 +26,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators'; import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component'; import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces'; import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component'; import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
@ -108,6 +109,7 @@ export class AdminMarketDataComponent
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
@ -181,20 +183,7 @@ export class AdminMarketDataComponent
} }
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) { public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm( this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
} }
public onGather7Days() { public onGather7Days() {

View File

@ -161,20 +161,20 @@
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button> </button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before"> <mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button <a
mat-menu-item mat-menu-item
(click)=" [queryParams]="{
onOpenAssetProfileDialog({ assetProfileDialog: true,
dataSource: element.dataSource, dataSource: element.dataSource,
symbol: element.symbol symbol: element.symbol
}) }"
" [routerLink]="[]"
> >
<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" />
<span i18n>Edit</span> <span i18n>Edit</span>
</span> </span>
</button> </a>
<button <button
mat-menu-item mat-menu-item
[disabled]="element.activitiesCount !== 0" [disabled]="element.activitiesCount !== 0"

View File

@ -12,6 +12,7 @@ import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminMarketDataComponent } from './admin-market-data.component'; import { AdminMarketDataComponent } from './admin-market-data.component';
import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module'; import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module'; import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
@ -31,6 +32,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
NgxSkeletonLoaderModule, NgxSkeletonLoaderModule,
RouterModule RouterModule
], ],
providers: [AdminMarketDataService],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAdminMarketDataModule {} export class GfAdminMarketDataModule {}

View File

@ -0,0 +1,26 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@angular/core';
import { takeUntil } from 'rxjs';
@Injectable()
export class AdminMarketDataService {
public constructor(private adminService: AdminService) {}
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
}

View File

@ -1,7 +1,9 @@
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 { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
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 } from '@ghostfolio/common/helper';
import { import {
AdminMarketDataDetails, AdminMarketDataDetails,
Currency, Currency,
@ -82,6 +84,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
public constructor( public constructor(
private adminMarketDataService: AdminMarketDataService,
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams, @Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
@ -171,6 +174,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.dialogRef.close(); this.dialogRef.close();
} }
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
this.dialogRef.close();
}
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
@ -195,15 +204,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

@ -44,6 +44,19 @@
> >
<ng-container i18n>Gather Profile Data</ng-container> <ng-container i18n>Gather Profile Data</ng-container>
</button> </button>
<button
mat-menu-item
type="button"
[disabled]="assetProfile?.activitiesCount !== 0"
(click)="
onDeleteProfileData({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>
<ng-container i18n>Delete</ng-container>
</button>
</mat-menu> </mat-menu>
</div> </div>

View File

@ -1,4 +1,5 @@
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module'; import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module'; import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module'; import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -36,6 +37,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
ReactiveFormsModule, ReactiveFormsModule,
TextFieldModule TextFieldModule
], ],
providers: [AdminMarketDataService],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class GfAssetProfileDialogModule {} export class GfAssetProfileDialogModule {}

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,12 +35,20 @@
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 class="d-none d-sm-inline-block text-monospace"
}}</span> [ngClass]="{
<span class="d-inline-block d-sm-none text-monospace">{{ 'text-line-through': element.role === 'INACTIVE'
(element.id | slice: 0 : 5) + '...' }"
}}</span> >{{ element.id }}</span
>
<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"

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

@ -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

@ -40,7 +40,11 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance" [value]="
isLoading
? undefined
: performance?.currentNetPerformanceWithCurrencyEffect
"
/> />
</div> </div>
<div class="col"> <div class="col">
@ -49,7 +53,9 @@
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : performance?.currentNetPerformancePercent isLoading
? undefined
: performance?.currentNetPerformancePercentWithCurrencyEffect
" "
/> />
</div> </div>

View File

@ -63,7 +63,8 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
} else if (this.showDetails === false) { } else if (this.showDetails === false) {
new CountUp( new CountUp(
'value', 'value',
this.performance?.currentNetPerformancePercent * 100, this.performance?.currentNetPerformancePercentWithCurrencyEffect *
100,
{ {
decimal: getNumberFormatDecimal(this.locale), decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2, decimalPlaces: 2,

View File

@ -64,7 +64,11 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentGrossPerformance" [value]="
isLoading
? undefined
: summary?.currentGrossPerformanceWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@ -85,7 +89,9 @@
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : summary?.currentGrossPerformancePercent isLoading
? undefined
: summary?.currentGrossPerformancePercentWithCurrencyEffect
" "
/> />
</div> </div>
@ -114,7 +120,11 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.currentNetPerformance" [value]="
isLoading
? undefined
: summary?.currentNetPerformanceWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@ -134,7 +144,11 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformancePercent" [value]="
isLoading
? undefined
: summary?.currentNetPerformancePercentWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@ -268,7 +282,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.netWorth" [value]="isLoading ? undefined : summary?.totalValueInBaseCurrency"
/> />
</div> </div>
</div> </div>
@ -283,7 +297,11 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent" [value]="
isLoading
? undefined
: summary?.annualizedPerformancePercentWithCurrencyEffect
"
/> />
</div> </div>
</div> </div>
@ -310,7 +328,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.dividend" [value]="isLoading ? undefined : summary?.dividendInBaseCurrency"
/> />
</div> </div>
</div> </div>

View File

@ -50,15 +50,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public dividendInBaseCurrency: number; public dividendInBaseCurrency: number;
public feeInBaseCurrency: number; public feeInBaseCurrency: number;
public firstBuyDate: string; public firstBuyDate: string;
public grossPerformance: number;
public grossPerformancePercent: number;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public investment: number; public investment: number;
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public netPerformance: number; public netPerformancePercentWithCurrencyEffect: number;
public netPerformancePercent: number; public netPerformanceWithCurrencyEffect: number;
public quantity: number; public quantity: number;
public quantityPrecision = 2; public quantityPrecision = 2;
public reportDataGlitchMail: string; public reportDataGlitchMail: string;
@ -99,15 +97,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
dividendInBaseCurrency, dividendInBaseCurrency,
feeInBaseCurrency, feeInBaseCurrency,
firstBuyDate, firstBuyDate,
grossPerformance,
grossPerformancePercent,
historicalData, historicalData,
investment, investment,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
netPerformance, netPerformancePercentWithCurrencyEffect,
netPerformancePercent, netPerformanceWithCurrencyEffect,
orders, orders,
quantity, quantity,
SymbolProfile, SymbolProfile,
@ -125,8 +121,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.dividendInBaseCurrency = dividendInBaseCurrency; this.dividendInBaseCurrency = dividendInBaseCurrency;
this.feeInBaseCurrency = feeInBaseCurrency; this.feeInBaseCurrency = feeInBaseCurrency;
this.firstBuyDate = firstBuyDate; this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map( this.historicalDataItems = historicalData.map(
(historicalDataItem) => { (historicalDataItem) => {
this.benchmarkDataItems.push({ this.benchmarkDataItems.push({
@ -144,8 +138,10 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.netPerformance = netPerformance; this.netPerformancePercentWithCurrencyEffect =
this.netPerformancePercent = netPerformancePercent; netPerformancePercentWithCurrencyEffect;
this.netPerformanceWithCurrencyEffect =
netPerformanceWithCurrencyEffect;
this.quantity = quantity; this.quantity = quantity;
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`; this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.sectors = {}; this.sectors = {};

View File

@ -44,7 +44,7 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="data.locale" [locale]="data.locale"
[unit]="data.baseCurrency" [unit]="data.baseCurrency"
[value]="netPerformance" [value]="netPerformanceWithCurrencyEffect"
>Change</gf-value >Change</gf-value
> >
</div> </div>
@ -55,7 +55,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="netPerformancePercent" [value]="netPerformancePercentWithCurrencyEffect"
>Performance</gf-value >Performance</gf-value
> >
</div> </div>
@ -171,11 +171,13 @@
size="medium" size="medium"
[locale]="data.locale" [locale]="data.locale"
[value]="transactionCount" [value]="transactionCount"
><ng-container *ngIf="transactionCount === 1">Activity</ng-container
><ng-container *ngIf="transactionCount !== 1"
>Activities</ng-container
></gf-value
> >
@if (transactionCount === 1) {
<ng-container i18n>Activity</ng-container>
} @else {
<ng-container i18n>Activities</ng-container>
}
</gf-value>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass" <gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
@ -191,17 +193,15 @@
>Asset Sub Class</gf-value >Asset Sub Class</gf-value
> >
</div> </div>
<ng-container @if (
*ngIf=" SymbolProfile?.countries?.length > 0 ||
SymbolProfile?.countries?.length > 0 || SymbolProfile?.sectors?.length > 0
SymbolProfile?.sectors?.length > 0 ) {
"
>
@if ( @if (
SymbolProfile?.countries?.length === 1 && SymbolProfile?.countries?.length === 1 &&
SymbolProfile?.sectors?.length === 1 SymbolProfile?.sectors?.length === 1
) { ) {
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
i18n i18n
size="medium" size="medium"
@ -210,18 +210,17 @@
>Sector</gf-value >Sector</gf-value
> >
</div> </div>
<div @if (SymbolProfile?.countries?.length === 1) {
*ngIf="SymbolProfile?.countries?.length === 1" <div class="col-6 mb-3">
class="col-6 mb-3" <gf-value
> i18n
<gf-value size="medium"
i18n [locale]="data.locale"
size="medium" [value]="SymbolProfile.countries[0].name"
[locale]="data.locale" >Country</gf-value
[value]="SymbolProfile.countries[0].name" >
>Country</gf-value </div>
> }
</div>
} @else { } @else {
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div> <div class="h5" i18n>Sectors</div>
@ -248,13 +247,32 @@
/> />
</div> </div>
} }
</ng-container> }
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center"> <div class="col-6 mb-3">
<hr /> <gf-value
<gf-data-provider-credits [dataProviderInfos]="[dataProviderInfo]"> i18n
</gf-data-provider-credits> size="medium"
<hr /> [hidden]="!SymbolProfile?.symbol"
[value]="SymbolProfile?.symbol"
>Symbol</gf-value
>
</div> </div>
<div class="col-6 mb-3">
<gf-value
size="medium"
[hidden]="!SymbolProfile?.isin"
[value]="SymbolProfile?.isin"
>ISIN</gf-value
>
</div>
@if (dataProviderInfo) {
<div class="col-md-12 mb-3 text-center">
<hr />
<gf-data-provider-credits [dataProviderInfos]="[dataProviderInfo]" />
<hr />
</div>
}
</div> </div>
<mat-tab-group <mat-tab-group
@ -308,31 +326,32 @@
</mat-tab> </mat-tab>
</mat-tab-group> </mat-tab-group>
<div *ngIf="tags?.length > 0" class="row"> @if (tags?.length > 0) {
<div class="col"> <div class="row">
<div class="h5" i18n>Tags</div> <div class="col">
<mat-chip-listbox> <div class="h5" i18n>Tags</div>
<mat-chip-option *ngFor="let tag of tags" disabled>{{ <mat-chip-listbox>
tag.name <mat-chip-option *ngFor="let tag of tags" disabled>{{
}}</mat-chip-option> tag.name
</mat-chip-listbox> }}</mat-chip-option>
</mat-chip-listbox>
</div>
</div> </div>
</div> }
<div @if (
*ngIf=" activities?.length > 0 && data.hasPermissionToReportDataGlitch === true
activities?.length > 0 && data.hasPermissionToReportDataGlitch === true ) {
" <div class="row">
class="row" <div class="col">
> <hr />
<div class="col"> <a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
<hr /> ><ion-icon class="mr-1" name="flag-outline"></ion-icon
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail" ><span i18n>Report Data Glitch</span></a
><ion-icon class="mr-1" name="flag-outline"></ion-icon >
><span i18n>Report Data Glitch</span></a </div>
>
</div> </div>
</div> }
</div> </div>
</div> </div>

View File

@ -17,7 +17,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[marketState]="position?.marketState" [marketState]="position?.marketState"
[range]="range" [range]="range"
[value]="position?.netPerformancePercentage" [value]="position?.netPerformancePercentageWithCurrencyEffect"
/> />
</div> </div>
<div *ngIf="isLoading" class="flex-grow-1"> <div *ngIf="isLoading" class="flex-grow-1">
@ -49,13 +49,13 @@
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[unit]="baseCurrency" [unit]="baseCurrency"
[value]="position?.netPerformance" [value]="position?.netPerformanceWithCurrencyEffect"
/> />
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="position?.netPerformancePercentage" [value]="position?.netPerformancePercentageWithCurrencyEffect"
/> />
</div> </div>
</div> </div>

View File

@ -29,7 +29,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams, @Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>, public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
private dataService: DataService, private dataService: DataService,
private formBuilder: FormBuilder private formBuilder: FormBuilder

View File

@ -35,9 +35,7 @@
<mat-option i18n value="READ_RESTRICTED" <mat-option i18n value="READ_RESTRICTED"
>Restricted view</mat-option >Restricted view</mat-option
> >
@if (data?.user?.settings?.isExperimentalFeatures) { <mat-option i18n value="READ">View</mat-option>
<mat-option i18n value="READ">View</mat-option>
}
</mat-select> </mat-select>
</mat-form-field> </mat-form-field>
</div> </div>

View File

@ -1,6 +1,5 @@
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams { export interface CreateOrUpdateAccessDialogParams {
access: Access; access: Access;
user: User;
} }

View File

@ -107,8 +107,7 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
alias: '', alias: '',
permissions: ['READ_RESTRICTED'], permissions: ['READ_RESTRICTED'],
type: 'PRIVATE' type: 'PRIVATE'
}, }
user: this.user
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -33,10 +33,8 @@
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title <mat-card-title>What else is included in Ghostfolio?</mat-card-title>
>What else is included in Ghostfolio?</mat-card-title </mat-card-header>
></mat-card-header
>
<mat-card-content> <mat-card-content>
Please find a feature overview to manage your wealth Please find a feature overview to manage your wealth
<a [routerLink]="routerLinkFeatures">here</a>. <a [routerLink]="routerLinkFeatures">here</a>.
@ -44,10 +42,8 @@
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title <mat-card-title>Can I use Ghostfolio anonymously?</mat-card-title>
>Can I use Ghostfolio anonymously?</mat-card-title </mat-card-header>
></mat-card-header
>
<mat-card-content> <mat-card-content>
Yes, the authentication system via security token enables you to sign Yes, the authentication system via security token enables you to sign
in securely and anonymously to Ghostfolio. There is no need for an in securely and anonymously to Ghostfolio. There is no need for an
@ -56,10 +52,8 @@
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title <mat-card-title>How can Ghostfolio be free?</mat-card-title>
>How can Ghostfolio be free?</mat-card-title </mat-card-header>
></mat-card-header
>
<mat-card-content <mat-card-content
>This project is driven by the efforts of contributors from around the >This project is driven by the efforts of contributors from around the
world. The world. The
@ -75,8 +69,8 @@
<mat-card-header> <mat-card-header>
<mat-card-title <mat-card-title
>Do you monetize or sell my financial data?</mat-card-title >Do you monetize or sell my financial data?</mat-card-title
></mat-card-header >
> </mat-card-header>
<mat-card-content <mat-card-content
>No, we value your privacy. We do not sell or share your financial >No, we value your privacy. We do not sell or share your financial
data with any third parties.</mat-card-content data with any third parties.</mat-card-content
@ -84,10 +78,8 @@
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title <mat-card-title>What is your business model?</mat-card-title>
>What is your business model?</mat-card-title </mat-card-header>
></mat-card-header
>
<mat-card-content <mat-card-content
>By offering >By offering
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a>, a <a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a>, a
@ -96,6 +88,15 @@
users.</mat-card-content users.</mat-card-content
> >
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>What is your product roadmap?</mat-card-title>
</mat-card-header>
<mat-card-content
>At this time, we do not have a public roadmap
available.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title <mat-card-title

View File

@ -18,6 +18,95 @@
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>. <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Which home server systems is Ghostfolio available
on?</mat-card-title
>
</mat-card-header>
<mat-card-content>
The community has made Ghostfolio available on various home server
systems, including
<a href="https://github.com/bigbeartechworld/big-bear-casaos"
>CasaOS</a
>, <a href="https://www.runtipi.io/docs/apps-available">Runtipi</a>,
<a href="https://truecharts.org/charts/stable/ghostfolio"
>TrueCharts</a
>, <a href="https://apps.umbrel.com/app/ghostfolio">Umbrel</a>, and
<a href="https://unraid.net/community/apps?q=ghostfolio">Unraid</a>.
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I add a new currency?</mat-card-title>
</mat-card-header>
<mat-card-content>
<p>
Ghostfolio manages currencies automatically based on all the
recorded activities. If you need an additional currency, you can
manually enter it.
</p>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Click on the <i>Add Currency</i> button</li>
<li>Insert e.g. <code>EUR</code> in the prompt</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>How do I resolve
<i>No exchange rate has been found</i> errors?</mat-card-title
>
</mat-card-header>
<mat-card-content>
<p>
In Ghostfolio, you are responsible for providing the relevant
historical exchange rates. This can be done with a one-time import
of the data. If you see errors like
<i
>Historical exchange rate at 2024-01-01 is not available from
"EUR" to "USD"</i
>
do the following:
</p>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Go to the <i>Market Data</i> section</li>
<li>Select <i>Filter by Currencies</i></li>
<li>Find the entry <i>USDEUR</i></li>
<li>
Click the menu item <i>Gather Historical Data</i> in the dialog
</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I add a new platform?</mat-card-title>
</mat-card-header>
<mat-card-content>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Go to the <i>Settings</i> section</li>
<li>Click on the <i>Add Platform</i> button</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>How do I add a new tag?</mat-card-title>
</mat-card-header>
<mat-card-content>
<ol>
<li>Go to the <i>Admin Control</i> panel</li>
<li>Go to the <i>Settings</i> section</li>
<li>Click on the <i>Add Tag</i> button</li>
</ol>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3"> <mat-card appearance="outlined" class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title>Which devices are supported?</mat-card-title> <mat-card-title>Which devices are supported?</mat-card-title>

View File

@ -10,6 +10,7 @@
app, asset, cryptocurrency, dashboard, etf, finance, management, app, asset, cryptocurrency, dashboard, etf, finance, management,
performance, portfolio, software, stock, trading, wealth, web3 performance, portfolio, software, stock, trading, wealth, web3
</li> </li>
<li i18n="@@myAccount">My Account</li>
<li i18n="@@slogan">Open Source Wealth Management Software</li> <li i18n="@@slogan">Open Source Wealth Management Software</li>
</ul> </ul>
</div> </div>

View File

@ -36,7 +36,6 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
export class ActivitiesPageComponent implements OnDestroy, OnInit { export class ActivitiesPageComponent implements OnDestroy, OnInit {
public activities: Activity[]; public activities: Activity[];
public dataSource: MatTableDataSource<Activity>; public dataSource: MatTableDataSource<Activity>;
public defaultAccountId: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateActivity: boolean; public hasPermissionToCreateActivity: boolean;
@ -323,7 +322,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
accounts: this.user?.accounts, accounts: this.user?.accounts,
activity: { activity: {
...aActivity, ...aActivity,
accountId: aActivity?.accountId ?? this.defaultAccountId, accountId: aActivity?.accountId,
date: new Date(), date: new Date(),
id: null, id: null,
fee: 0, fee: 0,
@ -399,10 +398,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
private updateUser(aUser: User) { private updateUser(aUser: User) {
this.user = aUser; this.user = aUser;
this.defaultAccountId = this.user?.accounts.find((account) => {
return account.isDefault;
})?.id;
this.hasPermissionToCreateActivity = this.hasPermissionToCreateActivity =
!this.hasImpersonationId && !this.hasImpersonationId &&
hasPermission(this.user.permissions, permissions.createOrder); hasPermission(this.user.permissions, permissions.createOrder);

View File

@ -20,6 +20,7 @@ import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client'; import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { isToday } from 'date-fns';
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs'; import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs';
import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators'; import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators';
@ -48,6 +49,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public defaultDateFormat: string; public defaultDateFormat: string;
public filteredTagsObservable: Observable<Tag[]> = of([]); public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false; public isLoading = false;
public isToday = isToday;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [ENTER, COMMA];
public tags: Tag[] = []; public tags: Tag[] = [];
@ -258,6 +260,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['currency'].setValue(currency); this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency); this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency); this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
if (['FEE', 'INTEREST'].includes(type)) {
if (this.activityForm.controls['accountId'].value) {
this.activityForm.controls['updateAccountBalance'].enable();
} else {
this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(
false
);
}
}
} }
} }
); );
@ -372,8 +385,15 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['unitPriceInCustomCurrency'].setValue(0); this.activityForm.controls['unitPriceInCustomCurrency'].setValue(0);
} }
this.activityForm.controls['updateAccountBalance'].disable(); if (
this.activityForm.controls['updateAccountBalance'].setValue(false); ['FEE', 'INTEREST'].includes(type) &&
this.activityForm.controls['accountId'].value
) {
this.activityForm.controls['updateAccountBalance'].enable();
} else {
this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false);
}
} else { } else {
this.activityForm.controls['accountId'].setValidators( this.activityForm.controls['accountId'].setValidators(
Validators.required Validators.required

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