Compare commits

..

107 Commits

Author SHA1 Message Date
bd4608e521 Release 1.138.0 (#838) 2022-04-16 21:03:28 +02:00
0d8362ca8f Feature/separate deposit and savings in fire calculator (#837)
* Separate deposit and savings

* Update changelog
2022-04-16 21:01:55 +02:00
638ae3f7fa Feature/add boringly getting rich guide to resources page (#836)
* Add "Boringly Getting Rich" guide

* Update changelog
2022-04-16 15:35:31 +02:00
6e7cf0380b Feature/export single draft (#835)
* Export single draft

* Update changelog
2022-04-16 11:33:01 +02:00
ec2ecab751 Clean up name (#834) 2022-04-16 10:21:32 +02:00
598fe41b8c Release 1.137.0 (#831) 2022-04-15 18:58:33 +02:00
ba7c98d325 Add test case for BUY and SELL (partially) (#826)
* Add test case for BUY and SELL (partially)

* Fix investment calculation for sell activities

* Do not show total value if sell activity

* Update changelog
2022-04-15 18:56:23 +02:00
65e062ad26 Simplify search (#828)
* Simplify search

* Update changelog
2022-04-15 12:39:33 +02:00
8526b5a027 Feature/export draft activities as ics (#830)
* Export draft activities as ICS

* Update changelog
2022-04-15 10:53:40 +02:00
f1feb04f29 Release 1.136.0 (#829) 2022-04-13 07:46:35 +02:00
500e09d95a Bugfix/fix loading state in fire calculator (#824)
* Fix loading state

* Update changelog
2022-04-12 19:57:23 +02:00
aef91d3e30 Feature/improve label in summary (#827)
* Improve label

* Update changelog
2022-04-12 18:04:48 +02:00
70723f8d5f Bugfix/fix projected total amount in fire calculator (#825)
* Fix calculation of projected total amount

* Update changelog
2022-04-11 22:10:45 +02:00
6cfd052781 Release 1.135.0 (#823) 2022-04-10 20:03:39 +02:00
23f2ac472e Feature/add fire calculator (#822)
* Add fire calculator

* Update changelog
2022-04-10 20:02:31 +02:00
d5ba624403 Feature/add support for terra (#820)
* Add Terra (LUNA1-USD)

* Update changelog
2022-04-10 19:38:27 +02:00
9b49ed77f7 Feature/add support for thor chain (#819)
* Add THORChain (RUNE-USD)

* Update changelog
2022-04-09 20:16:36 +02:00
08405d14d5 Release 1.134.0 (#818) 2022-04-09 14:50:13 +02:00
56b169e1c4 Feature/make header background solid (#817)
* Remove alpha

* Update changelog
2022-04-09 10:28:07 +02:00
67f2b326f3 Switch to new calculation engine (#814)
* Switch to new calculation engine

* Clean up old portfolio calculation engine (#815)

* Rename new portfolio calculation engine (#816)

* Update changelog
2022-04-09 10:17:31 +02:00
3d3a6c1204 Feature/improve fire section (#813)
* Improve FIRE section

* Update changelog
2022-04-09 09:03:39 +02:00
bfc8f87d88 Release 1.133.0 (#812) 2022-04-07 22:15:09 +02:00
957200854c Feature/improve empty state of proportion chart (#811)
* Improve empty state

* Update changelog
2022-04-07 22:13:41 +02:00
6575440877 Bugfix/fix dates in value component (#810)
* Fix dates

* Update changelog
2022-04-07 17:20:12 +02:00
255af6a6e9 Release 1.132.1 (#809) 2022-04-06 22:02:40 +02:00
795a6a6799 Release 1.132.0 (#808) 2022-04-06 21:23:20 +02:00
2a854e2574 Various improvements (#807) 2022-04-06 21:21:53 +02:00
52d113e71f Feature/improve label of average price (#805)
* Improve label

* Update changelog
2022-04-06 18:02:21 +02:00
204c7360c3 Feature/prepare for localized date format (#803)
* Support localized date and number format

* Update changelog
2022-04-05 21:02:07 +02:00
fa41e25c8f Release/1.131.1 (#804)
* Add API version

* Update changelog
2022-04-04 07:30:17 +02:00
ba765b9de6 Release 1.131.0 (#797) 2022-04-02 19:32:47 +02:00
fa79196278 Feature/display values in base currency on mobile (#796)
* Display values in base currency on mobile

* Update changelog
2022-04-02 19:28:32 +02:00
d1230ca3ad Support coupon codes on Google Play (#795) 2022-04-02 18:47:53 +02:00
69a1316cfe Feature/upgrade prisma to 3.11.1 (#794)
* Upgrade prisma dependencies to version 3.11.1

* Update changelog
2022-04-02 17:38:47 +02:00
a256b783bc Feature/upgrade yahoo finance to 2.3.0 (#792)
* Upgrade yahoo-finance2

* Update changelog
2022-04-02 17:36:49 +02:00
ebbdd47fa2 Exclude google login callback endpoint from versioning (#793) 2022-04-02 17:36:15 +02:00
3d21e2eac6 Improve upgrade guide (#780) 2022-04-02 12:06:06 +02:00
bc117fe601 Fix port (#788) 2022-04-02 10:47:26 +02:00
65f6bcb166 Feature/harmonize algebraic sign of gross and net performance percent (#776)
* Harmonize algebraic sign

* Update changelog
2022-04-02 10:44:19 +02:00
b8c43ecf89 Add documentation for import API (#787) 2022-04-02 10:29:41 +02:00
1214127ec0 Feature/rename orders to activities in import and export (#786)
* Rename orders to activities

* Update changelog
2022-04-02 10:26:17 +02:00
e986310302 Improve price (#785) 2022-04-02 09:52:59 +02:00
6762572658 Feature/setup api versioning (#783)
* Setup API versioning

* Update changelog
2022-04-02 08:46:24 +02:00
eb77652d6a Clean up import (#784) 2022-04-02 08:03:17 +02:00
a7b59f4ec6 Extend prerequisites (#781) 2022-04-02 06:47:58 +02:00
dd71f2be45 Feature/improve pricing page (#778)
* Improve pricing page

* Update changelog
2022-04-01 19:58:33 +02:00
d530cb38fa Feature/add 7 days in coupon system (#777)
* Add 7 days

* Update changelog
2022-04-01 19:49:05 +02:00
16b79a7e60 Release 1.130.0 (#774) 2022-03-30 21:16:43 +02:00
7f0c98cae6 Feature/setup fire page with 4 percent rule (#773)
* Setup page for FIRE

* Update changelog
2022-03-30 21:14:49 +02:00
57e4163848 Feature/add 14 days in coupon system (#772)
* Add 14 days

* Update changelog
2022-03-29 20:49:44 +02:00
14773bf1aa Bugfix/fix duplicate currency conversion in account calculations (#771)
* Fix currency conversion (duplicate)

* Update changelog
2022-03-29 17:47:08 +02:00
1a8fc5757a Release 1.129.0 (#770) 2022-03-26 13:13:07 +01:00
b4848be914 Feature/add developed vs emerging markets calculation (#767)
* Add allocations by market

* Update changelog
2022-03-26 13:11:30 +01:00
2b4319454d Feature/add bonds and emergency fund to feature overview (#768)
* Add bonds and emergency fund

* Update changelog
2022-03-25 23:37:06 +01:00
e2faaf6faa Feature/add hover effect to page tabs (#764)
* Add hover effect

* Update changelog
2022-03-21 18:59:00 +01:00
86a1589834 Release/1.128.0 (#763) 2022-03-19 14:39:25 +01:00
9f67993c03 Feature/fix issue with recent transactions (#750)
* Fix percentage performance issue with recent transactions

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-03-19 14:33:43 +01:00
32fb3551dc Feature/add default market price to scraper configuration (#762)
* Add default market price to scraper configuration

* Update changelog
2022-03-19 12:17:28 +01:00
30411b1502 Feature/add hover to table (#760)
* Add hover

* Update changelog
2022-03-19 09:56:50 +01:00
eb0444603b Bugfix/fix user currency of public page (#761)
* Fix user currency

* Update changelog
2022-03-19 09:25:20 +01:00
6e582fe505 Release 1.127.0 (#759) 2022-03-16 22:08:48 +01:00
402d73a12c Bugfix/fix get quotes for multiple ghostfolio symbols (#758)
* Support multiple symbols in getQuotes()

* Update changelog
2022-03-16 22:07:18 +01:00
4826a51199 Feature/improve handling of scraper configuration (#757)
* Improve handling of missing scraper configuration

* Update changelog
2022-03-15 17:13:42 +01:00
5356bf568e Release 1.126.0 (#756) 2022-03-14 17:41:23 +01:00
d8da574ae4 Feature/add support for bonds (#755)
* Add support for bonds

* Update changelog
2022-03-14 17:39:09 +01:00
e769fabbae Feature/add multilines to tooltips in proportion chart (#753)
* Introduce multilines for tooltips

* Update changelog
2022-03-13 21:39:06 +01:00
5a369f29d4 Feature/restructure portfolio summary tab (#754)
* Restructure portfolio summary

* Update changelog
2022-03-13 21:01:15 +01:00
122ba9046f Add type (#751)
* Add type

* Refactor import to import type
2022-03-13 21:00:40 +01:00
f781eb207c Release 1.125.0 (#752) 2022-03-12 18:40:09 +01:00
7b6893b5ed Feature/add support for emergency fund (#749)
* Add support for emergency fund

* Update changelog
2022-03-12 13:44:47 +01:00
07799573cb Harmonize button style (#748) 2022-03-10 20:11:32 +01:00
9cdef6a7cb Feature/upgrade nx to version 13.8.5 (#747)
* Upgrade Nx to version 13.8.5

* Update changelog
2022-03-09 18:19:48 +01:00
0d897bc461 Improve table header (#746) 2022-03-08 20:15:59 +01:00
e4908b51aa Feature/add context to logger (#745)
* Add contexts

* Update changelog
2022-03-07 17:20:07 +01:00
718b0de0a7 Release 1.124.0 (#744) 2022-03-06 13:48:17 +01:00
99655604d9 Feature/add support for coupon duration (#743)
* Add support for coupon duration

* Update changelog
2022-03-06 12:26:04 +01:00
b602e7690b Feature/upgrade prisma to version 3.10.0 (#742)
* Upgrade prisma to version 3.10.0

* Update changelog
2022-03-06 12:06:07 +01:00
7745dafe48 Feature/upgrade yahoo finance2 to 2.2.0 (#741)
* Upgrade yahoo-finance2 to version 2.2.0

* Update changelog
2022-03-06 12:04:38 +01:00
50184284e1 Feature/upgrade ngx skeleton loader to 5.0.0 (#740)
* Upgrade ngx-skeleton-loader to version 5.0.0

* Update changelog
2022-03-06 10:47:30 +01:00
f46533107d Release 1.123.0 (#739) 2022-03-05 11:09:07 +01:00
c216ab1d76 Eliminate data source from order model (#730)
* Eliminate currency, data source and symbol from order model

* Remove prefix for symbols with data source GHOSTFOLIO

* Update changelog
2022-03-05 11:07:27 +01:00
86acbf06f4 Feature/add data provider errors to api response (#738)
* Add data provider error details

* Update changelog
2022-03-05 11:00:02 +01:00
3de7d3f60e Bugfix/improve account calculations (#737)
* Improve account calculations

* Update changelog
2022-03-04 21:31:31 +01:00
63ed227f3f Release 1.122.0 (#732) 2022-03-01 21:36:09 +01:00
5bb20f6d5f Bugfix/fix undefined currencies after creating an activity (#731)
* Fix issue with undefined currencies after creating an activity

* Update changelog
2022-03-01 21:32:19 +01:00
b3e58d182a Feature/add support for click in portfolio proportion chart (#729)
* Add support for click

* Update changelog
2022-02-28 21:35:52 +01:00
93d6746739 Refactoring (#727) 2022-02-28 20:42:14 +01:00
e3f8b0cf52 Release 1.121.0 (#726) 2022-02-27 17:07:30 +01:00
c02bcd9bd8 Feature/migrate to yahoo finance2 (#722)
* Migrate to yahoo-finance2

* Add support for mutual funds

* Add url to symbol profile

* Clean up
2022-02-27 17:03:00 +01:00
6a4f1c0188 Release 1.120.0 (#724) 2022-02-25 08:06:01 +01:00
745ba978a3 Bugfix/fix zen mode (#723)
* Fix Zen mode

* Update changelog
2022-02-25 08:04:42 +01:00
46b91d3c3b Feature/improve portfolio page (#721)
* Improve page

* Update changelog
2022-02-23 19:00:13 +01:00
1dd670a7c3 Feature/improve labels in portfolio proportion chart (#720)
* Improve labels for OTHER and UNKNOWN

* Update changelog
2022-02-22 21:12:02 +01:00
68d07cc8d4 Release 1.119.0 (#719) 2022-02-22 20:05:13 +01:00
02809a529e Reorder (#718) 2022-02-21 20:06:39 +01:00
fd60569716 Release 1.118.0 (#717) 2022-02-20 17:01:06 +01:00
fed771525e Feature/extend market data in admin panel (#716)
* Extend market data view

* Update changelog
2022-02-20 16:57:46 +01:00
a5771f601d Improve calculation of overall performance percentage (#701)
* Improve calculation of overall performance percentage

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-02-20 16:39:43 +01:00
2a2a5f4da5 Feature/display features based on permissions (#714)
* Display features based on permissions

* Update changelog
2022-02-20 14:16:46 +01:00
06d5ec9182 Release 1.117.0 (#713) 2022-02-19 19:48:51 +01:00
122107c8a1 Feature/distinguish today s data point in admin panel (#711)
* Distinguish today's data point

* Update changelog
2022-02-19 19:46:36 +01:00
ca46a9827a Do not tweet on Sunday (#712) 2022-02-19 19:29:49 +01:00
4ec351369b Bugfix/add fallback to default account in import (#709)
* Add fallback to default account if account id is invalid

* Update changelog
2022-02-19 18:51:16 +01:00
dced06ebb5 Bugfix/improve allocations (#710)
* Fix allocations by account for non-unique account names

* Refactor calculations with big.js

* Update changelog
2022-02-19 18:41:12 +01:00
baa6a3d0f0 Feature/restructure api modules (#706)
* Restructure modules

* Update changelog
2022-02-18 19:32:25 +01:00
d3382f0809 Update time (#707) 2022-02-17 21:31:08 +01:00
1eb4041837 Feature/move countries and sectors chart (#704)
* Move countries and sectors charts

* Update changelog
2022-02-17 20:40:28 +01:00
203 changed files with 4893 additions and 7327 deletions

View File

@ -5,6 +5,268 @@ 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).
## 1.138.0 - 16.04.2022
### Added
- Added support to export a single future activity (draft) as an `.ics` file
- Added the _Boringly Getting Rich_ guide to the resources section
### Changed
- Separated the deposit and savings in the chart of the the _FIRE_ calculator
## 1.137.0 - 15.04.2022
### Added
- Added support to export future activities (drafts) as an `.ics` file
### Changed
- Migrated the search functionality to `yahoo-finance2`
### Fixed
- Fixed an issue in the average price / investment calculation for sell activities
## 1.136.0 - 13.04.2022
### Changed
- Changed the _Total_ label to _Total Assets_ in the portfolio summary tab on the home page
### Fixed
- Fixed an issue with the calculation of the projected total amount in the _FIRE_ calculator
- Fixed an issue with the loading state of the _FIRE_ calculator
## 1.135.0 - 10.04.2022
### Added
- Added a calculator to the _FIRE_ section
- Added support for the cryptocurrency _Terra_ (`LUNA1-USD`)
- Added support for the cryptocurrency _THORChain_ (`RUNE-USD`)
## 1.134.0 - 09.04.2022
### Changed
- Switched to the new calculation engine
- Improved the 4% rule in the _FIRE_ section
- Changed the background of the header to a solid color
## 1.133.0 - 07.04.2022
### Changed
- Improved the empty state of the portfolio proportion chart component
### Fixed
- Fixed an issue with dates in the value component
## 1.132.1 - 06.04.2022
### Fixed
- Fixed an issue with percentages in the value component
## 1.132.0 - 06.04.2022
### Added
- Added support for localization (date and number format) in user settings
### Changed
- Improved the label of the average price from _Ø Buy Price_ to _Average Unit Price_
## 1.131.1 - 04.04.2022
### Fixed
- Fixed the missing API version in the _Stripe_ success callback url
## 1.131.0 - 02.04.2022
### Added
- Added API versioning
- Added more durations in the coupon system
### Changed
- Display the value in base currency in the accounts table on mobile
- Display the value in base currency in the activities table on mobile
- Renamed `orders` to `activities` in import and export functionality
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
- Improved the pricing page
- Upgraded `prisma` from version `3.10.0` to `3.11.1`
- Upgraded `yahoo-finance2` from version `2.2.0` to `2.3.0`
## 1.130.0 - 30.03.2022
### Added
- Added a _FIRE_ (Financial Independence, Retire Early) section including the 4% rule
- Added more durations in the coupon system
### Fixed
- Fixed an issue with the currency conversion (duplicate) in the account calculations
## 1.129.0 - 26.03.2022
### Added
- Added the calculation for developed vs. emerging markets to the allocations page
- Added a hover effect to the page tabs
- Extended the feature overview page by _Bonds_ and _Emergency Fund_
## 1.128.0 - 19.03.2022
### Added
- Added the attribute `defaultMarketPrice` to the scraper configuration to improve the support for bonds
- Added a hover effect to the table style
### Fixed
- Fixed an issue with the user currency of the public page
- Fixed an issue of the performance calculation with recent activities in the new calculation engine
## 1.127.0 - 16.03.2022
### Changed
- Improved the error handling in the scraper configuration
### Fixed
- Fixed the support for multiple symbols of the data source `GHOSTFOLIO`
## 1.126.0 - 14.03.2022
### Added
- Added support for bonds
### Changed
- Restructured the portfolio summary tab on the home page
- Improved the tooltips in the portfolio proportion chart component by introducing multilines
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.125.0 - 12.03.2022
### Added
- Added support for an emergency fund
- Added the contexts to the logger commands
### Changed
- Upgraded `Nx` from version `13.8.1` to `13.8.5`
## 1.124.0 - 06.03.2022
### Added
- Added support for setting a duration in the coupon system
### Changed
- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
- Upgraded `prisma` from version `3.9.1` to `3.10.0`
- Upgraded `yahoo-finance2` from version `2.1.9` to `2.2.0`
## 1.123.0 - 05.03.2022
### Added
- Included data provider errors in the API response
### Changed
- Removed the redundant attributes (`currency`, `dataSource`, `symbol`) of the activity model
- Removed the prefix for symbols with the data source `GHOSTFOLIO`
### Fixed
- Improved the account calculations
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.122.0 - 01.03.2022
### Added
- Added support for click in the portfolio proportion chart component
### Fixed
- Fixed an issue with undefined currencies after creating an activity
## 1.121.0 - 27.02.2022
### Added
- Added support for mutual funds
- Added the url to the symbol profile model
### Changed
- Migrated from `yahoo-finance` to `yahoo-finance2`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.120.0 - 25.02.2022
### Changed
- Distinguished the labels _Other_ and _Unknown_ in the portfolio proportion chart component
- Improved the portfolio entry page
### Fixed
- Fixed the _Zen Mode_
## 1.119.0 - 21.02.2022
### Added
- Added a trial for the subscription
## 1.118.0 - 20.02.2022
### Changed
- Improved the calculation of the overall performance percentage in the new calculation engine
- Displayed features in features overview page based on permissions
- Extended the data points of historical data in the admin control panel
## 1.117.0 - 19.02.2022
### Changed
- Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel
- Restructured the server modules
### Fixed
- Fixed the allocations by account for non-unique account names
- Added a fallback to the default account if the `accountId` is invalid in the import functionality for activities
## 1.116.0 - 16.02.2022 ## 1.116.0 - 16.02.2022
### Added ### Added

View File

@ -79,6 +79,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- A local copy of this Git repository (clone)
### a. Run environment ### a. Run environment
@ -121,13 +122,11 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
### Migrate Database ### Upgrade Version
With the following command you can keep your database schema in sync after a Ghostfolio version update: 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
```bash 1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
```
## Development ## Development
@ -136,6 +135,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+) - [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
### Setup ### Setup
@ -162,10 +162,84 @@ Run `yarn start:client`
Run `yarn start:storybook` Run `yarn start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
```
## Testing ## Testing
Run `yarn test` Run `yarn test`
## Public API (experimental)
### Import Activities
#### Request
`POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body
```
{
"activities": [
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T00:00:00.000Z",
"fee": 19,
"quantity": 5,
"symbol": "MSFT"
"type": "BUY",
"unitPrice": 298.58
}
]
}
```
| Field | Type | Description |
| ---------- | ------------------- | -------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
#### Response
##### Success
`201 Created`
##### Error
`400 Bad Request`
```
{
"error": "Bad Request",
"message": [
"activities.1 is a duplicate activity"
]
}
```
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.

View File

@ -9,7 +9,7 @@
"schematics": {}, "schematics": {},
"architect": { "architect": {
"build": { "build": {
"builder": "@nrwl/node:build", "builder": "@nrwl/node:webpack",
"options": { "options": {
"outputPath": "dist/apps/api", "outputPath": "dist/apps/api",
"main": "apps/api/src/main.ts", "main": "apps/api/src/main.ts",
@ -33,7 +33,7 @@
"outputs": ["{options.outputPath}"] "outputs": ["{options.outputPath}"]
}, },
"serve": { "serve": {
"builder": "@nrwl/node:execute", "builder": "@nrwl/node:node",
"options": { "options": {
"buildTarget": "api:build" "buildTarget": "api:build"
} }

View File

@ -1,4 +1,4 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AccessController } from './access.controller'; import { AccessController } from './access.controller';
@ -7,7 +7,7 @@ import { AccessService } from './access.service';
@Module({ @Module({
controllers: [AccessController], controllers: [AccessController],
exports: [AccessService], exports: [AccessService],
imports: [], imports: [PrismaModule],
providers: [AccessService, PrismaService] providers: [AccessService]
}) })
export class AccessModule {} export class AccessModule {}

View File

@ -1,4 +1,4 @@
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy'; import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
nullifyValuesInObject, nullifyValuesInObject,
@ -35,7 +35,7 @@ export class AccountController {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -91,9 +91,10 @@ export class AccountController {
this.request.user.id this.request.user.id
); );
let accountsWithAggregations = await this.portfolioServiceStrategy let accountsWithAggregations =
.get() await this.portfolioService.getAccountsWithAggregations(
.getAccountsWithAggregations(impersonationUserId || this.request.user.id); impersonationUserId || this.request.user.id
);
if ( if (
impersonationUserId || impersonationUserId ||
@ -101,16 +102,18 @@ export class AccountController {
) { ) {
accountsWithAggregations = { accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [ ...nullifyValuesInObject(accountsWithAggregations, [
'totalBalance', 'totalBalanceInBaseCurrency',
'totalValue' 'totalValueInBaseCurrency'
]), ]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [ accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance', 'balance',
'balanceInBaseCurrency',
'convertedBalance', 'convertedBalance',
'fee', 'fee',
'quantity', 'quantity',
'unitPrice', 'unitPrice',
'value' 'value',
'valueInBaseCurrency'
]) ])
}; };
} }

View File

@ -13,6 +13,7 @@ import { AccountService } from './account.service';
@Module({ @Module({
controllers: [AccountController], controllers: [AccountController],
exports: [AccountService],
imports: [ imports: [
ConfigurationModule, ConfigurationModule,
DataProviderModule, DataProviderModule,

View File

@ -2,6 +2,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client'; import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@ -105,21 +106,26 @@ export class AccountService {
aUserId: string, aUserId: string,
aCurrency: string aCurrency: string
): Promise<CashDetails> { ): Promise<CashDetails> {
let totalCashBalance = 0; let totalCashBalanceInBaseCurrency = new Big(0);
const accounts = await this.accounts({ const accounts = await this.accounts({
where: { userId: aUserId } where: { userId: aUserId }
}); });
accounts.forEach((account) => { for (const account of accounts) {
totalCashBalance += this.exchangeRateDataService.toCurrency( totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
account.balance, this.exchangeRateDataService.toCurrency(
account.currency, account.balance,
aCurrency account.currency,
aCurrency
)
); );
}); }
return { accounts, balance: totalCashBalance }; return {
accounts,
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
};
} }
public async updateAccount( public async updateAccount(

View File

@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
export interface CashDetails { export interface CashDetails {
accounts: Account[]; accounts: Account[];
balance: number; balanceInBaseCurrency: number;
} }

View File

@ -11,7 +11,8 @@ import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem AdminMarketDataItem,
UniqueAsset
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Property } from '@prisma/client'; import { DataSource, Property } from '@prisma/client';
@ -30,13 +31,7 @@ export class AdminService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public async deleteProfileData({ public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
await this.marketDataService.deleteMany({ dataSource, symbol }); await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol }); await this.symbolProfileService.delete({ dataSource, symbol });
} }
@ -137,10 +132,7 @@ export class AdminService {
public async getMarketDataBySymbol({ public async getMarketDataBySymbol({
dataSource, dataSource,
symbol symbol
}: { }: UniqueAsset): Promise<AdminMarketDataDetails> {
dataSource: DataSource;
symbol: string;
}): Promise<AdminMarketDataDetails> {
return { return {
marketData: await this.marketDataService.marketDataItems({ marketData: await this.marketDataService.marketDataItems({
orderBy: { orderBy: {

View File

@ -1,18 +1,20 @@
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller'; import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@Module({ @Module({
controllers: [AuthDeviceController], controllers: [AuthDeviceController],
imports: [ imports: [
ConfigurationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}) }),
PrismaModule
], ],
providers: [AuthDeviceService, ConfigurationService, PrismaService] providers: [AuthDeviceService]
}) })
export class AuthDeviceModule {} export class AuthDeviceModule {}

View File

@ -9,7 +9,9 @@ import {
Post, Post,
Req, Req,
Res, Res,
UseGuards UseGuards,
VERSION_NEUTRAL,
Version
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -51,6 +53,7 @@ export class AuthController {
@Get('google/callback') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) { public googleLoginCallback(@Req() req, @Res() res) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = req.user.jwt; const jwt: string = req.user.jwt;

View File

@ -2,8 +2,8 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -15,20 +15,20 @@ import { JwtStrategy } from './jwt.strategy';
@Module({ @Module({
controllers: [AuthController], controllers: [AuthController],
imports: [ imports: [
ConfigurationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}), }),
PrismaModule,
SubscriptionModule, SubscriptionModule,
UserModule UserModule
], ],
providers: [ providers: [
AuthDeviceService, AuthDeviceService,
AuthService, AuthService,
ConfigurationService,
GoogleStrategy, GoogleStrategy,
JwtStrategy, JwtStrategy,
PrismaService,
WebAuthService WebAuthService
] ]
}) })

View File

@ -42,7 +42,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
done(null, user); done(null, user);
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'GoogleStrategy');
done(error, false); done(error, false);
} }
} }

View File

@ -95,7 +95,7 @@ export class WebAuthService {
}; };
verification = await verifyRegistrationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
@ -193,7 +193,7 @@ export class WebAuthService {
}; };
verification = verifyAuthenticationResponse(opts); verification = verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'WebAuthService');
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }

View File

@ -1,30 +1,27 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';
@Module({ @Module({
exports: [CacheService],
controllers: [CacheController],
imports: [ imports: [
ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
PrismaModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule
], ],
controllers: [CacheController], providers: [CacheService]
providers: [
CacheService,
ConfigurationService,
DataGatheringService,
PrismaService
]
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -1,13 +1,6 @@
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
Controller,
Get,
Headers,
Inject,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -14,12 +14,10 @@ export class ExportService {
activityIds?: string[]; activityIds?: string[];
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
let orders = await this.prismaService.order.findMany({ let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
accountId: true, accountId: true,
currency: true,
dataSource: true,
date: true, date: true,
fee: true, fee: true,
id: true, id: true,
@ -32,19 +30,19 @@ export class ExportService {
}); });
if (activityIds) { if (activityIds) {
orders = orders.filter((order) => { activities = activities.filter((activity) => {
return activityIds.includes(order.id); return activityIds.includes(activity.id);
}); });
} }
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
orders: orders.map( activities: activities.map(
({ ({
accountId, accountId,
currency,
date, date,
fee, fee,
id,
quantity, quantity,
SymbolProfile, SymbolProfile,
type, type,
@ -52,13 +50,14 @@ export class ExportService {
}) => { }) => {
return { return {
accountId, accountId,
currency,
date,
fee, fee,
id,
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
}; };
} }

View File

@ -1,5 +1,4 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client';
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator'; import { IsArray, ValidateNested } from 'class-validator';
@ -7,5 +6,5 @@ export class ImportDataDto {
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
orders: Order[]; activities: CreateOrderDto[];
} }

View File

@ -36,11 +36,11 @@ export class ImportController {
try { try {
return await this.importService.import({ return await this.importService.import({
orders: importData.orders, activities: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, ImportController);
throw new HttpException( throw new HttpException(
{ {

View File

@ -1,4 +1,5 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
@ -11,7 +12,10 @@ import { ImportController } from './import.controller';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
@Module({ @Module({
controllers: [ImportController],
imports: [ imports: [
AccountModule,
CacheModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
@ -19,7 +23,6 @@ import { ImportService } from './import.service';
PrismaModule, PrismaModule,
RedisCacheModule RedisCacheModule
], ],
controllers: [ImportController], providers: [ImportService]
providers: [CacheService, ImportService]
}) })
export class ImportModule {} export class ImportModule {}

View File

@ -1,36 +1,44 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { isSameDay, parseISO } from 'date-fns'; import { isSameDay, parseISO } from 'date-fns';
@Injectable() @Injectable()
export class ImportService { export class ImportService {
public constructor( public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService private readonly orderService: OrderService
) {} ) {}
public async import({ public async import({
orders, activities,
userId userId
}: { }: {
orders: Partial<Order>[]; activities: Partial<CreateOrderDto>[];
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
for (const order of orders) { for (const activity of activities) {
if (!order.dataSource) { if (!activity.dataSource) {
if (order.type === 'ITEM') { if (activity.type === 'ITEM') {
order.dataSource = 'MANUAL'; activity.dataSource = 'MANUAL';
} else { } else {
order.dataSource = this.dataProviderService.getPrimaryDataSource(); activity.dataSource = this.dataProviderService.getPrimaryDataSource();
} }
} }
} }
await this.validateOrders({ orders, userId }); await this.validateActivities({ activities, userId });
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
}
);
for (const { for (const {
accountId, accountId,
@ -42,21 +50,19 @@ export class ImportService {
symbol, symbol,
type, type,
unitPrice unitPrice
} of orders) { } of activities) {
await this.orderService.createOrder({ await this.orderService.createOrder({
accountId,
currency,
dataSource,
fee, fee,
quantity, quantity,
symbol,
type, type,
unitPrice, unitPrice,
userId, userId,
accountId: accountIds.includes(accountId) ? accountId : undefined,
date: parseISO(<string>(<unknown>date)), date: parseISO(<string>(<unknown>date)),
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency,
dataSource, dataSource,
symbol symbol
}, },
@ -73,24 +79,25 @@ export class ImportService {
} }
} }
private async validateOrders({ private async validateActivities({
orders, activities,
userId userId
}: { }: {
orders: Partial<Order>[]; activities: Partial<CreateOrderDto>[];
userId: string; userId: string;
}) { }) {
if ( if (
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT') activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) { ) {
throw new Error( throw new Error(
`Too many transactions (${this.configurationService.get( `Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT' 'MAX_ORDERS_TO_IMPORT'
)} at most)` )} at most)`
); );
} }
const existingOrders = await this.orderService.orders({ const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
where: { userId } where: { userId }
}); });
@ -98,38 +105,38 @@ export class ImportService {
for (const [ for (const [
index, index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } { currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of orders.entries()) { ] of activities.entries()) {
const duplicateOrder = existingOrders.find((order) => { const duplicateActivity = existingActivities.find((activity) => {
return ( return (
order.currency === currency && activity.SymbolProfile.currency === currency &&
order.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) && isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee && activity.fee === fee &&
order.quantity === quantity && activity.quantity === quantity &&
order.symbol === symbol && activity.SymbolProfile.symbol === symbol &&
order.type === type && activity.type === type &&
order.unitPrice === unitPrice activity.unitPrice === unitPrice
); );
}); });
if (duplicateOrder) { if (duplicateActivity) {
throw new Error(`orders.${index} is a duplicate transaction`); throw new Error(`activities.${index} is a duplicate activity`);
} }
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
const result = await this.dataProviderService.get([ const quotes = await this.dataProviderService.getQuotes([
{ dataSource, symbol } { dataSource, symbol }
]); ]);
if (result[symbol] === undefined) { if (quotes[symbol] === undefined) {
throw new Error( throw new Error(
`orders.${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 (result[symbol].currency !== currency) { if (quotes[symbol].currency !== currency) {
throw new Error( throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"` `activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
); );
} }
} }

View File

@ -1,10 +1,9 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module'; import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -14,7 +13,9 @@ import { InfoController } from './info.controller';
import { InfoService } from './info.service'; import { InfoService } from './info.service';
@Module({ @Module({
controllers: [InfoController],
imports: [ imports: [
ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
@ -22,16 +23,11 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PrismaModule,
PropertyModule, PropertyModule,
RedisCacheModule, RedisCacheModule,
SymbolProfileModule SymbolProfileModule
], ],
controllers: [InfoController], providers: [InfoService]
providers: [
ConfigurationService,
DataGatheringService,
InfoService,
PrismaService
]
}) })
export class InfoModule {} export class InfoModule {}

View File

@ -144,7 +144,7 @@ export class InfoService {
const contributors = await get(); const contributors = await get();
return contributors?.length; return contributors?.length;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'InfoService');
return undefined; return undefined;
} }
@ -165,7 +165,7 @@ export class InfoService {
const { stargazers_count } = await get(); const { stargazers_count } = await get();
return stargazers_count; return stargazers_count;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'InfoService');
return undefined; return undefined;
} }

View File

@ -114,6 +114,7 @@ export class OrderController {
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: { create: {
currency: data.currency,
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
}, },
@ -171,6 +172,14 @@ export class OrderController {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
}, },
SymbolProfile: {
connect: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } } User: { connect: { id: this.request.user.id } }
}, },
where: { where: {

View File

@ -1,5 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
@ -15,7 +15,10 @@ import { OrderController } from './order.controller';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
@Module({ @Module({
controllers: [OrderController],
exports: [OrderService],
imports: [ imports: [
CacheModule,
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
@ -26,8 +29,6 @@ import { OrderService } from './order.service';
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule
], ],
controllers: [OrderController], providers: [AccountService, OrderService]
providers: [AccountService, CacheService, OrderService],
exports: [OrderService]
}) })
export class OrderModule {} export class OrderModule {}

View File

@ -53,7 +53,13 @@ export class OrderService {
} }
public async createOrder( public async createOrder(
data: Prisma.OrderCreateInput & { accountId?: string; userId: string } data: Prisma.OrderCreateInput & {
accountId?: string;
currency?: string;
dataSource?: DataSource;
symbol?: string;
userId: string;
}
): Promise<Order> { ): Promise<Order> {
const defaultAccount = ( const defaultAccount = (
await this.accountService.getAccounts(data.userId) await this.accountService.getAccounts(data.userId)
@ -71,15 +77,13 @@ export class OrderService {
}; };
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const currency = data.currency; const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL'; const dataSource: DataSource = 'MANUAL';
const id = uuidv4(); const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol; const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined; Account = undefined;
data.dataSource = dataSource;
data.id = id; data.id = id;
data.symbol = null;
data.SymbolProfile.connectOrCreate.create.currency = currency; data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource; data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name; data.SymbolProfile.connectOrCreate.create.name = name;
@ -93,29 +97,32 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase(); data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
} }
await this.dataGatheringService.gatherProfileData([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
dataSource: data.dataSource, dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date, date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol symbol: data.SymbolProfile.connectOrCreate.create.symbol
} }
]); ]);
} }
this.dataGatheringService.gatherProfileData([
{
dataSource: data.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
await this.cacheService.flush(); await this.cacheService.flush();
delete data.accountId; delete data.accountId;
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.userId; delete data.userId;
const orderData: Prisma.OrderCreateInput = data; const orderData: Prisma.OrderCreateInput = data;
@ -193,50 +200,60 @@ export class OrderService {
value, value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency( feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.currency, order.SymbolProfile.currency,
userCurrency userCurrency
), ),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency( valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value, value,
order.currency, order.SymbolProfile.currency,
userCurrency userCurrency
) )
}; };
}); });
} }
public async updateOrder(params: { public async updateOrder({
data,
where
}: {
data: Prisma.OrderUpdateInput & {
currency?: string;
dataSource?: DataSource;
symbol?: string;
};
where: Prisma.OrderWhereUniqueInput; where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
}): Promise<Order> { }): Promise<Order> {
const { data, where } = params;
if (data.Account.connect.id_userId.id === null) { if (data.Account.connect.id_userId.id === null) {
delete data.Account; delete data.Account;
} }
let isDraft = false;
if (data.type === 'ITEM') { if (data.type === 'ITEM') {
const name = data.symbol; const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
data.symbol = null;
data.SymbolProfile = { update: { name } }; data.SymbolProfile = { update: { name } };
} } else {
isDraft = isAfter(data.date as Date, endOfToday());
const isDraft = isAfter(data.date as Date, endOfToday()); if (!isDraft) {
// Gather symbol data of order in the background, if not draft
if (!isDraft) { this.dataGatheringService.gatherSymbols([
// Gather symbol data of order in the background, if not draft {
this.dataGatheringService.gatherSymbols([ dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
{ date: <Date>data.date,
dataSource: <DataSource>data.dataSource, symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
date: <Date>data.date, }
symbol: <string>data.symbol ]);
} }
]);
} }
await this.cacheService.flush(); await this.cacheService.flush();
delete data.currency;
delete data.dataSource;
delete data.symbol;
return this.prismaService.order.update({ return this.prismaService.order.update({
data: { data: {
...data, ...data,

View File

@ -20,6 +20,13 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };
}
return { marketPrice: 0 };
default: default:
return { marketPrice: 0 }; return { marketPrice: 0 };
} }

View File

@ -40,7 +40,7 @@ export class CurrentRateService {
const today = resetHours(new Date()); const today = resetHours(new Date());
promises.push( promises.push(
this.dataProviderService this.dataProviderService
.get(dataGatheringItems) .getQuotes(dataGatheringItems)
.then((dataResultProvider) => { .then((dataResultProvider) => {
const result = []; const result = [];
for (const dataGatheringItem of dataGatheringItems) { for (const dataGatheringItem of dataGatheringItems) {

View File

@ -1,8 +1,7 @@
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js'; import Big from 'big.js';
export interface CurrentPositions { export interface CurrentPositions extends ResponseError {
hasErrors: boolean;
positions: TimelinePosition[]; positions: TimelinePosition[];
grossPerformance: Big; grossPerformance: Big;
grossPerformancePercentage: Big; grossPerformancePercentage: Big;

View File

@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock'; import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new'; import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
describe('PortfolioCalculatorNew', () => { describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
beforeEach(() => { beforeEach(() => {
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => { it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', currency: 'CHF',
orders: [ orders: [
@ -52,13 +52,13 @@ describe('PortfolioCalculatorNew', () => {
] ]
}); });
portfolioCalculatorNew.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
const spy = jest const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-22') parseDate('2021-11-22')
); );
@ -66,6 +66,7 @@ describe('PortfolioCalculatorNew', () => {
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('0'), currentValue: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'), grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'), grossPerformancePercentage: new Big('-0.0440867739678096571'),
hasErrors: false, hasErrors: false,

View File

@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock'; import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new'; import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
describe('PortfolioCalculatorNew', () => { describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
beforeEach(() => { beforeEach(() => {
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy', async () => { it.only('with BALN.SW buy', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', currency: 'CHF',
orders: [ orders: [
@ -41,13 +41,13 @@ describe('PortfolioCalculatorNew', () => {
] ]
}); });
portfolioCalculatorNew.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
const spy = jest const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-30') parseDate('2021-11-30')
); );
@ -55,6 +55,7 @@ describe('PortfolioCalculatorNew', () => {
expect(currentPositions).toEqual({ expect(currentPositions).toEqual({
currentValue: new Big('297.8'), currentValue: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'), grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'), grossPerformancePercentage: new Big('0.09004392386530014641'),
hasErrors: false, hasErrors: false,

View File

@ -1,73 +0,0 @@
import Big from 'big.js';
import { CurrentRateService } from './current-rate.service';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
describe('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('annualized performance percentage', () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'USD',
orders: []
});
it('Get annualized performance', async () => {
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: new Big(0)
})
.toNumber()
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: new Big(0.1025)
})
.toNumber()
).toBeCloseTo(0.729705);
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: new Big(0.05)
})
.toNumber()
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioCalculatorNew
.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: new Big(0.2374)
})
.toNumber()
).toBeCloseTo(0.145);
});
});
});

View File

@ -1,898 +0,0 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMilliseconds,
addMonths,
addYears,
endOfDay,
format,
isAfter,
isBefore,
max,
min
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from './interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculatorNew {
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
public constructor({
currency,
currentRateService,
orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
this.orders.sort((a, b) => a.date.localeCompare(b.date));
}
public computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
transactionCount: 1
};
}
symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.items = newItems;
}
lastDate = currentDate;
}
}
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints;
}
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
return {
currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0)
};
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
}
}
dates.push(resetHours(today));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = [];
let hasErrorsInSymbolMetrics = false;
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
marketSymbolMap,
start,
symbol: item.symbol
});
hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
positions,
hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors
};
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
}
return this.transactionPoints.map((transactionPoint) => {
return {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.plus(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0;
let j = -1;
for (
let currentDate = start;
!isAfter(currentDate, end);
currentDate = this.addToDate(
currentDate,
timelineSpecification[i].accuracy
)
) {
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
i++;
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
let periodEndDate = currentDate;
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
max([currentDate, nextEndDate])
]);
}
const timePeriodForDates = this.getTimePeriodForDate(
j,
currentDate,
endOfDay(periodEndDate)
);
currentDate = periodEndDate;
if (timePeriodForDates != null) {
timelinePeriodPromises.push(timePeriodForDates);
}
}
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
}
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
) {
let hasErrors = false;
let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.plus(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (
currentPosition.grossPerformancePercentage &&
initialValues[currentPosition.symbol]
) {
const currentInitialValue = initialValues[currentPosition.symbol];
completeInitialValue = completeInitialValue.plus(currentInitialValue);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
);
hasErrors = true;
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.plus(item.investment);
fees = fees.plus(item.fee);
}
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
userCurrency: this.currency
});
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
);
return null;
}
}
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
}
const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
currentDate = addDays(currentDate, 1)
) {
let value = new Big(0);
const currentDateAsString = format(currentDate, DATE_FORMAT);
let invalid = false;
if (j >= 0) {
for (const item of this.transactionPoints[j].items) {
if (
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
) {
invalid = true;
break;
}
value = value.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString
};
results.push(result);
}
}
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
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 addToDate(date: Date, accuracy: Accuracy): Date {
switch (accuracy) {
case 'day':
return addDays(date, 1);
case 'month':
return addMonths(date, 1);
case 'year':
return addYears(date, 1);
}
}
private getSymbolMetrics({
marketSymbolMap,
start,
symbol
}: {
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
return order.symbol === symbol;
});
if (orders.length <= 0) {
return {
hasErrors: false,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
hasErrors: true,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalUnits = new Big(0);
const holdingPeriodPerformances: {
grossReturn: Big;
netReturn: Big;
valueOfInvestment: Big;
}[] = [];
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtStartDate ?? new Big(0)
});
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtEndDate ?? new Big(0)
});
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
let sortIndex = new Date(order.date);
if (order.itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex((order) => {
return order.itemType === 'start';
});
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPrice
);
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
if (
!initialValue &&
order.itemType !== 'start' &&
order.itemType !== 'end'
) {
initialValue = transactionInvestment;
}
fees = fees.plus(order.fee);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
const valueOfInvestment = totalUnits.mul(order.unitPrice);
const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
totalInvestment = totalInvestment
.plus(transactionInvestment)
.plus(grossPerformanceFromSell);
lastAveragePrice = totalUnits.eq(0)
? new Big(0)
: totalInvestment.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestment)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
holdingPeriodPerformances.push({
grossReturn: grossHoldingPeriodReturn,
netReturn: netHoldingPeriodReturn,
valueOfInvestment: lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
});
}
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
let valueOfInvestmentSum = new Big(0);
for (const holdingPeriodPerformance of holdingPeriodPerformances) {
valueOfInvestmentSum = valueOfInvestmentSum.plus(
holdingPeriodPerformance.valueOfInvestment
);
}
let totalWeightedGrossPerformance = new Big(0);
let totalWeightedNetPerformance = new Big(0);
// Weight the holding period returns according to their value of investment
for (const holdingPeriodPerformance of holdingPeriodPerformances) {
totalWeightedGrossPerformance = totalWeightedGrossPerformance.plus(
holdingPeriodPerformance.grossReturn
.mul(holdingPeriodPerformance.valueOfInvestment)
.div(valueOfInvestmentSum)
);
totalWeightedNetPerformance = totalWeightedNetPerformance.plus(
holdingPeriodPerformance.netReturn
.mul(holdingPeriodPerformance.valueOfInvestment)
.div(valueOfInvestmentSum)
);
}
return {
initialValue,
hasErrors: !initialValue || !unitPriceAtEndDate,
netPerformance: totalNetPerformance,
netPerformancePercentage: totalWeightedNetPerformance,
grossPerformance: totalGrossPerformance,
grossPerformancePercentage: totalWeightedGrossPerformance
};
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

View File

@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock'; import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new'; import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
describe('PortfolioCalculatorNew', () => { describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
beforeEach(() => { beforeEach(() => {
@ -23,19 +23,19 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => { describe('get current positions', () => {
it('with no orders', async () => { it('with no orders', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', currency: 'CHF',
orders: [] orders: []
}); });
portfolioCalculatorNew.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
const spy = jest const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
new Date() new Date()
); );

View File

@ -0,0 +1,96 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.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;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG',
quantity: new Big(1),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('75.80')
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,15 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
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';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client'; import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
addDays, addDays,
addMilliseconds,
addMonths, addMonths,
addYears, addYears,
differenceInDays,
endOfDay, endOfDay,
format, format,
isAfter, isAfter,
@ -17,11 +17,12 @@ import {
max, max,
min min
} from 'date-fns'; } from 'date-fns';
import { flatten, isNumber } from 'lodash'; import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface'; import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface'; import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface'; import { TimelinePeriod } from './interfaces/timeline-period.interface';
import { import {
@ -32,22 +33,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface'; import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator { export class PortfolioCalculator {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = false;
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
public constructor( public constructor({
private currentRateService: CurrentRateService, currency,
private currency: string currentRateService,
) {} orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
public computeTransactionPoints(orders: PortfolioOrder[]) { this.orders.sort((a, b) => a.date.localeCompare(b.date));
orders.sort((a, b) => a.date.localeCompare(b.date)); }
public computeTransactionPoints() {
this.transactionPoints = []; this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {}; const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null; let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const order of orders) { for (const order of this.orders) {
const currentDate = order.date; const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
@ -59,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity const newQuantity = order.quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
} else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice)
);
}
}
currentTransactionPointItem = { currentTransactionPointItem = {
investment,
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee), fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity, quantity: newQuantity,
symbol: order.symbol, symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1
@ -140,7 +171,6 @@ export class PortfolioCalculator {
hasErrors: false, hasErrors: false,
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0), netPerformance: new Big(0),
netPerformancePercentage: new Big(0), netPerformancePercentage: new Big(0),
positions: [], positions: [],
@ -195,6 +225,7 @@ export class PortfolioCalculator {
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
} = {}; } = {};
for (const marketSymbol of marketSymbols) { for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT); const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) { if (!marketSymbolMap[date]) {
@ -207,108 +238,37 @@ export class PortfolioCalculator {
} }
} }
let hasErrors = false;
const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {};
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
const netPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT); const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) { if (firstIndex > 0) {
firstIndex--; firstIndex--;
} }
const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {};
const lastFees: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {}; const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
const currentDate =
i === firstIndex ? startString : this.transactionPoints[i].date;
const nextDate =
i + 1 < this.transactionPoints.length
? this.transactionPoints[i + 1].date
: todayString;
const items = this.transactionPoints[i].items;
for (const item of items) {
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(`Missing value for symbol ${item.symbol} at ${nextDate}`);
continue;
}
let lastInvestment: Big = new Big(0);
let lastQuantity: Big = item.quantity;
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
lastQuantity = lastQuantities[item.symbol];
}
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
let initialValue = itemValue?.mul(lastQuantity);
let investedValue = itemValue?.mul(item.quantity);
const isFirstOrderAndIsStartBeforeCurrentDate =
i === firstIndex &&
isBefore(parseDate(this.transactionPoints[i].date), start);
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
const fee = isFirstOrderAndIsStartBeforeCurrentDate
? new Big(0)
: item.fee.minus(lastFee);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment;
investedValue = item.investment;
}
if (i === firstIndex || !initialValues[item.symbol]) {
initialValues[item.symbol] = initialValue;
}
if (!item.quantity.eq(0)) {
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] = (
holdingPeriodReturns[item.symbol] ?? new Big(1)
).mul(holdingPeriodReturn);
grossPerformance[item.symbol] = (
grossPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue));
const netHoldingPeriodReturn = endValue.div(
initialValue.plus(cashFlow).plus(fee)
);
netHoldingPeriodReturns[item.symbol] = (
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
).mul(netHoldingPeriodReturn);
netPerformance[item.symbol] = (
netPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue).minus(fee));
}
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
lastFees[item.symbol] = item.fee;
}
}
const positions: TimelinePosition[] = []; const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol]; const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
const {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
marketSymbolMap,
start,
symbol: item.symbol
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({ positions.push({
averagePrice: item.quantity.eq(0) averagePrice: item.quantity.eq(0)
? new Big(0) ? new Big(0)
@ -316,31 +276,33 @@ export class PortfolioCalculator {
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: isValid grossPerformance: !hasErrors ? grossPerformance ?? null : null,
? grossPerformance[item.symbol] ?? null grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null, : null,
grossPerformancePercentage:
isValid && holdingPeriodReturns[item.symbol]
? holdingPeriodReturns[item.symbol].minus(1)
: null,
investment: item.investment, investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null, marketPrice: marketValue?.toNumber() ?? null,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null, netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: netPerformancePercentage: !hasErrors
isValid && netHoldingPeriodReturns[item.symbol] ? netPerformancePercentage ?? null
? netHoldingPeriodReturns[item.symbol].minus(1) : null,
: null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });
if (hasErrors) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
} }
const overall = this.calculateOverallPerformance(positions, initialValues); const overall = this.calculateOverallPerformance(positions, initialValues);
return { return {
...overall, ...overall,
errors,
positions, positions,
hasErrors: hasErrors || overall.hasErrors hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
}; };
} }
@ -458,20 +420,16 @@ export class PortfolioCalculator {
private calculateOverallPerformance( private calculateOverallPerformance(
positions: TimelinePosition[], positions: TimelinePosition[],
initialValues: { [p: string]: Big } initialValues: { [symbol: string]: Big }
) { ) {
let hasErrors = false;
let currentValue = new Big(0); let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0); let grossPerformancePercentage = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0); let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0); let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0); let sumOfWeights = new Big(0);
let netAnnualizedPerformance = new Big(0); let totalInvestment = new Big(0);
// use Date.now() to use the mock for today
const today = new Date(Date.now());
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.marketPrice) { if (currentPosition.marketPrice) {
@ -481,52 +439,50 @@ export class PortfolioCalculator {
} else { } else {
hasErrors = true; hasErrors = true;
} }
totalInvestment = totalInvestment.plus(currentPosition.investment); totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) { if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus( grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance currentPosition.grossPerformance
); );
netPerformance = netPerformance.plus(currentPosition.netPerformance); netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
hasErrors = true; hasErrors = true;
} }
if ( if (currentPosition.grossPerformancePercentage) {
currentPosition.grossPerformancePercentage && // Use the average from the initial value and the current investment as
initialValues[currentPosition.symbol] // a weight
) { const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
const currentInitialValue = initialValues[currentPosition.symbol]; .plus(currentPosition.investment)
completeInitialValue = completeInitialValue.plus(currentInitialValue); .div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus( grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue) currentPosition.grossPerformancePercentage.mul(weight)
);
netAnnualizedPerformance = netAnnualizedPerformance.plus(
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
today,
parseDate(currentPosition.firstBuyDate)
),
netPerformancePercent: currentPosition.netPerformancePercentage
}).mul(currentInitialValue)
); );
netPerformancePercentage = netPerformancePercentage.plus( netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue) currentPosition.netPerformancePercentage.mul(weight)
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}` `Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
'PortfolioCalculator'
); );
hasErrors = true; hasErrors = true;
} }
} }
if (!completeInitialValue.eq(0)) { if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
grossPerformancePercentage.div(completeInitialValue); netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = } else {
netPerformancePercentage.div(completeInitialValue); grossPerformancePercentage = new Big(0);
netAnnualizedPerformance = netPerformancePercentage = new Big(0);
netAnnualizedPerformance.div(completeInitialValue);
} }
return { return {
@ -534,7 +490,6 @@ export class PortfolioCalculator {
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
hasErrors, hasErrors,
netAnnualizedPerformance,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
totalInvestment totalInvestment
@ -581,7 +536,8 @@ export class PortfolioCalculator {
} catch (error) { } catch (error) {
Logger.error( Logger.error(
`Failed to fetch info for date ${startDate} with exception`, `Failed to fetch info for date ${startDate} with exception`,
error error,
'PortfolioCalculator'
); );
return null; return null;
} }
@ -687,6 +643,356 @@ export class PortfolioCalculator {
} }
} }
private getSymbolMetrics({
marketSymbolMap,
start,
symbol
}: {
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
return order.symbol === symbol;
});
if (orders.length <= 0) {
return {
hasErrors: false,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
hasErrors: true,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0);
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
let valueAtStartDate: Big;
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtStartDate
});
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtEndDate
});
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
let sortIndex = new Date(order.date);
if (order.itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex((order) => {
return order.itemType === 'start';
});
const indexOfEndOrder = orders.findIndex((order) => {
return order.itemType === 'end';
});
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
// Calculate the average start price as soon as any units are held
if (
averagePriceAtStartDate.eq(0) &&
i >= indexOfStartOrder &&
totalUnits.gt(0)
) {
averagePriceAtStartDate = totalInvestment.div(totalUnits);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPrice
);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
}
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
maxTotalInvestment = totalInvestment;
}
if (i === indexOfEndOrder && totalUnits.gt(0)) {
averagePriceAtEndDate = totalInvestment.div(totalUnits);
}
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
}
}
fees = fees.plus(order.fee);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
const valueOfInvestment = totalUnits.mul(order.unitPrice);
const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
totalInvestmentWithGrossPerformanceFromSell =
totalInvestmentWithGrossPerformanceFromSell
.plus(transactionInvestment)
.plus(grossPerformanceFromSell);
lastAveragePrice = totalUnits.eq(0)
? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestmentWithGrossPerformanceFromSell)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
}
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate)
);
const grossPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const netPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Average price: ${averagePriceAtStartDate.toFixed(
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
);
}
return {
initialValue,
grossPerformancePercentage,
netPerformancePercentage,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance
};
}
private isNextItemActive( private isNextItemActive(
timelineSpecification: TimelineSpecification[], timelineSpecification: TimelineSpecification[],
currentDate: Date, currentDate: Date,

View File

@ -1,25 +0,0 @@
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
@Injectable()
export class PortfolioServiceStrategy {
public constructor(
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceNew: PortfolioServiceNew,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public get() {
if (
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
) {
return this.portfolioServiceNew;
}
return this.portfolioService;
}
}

View File

@ -14,7 +14,7 @@ import {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformance, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary PortfolioSummary
@ -33,11 +33,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { ViewMode } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioServiceStrategy } from './portfolio-service.strategy'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
@ -45,7 +46,7 @@ export class PortfolioController {
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -56,9 +57,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<PortfolioChart> { ): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioServiceStrategy const historicalDataContainer = await this.portfolioService.getChart(
.get() impersonationId,
.getChart(impersonationId, range); range
);
let chartData = historicalDataContainer.items; let chartData = historicalDataContainer.items;
@ -105,22 +107,14 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let hasError = false; let hasError = false;
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioServiceStrategy await this.portfolioService.getDetails(
.get() impersonationId,
.getDetails(impersonationId, this.request.user.id, range); this.request.user.id,
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true; hasError = true;
@ -161,7 +155,11 @@ export class PortfolioController {
} }
} }
return { accounts, hasError, holdings }; const isBasicUser =
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic';
return { accounts, hasError, holdings: isBasicUser ? {} : holdings };
} }
@Get('investments') @Get('investments')
@ -179,9 +177,9 @@ export class PortfolioController {
); );
} }
let investments = await this.portfolioServiceStrategy let investments = await this.portfolioService.getInvestments(
.get() impersonationId
.getInvestments(impersonationId); );
if ( if (
impersonationId || impersonationId ||
@ -203,16 +201,19 @@ export class PortfolioController {
@Get('performance') @Get('performance')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPerformance( public async getPerformance(
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> { ): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioServiceStrategy const performanceInformation = await this.portfolioService.getPerformance(
.get() impersonationId,
.getPerformance(impersonationId, range); range
);
if ( if (
impersonationId || impersonationId ||
this.request.user.Settings.viewMode === ViewMode.ZEN ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
performanceInformation.performance = nullifyValuesInObject( performanceInformation.performance = nullifyValuesInObject(
@ -231,9 +232,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
const result = await this.portfolioServiceStrategy const result = await this.portfolioService.getPositions(
.get() impersonationId,
.getPositions(impersonationId, range); range
);
if ( if (
impersonationId || impersonationId ||
@ -273,9 +275,10 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium'; hasDetails = user.subscription.type === 'Premium';
} }
const { holdings } = await this.portfolioServiceStrategy const { holdings } = await this.portfolioService.getDetails(
.get() access.userId,
.getDetails(access.userId, access.userId); access.userId
);
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails, hasDetails,
@ -301,6 +304,7 @@ export class PortfolioController {
allocationCurrent: portfolioPosition.allocationCurrent, allocationCurrent: portfolioPosition.allocationCurrent,
countries: hasDetails ? portfolioPosition.countries : [], countries: hasDetails ? portfolioPosition.countries : [],
currency: portfolioPosition.currency, currency: portfolioPosition.currency,
markets: portfolioPosition.markets,
name: portfolioPosition.name, name: portfolioPosition.name,
sectors: hasDetails ? portfolioPosition.sectors : [], sectors: hasDetails ? portfolioPosition.sectors : [],
value: portfolioPosition.value / totalValue value: portfolioPosition.value / totalValue
@ -316,9 +320,17 @@ export class PortfolioController {
public async getSummary( public async getSummary(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> { ): Promise<PortfolioSummary> {
let summary = await this.portfolioServiceStrategy if (
.get() this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
.getSummary(impersonationId); this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let summary = await this.portfolioService.getSummary(impersonationId);
if ( if (
impersonationId || impersonationId ||
@ -331,6 +343,7 @@ export class PortfolioController {
'currentNetPerformance', 'currentNetPerformance',
'currentValue', 'currentValue',
'dividend', 'dividend',
'emergencyFund',
'fees', 'fees',
'items', 'items',
'netWorth', 'netWorth',
@ -351,9 +364,11 @@ export class PortfolioController {
@Param('dataSource') dataSource, @Param('dataSource') dataSource,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
let position = await this.portfolioServiceStrategy let position = await this.portfolioService.getPosition(
.get() dataSource,
.getPosition(dataSource, impersonationId, symbol); impersonationId,
symbol
);
if (position) { if (position) {
if ( if (
@ -394,6 +409,6 @@ export class PortfolioController {
); );
} }
return await this.portfolioServiceStrategy.get().getReport(impersonationId); return await this.portfolioService.getReport(impersonationId);
} }
} }

View File

@ -13,14 +13,13 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
@Module({ @Module({
exports: [PortfolioServiceStrategy], controllers: [PortfolioController],
exports: [PortfolioService],
imports: [ imports: [
AccessModule, AccessModule,
ConfigurationModule, ConfigurationModule,
@ -34,13 +33,10 @@ import { RulesService } from './rules.service';
SymbolProfileModule, SymbolProfileModule,
UserModule UserModule
], ],
controllers: [PortfolioController],
providers: [ providers: [
AccountService, AccountService,
CurrentRateService, CurrentRateService,
PortfolioService, PortfolioService,
PortfolioServiceNew,
PortfolioServiceStrategy,
RulesService RulesService
] ]
}) })

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,8 @@ 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 { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface'; import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator'; import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service';
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 { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment'; import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-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';
@ -20,12 +21,16 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config'; import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY,
baseCurrency
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
Accounts, Accounts,
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformanceResponse,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
Position, Position,
@ -35,6 +40,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type { import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -43,6 +49,7 @@ import { REQUEST } from '@nestjs/core';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client'; import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays,
endOfToday, endOfToday,
format, format,
isAfter, isAfter,
@ -62,8 +69,12 @@ import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioCalculator } from './portfolio-calculator';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
public constructor( public constructor(
@ -75,7 +86,8 @@ export class PortfolioService {
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService, private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService
) {} ) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> { public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
@ -99,15 +111,22 @@ export class PortfolioService {
} }
} }
const valueInBaseCurrency = details.accounts[account.id]?.current ?? 0;
const result = { const result = {
...account, ...account,
transactionCount, transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency( valueInBaseCurrency,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance, account.balance,
account.currency, account.currency,
userCurrency userCurrency
), ),
value: details.accounts[account.id]?.current ?? 0 value: this.exchangeRateDataService.toCurrency(
valueInBaseCurrency,
userCurrency,
account.currency
)
}; };
delete result.Order; delete result.Order;
@ -118,17 +137,26 @@ export class PortfolioService {
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> { public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId); const accounts = await this.getAccounts(aUserId);
let totalBalance = 0; let totalBalanceInBaseCurrency = new Big(0);
let totalValue = 0; let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0; let transactionCount = 0;
for (const account of accounts) { for (const account of accounts) {
totalBalance += account.convertedBalance; totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
totalValue += account.value; account.balanceInBaseCurrency
);
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount; transactionCount += account.transactionCount;
} }
return { accounts, totalBalance, totalValue, transactionCount }; return {
accounts,
transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
};
} }
public async getInvestments( public async getInvestments(
@ -136,15 +164,18 @@ export class PortfolioService {
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency userId,
); includeDrafts: true
});
const { transactionPoints } = await this.getTransactionPoints({ const portfolioCalculator = new PortfolioCalculator({
userId, currency: this.request.user.Settings.currency,
includeDrafts: true currentRateService: this.currentRateService,
orders: portfolioOrders
}); });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return [];
@ -185,12 +216,17 @@ export class PortfolioService {
): Promise<HistoricalDataContainer> { ): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency userId
); });
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return { return {
@ -270,24 +306,32 @@ export class PortfolioService {
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<PortfolioDetails & { hasErrors: boolean }> { ): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId); const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId });
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const emergencyFund = new Big(
const portfolioCalculator = new PortfolioCalculator( (user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
this.currentRateService,
userCurrency
); );
const userCurrency =
this.request.user?.Settings?.currency ??
user.Settings?.currency ??
baseCurrency;
const { orders, transactionPoints } = await this.getTransactionPoints({ const { orders, portfolioOrders, transactionPoints } =
userId await this.getTransactionPoints({
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
}); });
if (transactionPoints?.length <= 0) {
return { accounts: {}, holdings: {}, hasErrors: false };
}
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const startDate = this.getStartDate(aDateRange, portfolioStart); const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate startDate
@ -300,9 +344,11 @@ export class PortfolioService {
const holdings: PortfolioDetails['holdings'] = {}; const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus( const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency
); );
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => { const dataGatheringItems = currentPositions.positions.map((position) => {
return { return {
@ -315,7 +361,7 @@ export class PortfolioService {
); );
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItems), this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -338,7 +384,31 @@ export class PortfolioService {
const value = item.quantity.mul(item.marketPrice); const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
const markets: { [key in Market]: number } = {
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
holdings[item.symbol] = { holdings[item.symbol] = {
markets,
allocationCurrent: value.div(totalValue).toNumber(), allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(), allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
@ -346,7 +416,6 @@ export class PortfolioService {
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
dataSource: symbolProfile.dataSource, dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0, grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0, item.grossPerformancePercentage?.toNumber() ?? 0,
@ -366,6 +435,7 @@ export class PortfolioService {
const cashPositions = await this.getCashPositions({ const cashPositions = await this.getCashPositions({
cashDetails, cashDetails,
emergencyFund,
userCurrency, userCurrency,
investment: totalInvestment, investment: totalInvestment,
value: totalValue value: totalValue
@ -423,7 +493,7 @@ export class PortfolioService {
}; };
} }
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([ const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol aSymbol
]); ]);
@ -433,22 +503,24 @@ export class PortfolioService {
return order.type === 'BUY' || order.type === 'SELL'; return order.type === 'BUY' || order.type === 'SELL';
}) })
.map((order) => ({ .map((order) => ({
currency: order.currency, currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource, dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee), fee: new Big(order.fee),
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.SymbolProfile.symbol,
type: order.type, type: order.type,
unitPrice: new Big(order.unitPrice) unitPrice: new Big(order.unitPrice)
})); }));
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator({
this.currentRateService, currency: positionCurrency,
positionCurrency currentRateService: this.currentRateService,
); orders: portfolioOrders
portfolioCalculator.computeTransactionPoints(portfolioOrders); });
portfolioCalculator.computeTransactionPoints();
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
@ -552,9 +624,10 @@ export class PortfolioService {
SymbolProfile, SymbolProfile,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent:
position.grossPerformancePercentage?.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(), netPerformancePercent: position.netPerformancePercentage?.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
value: this.exchangeRateDataService.toCurrency( value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(), quantity.mul(marketPrice).toNumber(),
@ -563,7 +636,7 @@ export class PortfolioService {
) )
}; };
} else { } else {
const currentData = await this.dataProviderService.get([ const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol } { dataSource: DataSource.YAHOO, symbol: aSymbol }
]); ]);
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
@ -626,12 +699,16 @@ export class PortfolioService {
): Promise<{ hasErrors: boolean; positions: Position[] }> { ): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency userId
); });
const { transactionPoints } = await this.getTransactionPoints({ userId }); const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
@ -660,7 +737,7 @@ export class PortfolioService {
const symbols = positions.map((position) => position.symbol); const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(dataGatheringItem), this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -696,21 +773,24 @@ export class PortfolioService {
public async getPerformance( public async getPerformance(
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> { ): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency userId
); });
const { transactionPoints } = await this.getTransactionPoints({ userId }); const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
hasErrors: false, hasErrors: false,
performance: { performance: {
annualizedPerformancePercent: 0,
currentGrossPerformance: 0, currentGrossPerformance: 0,
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0, currentNetPerformance: 0,
@ -729,26 +809,34 @@ export class PortfolioService {
); );
const hasErrors = currentPositions.hasErrors; const hasErrors = currentPositions.hasErrors;
const annualizedPerformancePercent =
currentPositions.netAnnualizedPerformance.toNumber();
const currentValue = currentPositions.currentValue.toNumber(); const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = const currentGrossPerformance = currentPositions.grossPerformance;
currentPositions.grossPerformance.toNumber(); let currentGrossPerformancePercent =
const currentGrossPerformancePercent = currentPositions.grossPerformancePercentage;
currentPositions.grossPerformancePercentage.toNumber(); const currentNetPerformance = currentPositions.netPerformance;
const currentNetPerformance = currentPositions.netPerformance.toNumber(); let currentNetPerformancePercent =
const currentNetPerformancePercent = currentPositions.netPerformancePercentage;
currentPositions.netPerformancePercentage.toNumber();
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
}
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
}
return { return {
errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors, hasErrors: currentPositions.hasErrors || hasErrors,
performance: { performance: {
annualizedPerformancePercent, currentValue,
currentGrossPerformance, currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent, currentGrossPerformancePercent:
currentNetPerformance, currentGrossPerformancePercent.toNumber(),
currentNetPerformancePercent, currentNetPerformance: currentNetPerformance.toNumber(),
currentValue currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
} }
}; };
} }
@ -757,9 +845,10 @@ export class PortfolioService {
const currency = this.request.user.Settings.currency; const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { orders, transactionPoints } = await this.getTransactionPoints({ const { orders, portfolioOrders, transactionPoints } =
userId await this.getTransactionPoints({
}); userId
});
if (isEmpty(orders)) { if (isEmpty(orders)) {
return { return {
@ -767,10 +856,12 @@ export class PortfolioService {
}; };
} }
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator({
this.currentRateService, currency,
currency currentRateService: this.currentRateService,
); orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
@ -845,10 +936,11 @@ export class PortfolioService {
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> { public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const user = await this.userService.user({ id: userId });
const performanceInformation = await this.getPerformance(aImpersonationId); const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails( const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
userId, userId,
userCurrency userCurrency
); );
@ -857,6 +949,9 @@ export class PortfolioService {
userId userId
}); });
const dividend = this.getDividend(orders).toNumber(); const dividend = this.getDividend(orders).toNumber();
const emergencyFund = new Big(
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const fees = this.getFees(orders).toNumber(); const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber(); const items = this.getItems(orders).toNumber();
@ -864,15 +959,33 @@ export class PortfolioService {
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY'); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL'); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
const committedFunds = new Big(totalBuy).minus(totalSell); const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balance) const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue) .plus(performanceInformation.performance.currentValue)
.plus(items) .plus(items)
.toNumber(); .toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: []
})
.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercent
)
})
?.toNumber();
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
annualizedPerformancePercent,
cash,
dividend, dividend,
fees, fees,
firstOrderDate, firstOrderDate,
@ -880,10 +993,8 @@ export class PortfolioService {
netWorth, netWorth,
totalBuy, totalBuy,
totalSell, totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balance,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => { ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL'; return order.type === 'BUY' || order.type === 'SELL';
}).length }).length
@ -892,16 +1003,18 @@ export class PortfolioService {
private async getCashPositions({ private async getCashPositions({
cashDetails, cashDetails,
emergencyFund,
investment, investment,
userCurrency, userCurrency,
value value
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
emergencyFund: Big;
investment: Big; investment: Big;
userCurrency: string;
value: Big; value: Big;
userCurrency: string;
}) { }) {
const cashPositions = {}; const cashPositions: PortfolioDetails['holdings'] = {};
for (const account of cashDetails.accounts) { for (const account of cashDetails.accounts) {
const convertedBalance = this.exchangeRateDataService.toCurrency( const convertedBalance = this.exchangeRateDataService.toCurrency(
@ -925,6 +1038,7 @@ export class PortfolioService {
assetSubClass: AssetClass.CASH, assetSubClass: AssetClass.CASH,
countries: [], countries: [],
currency: account.currency, currency: account.currency,
dataSource: undefined,
grossPerformance: 0, grossPerformance: 0,
grossPerformancePercent: 0, grossPerformancePercent: 0,
investment: convertedBalance, investment: convertedBalance,
@ -942,6 +1056,28 @@ export class PortfolioService {
} }
} }
if (emergencyFund.gt(0)) {
cashPositions[ASSET_SUB_CLASS_EMERGENCY_FUND] = {
...cashPositions[userCurrency],
assetSubClass: ASSET_SUB_CLASS_EMERGENCY_FUND,
investment: emergencyFund.toNumber(),
name: ASSET_SUB_CLASS_EMERGENCY_FUND,
symbol: ASSET_SUB_CLASS_EMERGENCY_FUND,
value: emergencyFund.toNumber()
};
cashPositions[userCurrency].investment = new Big(
cashPositions[userCurrency].investment
)
.minus(emergencyFund)
.toNumber();
cashPositions[userCurrency].value = new Big(
cashPositions[userCurrency].value
)
.minus(emergencyFund)
.toNumber();
}
for (const symbol of Object.keys(cashPositions)) { for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency // Calculate allocations for each currency
cashPositions[symbol].allocationCurrent = new Big( cashPositions[symbol].allocationCurrent = new Big(
@ -971,7 +1107,7 @@ export class PortfolioService {
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency, order.SymbolProfile.currency,
this.request.user.Settings.currency this.request.user.Settings.currency
); );
}) })
@ -990,7 +1126,7 @@ export class PortfolioService {
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.currency, order.SymbolProfile.currency,
this.request.user.Settings.currency this.request.user.Settings.currency
); );
}) })
@ -1012,7 +1148,7 @@ export class PortfolioService {
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(), new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency, order.SymbolProfile.currency,
this.request.user.Settings.currency this.request.user.Settings.currency
); );
}) })
@ -1049,6 +1185,7 @@ export class PortfolioService {
}): Promise<{ }): Promise<{
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
@ -1060,41 +1197,45 @@ export class PortfolioService {
}); });
if (orders.length <= 0) { if (orders.length <= 0) {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [], portfolioOrders: [] };
} }
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource, dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big( fee: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
order.fee, order.fee,
order.currency, order.SymbolProfile.currency,
userCurrency userCurrency
) )
), ),
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.SymbolProfile.symbol,
type: order.type, type: order.type,
unitPrice: new Big( unitPrice: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
order.unitPrice, order.unitPrice,
order.currency, order.SymbolProfile.currency,
userCurrency userCurrency
) )
) )
})); }));
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator({
this.currentRateService, currency: userCurrency,
userCurrency currentRateService: this.currentRateService,
); orders: portfolioOrders
portfolioCalculator.computeTransactionPoints(portfolioOrders); });
portfolioCalculator.computeTransactionPoints();
return { return {
transactionPoints: portfolioCalculator.getTransactionPoints(), transactionPoints: portfolioCalculator.getTransactionPoints(),
orders orders,
portfolioOrders
}; };
} }
@ -1113,22 +1254,18 @@ export class PortfolioService {
return accountId === account.id; return accountId === account.id;
}); });
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.id] = { accounts[account.id] = {
balance: convertedBalance, balance: account.balance,
currency: account.currency, currency: account.currency,
current: convertedBalance, current: account.balance,
name: account.name, name: account.name,
original: convertedBalance original: account.balance
}; };
for (const order of ordersByAccount) { for (const order of ordersByAccount) {
let currentValueOfSymbol = let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice; order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice; let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') { if (order.type === 'SELL') {
@ -1178,7 +1315,7 @@ export class PortfolioService {
.map((order) => { .map((order) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice, order.quantity * order.unitPrice,
order.currency, order.SymbolProfile.currency,
currency currency
); );
}) })

View File

@ -1,3 +1,4 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CacheModule, Module } from '@nestjs/common'; import { CacheModule, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
@ -17,9 +18,10 @@ import { RedisCacheService } from './redis-cache.service';
store: redisStore, store: redisStore,
ttl: configurationService.get('CACHE_TTL') ttl: configurationService.get('CACHE_TTL')
}) })
}) }),
ConfigurationModule
], ],
providers: [ConfigurationService, RedisCacheService], providers: [RedisCacheService],
exports: [RedisCacheService] exports: [RedisCacheService]
}) })
export class RedisCacheModule {} export class RedisCacheModule {}

View File

@ -46,22 +46,25 @@ export class SubscriptionController {
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ?? ((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[]; [];
const isValid = coupons.some((coupon) => { const coupon = coupons.find((currentCoupon) => {
return coupon.code === couponCode; return currentCoupon.code === couponCode;
}); });
if (!isValid) { if (coupon === undefined) {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST StatusCodes.BAD_REQUEST
); );
} }
await this.subscriptionService.createSubscription(this.request.user.id); await this.subscriptionService.createSubscription({
duration: coupon.duration,
userId: this.request.user.id
});
// Destroy coupon // Destroy coupon
coupons = coupons.filter((coupon) => { coupons = coupons.filter((currentCoupon) => {
return coupon.code !== couponCode; return currentCoupon.code !== couponCode;
}); });
await this.propertyService.put({ await this.propertyService.put({
key: PROPERTY_COUPONS, key: PROPERTY_COUPONS,
@ -69,7 +72,8 @@ export class SubscriptionController {
}); });
Logger.log( Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon` `Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`,
'SubscriptionController'
); );
return { return {
@ -84,7 +88,10 @@ export class SubscriptionController {
req.query.checkoutSessionId req.query.checkoutSessionId
); );
Logger.log(`Subscription for user '${userId}' has been created via Stripe`); Logger.log(
`Subscription for user '${userId}' has been created via Stripe`,
'SubscriptionController'
);
res.redirect(`${this.configurationService.get('ROOT_URL')}/account`); res.redirect(`${this.configurationService.get('ROOT_URL')}/account`);
} }
@ -101,7 +108,7 @@ export class SubscriptionController {
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'SubscriptionController');
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST), getReasonPhrase(StatusCodes.BAD_REQUEST),

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,9 +7,9 @@ import { SubscriptionController } from './subscription.controller';
import { SubscriptionService } from './subscription.service'; import { SubscriptionService } from './subscription.service';
@Module({ @Module({
imports: [PropertyModule],
controllers: [SubscriptionController], controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService], exports: [SubscriptionService],
exports: [SubscriptionService] imports: [ConfigurationModule, PrismaModule, PropertyModule],
providers: [SubscriptionService]
}) })
export class SubscriptionModule {} export class SubscriptionModule {}

View File

@ -2,8 +2,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Subscription, User } from '@prisma/client'; import { Subscription } from '@prisma/client';
import { addDays, isBefore } from 'date-fns'; import { addMilliseconds, isBefore } from 'date-fns';
import ms, { StringValue } from 'ms';
import Stripe from 'stripe'; import Stripe from 'stripe';
@Injectable() @Injectable()
@ -44,7 +45,7 @@ export class SubscriptionService {
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: `${this.configurationService.get( success_url: `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}` )}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
}; };
if (couponId) { if (couponId) {
@ -64,13 +65,19 @@ export class SubscriptionService {
}; };
} }
public async createSubscription(aUserId: string) { public async createSubscription({
duration = '1 year',
userId
}: {
duration?: StringValue;
userId: string;
}) {
await this.prismaService.subscription.create({ await this.prismaService.subscription.create({
data: { data: {
expiresAt: addDays(new Date(), 365), expiresAt: addMilliseconds(new Date(), ms(duration)),
User: { User: {
connect: { connect: {
id: aUserId id: userId
} }
} }
} }
@ -83,7 +90,7 @@ export class SubscriptionService {
aCheckoutSessionId aCheckoutSessionId
); );
await this.createSubscription(session.client_reference_id); await this.createSubscription({ userId: session.client_reference_id });
await this.stripe.customers.update(session.customer as string, { await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id description: session.client_reference_id
@ -91,7 +98,7 @@ export class SubscriptionService {
return session.client_reference_id; return session.client_reference_id;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'SubscriptionService');
} }
} }

View File

@ -27,8 +27,10 @@ export class SymbolService {
dataGatheringItem: IDataGatheringItem; dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: number; includeHistoricalData?: number;
}): Promise<SymbolItem> { }): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]); const quotes = await this.dataProviderService.getQuotes([
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; dataGatheringItem
]);
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[] = []; let historicalData: HistoricalDataItem[] = [];
@ -93,7 +95,7 @@ export class SymbolService {
results.items = items; results.items = items;
return results; return results;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'SymbolService');
throw error; throw error;
} }

View File

@ -1,3 +1,5 @@
export interface UserSettings { export interface UserSettings {
emergencyFund?: number;
locale?: string;
isRestrictedView?: boolean; isRestrictedView?: boolean;
} }

View File

@ -1,11 +1,15 @@
import { IsBoolean, IsOptional } from 'class-validator'; import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsBoolean() @IsNumber()
@IsOptional() @IsOptional()
isNewCalculationEngine?: boolean; emergencyFund?: number;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isRestrictedView?: boolean; isRestrictedView?: boolean;
@IsString()
@IsOptional()
locale?: string;
} }

View File

@ -2,17 +2,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -23,7 +20,6 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Provider, Role } from '@prisma/client';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -64,8 +60,13 @@ export class UserController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> { public async getUser(
return this.userService.getUser(this.request.user); @Headers('accept-language') acceptLanguage: string
): Promise<User> {
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]
);
} }
@Post() @Post()
@ -119,7 +120,7 @@ export class UserController {
}; };
for (const key in userSettings) { for (const key in userSettings) {
if (userSettings[key] === false) { if (userSettings[key] === false || userSettings[key] === null) {
delete userSettings[key]; delete userSettings[key];
} }
} }

View File

@ -1,6 +1,6 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module'; import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -9,16 +9,18 @@ import { UserController } from './user.controller';
import { UserService } from './user.service'; import { UserService } from './user.service';
@Module({ @Module({
controllers: [UserController],
exports: [UserService],
imports: [ imports: [
ConfigurationModule,
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}), }),
PrismaModule,
PropertyModule, PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [UserController], providers: [UserService]
providers: [ConfigurationService, PrismaService, UserService],
exports: [UserService]
}) })
export class UserModule {} export class UserModule {}

View File

@ -15,7 +15,7 @@ import {
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Provider, Role, User, ViewMode } from '@prisma/client'; import { Prisma, Role, User, ViewMode } from '@prisma/client';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface'; import { UserSettings } from './interfaces/user-settings.interface';
@ -33,14 +33,17 @@ export class UserService {
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
public async getUser({ public async getUser(
Account, {
alias, Account,
id, alias,
permissions, id,
Settings, permissions,
subscription Settings,
}: UserWithSettings): Promise<IUser> { subscription
}: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({ const access = await this.prismaService.access.findMany({
include: { include: {
User: true User: true
@ -63,8 +66,8 @@ export class UserService {
accounts: Account, accounts: Account,
settings: { settings: {
...(<UserSettings>Settings.settings), ...(<UserSettings>Settings.settings),
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
} }
}; };
@ -144,13 +147,6 @@ export class UserService {
user.subscription = this.subscriptionService.getSubscription( user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription userFromDatabase?.Subscription
); );
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
} }
return user; return user;

View File

@ -0,0 +1,26 @@
[
"AT",
"AU",
"BE",
"CA",
"CH",
"DE",
"DK",
"ES",
"FI",
"FR",
"GB",
"HK",
"IE",
"IL",
"IT",
"JP",
"LU",
"NL",
"NO",
"NZ",
"PT",
"SE",
"SG",
"US"
]

View File

@ -0,0 +1,28 @@
[
"AE",
"BR",
"CL",
"CN",
"CO",
"CY",
"CZ",
"EG",
"GR",
"HK",
"HU",
"ID",
"IN",
"KR",
"KW",
"MX",
"MY",
"PE",
"PH",
"PL",
"QA",
"SA",
"TH",
"TR",
"TW",
"ZA"
]

View File

@ -32,7 +32,6 @@ export class TransformDataSourceInResponseInterceptor<T>
activity.SymbolProfile.dataSource = encodeDataSource( activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource activity.SymbolProfile.dataSource
); );
activity.dataSource = encodeDataSource(activity.dataSource);
return activity; return activity;
}); });
} }
@ -41,6 +40,14 @@ export class TransformDataSourceInResponseInterceptor<T>
data.dataSource = encodeDataSource(data.dataSource); data.dataSource = encodeDataSource(data.dataSource);
} }
if (data.errors) {
for (const error of data.errors) {
if (error.dataSource) {
error.dataSource = encodeDataSource(error.dataSource);
}
}
}
if (data.holdings) { if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) { for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) { if (data.holdings[symbol].dataSource) {
@ -58,13 +65,6 @@ export class TransformDataSourceInResponseInterceptor<T>
}); });
} }
if (data.orders) {
data.orders.map((order) => {
order.dataSource = encodeDataSource(order.dataSource);
return order;
});
}
if (data.positions) { if (data.positions) {
data.positions.map((position) => { data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource); position.dataSource = encodeDataSource(position.dataSource);

View File

@ -1,4 +1,4 @@
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
@ -7,8 +7,11 @@ import { environment } from './environments/environment';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors(); app.enableCors();
const globalPrefix = 'api'; app.enableVersioning({
app.setGlobalPrefix(globalPrefix); defaultVersion: '1',
type: VersioningType.URI
});
app.setGlobalPrefix('api');
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,

View File

@ -1,10 +1,10 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition PortfolioPosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces'; import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,6 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -23,8 +23,8 @@ export class CronService {
await this.exchangeRateDataService.loadCurrencies(); await this.exchangeRateDataService.loadCurrencies();
} }
@Cron(CronExpression.EVERY_DAY_AT_6PM) @Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtSixPM() { public async runEveryDayAtFivePM() {
this.twitterBotService.tweetFearAndGreedIndex(); this.twitterBotService.tweetFearAndGreedIndex();
} }

View File

@ -4,8 +4,10 @@
"ATOM": "Cosmos", "ATOM": "Cosmos",
"AVAX": "Avalanche", "AVAX": "Avalanche",
"DOT": "Polkadot", "DOT": "Polkadot",
"LUNA1": "Terra",
"MATIC": "Polygon", "MATIC": "Polygon",
"MINA": "Mina Protocol", "MINA": "Mina Protocol",
"RUNE": "THORChain",
"SHIB": "Shiba Inu", "SHIB": "Shiba Inu",
"SOL": "Solana", "SOL": "Solana",
"UNI3": "Uniswap" "UNI3": "Uniswap"

View File

@ -4,6 +4,7 @@ import {
PROPERTY_LOCKED_DATA_GATHERING PROPERTY_LOCKED_DATA_GATHERING
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
@ -39,7 +40,7 @@ export class DataGatheringService {
const isDataGatheringNeeded = await this.isDataGatheringNeeded(); const isDataGatheringNeeded = await this.isDataGatheringNeeded();
if (isDataGatheringNeeded) { if (isDataGatheringNeeded) {
Logger.log('7d data gathering has been started.'); Logger.log('7d data gathering has been started.', 'DataGatheringService');
console.time('data-gathering-7d'); console.time('data-gathering-7d');
await this.prismaService.property.create({ await this.prismaService.property.create({
@ -63,7 +64,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'DataGatheringService');
} }
await this.prismaService.property.delete({ await this.prismaService.property.delete({
@ -72,7 +73,10 @@ export class DataGatheringService {
} }
}); });
Logger.log('7d data gathering has been completed.'); Logger.log(
'7d data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-7d'); console.timeEnd('data-gathering-7d');
} }
} }
@ -83,7 +87,10 @@ export class DataGatheringService {
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
Logger.log('Max data gathering has been started.'); Logger.log(
'Max data gathering has been started.',
'DataGatheringService'
);
console.time('data-gathering-max'); console.time('data-gathering-max');
await this.prismaService.property.create({ await this.prismaService.property.create({
@ -107,7 +114,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'DataGatheringService');
} }
await this.prismaService.property.delete({ await this.prismaService.property.delete({
@ -116,24 +123,24 @@ export class DataGatheringService {
} }
}); });
Logger.log('Max data gathering has been completed.'); Logger.log(
'Max data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-max'); console.timeEnd('data-gathering-max');
} }
} }
public async gatherSymbol({ public async gatherSymbol({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: PROPERTY_LOCKED_DATA_GATHERING } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
Logger.log(`Symbol data gathering for ${symbol} has been started.`); Logger.log(
`Symbol data gathering for ${symbol} has been started.`,
'DataGatheringService'
);
console.time('data-gathering-symbol'); console.time('data-gathering-symbol');
await this.prismaService.property.create({ await this.prismaService.property.create({
@ -164,7 +171,7 @@ export class DataGatheringService {
where: { key: PROPERTY_LAST_DATA_GATHERING } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'DataGatheringService');
} }
await this.prismaService.property.delete({ await this.prismaService.property.delete({
@ -173,7 +180,10 @@ export class DataGatheringService {
} }
}); });
Logger.log(`Symbol data gathering for ${symbol} has been completed.`); Logger.log(
`Symbol data gathering for ${symbol} has been completed.`,
'DataGatheringService'
);
console.timeEnd('data-gathering-symbol'); console.timeEnd('data-gathering-symbol');
} }
} }
@ -210,42 +220,55 @@ export class DataGatheringService {
}); });
} }
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'DataGatheringService');
} finally { } finally {
return undefined; return undefined;
} }
} }
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) { public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
Logger.log('Profile data gathering has been started.'); Logger.log(
'Profile data gathering has been started.',
'DataGatheringService'
);
console.time('data-gathering-profile'); console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems; let dataGatheringItems = aDataGatheringItems?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) { if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData(); dataGatheringItems = await this.getSymbolsProfileData();
} }
const currentData = await this.dataProviderService.get(dataGatheringItems); const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles( const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => { dataGatheringItems.map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
for (const [symbol, response] of Object.entries(currentData)) { for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => { const symbolMapping = symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol; return symbolProfile.symbol === symbol;
})?.symbolMapping; })?.symbolMapping;
for (const dataEnhancer of this.dataEnhancers) { for (const dataEnhancer of this.dataEnhancers) {
try { try {
currentData[symbol] = await dataEnhancer.enhance({ assetProfiles[symbol] = await dataEnhancer.enhance({
response, response: assetProfile,
symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol symbol: symbolMapping?.[dataEnhancer.getName()] ?? symbol
}); });
} catch (error) { } catch (error) {
Logger.error(`Failed to enhance data for symbol ${symbol}`, error); Logger.error(
`Failed to enhance data for symbol ${symbol} by ${dataEnhancer.getName()}`,
error,
'DataGatheringService'
);
} }
} }
@ -256,8 +279,9 @@ export class DataGatheringService {
currency, currency,
dataSource, dataSource,
name, name,
sectors sectors,
} = currentData[symbol]; url
} = assetProfiles[symbol];
try { try {
await this.prismaService.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
@ -269,7 +293,8 @@ export class DataGatheringService {
dataSource, dataSource,
name, name,
sectors, sectors,
symbol symbol,
url
}, },
update: { update: {
assetClass, assetClass,
@ -277,7 +302,8 @@ export class DataGatheringService {
countries, countries,
currency, currency,
name, name,
sectors sectors,
url
}, },
where: { where: {
dataSource_symbol: { dataSource_symbol: {
@ -287,11 +313,18 @@ export class DataGatheringService {
} }
}); });
} catch (error) { } catch (error) {
Logger.error(`${symbol}: ${error?.meta?.cause}`); Logger.error(
`${symbol}: ${error?.meta?.cause}`,
error,
'DataGatheringService'
);
} }
} }
Logger.log('Profile data gathering has been completed.'); Logger.log(
'Profile data gathering has been completed.',
'DataGatheringService'
);
console.timeEnd('data-gathering-profile'); console.timeEnd('data-gathering-profile');
} }
@ -300,6 +333,10 @@ export class DataGatheringService {
let symbolCounter = 0; let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) { for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
if (dataSource === 'MANUAL') {
continue;
}
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length; this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try { try {
@ -347,10 +384,11 @@ export class DataGatheringService {
} catch {} } catch {}
} else { } else {
Logger.warn( Logger.warn(
`Failed to gather data for symbol ${symbol} at ${format( `Failed to gather data for symbol ${symbol} from ${dataSource} at ${format(
currentDate, currentDate,
DATE_FORMAT DATE_FORMAT
)}.` )}.`,
'DataGatheringService'
); );
} }
@ -366,14 +404,15 @@ export class DataGatheringService {
} }
} catch (error) { } catch (error) {
hasError = true; hasError = true;
Logger.error(error); Logger.error(error, 'DataGatheringService');
} }
if (symbolCounter > 0 && symbolCounter % 100 === 0) { if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log( Logger.log(
`Data gathering progress: ${( `Data gathering progress: ${(
this.dataGatheringProgress * 100 this.dataGatheringProgress * 100
).toFixed(2)}%` ).toFixed(2)}%`,
'DataGatheringService'
); );
} }
@ -463,7 +502,7 @@ export class DataGatheringService {
} }
public async reset() { public async reset() {
Logger.log('Data gathering has been reset.'); Logger.log('Data gathering has been reset.', 'DataGatheringService');
await this.prismaService.property.deleteMany({ await this.prismaService.property.deleteMany({
where: { where: {
@ -538,19 +577,24 @@ export class DataGatheringService {
} }
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> { private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const distinctOrders = await this.prismaService.order.findMany({ const symbolProfiles = await this.prismaService.symbolProfile.findMany({
distinct: ['symbol'], orderBy: [{ symbol: 'asc' }]
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
}); });
return distinctOrders.filter((distinctOrder) => { return symbolProfiles
return ( .filter((symbolProfile) => {
distinctOrder.dataSource !== DataSource.GHOSTFOLIO && return (
distinctOrder.dataSource !== DataSource.MANUAL && symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.RAKUTEN symbolProfile.dataSource !== DataSource.MANUAL &&
); symbolProfile.dataSource !== DataSource.RAKUTEN
}); );
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
} }
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {

View File

@ -1,16 +1,16 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '../../interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces'; import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
@Injectable() @Injectable()
@ -29,25 +29,23 @@ export class AlphaVantageService implements DataProviderInterface {
return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY'); return !!this.configurationService.get('ALPHA_VANTAGE_API_KEY');
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
return {}; return {
dataSource: this.getName()
};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
if (aSymbols.length <= 0) { const symbol = aSymbol;
return {};
}
const symbol = aSymbols[0];
try { try {
const historicalData: { const historicalData: {
@ -78,7 +76,7 @@ export class AlphaVantageService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error, symbol); Logger.error(error, 'AlphaVantageService');
return {}; return {};
} }
@ -88,6 +86,12 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery); const result = await this.alphaVantage.data.search(aQuery);

View File

@ -1,5 +1,7 @@
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface'; import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { SymbolProfile } from '@prisma/client';
import bent from 'bent'; import bent from 'bent';
const getJSON = bent('json'); const getJSON = bent('json');
@ -21,9 +23,9 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
response, response,
symbol symbol
}: { }: {
response: IDataProviderResponse; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<IDataProviderResponse> { }): Promise<Partial<SymbolProfile>> {
if ( if (
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF') !(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
) { ) {
@ -40,7 +42,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
); );
}); });
if (!response.countries || response.countries.length === 0) { if (
!response.countries ||
(response.countries as unknown as Country[]).length === 0
) {
response.countries = []; response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) { for (const [name, value] of Object.entries<any>(holdings.countries)) {
let countryCode: string; let countryCode: string;
@ -65,7 +70,10 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
} }
} }
if (!response.sectors || response.sectors.length === 0) { if (
!response.sectors ||
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = []; response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) { for (const [name, value] of Object.entries<any>(holdings.sectors)) {
response.sectors.push({ response.sectors.push({

View File

@ -10,7 +10,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';
import { groupBy, isEmpty } from 'lodash'; import { groupBy, isEmpty } from 'lodash';
@ -23,42 +23,6 @@ export class DataProviderService {
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).get(symbols)
);
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
await Promise.all(promises);
return response;
}
public async getHistorical( public async getHistorical(
aItems: IDataGatheringItem[], aItems: IDataGatheringItem[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
@ -118,7 +82,7 @@ export class DataProviderService {
return r; return r;
}, {}); }, {});
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'DataProviderService');
} finally { } finally {
return response; return response;
} }
@ -144,7 +108,7 @@ export class DataProviderService {
if (dataProvider.canHandle(symbol)) { if (dataProvider.canHandle(symbol)) {
promises.push( promises.push(
dataProvider dataProvider
.getHistorical([symbol], undefined, from, to) .getHistorical(symbol, undefined, from, to)
.then((data) => ({ data: data?.[symbol], symbol })) .then((data) => ({ data: data?.[symbol], symbol }))
); );
} }
@ -158,6 +122,82 @@ export class DataProviderService {
return result; return result;
} }
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
for (const symbol of symbols) {
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
);
promises.push(
promise.then((symbolProfile) => {
response[symbol] = symbolProfile;
})
);
}
}
await Promise.all(promises);
return response;
}
public async getQuotes(items: IDataGatheringItem[]): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
[symbol: string]: IDataProviderResponse;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).getQuotes(symbols)
);
promises.push(
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
await Promise.all(promises);
return response;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
@ -184,10 +224,6 @@ export class DataProviderService {
}; };
} }
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {
for (const dataProviderInterface of this.dataProviderInterfaces) { for (const dataProviderInterface of this.dataProviderInterfaces) {
if (dataProviderInterface.getName() === providerName) { if (dataProviderInterface.getName() === providerName) {

View File

@ -7,17 +7,13 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
DATE_FORMAT,
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { addDays, format, isBefore } from 'date-fns';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
@ -29,73 +25,61 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
) {} ) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol); return true;
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
if (aSymbols.length <= 0) { return {
return {}; dataSource: this.getName()
} };
try {
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol
}
});
return {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
}
};
} catch (error) {
Logger.error(error);
}
return {};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
if (aSymbols.length <= 0) {
return {};
}
try { try {
const [symbol] = aSymbols; const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles( const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol] [symbol]
); );
const scraperConfiguration = symbolProfile?.scraperConfiguration; const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {}); if (defaultMarketPrice) {
const historical: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {
[symbol]: {}
};
let date = from;
while (isBefore(date, to)) {
historical[symbol][format(date, DATE_FORMAT)] = {
marketPrice: defaultMarketPrice
};
date = addDays(date, 1);
}
return historical;
} else if (selector === undefined || url === undefined) {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const html = await get(); const html = await get();
const $ = cheerio.load(html); const $ = cheerio.load(html);
const value = this.extractNumberFromString( const value = this.extractNumberFromString($(selector).text());
$(scraperConfiguration?.selector).text()
);
return { return {
[symbol]: { [symbol]: {
@ -105,7 +89,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
} }
}; };
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'GhostfolioScraperApiService');
} }
return {}; return {};
@ -115,6 +99,52 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO; return DataSource.GHOSTFOLIO;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
const response: { [symbol: string]: IDataProviderResponse } = {};
if (aSymbols.length <= 0) {
return response;
}
try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: aSymbols.length,
where: {
symbol: {
in: aSymbols
}
}
});
for (const symbolProfile of symbolProfiles) {
response[symbolProfile.symbol] = {
currency: symbolProfile.currency,
dataSource: this.getName(),
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice,
marketState: MarketState.delayed
};
}
return response;
} catch (error) {
Logger.error(error, 'GhostfolioScraperApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {

View File

@ -1,4 +1,5 @@
export interface ScraperConfiguration { export interface ScraperConfiguration {
defaultMarketPrice?: number;
selector: string; selector: string;
url: string; url: string;
} }

View File

@ -11,7 +11,7 @@ import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.se
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet'; import { GoogleSpreadsheet } from 'google-spreadsheet';
@ -27,7 +27,62 @@ export class GoogleSheetsService implements DataProviderInterface {
return true; return true;
} }
public async get( public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
return {
dataSource: this.getName()
};
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
try {
const symbol = aSymbol;
const sheet = await this.getSheet({
symbol,
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
});
const rows = await sheet.getRows();
const historicalData: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
rows
.filter((row, index) => {
return index >= 1;
})
.forEach((row) => {
const date = parseDate(row._rawData[0]);
const close = parseFloat(row._rawData[1]);
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error, 'GoogleSheetsService');
}
return {};
}
public getName(): DataSource {
return DataSource.GOOGLE_SHEETS;
}
public async getQuotes(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) { if (aSymbols.length <= 0) {
@ -66,63 +121,12 @@ export class GoogleSheetsService implements DataProviderInterface {
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'GoogleSheetsService');
} }
return {}; return {};
} }
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const sheet = await this.getSheet({
symbol,
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
});
const rows = await sheet.getRows();
const historicalData: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
rows
.filter((row, index) => {
return index >= 1;
})
.forEach((row) => {
const date = parseDate(row._rawData[0]);
const close = parseFloat(row._rawData[1]);
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error);
}
return {};
}
public getName(): DataSource {
return DataSource.GOOGLE_SHEETS;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({ const items = await this.prismaService.symbolProfile.findMany({
select: { select: {

View File

@ -1,13 +1,13 @@
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces'; import { SymbolProfile } from '@prisma/client';
export interface DataEnhancerInterface { export interface DataEnhancerInterface {
enhance({ enhance({
response, response,
symbol symbol
}: { }: {
response: IDataProviderResponse; response: Partial<SymbolProfile>;
symbol: string; symbol: string;
}): Promise<IDataProviderResponse>; }): Promise<Partial<SymbolProfile>>;
getName(): string; getName(): string;
} }

View File

@ -4,23 +4,27 @@ import {
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
get(aSymbols: string[]): Promise<{ [symbol: string]: IDataProviderResponse }>; getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
getHistorical( getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity, aGranularity: Granularity,
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}>; }>; // TODO: Return only one symbol
getName(): DataSource; getName(): DataSource;
getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }>;
search(aQuery: string): Promise<{ items: LookupItem[] }>; search(aQuery: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -6,7 +6,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
@Injectable() @Injectable()
export class ManualService implements DataProviderInterface { export class ManualService implements DataProviderInterface {
@ -16,14 +16,16 @@ export class ManualService implements DataProviderInterface {
return false; return false;
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
return {}; return {
dataSource: this.getName()
};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
@ -37,6 +39,12 @@ export class ManualService implements DataProviderInterface {
return DataSource.MANUAL; return DataSource.MANUAL;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

View File

@ -1,25 +1,22 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource, SymbolProfile } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
export class RakutenRapidApiService implements DataProviderInterface { export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
@ -29,50 +26,24 @@ export class RakutenRapidApiService implements DataProviderInterface {
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY'); return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
} }
public async get( public async getAssetProfile(
aSymbols: string[] aSymbol: string
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<Partial<SymbolProfile>> {
if (aSymbols.length <= 0) { return {
return {}; dataSource: this.getName()
} };
try {
const symbol = aSymbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
}
};
}
} catch (error) {
Logger.error(error);
}
return {};
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbol: string,
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
from: Date, from: Date,
to: Date to: Date
): Promise<{ ): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> { }> {
if (aSymbols.length <= 0) {
return {};
}
try { try {
const symbol = aSymbols[0]; const symbol = aSymbol;
if (symbol === ghostfolioFearAndGreedIndexSymbol) { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
@ -129,6 +100,35 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN; return DataSource.RAKUTEN;
} }
public async getQuotes(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const symbol = aSymbols[0];
if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex();
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open
}
};
}
} catch (error) {
Logger.error(error, 'RakutenRapidApiService');
}
return {};
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }
@ -158,7 +158,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
const { fgi } = await get(); const { fgi } = await get();
return fgi; return fgi;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'RakutenRapidApiService');
return undefined; return undefined;
} }

View File

@ -1,32 +0,0 @@
export interface IYahooFinanceHistoricalResponse {
adjClose: number;
close: number;
date: Date;
high: number;
low: number;
open: number;
symbol: string;
volume: number;
}
export interface IYahooFinanceQuoteResponse {
price: IYahooFinancePrice;
summaryProfile: IYahooFinanceSummaryProfile;
}
export interface IYahooFinancePrice {
currency: string;
exchangeName: string;
longName: string;
marketState: string;
quoteType: string;
regularMarketPrice: number;
shortName: string;
}
export interface IYahooFinanceSummaryProfile {
country?: string;
industry?: string;
sector?: string;
website?: string;
}

View File

@ -1,32 +1,29 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service'; import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config'; import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js';
import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns';
import * as yahooFinance from 'yahoo-finance';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface'; import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { import {
IYahooFinanceHistoricalResponse, AssetClass,
IYahooFinancePrice, AssetSubClass,
IYahooFinanceQuoteResponse DataSource,
} from './interfaces/interfaces'; SymbolProfile
} from '@prisma/client';
import Big from 'big.js';
import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor( public constructor(
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}
@ -73,7 +70,124 @@ export class YahooFinanceService implements DataProviderInterface {
return aSymbol; return aSymbol;
} }
public async get( public async getAssetProfile(
aSymbol: string
): Promise<Partial<SymbolProfile>> {
const response: Partial<SymbolProfile> = {};
try {
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
const assetProfile = await yahooFinance.quoteSummary(symbol, {
modules: ['price', 'summaryProfile']
});
const { assetClass, assetSubClass } = this.parseAssetClass(
assetProfile.price
);
response.assetClass = assetClass;
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name =
assetProfile.price.longName || assetProfile.price.shortName || symbol;
response.symbol = aSymbol;
if (
assetSubClass === AssetSubClass.STOCK &&
assetProfile.summaryProfile?.country
) {
// Add country if asset is stock and country available
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === assetProfile.summaryProfile?.country;
});
if (code) {
response.countries = [{ code, weight: 1 }];
}
} catch {}
if (assetProfile.summaryProfile?.sector) {
response.sectors = [
{ name: assetProfile.summaryProfile?.sector, weight: 1 }
];
}
}
const url = assetProfile.summaryProfile?.website;
if (url) {
response.url = url;
}
} catch {}
return response;
}
public async getHistorical(
aSymbol: string,
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
const yahooFinanceSymbol = this.convertToYahooFinanceSymbol(aSymbol);
try {
const historicalResult = await yahooFinance.historical(
yahooFinanceSymbol,
{
interval: '1d',
period1: format(from, DATE_FORMAT),
period2: format(to, DATE_FORMAT)
}
);
const response: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
// Convert symbol back
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
for (const historicalItem of historicalResult) {
let marketPrice = historicalItem.close;
if (symbol === 'USDGBp') {
// Convert GPB to GBp (pence)
marketPrice = new Big(marketPrice).mul(100).toNumber();
}
response[symbol][format(historicalItem.date, DATE_FORMAT)] = {
marketPrice,
performance: historicalItem.open - historicalItem.close
};
}
return response;
} catch (error) {
Logger.warn(
`Skipping yahooFinance2.getHistorical("${aSymbol}"): [${error.name}] ${error.message}`,
'YahooFinanceService'
);
return {};
}
}
public getName(): DataSource {
return DataSource.YAHOO;
}
public async getQuotes(
aSymbols: string[] aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) { if (aSymbols.length <= 0) {
@ -86,157 +200,50 @@ export class YahooFinanceService implements DataProviderInterface {
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
const data: { const quotes = await yahooFinance.quote(yahooFinanceSymbols);
[symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({
modules: ['price', 'summaryProfile'],
symbols: yahooFinanceSymbols
});
for (const [yahooFinanceSymbol, value] of Object.entries(data)) { for (const quote of quotes) {
// Convert symbols back // Convert symbols back
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol); const symbol = this.convertFromYahooFinanceSymbol(quote.symbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
response[symbol] = { response[symbol] = {
assetClass, currency: quote.currency,
assetSubClass,
currency: value.price?.currency,
dataSource: this.getName(), dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || quote.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCryptocurrency(symbol) this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open ? MarketState.open
: MarketState.closed, : MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0, marketPrice: quote.regularMarketPrice || 0
name: value.price?.longName || value.price?.shortName || symbol
}; };
if (value.price?.currency === 'GBp') { if (symbol === 'USDGBP' && yahooFinanceSymbols.includes('USDGBp=X')) {
// Convert GBp (pence) to GBP // Convert GPB to GBp (pence)
response[symbol].currency = 'GBP'; response['USDGBp'] = {
response[symbol].marketPrice = new Big( ...response[symbol],
value.price?.regularMarketPrice ?? 0 currency: 'GBp',
) marketPrice: new Big(response[symbol].marketPrice)
.div(100) .mul(100)
.toNumber(); .toNumber()
}
// Add country if stock and available
if (
assetSubClass === AssetSubClass.STOCK &&
value.summaryProfile?.country
) {
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === value.summaryProfile?.country;
});
if (code) {
response[symbol].countries = [{ code, weight: 1 }];
}
} catch {}
if (value.summaryProfile?.sector) {
response[symbol].sectors = [
{ name: value.summaryProfile?.sector, weight: 1 }
];
}
}
// Add url if available
const url = value.summaryProfile?.website;
if (url) {
response[symbol].url = url;
}
}
return response;
} catch (error) {
Logger.error(error);
return {};
}
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
if (isSameDay(from, to)) {
to = addDays(to, 1);
}
const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooFinanceSymbol(symbol);
});
try {
const historicalData: {
[symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({
symbols: yahooFinanceSymbols,
from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT)
});
const response: {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {};
for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
historicalData
)) {
// Convert symbols back
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {};
timeSeries.forEach((timeSerie) => {
response[symbol][format(timeSerie.date, DATE_FORMAT)] = {
marketPrice: timeSerie.close,
performance: timeSerie.open - timeSerie.close
}; };
}); }
} }
return response; return response;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'YahooFinanceService');
return {}; return {};
} }
} }
public getName(): DataSource {
return DataSource.YAHOO;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const searchResult = await yahooFinance.search(aQuery);
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const searchResult = await get(); const quotes = searchResult.quotes
const symbols: string[] = searchResult.quotes
.filter((quote) => { .filter((quote) => {
// filter out undefined symbols // filter out undefined symbols
return quote.symbol; return quote.symbol;
@ -247,8 +254,7 @@ export class YahooFinanceService implements DataProviderInterface {
this.cryptocurrencyService.isCryptocurrency( this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency) symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
)) || )) ||
quoteType === 'EQUITY' || ['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType)
quoteType === 'ETF'
); );
}) })
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
@ -259,27 +265,38 @@ export class YahooFinanceService implements DataProviderInterface {
} }
return true; return true;
})
.map(({ symbol }) => {
return symbol;
}); });
const marketData = await this.get(symbols); const marketData = await yahooFinance.quote(
quotes.map(({ symbol }) => {
return symbol;
})
);
for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === marketDataItem.symbol;
});
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
for (const [symbol, value] of Object.entries(marketData)) {
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: marketDataItem.currency,
dataSource: this.getName(), dataSource: this.getName(),
name: value.name name: quote?.longname || quote?.shortname || symbol
}); });
} }
} catch {} } catch (error) {
Logger.error(error, 'YahooFinanceService');
}
return { items }; return { items };
} }
private parseAssetClass(aPrice: IYahooFinancePrice): { private parseAssetClass(aPrice: Price): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
} { } {
@ -299,16 +316,12 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF; assetSubClass = AssetSubClass.ETF;
break; break;
case 'mutualfund':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.MUTUALFUND;
break;
} }
return { assetClass, assetSubClass }; return { assetClass, assetSubClass };
} }
private parseExchange(aString: string): string {
if (aString?.toLowerCase() === 'ccc') {
return UNKNOWN_KEY;
}
return aString;
}
} }

View File

@ -2,7 +2,7 @@ import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isEmpty, isNumber, uniq } from 'lodash'; import { isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
@ -61,7 +61,7 @@ export class ExchangeRateDataService {
if (Object.keys(result).length !== this.currencyPairs.length) { if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback // Load currencies directly from data provider as a fallback
// if historical data is not fully available // if historical data is not fully available
const historicalData = await this.dataProviderService.get( const historicalData = await this.dataProviderService.getQuotes(
this.currencyPairs.map(({ dataSource, symbol }) => { this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
}) })
@ -114,6 +114,10 @@ export class ExchangeRateDataService {
aFromCurrency: string, aFromCurrency: string,
aToCurrency: string aToCurrency: string
) { ) {
if (aValue === 0) {
return 0;
}
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => { const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
return isNaN(exchangeRate); return isNaN(exchangeRate);
}); });
@ -145,7 +149,8 @@ export class ExchangeRateDataService {
// Fallback with error, if currencies are not available // Fallback with error, if currencies are not available
Logger.error( Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}` `No exchange rate has been found for ${aFromCurrency}${aToCurrency}`,
'ExchangeRateDataService'
); );
return aValue; return aValue;
} }
@ -187,12 +192,7 @@ export class ExchangeRateDataService {
await this.prismaService.symbolProfile.findMany({ await this.prismaService.symbolProfile.findMany({
distinct: ['currency'], distinct: ['currency'],
orderBy: [{ currency: 'asc' }], orderBy: [{ currency: 'asc' }],
select: { currency: true }, select: { currency: true }
where: {
currency: {
not: null
}
}
}) })
).forEach((symbolProfile) => { ).forEach((symbolProfile) => {
currencies.push(symbolProfile.currency); currencies.push(symbolProfile.currency);
@ -206,7 +206,7 @@ export class ExchangeRateDataService {
currencies = currencies.concat(customCurrencies); currencies = currencies.concat(customCurrencies);
} }
return uniq(currencies).sort(); return uniq(currencies).filter(Boolean).sort();
} }
private prepareCurrencyPairs(aCurrencies: string[]) { private prepareCurrencyPairs(aCurrencies: string[]) {

View File

@ -33,19 +33,10 @@ export interface IDataProviderHistoricalResponse {
} }
export interface IDataProviderResponse { export interface IDataProviderResponse {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countries?: { code: string; weight: number }[];
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
exchange?: string;
marketChange?: number;
marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name?: string;
sectors?: { name: string; weight: number }[];
url?: string;
} }
export interface IDataGatheringItem { export interface IDataGatheringItem {

View File

@ -2,6 +2,7 @@ import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-dat
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface'; import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, MarketData, Prisma } from '@prisma/client'; import { DataSource, MarketData, Prisma } from '@prisma/client';
@ -9,13 +10,7 @@ import { DataSource, MarketData, Prisma } from '@prisma/client';
export class MarketDataService { export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async deleteMany({ public async deleteMany({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.marketData.deleteMany({ return this.prismaService.marketData.deleteMany({
where: { where: {
dataSource, dataSource,

View File

@ -79,6 +79,7 @@ export class SymbolProfileService {
if (scraperConfiguration) { if (scraperConfiguration) {
return { return {
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
selector: scraperConfiguration.selector as string, selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string url: scraperConfiguration.url as string
}; };

View File

@ -6,6 +6,7 @@ import {
} from '@ghostfolio/common/config'; } from '@ghostfolio/common/config';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper'; import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { isSunday } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2'; import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable() @Injectable()
@ -27,7 +28,10 @@ export class TwitterBotService {
} }
public async tweetFearAndGreedIndex() { public async tweetFearAndGreedIndex() {
if (!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
isSunday(new Date())
) {
return; return;
} }
@ -50,11 +54,12 @@ export class TwitterBotService {
); );
Logger.log( Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}` `Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`,
'TwitterBotService'
); );
} }
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error, 'TwitterBotService');
} }
} }
} }

View File

@ -1,16 +1,14 @@
import { import { DEFAULT_DATE_FORMAT_MONTH_YEAR } from '@ghostfolio/common/config';
DEFAULT_DATE_FORMAT, import { getDateFormatString } from '@ghostfolio/common/helper';
DEFAULT_DATE_FORMAT_MONTH_YEAR
} from '@ghostfolio/common/config';
export const DateFormats = { export const DateFormats = {
display: { display: {
dateInput: DEFAULT_DATE_FORMAT, dateInput: getDateFormatString(),
monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR, monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR,
dateA11yLabel: DEFAULT_DATE_FORMAT, dateA11yLabel: getDateFormatString(),
monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR
}, },
parse: { parse: {
dateInput: DEFAULT_DATE_FORMAT dateInput: getDateFormatString()
} }
}; };

View File

@ -113,6 +113,13 @@ const routes: Routes = [
(m) => m.AnalysisPageModule (m) => m.AnalysisPageModule
) )
}, },
{
path: 'portfolio/fire',
loadChildren: () =>
import('./pages/portfolio/fire/fire-page.module').then(
(m) => m.FirePageModule
)
},
{ {
path: 'portfolio/report', path: 'portfolio/report',
loadChildren: () => loadChildren: () =>

View File

@ -78,32 +78,54 @@
</ng-container> </ng-container>
<ng-container matColumnDef="balance"> <ng-container matColumnDef="balance">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Cash Balance Cash Balance
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="element.convertedBalance" [value]="element.balance"
></gf-value> ></gf-value>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td
*matFooterCellDef
class="d-none d-lg-table-cell px-1 text-right"
mat-footer-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="totalBalance" [value]="totalBalanceInBaseCurrency"
></gf-value> ></gf-value>
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Value Value
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -111,12 +133,51 @@
[value]="element.value" [value]="element.value"
></gf-value> ></gf-value>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td
*matFooterCellDef
class="d-none d-lg-table-cell px-1 text-right"
mat-footer-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="totalValue" [value]="totalValueInBaseCurrency"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="d-lg-none d-xl-none px-1 text-right"
i18n
mat-header-cell
>
Value
</th>
<td
*matCellDef="let element"
class="d-lg-none d-xl-none px-1 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.valueInBaseCurrency"
></gf-value>
</td>
<td
*matFooterCellDef
class="d-lg-none d-xl-none px-1 text-right"
mat-footer-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValueInBaseCurrency"
></gf-value> ></gf-value>
</td> </td>
</ng-container> </ng-container>
@ -133,16 +194,17 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">
Edit <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button> </button>
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0" [disabled]="element.isDefault || element.Order?.length > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -24,8 +24,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string; @Input() deviceType: string;
@Input() locale: string; @Input() locale: string;
@Input() showActions: boolean; @Input() showActions: boolean;
@Input() totalBalance: number; @Input() totalBalanceInBaseCurrency: number;
@Input() totalValue: number; @Input() totalValueInBaseCurrency: number;
@Input() transactionCount: number; @Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>(); @Output() accountDeleted = new EventEmitter<string>();
@ -50,7 +50,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
'transactions', 'transactions',
'balance', 'balance',
'value', 'value',
'currency' 'currency',
'valueInBaseCurrency'
]; ];
if (this.showActions) { if (this.showActions) {

View File

@ -18,8 +18,10 @@
available: available:
marketDataByMonth[itemByMonth.key][ marketDataByMonth[itemByMonth.key][
i + 1 < 10 ? '0' + (i + 1) : i + 1 i + 1 < 10 ? '0' + (i + 1) : i + 1
]?.day === ]?.marketPrice,
i + 1 today: isToday(
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
)
}" }"
[title]=" [title]="
(itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1) (itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)

View File

@ -25,5 +25,10 @@
&.available { &.available {
background-color: var(--success); background-color: var(--success);
} }
&.today {
background-color: rgba(var(--palette-accent-500), 1);
cursor: default;
}
} }
} }

View File

@ -8,11 +8,22 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import {
import { DATE_FORMAT } from '@ghostfolio/common/helper'; DATE_FORMAT,
getDateFormatString,
getLocale
} from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format, isBefore, isValid, parse } from 'date-fns'; import {
addDays,
format,
isBefore,
isSameDay,
isValid,
parse,
parseISO
} from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -26,17 +37,21 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
}) })
export class AdminMarketDataDetailComponent implements OnChanges, OnInit { export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string;
@Input() locale = getLocale();
@Input() marketData: MarketData[]; @Input() marketData: MarketData[];
@Input() symbol: string; @Input() symbol: string;
@Output() marketDataChanged = new EventEmitter<boolean>(); @Output() marketDataChanged = new EventEmitter<boolean>();
public days = Array(31); public days = Array(31);
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public marketDataByMonth: { public marketDataByMonth: {
[yearMonth: string]: { [day: string]: MarketData & { day: number } }; [yearMonth: string]: {
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
};
} = {}; } = {};
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -51,15 +66,38 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
this.historicalDataItems = this.marketData.map((marketDataItem) => { this.historicalDataItems = this.marketData.map((marketDataItem) => {
return { return {
date: format(marketDataItem.date, DATE_FORMAT), date: format(marketDataItem.date, DATE_FORMAT),
value: marketDataItem.marketPrice value: marketDataItem.marketPrice
}; };
}); });
let date = parseISO(this.dateOfFirstActivity);
const missingMarketData: Partial<MarketData>[] = [];
if (this.historicalDataItems?.[0]?.date) {
while (
isBefore(
date,
parse(this.historicalDataItems[0].date, DATE_FORMAT, new Date())
)
) {
missingMarketData.push({
date,
marketPrice: undefined
});
date = addDays(date, 1);
}
}
this.marketDataByMonth = {}; this.marketDataByMonth = {};
for (const marketDataItem of this.marketData) { for (const marketDataItem of [...missingMarketData, ...this.marketData]) {
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10); const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
const key = format(marketDataItem.date, 'yyyy-MM'); const key = format(marketDataItem.date, 'yyyy-MM');
@ -70,8 +108,9 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
this.marketDataByMonth[key][ this.marketDataByMonth[key][
currentDay < 10 ? `0${currentDay}` : currentDay currentDay < 10 ? `0${currentDay}` : currentDay
] = { ] = {
...marketDataItem, date: marketDataItem.date,
day: currentDay day: currentDay,
marketPrice: marketDataItem.marketPrice
}; };
} }
} }
@ -82,6 +121,11 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
return isValid(date) && isBefore(date, new Date()); return isValid(date) && isBefore(date, new Date());
} }
public isToday(aDateString: string) {
const date = parse(aDateString, DATE_FORMAT, new Date());
return isValid(date) && isSameDay(date, new Date());
}
public onOpenMarketDataDetail({ public onOpenMarketDataDetail({
day, day,
yearMonth yearMonth
@ -89,13 +133,18 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string; day: string;
yearMonth: string; yearMonth: string;
}) { }) {
const date = new Date(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice; const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) {
return;
}
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: { data: {
date,
marketPrice, marketPrice,
dataSource: this.dataSource, dataSource: this.dataSource,
date: new Date(`${yearMonth}-${day}`),
symbol: this.symbol symbol: this.symbol
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -7,7 +7,9 @@ import {
} from '@angular/core'; } from '@angular/core';
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 { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -22,9 +24,10 @@ import { takeUntil } from 'rxjs/operators';
export class AdminMarketDataComponent implements OnDestroy, OnInit { export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = []; public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -34,8 +37,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
) {} private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
}
});
}
/** /**
* Initializes the controller * Initializes the controller
@ -44,39 +60,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.fetchAdminMarketData(); this.fetchAdminMarketData();
} }
public onDeleteProfileData({ public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService this.adminService
.deleteProfileData({ dataSource, symbol }) .deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherProfileDataBySymbol({ public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol }) .gatherProfileDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {}); .subscribe(() => {});
} }
public onGatherSymbol({ public onGatherSymbol({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService this.adminService
.gatherSymbol({ dataSource, symbol }) .gatherSymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
@ -93,13 +91,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
} }
} }
public setCurrentProfile({ public setCurrentProfile({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.marketDataDetails = []; this.marketDataDetails = [];
if (this.currentSymbol === symbol) { if (this.currentSymbol === symbol) {
@ -129,13 +121,7 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
}); });
} }
private fetchAdminMarketDataBySymbol({ private fetchAdminMarketDataBySymbol({ dataSource, symbol }: UniqueAsset) {
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol }) .fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))

View File

@ -64,6 +64,8 @@
<td class="p-1" colspan="6"> <td class="p-1" colspan="6">
<gf-admin-market-data-detail <gf-admin-market-data-detail
[dataSource]="item.dataSource" [dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date"
[locale]="user?.settings?.locale"
[marketData]="marketDataDetails" [marketData]="marketDataDetails"
[symbol]="item.symbol" [symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"

View File

@ -5,7 +5,6 @@ import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_DATE_FORMAT,
PROPERTY_COUPONS, PROPERTY_COUPONS,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -20,6 +19,7 @@ import {
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { uniq } from 'lodash'; import { uniq } from 'lodash';
import { StringValue } from 'ms';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -29,11 +29,11 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html' templateUrl: './admin-overview.html'
}) })
export class AdminOverviewComponent implements OnDestroy, OnInit { export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days';
public coupons: Coupon[]; public coupons: Coupon[];
public customCurrencies: string[]; public customCurrencies: string[];
public dataGatheringInProgress: boolean; public dataGatheringInProgress: boolean;
public dataGatheringProgress: number; public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[]; public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;
@ -105,7 +105,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
public onAddCoupon() { public onAddCoupon() {
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }]; const coupons = [
...this.coupons,
{ code: this.generateCouponCode(16), duration: this.couponDuration }
];
this.putCoupons(coupons); this.putCoupons(coupons);
} }
@ -118,6 +121,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
} }
public onChangeCouponDuration(aCouponDuration: StringValue) {
this.couponDuration = aCouponDuration;
}
public onDeleteCoupon(aCouponCode: string) { public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?'); const confirmation = confirm('Do you really want to delete this coupon?');

View File

@ -156,11 +156,14 @@
></mat-slide-toggle> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3"> <div
*ngIf="hasPermissionForSubscription"
class="d-flex my-3 subscription"
>
<div class="w-50" i18n>Coupons</div> <div class="w-50" i18n>Coupons</div>
<div class="w-50"> <div class="w-50">
<div *ngFor="let coupon of coupons"> <div *ngFor="let coupon of coupons">
<span>{{ coupon.code }}</span> <span>{{ coupon.code }} ({{ coupon.duration }})</span>
<button <button
class="mini-icon mx-1 no-min-width px-2" class="mini-icon mx-1 no-min-width px-2"
mat-button mat-button
@ -170,10 +173,27 @@
</button> </button>
</div> </div>
<div class="mt-2"> <div class="mt-2">
<button color="primary" mat-flat-button (click)="onAddCoupon()"> <form #couponForm="ngForm">
<ion-icon class="mr-1" name="add-outline"></ion-icon> <mat-form-field appearance="outline" class="mr-2">
<span i18n>Add Coupon</span> <mat-select
</button> name="duration"
[value]="couponDuration"
(selectionChange)="onChangeCouponDuration($event.value)"
>
<mat-option value="7 days">7 Days</mat-option>
<mat-option value="14 days">14 Days</mat-option>
<mat-option value="30 days">30 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option>
</mat-select>
</mat-form-field>
<button
color="primary"
mat-flat-button
(click)="onAddCoupon()"
>
<span i18n>Add</span>
</button>
</form>
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,7 +1,9 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
@ -12,11 +14,14 @@ import { AdminOverviewComponent } from './admin-overview.component';
declarations: [AdminOverviewComponent], declarations: [AdminOverviewComponent],
exports: [], exports: [],
imports: [ imports: [
FormsModule,
CommonModule, CommonModule,
GfValueModule, GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatSlideToggleModule MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule
], ],
providers: [CacheService], providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -20,4 +20,10 @@
} }
} }
} }
.subscription {
.mat-form-field {
max-width: 100%;
}
}
} }

View File

@ -14,12 +14,12 @@
Accounts Accounts
</th> </th>
<th class="mat-header-cell px-1 py-2 text-right" i18n> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Transactions Activities
</th> </th>
<th class="mat-header-cell px-1 py-2 text-right" i18n> <th class="mat-header-cell px-1 py-2 text-right" i18n>
Engagement per Day Engagement per Day
</th> </th>
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th> <th class="mat-header-cell px-1 py-2" i18n>Last Request</th>
<th class="mat-header-cell px-1 py-2"></th> <th class="mat-header-cell px-1 py-2"></th>
</tr> </tr>
</thead> </thead>
@ -68,12 +68,12 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="userItem.id === user?.id" [disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)" (click)="onDeleteUser(userItem.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -5,7 +5,7 @@
z-index: 999; z-index: 999;
.mat-toolbar { .mat-toolbar {
background-color: rgba(var(--light-disabled-text)); background-color: var(--light-background);
.spacer { .spacer {
flex: 1 1 auto; flex: 1 1 auto;
@ -27,6 +27,6 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-toolbar { .mat-toolbar {
background-color: rgba(39, 39, 39, $alpha-disabled-text); background-color: var(--dark-background);
} }
} }

View File

@ -93,7 +93,9 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
}); });
this.dateRange = this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max'; this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.update(); this.update();
} }

View File

@ -1,5 +1,5 @@
<div class="container justify-content-center p-3"> <div class="container justify-content-center p-3">
<div class="mb-3 text-center"> <div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
<gf-toggle <gf-toggle
[defaultValue]="dateRange" [defaultValue]="dateRange"
[isLoading]="positions === undefined" [isLoading]="positions === undefined"
@ -25,7 +25,7 @@
<a <a
class="mt-3" class="mt-3"
i18n i18n
mat-button mat-stroked-button
[routerLink]="['/portfolio', 'activities']" [routerLink]="['/portfolio', 'activities']"
>Manage Activities</a >Manage Activities</a
> >

View File

@ -7,7 +7,11 @@ import {
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config'; import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces'; import {
PortfolioPerformance,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
@ -24,6 +28,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange; public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions; public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string; public deviceType: string;
public errors: UniqueAsset[];
public hasError: boolean; public hasError: boolean;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean; public hasPermissionToCreateOrder: boolean;
@ -32,6 +37,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public isAllTimeLow: boolean; public isAllTimeLow: boolean;
public isLoadingPerformance = true; public isLoadingPerformance = true;
public performance: PortfolioPerformance; public performance: PortfolioPerformance;
public showDetails = false;
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -79,7 +85,14 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
}); });
this.dateRange = this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max'; this.user.settings.viewMode === 'ZEN'
? 'max'
: <DateRange>this.settingsStorageService.getSetting(RANGE) ?? 'max';
this.showDetails =
!this.hasImpersonationId &&
!this.user.settings.isRestrictedView &&
this.user.settings.viewMode !== 'ZEN';
this.update(); this.update();
} }
@ -118,6 +131,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.fetchPortfolioPerformance({ range: this.dateRange }) .fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe((response) => {
this.errors = response.errors;
this.hasError = response.hasErrors; this.hasError = response.hasErrors;
this.performance = response.performance; this.performance = response.performance;
this.isLoadingPerformance = false; this.isLoadingPerformance = false;

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