Compare commits

...

99 Commits

Author SHA1 Message Date
0c04f10e19 Release 1.152.0 (#955) 2022-05-26 19:01:08 +02:00
2c4c16ec99 Feature/extend markets overview by benchmarks (#953)
* Add benchmarks to markets overview

* Update changelog
2022-05-26 18:59:29 +02:00
4711b0d1ed Improve instructions for Unraid (#954) 2022-05-26 17:59:09 +02:00
a8521e0ecf Update README.md (#943)
* Update README.md for Unraid users
2022-05-26 17:52:14 +02:00
424748ae90 Feature/add ghostfolio trailer to landing page (#952)
* Add link to Ghostfolio trailer

* Update changelog
2022-05-26 10:30:13 +02:00
9c4d8bdf4b Release 1.151.0 (#949) 2022-05-24 20:58:24 +02:00
332203b9e2 Feature/add support to set the base currency via env variable (#948)
* Set base currency via environment variable

* Update changelog
2022-05-24 20:55:55 +02:00
f48832c671 Bugfix/add missing conversion of countries (#941)
* Add missing conversion of countries for SymbolProfileOverrides

* Update changelog
2022-05-23 18:04:09 +02:00
ae8a203526 Add type (#939) 2022-05-22 21:14:22 +02:00
d0c1506ded Release 1.150.0 (#940) 2022-05-21 20:00:34 +02:00
af0863d193 Bugfix/fix currency conversion in accounts (#937)
* Fix currency conversion in accounts

* Update changelog
2022-05-21 19:58:47 +02:00
f5819cc399 Bugfix/fix countries in symbol profile overrides (#936)
* Fix countries

* Update changelog
2022-05-20 20:16:23 +02:00
977c5a9544 Feature/skip data enhancement if data is inaccurate (#935)
* Skip data enhancer if data is inaccurate

* Update changelog
2022-05-20 20:15:19 +02:00
b9cd42cd53 Move dependencies to devDependencies (#934) 2022-05-20 20:14:33 +02:00
379977008d Simplify intro text (#933) 2022-05-19 21:05:14 +02:00
38f9d54705 Release 1.149.0 (#927) 2022-05-16 21:50:43 +02:00
5cb6e5dec6 Feature/support filtering by asset class on the allocations page (#926)
* Support filtering by asset class

* Update changelog
2022-05-16 21:49:22 +02:00
4a123c38f2 Refactor placeholder (#925) 2022-05-16 21:17:58 +02:00
160335302a Feature/group filters by type (#922)
* Add groups to activities filter component

* Update changelog
2022-05-15 21:51:31 +02:00
f1483569a2 Release 1.148.0 (#921) 2022-05-14 13:55:25 +02:00
5391b88c42 Feature/add report data glitch button (#920)
* Add report data glitch button

* Update changelog
2022-05-14 13:53:43 +02:00
2b63f7e707 Feature/support enter to submit create or update transaction dialog form (#913)
* Support enter key press to submit form

* Update changelog
2022-05-14 10:56:07 +02:00
d5c96d1cb7 Bugfix/fix date picker date format (#912)
* Fix date picker date format

* Update changelog
2022-05-14 10:55:09 +02:00
1a4dc51825 Bugfix/fix state of delete account button (#911)
* Fix disable state

* Update changelog
2022-05-13 06:48:40 +02:00
d094bae7de Bugfix/fix issue in activities filter component with typing (#910)
* Handle filter (selecting) or search term (typing)

* Update changelog
2022-05-12 07:48:12 +02:00
57bf10e7e7 Release 1.147.0 (#904) 2022-05-10 21:25:25 +02:00
c1d460cead Improve filtering (#901) 2022-05-10 21:24:36 +02:00
dfa67b275c Feature/improve filtering on allocations page (#900)
* Include cash positions on allocations page (with no filtering)

* Update changelog
2022-05-10 19:22:57 +02:00
80862e5c2a Release 1.146.3 (#899) 2022-05-08 22:54:07 +02:00
904d4db219 Refactor build:all and build:dev scripts (#898) 2022-05-08 22:52:46 +02:00
10f13eec48 Release 1.146.2 (#897) 2022-05-08 22:18:00 +02:00
ea3a9d3b79 Feature/eliminate circular dependencies in common library (#896)
* Eliminate circular dependencies

* Update changelog
2022-05-08 22:16:47 +02:00
e55b05fe3d Release 1.146.1 (#895) 2022-05-08 16:57:23 +02:00
32dd76be5f Fix path to jest files (#894) 2022-05-08 16:55:14 +02:00
ff9b6bb4df Release 1.146.0 (#893) 2022-05-08 16:04:43 +02:00
5be95b7b63 Feature/simplify about page (#892)
* Simplify about page

* Update changelog
2022-05-08 16:02:44 +02:00
b3e07c8446 Feature/support permissions in fire calculator (#891)
* Support hasPermissionToUpdateUserSettings

* Update changelog
2022-05-08 15:59:19 +02:00
eb9cece4e4 Update browserslist database (#890) 2022-05-08 15:56:39 +02:00
b331f5f04d Feature/setup nx cloud (#889)
* Upgrade angular, Nx and storybook

* Setup Nx Cloud

* Update changelog
2022-05-08 15:52:21 +02:00
34cbdd7c2a Upgrade angular, Nx and storybook (#888)
* Upgrade angular, Nx and storybook

* Update changelog
2022-05-08 09:26:33 +02:00
57314d62ee Feature/improve allocations page with no filter (#887)
* Improve accounts for no filters

* Update changelog
2022-05-07 22:33:57 +02:00
40380346e6 Feature/setup bull queue system (#886)
* Setup @nestjs/bull and asset profile data gathering job

* Update changelog
2022-05-07 20:00:51 +02:00
5622c4cf7e Feature/harmonize no data available label (#885)
* Harmonize label for UNKNOWN_KEY

* Update changelog
2022-05-07 14:11:42 +02:00
21173bed21 Release 1.145.0 (#884) 2022-05-07 11:47:28 +02:00
16dd8f7652 Feature/refactor filters with interface (#883)
* Refactor filtering with an interface

* Filter by accounts

* Update changelog
2022-05-07 11:44:29 +02:00
ce6b5fb7cb Bugfix/fix tooltip in proportion chart after update (#882)
* Keep tooltip configuration up to date

* Update changelog
2022-05-01 08:38:57 +02:00
f6f62db830 Feature/add support for private equity (#881)
* Add support for private equity

* Update changelog
2022-04-30 22:16:13 +02:00
01103f3db4 Feature/add asset and asset sub class to wealth items form (#880)
* Add asset and asset sub class

* Update changelog
2022-04-30 21:47:10 +02:00
e9e9f1a124 Release 1.144.0 (#879) 2022-04-30 11:51:49 +02:00
751256f158 Feature/add support for real estate and precious metal (#878)
* Add support for real estate and precious metal

* Update changelog
2022-04-30 11:49:58 +02:00
c2a1cbd20f Feature/improve layout of position detail dialog (#877)
* Improve layout

* Update changelog
2022-04-30 10:48:02 +02:00
04044f8720 Support futures (#845)
* Support futures

* Upgrade yahoo-finance2 to version 2.3.2

* Update changelog
2022-04-30 09:55:24 +02:00
4dc76817ce Bugfix/fix import validation for numbers equal zero (#875)
* Fix import validation for numbers equal 0

* Update changelog
2022-04-29 13:10:45 +02:00
1f0bd5a7db Bugfix/fix color of spinner in filter component (#873)
* Fix color for dark mode

* Update changelog
2022-04-27 17:30:57 +02:00
b6cd007ad4 Release/1.143.0 (#871)
* Release 1.143.0
  * Improve filtering by tags
2022-04-26 22:31:53 +02:00
b4bc72c6f9 Release 1.142.0 (#869) 2022-04-25 22:41:02 +02:00
899fa0370e Feature/improve users table of admin control panel (#866)
* Improve users table

* Update changelog
2022-04-25 22:39:08 +02:00
da27504aa1 Add orderBy statement to make debugging easier (#868) 2022-04-25 22:37:56 +02:00
b7bbc029ac Feature/render tags in dialogs (#864)
* Render tags

* Update changelog
2022-04-25 22:37:34 +02:00
c61a415fb2 Bugfix/change date to utc in data gathering service (#867)
* Change date to UTC

* Update changelog
2022-04-25 18:12:42 +02:00
8ff811ed28 Release/1.141.1 (#863) 2022-04-24 17:27:00 +02:00
9a2ea0a4ed Release 1.141.0 (#862) 2022-04-24 16:24:21 +02:00
bad9d17c44 Setup allocations page and endpoint (#859)
* Setup tagging system

* Update changelog
2022-04-24 16:23:03 +02:00
ea89ca5734 Feature/simplify ids in database schema (#861)
* Simplify ids in database schema
  * Access
  * Order
  * Subscription

* Update changelog
2022-04-24 09:35:01 +02:00
8f61f7c169 Feature/extract activities table filter component (#858)
* Extract activities table component

* Update changelog
2022-04-23 19:22:20 +02:00
edca05f542 Feature/change get started url of public page (#857)
* Change url

* Update changelog
2022-04-23 16:48:23 +02:00
283f054ee2 Feature/upgrade prisma to version 3.12.0 (#856)
* Upgrade prisma to version 3.12.0

* Update changelog
2022-04-23 13:38:41 +02:00
e9a46cb224 Release 1.140.2 (#854) 2022-04-23 09:52:22 +02:00
4a75c6d483 Release 1.140.1 (#853) 2022-04-22 21:45:50 +02:00
bbe9183fb0 Release 1.140.0 (#852) 2022-04-22 21:29:08 +02:00
1b03ddc586 Feature/add symbol profile overrides model (#851)
* Add symbol profile overrides model

* Update changelog
2022-04-22 21:27:55 +02:00
beb12637ce Bugfix/fix total calculation for sell and dividend (#850)
* Fix calculation for sell and dividend activities

* Update changelog
2022-04-22 19:29:18 +02:00
20358d9105 Feature/persist savings rate (#849)
* Persist savings rate

* Update changelog
2022-04-21 23:07:19 +02:00
0e4c39d145 Feature/reuse value component in ghostfolio in numbers section (#846)
* Reuse value component

* Update changelog
2022-04-19 17:06:12 +02:00
83ebacbb06 Add Buy me a coffee link (#848) 2022-04-18 21:32:54 +02:00
7c58c5fb7f Setup funding.yml (#847) 2022-04-18 21:20:30 +02:00
f3271ab1ff Feature/upgrade yahoo finance2 to version 2.3.1 (#844)
* Upgrade yahoo-finance2 to version 2.3.1

* Update changelog
2022-04-18 17:10:00 +02:00
9f597cbff1 Release 1.139.0 (#843) 2022-04-18 11:59:16 +02:00
90efc2ac51 Feature/beautify etf names in asset profile (#842)
* Beautify ETF names

* Update changelog
2022-04-18 11:57:57 +02:00
056b318d86 Bugfix/fix end date in ics files (#841)
* Fix end date

* Update changelog
2022-04-18 11:31:16 +02:00
82ede2fe32 Bugfix/fix fear and greed data source (#840)
* Fix data source of Fear & Greed Index

* Update changelog
2022-04-18 10:49:02 +02:00
8ae041faa0 Bugfix/fix issue in fire calculator after changing investment horizon (#839)
* Properly update chart datasets and improve tooltip

* Update changelog
2022-04-18 10:31:16 +02:00
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
177 changed files with 5570 additions and 2680 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']

View File

@ -5,6 +5,249 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.152.0 - 26.05.2022
### Added
- Added the _Ghostfolio_ trailer to the landing page
- Extended the markets overview by benchmarks (current change to the all time high)
## 1.151.0 - 24.05.2022
### Added
- Added support to set the base currency as an environment variable (`BASE_CURRENCY`)
### Fixed
- Fixed an issue with the missing conversion of countries in the symbol profile overrides
## 1.150.0 - 21.05.2022
### Changed
- Skipped data enhancer (_Trackinsight_) if data is inaccurate
### Fixed
- Fixed an issue with the currency conversion in the account calculations
- Fixed an issue with countries in the symbol profile overrides
## 1.149.0 - 16.05.2022
### Added
- Added groups to the activities filter component
- Added support for filtering by asset class on the allocations page
## 1.148.0 - 14.05.2022
### Added
- Supported enter key press to submit the form of the create or edit transaction dialog
- Added a _Report Data Glitch_ button to the position detail dialog
### Fixed
- Fixed the date format of the date picker and support manual changes
- Fixed the state of the account delete button (disable if account contains activities)
- Fixed an issue in the activities filter component (typing a search term)
## 1.147.0 - 10.05.2022
### Changed
- Improved the allocations page with no filtering (include cash positions)
## 1.146.3 - 08.05.2022
### Added
- Set up a queue for the data gathering jobs
- Set up _Nx Cloud_
### Changed
- Migrated the asset profile data gathering to the queue design pattern
- Improved the allocations page with no filtering
- Harmonized the _No data available_ label in the portfolio proportion chart component
- Improved the _FIRE_ calculator for the _Live Demo_
- Simplified the about page
- Upgraded `angular` from version `13.2.2` to `13.3.6`
- Upgraded `Nx` from version `13.8.5` to `14.1.4`
- Upgraded `storybook` from version `6.4.18` to `6.4.22`
### Fixed
- Eliminated the circular dependencies in the `@ghostfolio/common` library
## 1.145.0 - 07.05.2022
### Added
- Added support for filtering by accounts on the allocations page
- Added support for private equity
- Extended the form to set the asset and asset sub class for (wealth) items
### Changed
- Refactored the filtering (activities table and allocations page)
### Fixed
- Fixed the tooltip update in the portfolio proportion chart component
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.144.0 - 30.04.2022
### Added
- Added support for commodities (via futures)
- Added support for real estate
### Changed
- Improved the layout of the position detail dialog
- Upgraded `yahoo-finance2` from version `2.3.1` to `2.3.2`
### Fixed
- Fixed the import validation for numbers equal 0
- Fixed the color of the spinner in the activities filter component (dark mode)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.143.0 - 26.04.2022
### Changed
- Improved the filtering by tags
## 1.142.0 - 25.04.2022
### Added
- Added the tags to the create or edit transaction dialog
- Added the tags to the position detail dialog
### Changed
- Changed the date to UTC in the data gathering service
- Reused the value component in the users table of the admin control panel
## 1.141.1 - 24.04.2022
### Added
- Added the database migration
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.141.0 - 24.04.2022
### Added
- Added a tagging system for activities
### Changed
- Extracted the activities table filter to a dedicated component
- Changed the url of the _Get Started_ link to `https://ghostfol.io` on the public page
- Simplified `@@id` using multiple fields with `@id` in the database schema of (`Access`, `Order`, `Subscription`)
- Upgraded `prisma` from version `3.11.1` to `3.12.0`
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.140.2 - 22.04.2022
### Added
- Added support for sub-labels in the value component
- Added a symbol profile overrides model for manual adjustments
### Changed
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
- Persisted the savings rate in the _FIRE_ calculator
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
### Fixed
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.139.0 - 18.04.2022
### Added
- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
### Changed
- Beautified the ETF names in the symbol profile
### Fixed
- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
- Fixed the data source of the _Fear & Greed Index_ (market mood)
## 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 _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
@ -248,7 +491,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Upgraded `angular` from version `13.1.2` to `13.2.3`
- Upgraded `angular` from version `13.1.2` to `13.2.2`
- Upgraded `Nx` from version `13.4.1` to `13.8.1`
- Upgraded `storybook` from version `6.4.9` to `6.4.18`

View File

@ -22,8 +22,8 @@ RUN node decorate-angular-cli.js
COPY ./angular.json angular.json
COPY ./nx.json nx.json
COPY ./replace.build.js replace.build.js
COPY ./jest.preset.js jest.preset.js
COPY ./jest.config.js jest.config.js
COPY ./jest.preset.ts jest.preset.ts
COPY ./jest.config.ts jest.config.ts
COPY ./tsconfig.base.json tsconfig.base.json
COPY ./libs libs
COPY ./apps apps

View File

@ -9,7 +9,7 @@
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Wealth Management Software made for Humans</strong>
<strong>Open Source Wealth Management Software</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
@ -24,10 +24,11 @@
</p>
</div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div>
## Ghostfolio Premium
@ -62,6 +63,10 @@ Ghostfolio is for you if you are...
- ✅ Zen Mode
- ✅ Mobile-first design
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Technology Stack
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
@ -128,6 +133,10 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
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`
## Run with _Unraid_ (self-hosting)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
## Development
### Prerequisites
@ -246,6 +255,8 @@ Ghostfolio is **100% free** and **open source**. We encourage and support an act
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## License
© 2022 [Ghostfolio](https://ghostfol.io)

View File

@ -47,7 +47,7 @@
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/api/jest.config.js",
"jestConfig": "apps/api/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/api"]
@ -180,7 +180,7 @@
"test": {
"builder": "@nrwl/jest:jest",
"options": {
"jestConfig": "apps/client/jest.config.js",
"jestConfig": "apps/client/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["coverage/apps/client"]
@ -225,7 +225,7 @@
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/common"],
"options": {
"jestConfig": "libs/common/jest.config.js",
"jestConfig": "libs/common/jest.config.ts",
"passWithNoTests": true
}
}
@ -247,7 +247,7 @@
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.js",
"jestConfig": "libs/ui/jest.config.ts",
"passWithNoTests": true
}
},

View File

@ -1,6 +1,6 @@
module.exports = {
displayName: 'api',
preset: '../../jest.preset.js',
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json'
@ -12,5 +12,6 @@ module.exports = {
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node'
testEnvironment: 'node',
preset: '../../jest.preset.ts'
};

View File

@ -78,8 +78,12 @@ export class AccessController {
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
const access = await this.accessService.access({ id });
if (
!hasPermission(this.request.user.permissions, permissions.deleteAccess)
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
!access ||
access.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -88,10 +92,7 @@ export class AccessController {
}
return this.accessService.deleteAccess({
id_userId: {
id,
userId: this.request.user.id
}
id
});
}
}

View File

@ -1,8 +1,10 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -102,22 +104,43 @@ export class AccountService {
});
}
public async getCashDetails(
aUserId: string,
aCurrency: string
): Promise<CashDetails> {
public async getCashDetails({
currency,
filters = [],
userId
}: {
currency: string;
filters?: Filter[];
userId: string;
}): Promise<CashDetails> {
let totalCashBalanceInBaseCurrency = new Big(0);
const accounts = await this.accounts({
where: { userId: aUserId }
const where: Prisma.AccountWhereInput = { userId };
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount?.length > 0) {
where.id = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
const accounts = await this.accounts({ where });
for (const account of accounts) {
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
currency
)
);
}

View File

@ -1,6 +1,10 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
@ -8,6 +12,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import {
Body,
Controller,
@ -23,6 +28,7 @@ import {
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { Queue } from 'bull';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -33,6 +39,8 @@ import { UpdateMarketDataDto } from './update-market-data.dto';
export class AdminController {
public constructor(
private readonly adminService: AdminService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService,
private readonly marketDataService: MarketDataService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -71,10 +79,16 @@ export class AdminController {
);
}
await this.dataGatheringService.gatherProfileData();
this.dataGatheringService.gatherMax();
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return;
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
this.dataGatheringService.gatherMax();
}
@Post('gather/profile-data')
@ -92,9 +106,14 @@ export class AdminController {
);
}
this.dataGatheringService.gatherProfileData();
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
return;
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
}
@Post('gather/profile-data/:dataSource/:symbol')
@ -115,9 +134,10 @@ export class AdminController {
);
}
this.dataGatheringService.gatherProfileData([{ dataSource, symbol }]);
return;
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
@Post('gather/:dataSource/:symbol')

View File

@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
@ -15,11 +15,13 @@ import {
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { DataSource, Property } from '@prisma/client';
import { Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@Injectable()
export class AdminService {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
@ -29,7 +31,9 @@ export class AdminService {
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
@ -43,15 +47,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== baseCurrency;
return currency !== this.baseCurrency;
})
.map((currency) => {
return {
label1: baseCurrency,
label1: this.baseCurrency,
label2: currency,
value: this.exchangeRateDataService.toCurrency(
1,
baseCurrency,
this.baseCurrency,
currency
)
};

View File

@ -9,6 +9,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@ -19,6 +20,7 @@ import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
@ -36,6 +38,13 @@ import { UserModule } from './user/user.module';
AccountModule,
AuthDeviceModule,
AuthModule,
BenchmarkModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT, 10)
}
}),
CacheModule,
ConfigModule.forRoot(),
ConfigurationModule,

View File

@ -0,0 +1,32 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { BenchmarkService } from './benchmark.service';
@Controller('benchmark')
export class BenchmarkController {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
};
}
}

View File

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

View File

@ -0,0 +1,77 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import Big from 'big.js';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getBenchmarks(
benchmarkAssets: UniqueAsset[]
): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) {
return benchmarks;
}
} catch {}
const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([
this.dataProviderService.getQuotes(benchmarkAssets),
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
]);
for (const benchmarkAsset of benchmarkAssets) {
promises.push(this.marketDataService.getMax(benchmarkAsset));
}
const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
return {
name: assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === benchmarkAssets[index].dataSource &&
symbol === benchmarkAssets[index].symbol
);
})?.name,
performances: {
allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber()
}
}
};
});
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks)
);
return benchmarks;
}
}

View File

@ -42,6 +42,7 @@ export class ExportService {
accountId,
date,
fee,
id,
quantity,
SymbolProfile,
type,
@ -49,13 +50,14 @@ export class ExportService {
}) => {
return {
accountId,
date,
fee,
id,
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
};
}

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -26,7 +27,8 @@ import { InfoService } from './info.service';
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
SymbolProfileModule,
TagModule
],
providers: [InfoService]
})

View File

@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
DEMO_USER_ID,
PROPERTY_IS_READ_ONLY_MODE,
@ -33,7 +34,8 @@ export class InfoService {
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
private readonly redisCacheService: RedisCacheService,
private readonly tagService: TagService
) {}
public async get(): Promise<InfoItem> {
@ -52,9 +54,15 @@ export class InfoService {
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
} else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
}
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
@ -95,11 +103,13 @@ export class InfoService {
isReadOnlyMode,
platforms,
systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
subscriptions: await this.getSubscriptions(),
tags: await this.tagService.get()
};
}

View File

@ -1,4 +1,4 @@
import { DataSource, Type } from '@prisma/client';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
IsEnum,
IsISO8601,
@ -10,14 +10,22 @@ import {
export class CreateOrderDto {
@IsString()
@IsOptional()
accountId: string;
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
dataSource: DataSource;
dataSource?: DataSource;
@IsISO8601()
date: string;

View File

@ -42,8 +42,12 @@ export class OrderController {
@Delete(':id')
@UseGuards(AuthGuard('jwt'))
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
const order = await this.orderService.order({ id });
if (
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
!order ||
order.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -52,10 +56,7 @@ export class OrderController {
}
return this.orderService.deleteOrder({
id_userId: {
id,
userId: this.request.user.id
}
id
});
}
@ -135,23 +136,15 @@ export class OrderController {
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const originalOrder = await this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
id
});
if (!originalOrder) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
!originalOrder ||
originalOrder.userId !== this.request.user.id
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
@ -178,15 +171,17 @@ export class OrderController {
dataSource: data.dataSource,
symbol: data.symbol
}
},
update: {
assetClass: data.assetClass,
assetSubClass: data.assetSubClass,
name: data.symbol
}
},
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
}
id
}
});
}

View File

@ -4,11 +4,26 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataSource,
Order,
Prisma,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
import { Queue } from 'bull';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface';
@ -18,6 +33,8 @@ export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly cacheService: CacheService,
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService,
@ -55,6 +72,8 @@ export class OrderService {
public async createOrder(
data: Prisma.OrderCreateInput & {
accountId?: string;
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string;
dataSource?: DataSource;
symbol?: string;
@ -77,6 +96,8 @@ export class OrderService {
};
if (data.type === 'ITEM') {
const assetClass = data.assetClass;
const assetSubClass = data.assetSubClass;
const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
@ -84,6 +105,8 @@ export class OrderService {
Account = undefined;
data.id = id;
data.SymbolProfile.connectOrCreate.create.assetClass = assetClass;
data.SymbolProfile.connectOrCreate.create.assetSubClass = assetSubClass;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name;
@ -97,12 +120,10 @@ export class OrderService {
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
await this.dataGatheringService.gatherProfileData([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
});
const isDraft = isAfter(data.date as Date, endOfToday());
@ -120,6 +141,8 @@ export class OrderService {
await this.cacheService.flush();
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
delete data.currency;
delete data.dataSource;
delete data.symbol;
@ -151,11 +174,13 @@ export class OrderService {
}
public async getOrders({
filters,
includeDrafts = false,
types,
userCurrency,
userId
}: {
filters?: Filter[];
includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
@ -163,10 +188,64 @@ export class OrderService {
}): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId };
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount?.length > 0) {
where.accountId = {
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}
if (includeDrafts === false) {
where.isDraft = false;
}
if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
SymbolProfileOverrides: {
is: null
}
}
]
},
{
SymbolProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {
OR: filtersByTag.map(({ id }) => {
return { id };
})
}
};
}
if (types) {
where.OR = types.map((type) => {
return {
@ -188,7 +267,8 @@ export class OrderService {
}
},
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
SymbolProfile: true,
tags: true
},
orderBy: { date: 'asc' }
})
@ -217,6 +297,8 @@ export class OrderService {
where
}: {
data: Prisma.OrderUpdateInput & {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
currency?: string;
dataSource?: DataSource;
symbol?: string;
@ -230,10 +312,10 @@ export class OrderService {
let isDraft = false;
if (data.type === 'ITEM') {
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
data.SymbolProfile = { update: { name } };
delete data.SymbolProfile.connect;
} else {
delete data.SymbolProfile.update;
isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
@ -250,6 +332,8 @@ export class OrderService {
await this.cacheService.flush();
delete data.assetClass;
delete data.assetSubClass;
delete data.currency;
delete data.dataSource;
delete data.symbol;

View File

@ -1,10 +1,24 @@
import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
import { AssetClass, AssetSubClass, DataSource, Type } from '@prisma/client';
import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class UpdateOrderDto {
@IsOptional()
@IsString()
accountId: string;
accountId?: string;
@IsEnum(AssetClass, { each: true })
@IsOptional()
assetClass?: AssetClass;
@IsEnum(AssetSubClass, { each: true })
@IsOptional()
assetSubClass?: AssetSubClass;
@IsString()
currency: string;

View File

@ -1,6 +1,7 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
@ -20,14 +21,24 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };
}
return { marketPrice: 0 };
default:
return { marketPrice: 0 };
}
}
export const CurrentRateServiceMock = {
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
const result = [];
getValues: ({
dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
@ -37,8 +48,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol
});
}
@ -48,8 +61,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol
});
}

View File

@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize();
@ -96,15 +102,15 @@ describe('CurrentRateService', () => {
},
userCurrency: 'CHF'
})
).toMatchObject([
).toMatchObject<GetValueObject[]>([
{
date: undefined,
marketPrice: 1841.823902,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPrice: 1847.839966,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]);

View File

@ -28,13 +28,7 @@ export class CurrentRateService {
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<
{
date: Date;
marketPrice: number;
symbol: string;
}[]
>[] = [];
const promises: Promise<GetValueObject[]>[] = [];
if (includeToday) {
const today = resetHours(new Date());
@ -42,16 +36,17 @@ export class CurrentRateService {
this.dataProviderService
.getQuotes(dataGatheringItems)
.then((dataResultProvider) => {
const result = [];
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date: today,
marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]
?.marketPrice ?? 0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
symbol: dataGatheringItem.symbol
});
}
@ -74,11 +69,12 @@ export class CurrentRateService {
return data.map((marketDataItem) => {
return {
date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
symbol: marketDataItem.symbol
};
});

View File

@ -1,5 +1,5 @@
export interface GetValueObject {
date: Date;
marketPrice: number;
marketPriceInBaseCurrency: number;
symbol: string;
}

View File

@ -1,5 +1,9 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import {
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client';
export interface PortfolioPositionDetail {
averagePrice: number;
@ -16,6 +20,7 @@ export interface PortfolioPositionDetail {
orders: OrderWithAccount[];
quantity: number;
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];
transactionCount: number;
value: number;
}
@ -25,10 +30,3 @@ export interface HistoricalDataContainer {
isAllTimeLow: boolean;
items: HistoricalDataItem[];
}
export interface HistoricalDataItem {
averagePrice?: number;
date: string;
grossPerformancePercent?: number;
value: number;
}

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

View File

@ -77,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity
.mul(factor)
.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 = {
investment,
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
@ -218,9 +231,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
marketSymbol.marketPriceInBaseCurrency
);
}
}
@ -535,9 +548,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
marketSymbol.marketPriceInBaseCurrency
);
}
}

View File

@ -8,9 +8,9 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
Filter,
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,
@ -19,7 +19,7 @@ import {
PortfolioReport,
PortfolioSummary
} from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
Get,
@ -42,6 +42,8 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
private baseCurrency: string;
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@ -49,7 +51,9 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
@ -105,15 +109,44 @@ export class PortfolioController {
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange,
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'TAG'
};
})
];
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
range,
filters
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
@ -159,7 +192,11 @@ export class PortfolioController {
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic';
return { accounts, hasError, holdings: isBasicUser ? {} : holdings };
return {
accounts,
hasError,
holdings: isBasicUser ? {} : holdings
};
}
@Get('investments')
@ -293,7 +330,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.currency ?? baseCurrency
this.request.user?.Settings?.currency ?? this.baseCurrency
);
})
.reduce((a, b) => a + b, 0);

View File

@ -15,20 +15,21 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY,
baseCurrency
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
PortfolioDetails,
PortfolioPerformanceResponse,
PortfolioReport,
@ -46,7 +47,12 @@ import type {
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
import {
AssetClass,
DataSource,
Tag,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js';
import {
differenceInDays,
@ -62,11 +68,10 @@ import {
subDays,
subYears
} from 'date-fns';
import { isEmpty, sortBy } from 'lodash';
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
import {
HistoricalDataContainer,
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioCalculator } from './portfolio-calculator';
@ -77,8 +82,11 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable()
export class PortfolioService {
private baseCurrency: string;
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -88,7 +96,9 @@ export class PortfolioService {
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
@ -303,7 +313,8 @@ export class PortfolioService {
public async getDetails(
aImpersonationId: string,
aUserId: string,
aDateRange: DateRange = 'max'
aDateRange: DateRange = 'max',
aFilters?: Filter[]
): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId, aUserId);
const user = await this.userService.user({ id: userId });
@ -312,13 +323,14 @@ export class PortfolioService {
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
);
const userCurrency =
this.request.user?.Settings?.currency ??
user.Settings?.currency ??
baseCurrency;
this.request.user?.Settings?.currency ??
this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId
userId,
filters: aFilters
});
const portfolioCalculator = new PortfolioCalculator({
@ -337,10 +349,11 @@ export class PortfolioService {
startDate
);
const cashDetails = await this.accountService.getCashDetails(
const cashDetails = await this.accountService.getCashDetails({
userId,
userCurrency
);
currency: userCurrency,
filters: aFilters
});
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus(
@ -362,7 +375,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols)
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -433,24 +446,32 @@ export class PortfolioService {
};
}
const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestment,
value: totalValue
});
if (
aFilters?.length === 0 ||
(aFilters?.length === 1 &&
aFilters[0].type === 'ASSET_CLASS' &&
aFilters[0].id === 'CASH')
) {
const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
userCurrency,
investment: totalInvestment,
value: totalValue
});
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
for (const symbol of Object.keys(cashPositions)) {
holdings[symbol] = cashPositions[symbol];
}
}
const accounts = await this.getValueOfAccounts(
const accounts = await this.getValueOfAccounts({
orders,
portfolioItemsNow,
userCurrency,
userId
);
userId,
filters: aFilters
});
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
}
@ -472,8 +493,11 @@ export class PortfolioService {
);
});
let tags: Tag[] = [];
if (orders.length <= 0) {
return {
tags,
averagePrice: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
@ -494,12 +518,13 @@ export class PortfolioService {
}
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
const [SymbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
tags = tags.concat(order.tags);
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
@ -514,6 +539,8 @@ export class PortfolioService {
unitPrice: new Big(order.unitPrice)
}));
tags = uniqBy(tags, 'id');
const portfolioCalculator = new PortfolioCalculator({
currency: positionCurrency,
currentRateService: this.currentRateService,
@ -622,6 +649,7 @@ export class PortfolioService {
netPerformance,
orders,
SymbolProfile,
tags,
transactionCount,
averagePrice: averagePrice.toNumber(),
grossPerformancePercent:
@ -678,6 +706,7 @@ export class PortfolioService {
minPrice,
orders,
SymbolProfile,
tags,
averagePrice: 0,
firstBuyDate: undefined,
grossPerformance: undefined,
@ -738,7 +767,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols)
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -758,8 +787,7 @@ export class PortfolioService {
position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(),
marketState:
dataProviderResponses[position.symbol]?.marketState ??
MarketState.delayed,
dataProviderResponses[position.symbol]?.marketState ?? 'delayed',
name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage:
@ -873,12 +901,12 @@ export class PortfolioService {
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = await this.getValueOfAccounts(
const accounts = await this.getValueOfAccounts({
orders,
portfolioItemsNow,
currency,
userId
);
userId,
userCurrency: currency
});
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
@ -940,10 +968,10 @@ export class PortfolioService {
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
userId,
userCurrency
);
currency: userCurrency
});
const orders = await this.orderService.getOrders({
userCurrency,
userId
@ -1043,7 +1071,7 @@ export class PortfolioService {
grossPerformancePercent: 0,
investment: convertedBalance,
marketPrice: 0,
marketState: MarketState.open,
marketState: 'open',
name: account.currency,
netPerformance: 0,
netPerformancePercent: 0,
@ -1177,9 +1205,11 @@ export class PortfolioService {
}
private async getTransactionPoints({
filters,
includeDrafts = false,
userId
}: {
filters?: Filter[];
includeDrafts?: boolean;
userId: string;
}): Promise<{
@ -1187,9 +1217,11 @@ export class PortfolioService {
orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[];
}> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const userCurrency =
this.request.user?.Settings?.currency ?? this.baseCurrency;
const orders = await this.orderService.getOrders({
filters,
includeDrafts,
userCurrency,
userId,
@ -1233,21 +1265,42 @@ export class PortfolioService {
portfolioCalculator.computeTransactionPoints();
return {
transactionPoints: portfolioCalculator.getTransactionPoints(),
orders,
portfolioOrders
portfolioOrders,
transactionPoints: portfolioCalculator.getTransactionPoints()
};
}
private async getValueOfAccounts(
orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency: string,
userId: string
) {
private async getValueOfAccounts({
filters = [],
orders,
portfolioItemsNow,
userCurrency,
userId
}: {
filters?: Filter[];
orders: OrderWithAccount[];
portfolioItemsNow: { [p: string]: TimelinePosition };
userCurrency: string;
userId: string;
}) {
const accounts: PortfolioDetails['accounts'] = {};
const currentAccounts = await this.accountService.getAccounts(userId);
let currentAccounts = [];
if (filters.length === 0) {
currentAccounts = await this.accountService.getAccounts(userId);
} else {
const accountIds = uniq(
orders.map(({ accountId }) => {
return accountId;
})
);
currentAccounts = await this.accountService.accounts({
where: { id: { in: accountIds } }
});
}
for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => {
@ -1257,34 +1310,47 @@ export class PortfolioService {
accounts[account.id] = {
balance: account.balance,
currency: account.currency,
current: account.balance,
current: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
name: account.name,
original: account.balance
original: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
};
for (const order of ordersByAccount) {
let currentValueOfSymbol =
let currentValueOfSymbolInBaseCurrency =
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
userCurrency
);
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
currentValueOfSymbolInBaseCurrency *= -1;
originalValueOfSymbolInBaseCurrency *= -1;
}
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol;
currentValueOfSymbolInBaseCurrency;
accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol;
originalValueOfSymbolInBaseCurrency;
} else {
accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol,
current: currentValueOfSymbolInBaseCurrency,
name: account.name,
original: originalValueOfSymbol
original: originalValueOfSymbolInBaseCurrency
};
}
}

View File

@ -1,9 +1,7 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataSource } from '@prisma/client';
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
export interface SymbolItem {
export interface SymbolItem extends UniqueAsset {
currency: string;
dataSource: DataSource;
historicalData: HistoricalDataItem[];
marketPrice: number;
}

View File

@ -1,4 +1,3 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import {
IDataGatheringItem,
@ -6,6 +5,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format, subDays } from 'date-fns';
@ -55,7 +55,8 @@ export class SymbolService {
currency,
historicalData,
marketPrice,
dataSource: dataGatheringItem.dataSource
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
};
}

View File

@ -1,4 +0,0 @@
export interface Access {
alias?: string;
id: string;
}

View File

@ -12,4 +12,8 @@ export class UpdateUserSettingDto {
@IsString()
@IsOptional()
locale?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;
}

View File

@ -34,7 +34,7 @@ import { UserService } from './user.service';
export class UserController {
public constructor(
private readonly configurationService: ConfigurationService,
private jwtService: JwtService,
private readonly jwtService: JwtService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService

View File

@ -2,6 +2,7 @@ import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscriptio
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt';
@ -19,7 +20,8 @@ import { UserService } from './user.service';
}),
PrismaModule,
PropertyModule,
SubscriptionModule
SubscriptionModule,
TagModule
],
providers: [UserService]
})

View File

@ -2,18 +2,14 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import {
getPermissions,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User, ViewMode } from '@prisma/client';
@ -26,12 +22,17 @@ const crypto = require('crypto');
export class UserService {
public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService
) {}
private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getUser(
{
@ -51,12 +52,21 @@ export class UserService {
orderBy: { User: { alias: 'asc' } },
where: { GranteeUser: { id } }
});
let tags = await this.tagService.getByUser(id);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
subscription.type === 'Basic'
) {
tags = [];
}
return {
alias,
id,
permissions,
subscription,
tags,
access: access.map((accessItem) => {
return {
alias: accessItem.User.alias,
@ -92,19 +102,69 @@ export class UserService {
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prismaService.user.findUnique({
const {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
Subscription,
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput
});
const user: UserWithSettings = userFromDatabase;
const user: UserWithSettings = {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
thirdPartyId,
updatedAt
};
let currentPermissions = getPermissions(userFromDatabase.role);
if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (user) {
// Set default settings if needed
user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: user?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
}
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode);
@ -125,29 +185,7 @@ export class UserService {
}
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (userFromDatabase) {
// Set default settings if needed
userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription
);
}
user.permissions = currentPermissions.sort();
return user;
}
@ -186,14 +224,14 @@ export class UserService {
...data,
Account: {
create: {
currency: baseCurrency,
currency: this.baseCurrency,
isDefault: true,
name: 'Default Account'
}
},
Settings: {
create: {
currency: baseCurrency
currency: this.baseCurrency
}
}
}

View File

@ -12,6 +12,7 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),

View File

@ -1,5 +1,11 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { InjectQueue } from '@nestjs/bull';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { Queue } from 'bull';
import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service';
@ -8,6 +14,8 @@ import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()
export class CronService {
public constructor(
@InjectQueue(DATA_GATHERING_QUEUE)
private readonly dataGatheringQueue: Queue,
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService
@ -30,6 +38,13 @@ export class CronService {
@Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData();
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
for (const { dataSource, symbol } of uniqueAssets) {
await this.dataGatheringQueue.add(GATHER_ASSET_PROFILE_PROCESS, {
dataSource,
symbol
});
}
}
}

View File

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

View File

@ -3,13 +3,19 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DATA_GATHERING_QUEUE } from '@ghostfolio/common/config';
import { BullModule } from '@nestjs/bull';
import { Module } from '@nestjs/common';
import { DataGatheringProcessor } from './data-gathering.processor';
import { ExchangeRateDataModule } from './exchange-rate-data.module';
import { SymbolProfileModule } from './symbol-profile.module';
@Module({
imports: [
BullModule.registerQueue({
name: DATA_GATHERING_QUEUE
}),
ConfigurationModule,
DataEnhancerModule,
DataProviderModule,
@ -17,7 +23,7 @@ import { SymbolProfileModule } from './symbol-profile.module';
PrismaModule,
SymbolProfileModule
],
providers: [DataGatheringService],
exports: [DataEnhancerModule, DataGatheringService]
providers: [DataGatheringProcessor, DataGatheringService],
exports: [BullModule, DataEnhancerModule, DataGatheringService]
})
export class DataGatheringModule {}

View File

@ -0,0 +1,27 @@
import {
DATA_GATHERING_QUEUE,
GATHER_ASSET_PROFILE_PROCESS
} from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Process, Processor } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Job } from 'bull';
import { DataGatheringService } from './data-gathering.service';
@Injectable()
@Processor(DATA_GATHERING_QUEUE)
export class DataGatheringProcessor {
public constructor(
private readonly dataGatheringService: DataGatheringService
) {}
@Process(GATHER_ASSET_PROFILE_PROCESS)
public async gatherAssetProfile(job: Job<UniqueAsset>) {
try {
await this.dataGatheringService.gatherAssetProfiles([job.data]);
} catch (error) {
Logger.error(error, 'DataGatheringProcessor');
}
}
}

View File

@ -226,31 +226,33 @@ export class DataGatheringService {
}
}
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
Logger.log(
'Profile data gathering has been started.',
'DataGatheringService'
);
console.time('data-gathering-profile');
public async gatherAssetProfiles(aUniqueAssets?: UniqueAsset[]) {
let uniqueAssets = aUniqueAssets?.filter((dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL';
});
let dataGatheringItems = aDataGatheringItems?.filter(
(dataGatheringItem) => {
return dataGatheringItem.dataSource !== 'MANUAL';
}
);
if (!dataGatheringItems) {
dataGatheringItems = await this.getSymbolsProfileData();
if (!uniqueAssets) {
uniqueAssets = await this.getUniqueAssets();
}
Logger.log(
`Asset profile data gathering has been started for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
const assetProfiles = await this.dataProviderService.getAssetProfiles(
dataGatheringItems
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
dataGatheringItems.map(({ symbol }) => {
return symbol;
})
uniqueAssets
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(
uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {
@ -322,10 +324,13 @@ export class DataGatheringService {
}
Logger.log(
'Profile data gathering has been completed.',
`Asset profile data gathering has been completed for ${uniqueAssets
.map(({ dataSource, symbol }) => {
return `${symbol} (${dataSource})`;
})
.join(',')}.`,
'DataGatheringService'
);
console.timeEnd('data-gathering-profile');
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -377,7 +382,14 @@ export class DataGatheringService {
data: {
dataSource,
symbol,
date: currentDate,
date: new Date(
Date.UTC(
getYear(currentDate),
getMonth(currentDate),
getDate(currentDate),
0
)
),
marketPrice: lastMarketPrice
}
});
@ -501,6 +513,27 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
public async getUniqueAssets(): Promise<UniqueAsset[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter(({ dataSource }) => {
return (
dataSource !== DataSource.GHOSTFOLIO &&
dataSource !== DataSource.MANUAL &&
dataSource !== DataSource.RAKUTEN
);
})
.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
});
}
public async reset() {
Logger.log('Data gathering has been reset.', 'DataGatheringService');
@ -537,6 +570,7 @@ export class DataGatheringService {
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
orderBy: [{ symbol: 'asc' }],
where: {
date: { gt: startDate }
}
@ -576,27 +610,6 @@ export class DataGatheringService {
return [...currencyPairsToGather, ...symbolProfilesToGather];
}
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return symbolProfiles
.filter((symbolProfile) => {
return (
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
symbolProfile.dataSource !== DataSource.MANUAL &&
symbolProfile.dataSource !== DataSource.RAKUTEN
);
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
}
private async isDataGatheringNeeded() {
const lastDataGathering = await this.getLastDataGathering();

View File

@ -32,7 +32,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
const holdings = await getJSON(
const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
).catch(() => {
return getJSON(
@ -42,12 +42,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
);
});
if (result.weight < 0.95) {
// Skip if data is inaccurate
return response;
}
if (
!response.countries ||
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) {
for (const [name, value] of Object.entries<any>(result.countries)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
@ -75,7 +80,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
for (const [name, value] of Object.entries<any>(result.sectors)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight

View File

@ -2,8 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -47,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try {
const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
@ -109,9 +107,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
@ -133,7 +130,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
marketPrice: marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbolProfile.symbol;
}).marketPrice,
marketState: MarketState.delayed
marketState: 'delayed'
};
}

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
@ -92,9 +91,8 @@ export class GoogleSheetsService implements DataProviderInterface {
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
@ -114,7 +112,7 @@ export class GoogleSheetsService implements DataProviderInterface {
return symbolProfile.symbol === symbol;
})?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
marketState: 'delayed'
};
}
}

View File

@ -3,8 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
@ -17,8 +16,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
@Injectable()
export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService
@ -120,7 +117,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
currency: undefined,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open
marketState: 'open'
}
};
}

View File

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceService } from './yahoo-finance.service';
@ -25,13 +26,18 @@ jest.mock(
);
describe('YahooFinanceService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
yahooFinanceService = new YahooFinanceService(
configurationService,
cryptocurrencyService
);
});
it('convertFromYahooFinanceSymbol', async () => {

View File

@ -1,12 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
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';
@ -16,7 +15,6 @@ import {
DataSource,
SymbolProfile
} 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';
@ -25,11 +23,14 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
return true;
@ -37,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface {
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${baseCurrency}$`),
baseCurrency
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
return symbol.replace('=X', '');
}
@ -51,12 +52,15 @@ export class YahooFinanceService implements DataProviderInterface {
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)
) {
// Add a dash before the last three characters
@ -64,8 +68,8 @@ export class YahooFinanceService implements DataProviderInterface {
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${baseCurrency}$`),
`-${baseCurrency}`
new RegExp(`-?${this.baseCurrency}$`),
`-${this.baseCurrency}`
);
}
}
@ -92,8 +96,12 @@ export class YahooFinanceService implements DataProviderInterface {
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name =
assetProfile.price.longName || assetProfile.price.shortName || symbol;
response.name = this.formatName({
longName: assetProfile.price.longName,
quoteType: assetProfile.price.quoteType,
shortName: assetProfile.price.shortName,
symbol: assetProfile.price.symbol
});
response.symbol = aSymbol;
if (
@ -215,8 +223,8 @@ export class YahooFinanceService implements DataProviderInterface {
marketState:
quote.marketState === 'REGULAR' ||
this.cryptocurrencyService.isCryptocurrency(symbol)
? MarketState.open
: MarketState.closed,
? 'open'
: 'closed',
marketPrice: quote.regularMarketPrice || 0
};
@ -244,57 +252,63 @@ export class YahooFinanceService implements DataProviderInterface {
const items: LookupItem[] = [];
try {
const get = bent(
`${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 searchResult = await yahooFinance.search(aQuery);
const quotes = searchResult.quotes
.filter((quote) => {
// filter out undefined symbols
// Filter out undefined symbols
return quote.symbol;
})
.filter(({ quoteType, symbol }) => {
return (
(quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
symbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)) ||
['EQUITY', 'ETF', 'MUTUALFUND'].includes(quoteType)
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
);
})
.filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before
return symbol.includes(baseCurrency);
return symbol.includes(this.baseCurrency);
} else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F
return symbol.length === 4;
}
return true;
});
const marketData = await this.getQuotes(
const marketData = await yahooFinance.quote(
quotes.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, value] of Object.entries(marketData)) {
const quote = quotes.find((currentQuote: any) => {
return currentQuote.symbol === symbol;
for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === marketDataItem.symbol;
});
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
items.push({
symbol,
currency: value.currency,
currency: marketDataItem.currency,
dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol
name: this.formatName({
longName: quote.longname,
quoteType: quote.quoteType,
shortName: quote.shortname,
symbol: quote.symbol
})
});
}
} catch (error) {
@ -304,6 +318,40 @@ export class YahooFinanceService implements DataProviderInterface {
return { items };
}
private formatName({
longName,
quoteType,
shortName,
symbol
}: {
longName: Price['longName'];
quoteType: Price['quoteType'];
shortName: Price['shortName'];
symbol: Price['symbol'];
}) {
let name = longName;
if (name) {
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
}
if (quoteType === 'FUTURE') {
// "Gold Jun 22" -> "Gold"
name = shortName?.slice(0, -6);
}
return name || shortName || symbol;
}
private parseAssetClass(aPrice: Price): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
@ -323,6 +371,20 @@ export class YahooFinanceService implements DataProviderInterface {
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
case 'future':
assetClass = AssetClass.COMMODITY;
assetSubClass = AssetSubClass.COMMODITY;
if (
aPrice?.shortName?.toLowerCase()?.startsWith('gold') ||
aPrice?.shortName?.toLowerCase()?.startsWith('palladium') ||
aPrice?.shortName?.toLowerCase()?.startsWith('platinum') ||
aPrice?.shortName?.toLowerCase()?.startsWith('silver')
) {
assetSubClass = AssetSubClass.PRECIOUS_METAL;
}
break;
case 'mutualfund':
assetClass = AssetClass.EQUITY;

View File

@ -1,12 +1,18 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({
imports: [DataProviderModule, PrismaModule, PropertyModule],
imports: [
ConfigurationModule,
DataProviderModule,
PrismaModule,
PropertyModule
],
providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService]
})

View File

@ -1,9 +1,10 @@
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns';
import { isNumber, uniq } from 'lodash';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@ -11,11 +12,13 @@ import { PropertyService } from './property/property.service';
@Injectable()
export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {};
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
@ -24,7 +27,7 @@ export class ExchangeRateDataService {
}
public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [baseCurrency];
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
}
public getCurrencyPairs() {
@ -32,6 +35,7 @@ export class ExchangeRateDataService {
}
public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies();
this.currencyPairs = [];
this.exchangeRates = {};
@ -212,14 +216,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies
.filter((currency) => {
return currency !== baseCurrency;
return currency !== this.baseCurrency;
})
.map((currency) => {
return {
currency1: baseCurrency,
currency1: this.baseCurrency,
currency2: currency,
dataSource: this.dataProviderService.getPrimaryDataSource(),
symbol: `${baseCurrency}${currency}`
symbol: `${this.baseCurrency}${currency}`
};
});
}

View File

@ -3,6 +3,7 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?

View File

@ -1,18 +1,11 @@
import { MarketState } from '@ghostfolio/common/types';
import {
Account,
AssetClass,
AssetSubClass,
DataSource,
SymbolProfile,
Type as TypeOfOrder
} from '@prisma/client';
export const MarketState = {
closed: 'closed',
delayed: 'delayed',
open: 'open'
};
export interface IOrder {
account: Account;
currency: string;
@ -44,5 +37,3 @@ export interface IDataGatheringItem {
date?: Date;
symbol: string;
}
export type MarketState = typeof MarketState[keyof typeof MarketState];

View File

@ -34,6 +34,20 @@ export class MarketDataService {
});
}
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
const aggregations = await this.prismaService.marketData.aggregate({
_max: {
marketPrice: true
},
where: {
dataSource,
symbol
}
});
return aggregations._max.marketPrice;
}
public async getRange({
dateQuery,
symbols

View File

@ -1,14 +1,21 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
EnhancedSymbolProfile,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import {
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable()
export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {}
@ -32,10 +39,40 @@ export class SymbolProfileService {
}
public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
.findMany({
include: { SymbolProfileOverrides: true },
where: {
AND: [
{
dataSource: {
in: aUniqueAssets.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: aUniqueAssets.map(({ symbol }) => {
return symbol;
})
}
}
]
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
/**
* @deprecated
*/
public async getSymbolProfilesBySymbols(
symbols: string[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
.findMany({
include: { SymbolProfileOverrides: true },
where: {
symbol: {
in: symbols
@ -45,30 +82,66 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => ({
...symbolProfile,
countries: this.getCountries(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
}));
private getSymbols(
symbolProfiles: (SymbolProfile & {
SymbolProfileOverrides: SymbolProfileOverrides;
})[]
): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => {
const item = {
...symbolProfile,
countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray
),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
};
if (item.SymbolProfileOverrides) {
item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
if (
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
item.countries = this.getCountries(
item.SymbolProfileOverrides
?.countries as unknown as Prisma.JsonArray
);
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
item.sectors;
delete item.SymbolProfileOverrides;
}
return item;
});
}
private getCountries(symbolProfile: SymbolProfile): Country[] {
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
(country) => {
const { code, weight } = country as Prisma.JsonObject;
private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
if (aCountries === null) {
return [];
}
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
}
);
return aCountries.map((country: Pick<Country, 'code' | 'weight'>) => {
const { code, weight } = country;
return {
code,
weight,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY
};
});
}
private getScraperConfiguration(

View File

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

View File

@ -0,0 +1,30 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
@Injectable()
export class TagService {
public constructor(private readonly prismaService: PrismaService) {}
public async get() {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
}
});
}
public async getByUser(userId: string) {
return this.prismaService.tag.findMany({
orderBy: {
name: 'asc'
},
where: {
orders: {
some: {
userId
}
}
}
});
}
}

View File

@ -6,6 +6,6 @@
"emitDecoratorMetadata": true,
"target": "es2015"
},
"exclude": ["**/*.spec.ts", "**/*.test.ts"],
"exclude": ["**/*.spec.ts", "**/*.test.ts", "jest.config.ts"],
"include": ["**/*.ts"]
}

View File

@ -5,5 +5,5 @@
"module": "commonjs",
"types": ["jest", "node"]
},
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"]
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts", "jest.config.ts"]
}

View File

@ -1,6 +1,6 @@
module.exports = {
displayName: 'client',
preset: '../../jest.preset.js',
setupFilesAfterEnv: ['<rootDir>/src/test-setup.ts'],
globals: {
'ts-jest': {
@ -17,5 +17,6 @@ module.exports = {
transform: {
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
},
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)']
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)'],
preset: '../../jest.preset.ts'
};

View File

@ -1,20 +1,28 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { format, isValid } from 'date-fns';
import * as deDateFnsLocale from 'date-fns/locale/de/index';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { format, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter {
/**
* @constructor
*/
public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform
) {
super(matDateLocale, platform);
}
/**
* Formats a date as a string
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, getDateFormatString(this.locale));
}
/**
* Sets the first day of the week to Monday
*/
@ -22,44 +30,10 @@ export class CustomDateAdapter extends NativeDateAdapter {
return 1;
}
/**
* Formats a date as a string according to the given format
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, aParseFormat, {
locale: <any>deDateFnsLocale
});
}
/**
* Parses a date from a provided value
*/
public parse(aValue: any): Date {
let date: Date;
try {
// TODO
// Native date parser from the following formats:
// - 'd.M.yyyy'
// - 'dd.MM.yyyy'
// https://github.com/you-dont-need/You-Dont-Need-Momentjs#string--date-format
const datePattern = /^(\d{1,2}).(\d{1,2}).(\d{4})$/;
const [, day, month, year] = datePattern.exec(aValue);
date = new Date(
parseInt(year, 10),
parseInt(month, 10) - 1, // monthIndex
parseInt(day, 10)
);
} catch (error) {
} finally {
const isDateValid = date && isValid(date);
if (isDateValid) {
return date;
}
return null;
}
public parse(aValue: string): Date {
return parse(aValue, getDateFormatString(this.locale), new Date());
}
}

View File

@ -194,16 +194,17 @@
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateAccount(element)">
Edit
<button mat-menu-item (click)="onUpdateAccount(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button
i18n
mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0"
[disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)"
>
Delete
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>

View File

@ -8,11 +8,13 @@ import {
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DATE_FORMAT,
getDateFormatString,
getLocale
} from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client';
import {
@ -53,14 +55,24 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
};
} = {};
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private deviceService: DeviceDetectorService,
private dialog: MatDialog
private dialog: MatDialog,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}
public ngOnInit() {}
@ -145,7 +157,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
date,
marketPrice,
dataSource: this.dataSource,
symbol: this.symbol
symbol: this.symbol,
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -1,3 +1,4 @@
import { User } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams {
@ -5,4 +6,5 @@ export interface MarketDataDetailDialogParams {
date: Date;
marketPrice: number;
symbol: string;
user: User;
}

View File

@ -5,6 +5,7 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { Subject, takeUntil } from 'rxjs';
@ -24,11 +25,16 @@ export class MarketDataDetailDialog implements OnDestroy {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
@Inject(MAT_DATE_LOCALE) private locale: string
) {}
public ngOnInit() {}
public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
}
public onCancel(): void {
this.dialogRef.close({ withRefresh: false });

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { AdminData } from '@ghostfolio/common/interfaces';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { AdminData, User } from '@ghostfolio/common/interfaces';
import {
differenceInSeconds,
formatDistanceToNowStrict,
@ -15,6 +16,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-users.html'
})
export class AdminUsersComponent implements OnDestroy, OnInit {
public user: User;
public users: AdminData['users'];
private unsubscribeSubject = new Subject<void>();
@ -24,8 +26,17 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
*/
public constructor(
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;
}
});
}
/**
* Initializes the controller

View File

@ -45,14 +45,27 @@
<td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }}
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.accountCount }}
<td class="mat-cell px-1 py-2">
<gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.accountCount"
></gf-value>
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.transactionCount }}
<td class="mat-cell px-1 py-2">
<gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[value]="userItem.transactionCount"
></gf-value>
</td>
<td class="mat-cell px-1 py-2 text-right">
{{ userItem.engagement | number: '1.0-0' }}
<td class="mat-cell px-1 py-2">
<gf-value
class="align-items-end"
[locale]="user?.settings?.locale"
[precision]="0"
[value]="userItem.engagement"
></gf-value>
</td>
<td class="mat-cell px-1 py-2">
{{ formatDistanceToNow(userItem.lastActivity) }}
@ -68,12 +81,12 @@
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button
i18n
mat-menu-item
[disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)"
>
Delete
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>

View File

@ -2,13 +2,14 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AdminUsersComponent } from './admin-users.component';
@NgModule({
declarations: [AdminUsersComponent],
exports: [],
imports: [CommonModule, MatButtonModule, MatMenuModule],
imports: [CommonModule, GfValueModule, MatButtonModule, MatMenuModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminUsersModule {}

View File

@ -18,6 +18,8 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({
selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'],
@ -126,12 +128,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -1,10 +1,14 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import {
Benchmark,
HistoricalDataItem,
InfoItem,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -15,6 +19,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-market.html'
})
export class HomeMarketComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[];
public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
@ -70,6 +75,15 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
});
}
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});

View File

@ -1,13 +1,12 @@
<div
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
>
<div class="no-gutters row w-100">
<div class="container">
<h3 class="mb-3 text-center" i18n>Markets</h3>
<div class="mb-5 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-5"
class="mb-3"
yMax="100"
yMaxLabel="Greed"
yMin="0"
@ -23,4 +22,20 @@
></gf-fear-and-greed-index>
</div>
</div>
<div class="mb-3 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
*ngFor="let benchmark of benchmarks"
class="py-2"
[benchmark]="benchmark"
[locale]="user?.settings?.locale"
></gf-benchmark>
<gf-benchmark
*ngIf="!benchmarks"
class="py-2"
[benchmark]="undefined"
></gf-benchmark>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { HomeMarketComponent } from './home-market.component';
@ -8,7 +9,12 @@ import { HomeMarketComponent } from './home-market.component';
@NgModule({
declarations: [HomeMarketComponent],
exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
imports: [
CommonModule,
GfBenchmarkModule,
GfFearAndGreedIndexModule,
GfLineChartModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -10,7 +10,10 @@ import {
ViewChild
} from '@angular/core';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
parseDate,
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
Chart,
@ -148,19 +151,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
display: false
},
ticks: {
display: true,
callback: (tickValue, index, ticks) => {
if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry
if (typeof tickValue === 'number') {
return tickValue.toFixed(2);
}
return tickValue;
}
return '';
callback: (value: number) => {
return transformTickToAbbreviation(value);
},
display: true,
mirror: true,
z: 1
}

View File

@ -119,7 +119,7 @@
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Total</div>
<div class="d-flex flex-grow-1" i18n>Total Assets</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"

View File

@ -5,6 +5,7 @@ export interface PositionDetailDialogParams {
dataSource: DataSource;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
locale: string;
symbol: string;
}

View File

@ -9,9 +9,10 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { SymbolProfile } from '@prisma/client';
import { Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -44,10 +45,12 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public orders: OrderWithAccount[];
public quantity: number;
public quantityPrecision = 2;
public reportDataGlitchMail: string;
public sectors: {
[name: string]: { name: string; value: number };
};
public SymbolProfile: SymbolProfile;
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public transactionCount: number;
public value: number;
@ -83,12 +86,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
orders,
quantity,
SymbolProfile,
tags,
transactionCount,
value
}) => {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
@ -115,6 +120,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.quantity = quantity;
this.sectors = {};
this.SymbolProfile = SymbolProfile;
this.tags = tags;
this.transactionCount = transactionCount;
this.value = value;
@ -211,14 +217,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile(
data,
`ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
format: 'json'
});
});
}

View File

@ -168,7 +168,7 @@
</ng-container>
<ng-template #charts>
<div class="col-md-6 mb-3">
<div class="h4" i18n>Sectors</div>
<div class="h5" i18n>Sectors</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
@ -179,7 +179,7 @@
></gf-portfolio-proportion-chart>
</div>
<div class="col-md-6 mb-3">
<div class="h4" i18n>Countries</div>
<div class="h5" i18n>Countries</div>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true"
@ -192,23 +192,49 @@
</ng-template>
</ng-container>
</div>
</div>
<gf-activities-table
*ngIf="orders?.length > 0"
[activities]="orders"
[baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
<div *ngIf="orders?.length > 0" class="row">
<div class="col mb-3">
<div class="h5 mb-0" i18n>Activities</div>
<gf-activities-table
[activities]="orders"
[baseCurrency]="data.baseCurrency"
[deviceType]="data.deviceType"
[hasPermissionToCreateActivity]="false"
[hasPermissionToExportActivities]="!hasImpersonationId"
[hasPermissionToFilter]="false"
[hasPermissionToImportActivities]="false"
[hasPermissionToOpenDetails]="false"
[locale]="data.locale"
[showActions]="false"
[showSymbolColumn]="false"
(export)="onExport()"
></gf-activities-table>
</div>
</div>
<div *ngIf="tags?.length > 0" class="row">
<div class="col mb-3">
<div class="h5" i18n>Tags</div>
<mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
</mat-chip-list>
</div>
</div>
<div
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
class="row"
>
<div class="col mb-3">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span></a
>
</div>
</div>
</div>
</div>
<gf-dialog-footer

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDialogModule } from '@angular/material/dialog';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '@ghostfolio/client/components/dialog-header/dialog-header.module';
@ -24,6 +25,7 @@ import { PositionDetailDialog } from './position-detail-dialog.component';
GfPortfolioProportionChartModule,
GfValueModule,
MatButtonModule,
MatChipsModule,
MatDialogModule,
NgxSkeletonLoaderModule
],

View File

@ -73,11 +73,6 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
}
}
/*public applyFilter(event: Event) {
const filterValue = (event.target as HTMLInputElement).value;
this.dataSource.filter = filterValue.trim().toLowerCase();
}*/
public onOpenPositionDialog({ dataSource, symbol }: UniqueAsset): void {
this.router.navigate([], {
queryParams: { dataSource, symbol, positionDetailDialog: true }

View File

@ -5,7 +5,6 @@ import {
OnChanges,
OnInit
} from '@angular/core';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { Position } from '@ghostfolio/common/interfaces';
@Component({
@ -42,10 +41,7 @@ export class PositionsComponent implements OnChanges, OnInit {
this.positionsWithPriority = [];
for (const portfolioPosition of this.positions) {
if (
portfolioPosition.marketState === MarketState.open ||
this.range !== '1d'
) {
if (portfolioPosition.marketState === 'open' || this.range !== '1d') {
// Only show positions where the market is open in today's view
this.positionsWithPriority.push(portfolioPosition);
} else {

View File

@ -1,7 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -17,12 +16,10 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html'
})
export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public lastPublish = environment.lastPublish;
public statistics: Statistics;
public user: User;
public version = environment.version;

View File

@ -2,103 +2,101 @@
<div class="mb-5 row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>About Ghostfolio</h3>
<mat-card class="about-container">
<mat-card-content>
<p>
<strong>Ghostfolio</strong> is a lightweight wealth management
application for individuals to keep track of their wealth like
stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions. The source code is fully available as open
source software (OSS). The project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="lastPublish">
This instance is running Ghostfolio {{ version }} and has been
last published on {{ lastPublish }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new <a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
<div class="about-container">
<p>
<strong>Ghostfolio</strong> is a lightweight wealth management
application for individuals to keep track of stocks, ETFs or
cryptocurrencies and make solid, data-driven investment decisions. The
source code is fully available as open source software (OSS). The
project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
</mat-card-content>
</mat-card>
and is driven by the efforts of its
<a
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
title="Contributors to Ghostfolio"
>contributors</a
>.
<ng-container *ngIf="version">
This instance is running Ghostfolio {{ version }}.
</ng-container>
<ng-container *ngIf="hasPermissionForStatistics" i18n
>Check the system status at
<a href="https://status.ghostfol.io" title="Ghostfolio status"
>status.ghostfol.io</a
>.</ng-container
>
</p>
<p>
If you encounter a bug or would like to suggest an improvement or a
new
<a [routerLink]="['/features']">feature</a>, please join the
Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack community"
>Slack community</a
>, tweet to
<a
href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter"
>@ghostfolio_</a
>, send an e-mail to
<a href="mailto:hi@ghostfol.io" title="Send an e-mail"
>hi@ghostfol.io</a
>
or open an issue at
<a
href="https://github.com/ghostfolio/ghostfolio"
title="Find Ghostfolio on GitHub"
>GitHub</a
>.
</p>
<p class="text-center">
<a
class="mx-2"
href="https://twitter.com/ghostfolio_"
mat-icon-button
title="Follow Ghostfolio on Twitter"
>
<ion-icon name="logo-twitter" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="mailto:hi@ghostfol.io"
mat-icon-button
title="Send an e-mail"
>
<ion-icon name="mail" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a
class="mx-2"
href="https://github.com/ghostfolio/ghostfolio"
mat-icon-button
title="Find Ghostfolio on GitHub"
>
<ion-icon name="logo-github" size="large"></ion-icon>
</a>
</p>
<div
*ngIf="hasPermissionForSubscription"
class="d-flex justify-content-center"
>
<div
class="independent-and-bootstrapped-logo mb-2"
title="Ghostfolio is an independent & bootstrapped business"
></div>
</div>
</div>
</div>
</div>
@ -109,38 +107,39 @@
<mat-card-content>
<div class="row">
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 24 hours)</small
>
</div>
<gf-value
label="Active Users"
size="large"
subLabel="(Last 24 hours)"
[value]="statistics?.activeUsers1d ?? '-'"
></gf-value>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3>
<div class="h6 mb-0">
<span i18n>New Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
>
</div>
<gf-value
label="New Users"
size="large"
subLabel="(Last 30 days)"
[value]="statistics?.newUsers30d ?? '-'"
></gf-value>
</div>
<div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3>
<div class="h6 mb-0">
<span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
>
</div>
<gf-value
label="Active Users"
size="large"
subLabel="(Last 30 days)"
[value]="statistics?.activeUsers30d ?? '-'"
></gf-value>
</div>
<div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
>
<h3 class="mb-0">
{{ statistics?.slackCommunityUsers ?? '-' }}
</h3>
<div class="h6 mb-0" i18n>Users in Slack community</div>
<gf-value
label="Users in Slack community"
size="large"
[value]="statistics?.slackCommunityUsers ?? '-'"
></gf-value>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
@ -148,10 +147,11 @@
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
>
<h3 class="mb-0">
{{ statistics?.gitHubContributors ?? '-' }}
</h3>
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
<gf-value
label="Contributors on GitHub"
size="large"
[value]="statistics?.gitHubContributors ?? '-'"
></gf-value>
</a>
</div>
<div class="col-xs-12 col-md-4 my-2">
@ -159,8 +159,11 @@
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers"
>
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Stars on GitHub</div>
<gf-value
label="Stars on GitHub"
size="large"
[value]="statistics?.gitHubStargazers ?? '-'"
></gf-value>
</a>
</div>
</div>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component';
@ -12,6 +13,7 @@ import { AboutPageComponent } from './about-page.component';
imports: [
AboutPageRoutingModule,
CommonModule,
GfValueModule,
MatButtonModule,
MatCardModule
],

View File

@ -2,15 +2,13 @@
color: rgb(var(--dark-primary-text));
display: block;
.mat-card {
&.about-container {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
.about-container {
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
&:hover {
color: rgba(var(--palette-primary-300), 1);
}
}
@ -29,7 +27,7 @@
:host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text));
.mat-card {
.about-container {
.independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg');
opacity: 1;

View File

@ -20,7 +20,6 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -43,7 +42,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[];
public baseCurrency = baseCurrency;
public baseCurrency: string;
public coupon: number;
public couponId: string;
public currencies: string[] = [];
@ -79,8 +78,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private userService: UserService,
public webAuthnService: WebAuthnService
) {
const { currencies, globalPermissions, subscriptions } =
const { baseCurrency, currencies, globalPermissions, subscriptions } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies;

View File

@ -47,9 +47,21 @@
<strong>personal investment strategy</strong>.
</h2>
<p class="lead">
<strong>Ghostfolio</strong> empowers busy people to keep track of their
wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions.
<strong>Ghostfolio</strong> empowers busy people to keep track of
stocks, ETFs or cryptocurrencies and make solid, data-driven investment
decisions.
</p>
<p>
<a
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
title="Watch the Ghostfol.io Trailer on YouTube"
>
<img
alt="Ghostfol.io Trailer"
src="./assets/images/video-preview.jpg"
style="max-width: 100%; width: 40rem"
/>
</a>
</p>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -8,16 +9,18 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { prettifySymbol } from '@ghostfolio/common/helper';
import {
Filter,
PortfolioDetails,
PortfolioPosition,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
@Component({
host: { class: 'page' },
@ -32,6 +35,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: number;
};
};
public activeFilters: Filter[] = [];
public allFilters: Filter[];
public continents: {
[code: string]: { name: string; value: number };
};
@ -39,7 +44,9 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
[code: string]: { name: string; value: number };
};
public deviceType: string;
public filters$ = new Subject<Filter[]>();
public hasImpersonationId: boolean;
public isLoading = false;
public markets: {
[key in Market]: { name: string; value: number };
};
@ -48,6 +55,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
{ label: 'Initial', value: 'original' },
{ label: 'Current', value: 'current' }
];
public placeholder = '';
public portfolioDetails: PortfolioDetails;
public positions: {
[symbol: string]: Pick<
@ -76,6 +84,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public user: User;
private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...';
private unsubscribeSubject = new Subject<void>();
/**
@ -120,14 +129,28 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.hasImpersonationId = !!aId;
});
this.dataService
.fetchPortfolioDetails({})
.pipe(takeUntil(this.unsubscribeSubject))
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? this.SEARCH_PLACEHOLDER : '';
return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe((portfolioDetails) => {
this.portfolioDetails = portfolioDetails;
this.initializeAnalysisData(this.period);
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
@ -137,12 +160,47 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
const accountFilters: Filter[] = this.user.accounts
.filter(({ accountType }) => {
return accountType === 'SECURITIES';
})
.map(({ id, name }) => {
return {
id,
label: name,
type: 'ACCOUNT'
};
});
const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: assetClass,
type: 'ASSET_CLASS'
});
}
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: name,
type: 'TAG'
};
});
this.allFilters = [
...accountFilters,
...assetClassFilters,
...tagFilters
];
this.changeDetectorRef.markForCheck();
}
});
}
public initializeAnalysisData(aPeriod: string) {
public initialize() {
this.accounts = {};
this.continents = {
[UNKNOWN_KEY]: {
@ -185,6 +243,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0
}
};
}
public initializeAnalysisData(aPeriod: string) {
this.initialize();
for (const [id, { current, name, original }] of Object.entries(
this.portfolioDetails.accounts
@ -305,14 +367,12 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}
}
if (position.assetClass === AssetClass.EQUITY) {
this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource,
name: position.name,
symbol: prettifySymbol(symbol),
value: aPeriod === 'original' ? position.investment : position.value
};
}
this.symbols[prettifySymbol(symbol)] = {
dataSource: position.dataSource,
name: position.name,
symbol: prettifySymbol(symbol),
value: aPeriod === 'original' ? position.investment : position.value
};
}
const marketsTotal =
@ -362,12 +422,16 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -2,6 +2,12 @@
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Allocations</h3>
<gf-activities-filter
[allFilters]="allFilters"
[isLoading]="isLoading"
[placeholder]="placeholder"
(valueChanged)="filters$.next($event)"
></gf-activities-filter>
</div>
</div>
<div class="proportion-charts row">
@ -89,7 +95,7 @@
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Symbol</span
><span i18n>By Position</span
><ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"

View File

@ -4,6 +4,7 @@ import { MatCardModule } from '@angular/material/card';
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -16,6 +17,7 @@ import { AllocationsPageComponent } from './allocations-page.component';
imports: [
AllocationsPageRoutingModule,
CommonModule,
GfActivitiesFilterModule,
GfPortfolioProportionChartModule,
GfPositionsTableModule,
GfToggleModule,

View File

@ -1,9 +1,10 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import Big from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -14,7 +15,9 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './fire-page.html'
})
export class FirePageComponent implements OnDestroy, OnInit {
public deviceType: string;
public fireWealth: Big;
public hasPermissionToUpdateUserSettings: boolean;
public isLoading = false;
public user: User;
public withdrawalRatePerMonth: Big;
@ -28,7 +31,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
private deviceService: DeviceDetectorService,
private userService: UserService
) {}
@ -37,6 +40,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
*/
public ngOnInit() {
this.isLoading = true;
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService
.fetchPortfolioSummary()
@ -61,11 +65,23 @@ export class FirePageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.hasPermissionToUpdateUserSettings = hasPermission(
this.user.permissions,
permissions.updateUserSettings
);
this.changeDetectorRef.markForCheck();
}
});
}
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();

View File

@ -2,7 +2,7 @@
<div class="row">
<div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
<div class="mb-4">
<div class="mb-5">
<h4 i18n>4% Rule</h4>
<div *ngIf="isLoading">
<ngx-skeleton-loader
@ -41,7 +41,7 @@
[value]="withdrawalRatePerMonth?.toNumber()"
></gf-value>
per month</span
>, based on your investment of
>, based on your total assets of
<gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
@ -53,4 +53,16 @@
</div>
</div>
</div>
<div>
<h4 class="mb-3" i18n>Calculator</h4>
<gf-fire-calculator
[currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()"
[hasPermissionToUpdateUserSettings]="hasPermissionToUpdateUserSettings"
[locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -11,6 +12,7 @@ import { FirePageComponent } from './fire-page.component';
imports: [
CommonModule,
FirePageRoutingModule,
GfFireCalculatorModule,
GfValueModule,
NgxSkeletonLoaderModule
],

View File

@ -8,12 +8,13 @@ import {
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { Type } from '@prisma/client';
import { AssetClass, AssetSubClass, Type } from '@prisma/client';
import { isUUID } from 'class-validator';
import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs';
@ -39,26 +40,33 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete;
public activityForm: FormGroup;
public assetClasses = Object.keys(AssetClass);
public assetSubClasses = Object.keys(AssetSubClass);
public currencies: string[] = [];
public currentMarketPrice = null;
public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false;
public platforms: { id: string; name: string }[];
public total = 0;
public Validators = Validators;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams,
private dataService: DataService,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
@Inject(MAT_DATE_LOCALE) private locale: string
) {}
public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;
@ -66,6 +74,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm = this.formBuilder.group({
accountId: [this.data.activity?.accountId, Validators.required],
assetClass: [this.data.activity?.SymbolProfile?.assetClass],
assetSubClass: [this.data.activity?.SymbolProfile?.assetSubClass],
currency: [
this.data.activity?.SymbolProfile?.currency,
Validators.required
@ -85,10 +95,30 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
},
Validators.required
],
tags: [this.data.activity?.tags],
type: [undefined, Validators.required], // Set after value changes subscription
unitPrice: [this.data.activity?.unitPrice, Validators.required]
});
this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (
this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM'
) {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value +
this.activityForm.controls['fee'].value ?? 0;
} else {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0;
}
});
this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol'
].valueChanges.pipe(
@ -100,9 +130,11 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
filteredLookupItemsObservable
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
return filteredLookupItemsObservable;
}
@ -111,45 +143,47 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
})
);
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => {
if (type === 'ITEM') {
this.activityForm.controls['accountId'].removeValidators(
Validators.required
);
this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators(
Validators.required
);
this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1);
this.activityForm.controls['searchSymbol'].removeValidators(
Validators.required
);
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
} else {
this.activityForm.controls['accountId'].setValidators(
Validators.required
);
this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['dataSource'].setValidators(
Validators.required
);
this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['name'].removeValidators(
Validators.required
);
this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['searchSymbol'].setValidators(
Validators.required
);
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
}
});
this.activityForm.controls['type'].valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((type: Type) => {
if (type === 'ITEM') {
this.activityForm.controls['accountId'].removeValidators(
Validators.required
);
this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['currency'].setValue(
this.data.user.settings.baseCurrency
);
this.activityForm.controls['dataSource'].removeValidators(
Validators.required
);
this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['name'].setValidators(Validators.required);
this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['quantity'].setValue(1);
this.activityForm.controls['searchSymbol'].removeValidators(
Validators.required
);
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
} else {
this.activityForm.controls['accountId'].setValidators(
Validators.required
);
this.activityForm.controls['accountId'].updateValueAndValidity();
this.activityForm.controls['dataSource'].setValidators(
Validators.required
);
this.activityForm.controls['dataSource'].updateValueAndValidity();
this.activityForm.controls['name'].removeValidators(
Validators.required
);
this.activityForm.controls['name'].updateValueAndValidity();
this.activityForm.controls['searchSymbol'].setValidators(
Validators.required
);
this.activityForm.controls['searchSymbol'].updateValueAndValidity();
}
});
this.activityForm.controls['type'].setValue(this.data.activity?.type);
@ -209,6 +243,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public onSubmit() {
const activity: CreateOrderDto | UpdateOrderDto = {
accountId: this.activityForm.controls['accountId'].value,
assetClass: this.activityForm.controls['assetClass'].value,
assetSubClass: this.activityForm.controls['assetSubClass'].value,
currency: this.activityForm.controls['currency'].value,
date: this.activityForm.controls['date'].value,
dataSource: this.activityForm.controls['dataSource'].value,

View File

@ -1,6 +1,7 @@
<form
class="d-flex flex-column h-100"
[formGroup]="activityForm"
(keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>
@ -134,13 +135,55 @@
>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Class</mat-label>
<mat-select formControlName="assetClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetClass of assetClasses"
[value]="assetClass"
>{{ assetClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['type']?.value !== 'ITEM' }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Asset Sub-Class</mat-label>
<mat-select formControlName="assetSubClass">
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let assetSubClass of assetSubClasses"
[value]="assetSubClass"
>{{ assetSubClass }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
<div
[ngClass]="{ 'd-none': activityForm.controls['tags']?.value?.length <= 0 }"
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Tags</mat-label>
<mat-chip-list>
<mat-chip *ngFor="let tag of activityForm.controls['tags']?.value">
{{ tag.name }}
</mat-chip>
</mat-chip-list>
</mat-form-field>
</div>
</div>
<div class="d-flex" mat-dialog-actions>
<gf-value
class="flex-grow-1"
[currency]="activityForm.controls['currency'].value"
[currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
[locale]="data.user?.settings?.locale"
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0"
[value]="total"
></gf-value>
<div>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>

View File

@ -3,6 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatButtonModule } from '@angular/material/button';
import { MatChipsModule } from '@angular/material/chips';
import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -24,6 +25,7 @@ import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-
FormsModule,
MatAutocompleteModule,
MatButtonModule,
MatChipsModule,
MatDatepickerModule,
MatDialogModule,
MatFormFieldModule,

View File

@ -5,8 +5,10 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -50,6 +52,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute,
@ -152,14 +155,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile(
data,
`ghostfolio-export-${format(
for (const activity of data.activities) {
delete activity.id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.json`,
'text/plain'
);
format: 'json'
});
});
}
public onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: this.icsService.transformActivitiesToIcsContent(
data.activities
),
contentType: 'text/calendar',
fileName: `ghostfolio-draft${
data.activities.length > 1 ? 's' : ''
}-${format(parseISO(data.meta.date), 'yyyyMMddHHmmss')}.ics`,
format: 'string'
});
});
}
@ -382,12 +407,16 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -15,6 +15,7 @@
(activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)"
(export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()"
></gf-activities-table>
</div>

View File

@ -1,7 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -13,7 +12,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './pricing-page.html'
})
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public baseCurrency: string;
public coupon: number;
public isLoggedIn: boolean;
public price: number;
@ -29,8 +28,9 @@ export class PricingPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
const { subscriptions } = this.dataService.fetchInfo();
const { baseCurrency, subscriptions } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = this.price = subscriptions?.[0]?.coupon;
this.price = subscriptions?.[0]?.price;
}

View File

@ -10,7 +10,7 @@
<div class="col-md-12 allocations-by-symbol">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Symbols</mat-card-title>
<mat-card-title class="text-truncate" i18n>Positions</mat-card-title>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
@ -119,7 +119,7 @@
Ghostfolio empowers you to keep track of your wealth.
</p>
<div class="py-2 text-center">
<a color="primary" i18n mat-flat-button [routerLink]="['/']">
<a color="primary" href="https://ghostfol.io" i18n mat-flat-button>
Get Started
</a>
</div>

View File

@ -1,105 +1,117 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Resources</h3>
<mat-card class="mb-3">
<mat-card-content>
<h4 class="mb-3">Market</h4>
<div class="mb-5">
<div class="mb-4 media">
<div class="media-body">
<h5 class="mt-0">Fear & Greed Index</h5>
<div class="mb-1">
The fear and greed index was developed by <i>CNNMoney</i> to
measure the primary emotions (fear and greed) that influence
how much investors are willing to pay for stocks.
</div>
<div>
<a
href="https://money.cnn.com/data/fear-and-greed/"
target="_blank"
>Fear & Greed Index →</a
>
</div>
</div>
<h1 class="d-flex h3 justify-content-center mb-3" i18n>Resources</h1>
<h2 class="h4 mb-3">Guides</h2>
<div class="mb-5">
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Boringly Getting Rich</h3>
<div class="mb-1">
The <i>Boringly Getting Rich</i> guide supports you to get started
with investing. It introduces a strategy utilizing a broadly
diversified, low-cost portfolio excluding the risks of individual
stocks.
</div>
<div class="media">
<div class="media-body">
<h5 class="mt-0">Inflation Chart</h5>
<div class="mb-1">
Inflation Chart helps you find the intrinsic value of stock
markets, stock prices, goods and services by adjusting them to
the amount of the money supply (M0, M1, M2) or price of other
goods (food or oil).
</div>
<div>
<a href="https://inflationchart.com" target="_blank"
>Inflation Chart →</a
>
</div>
</div>
<div>
<a href="https://herget.me/investing-guide" target="_blank"
>Boringly Getting Rich →</a
>
</div>
</div>
<h4 class="mb-3">Glossary</h4>
<div>
<div class="mb-4 media">
<!--<img src="" class="mr-3" />-->
<div class="media-body">
<h5 class="mt-0">Buy and Hold</h5>
<div class="mb-1">
Buy and hold is a passive investment strategy where you buy
assets and hold them for a long period regardless of
fluctuations in the market.
</div>
<div>
<a
href="https://www.investopedia.com/terms/b/buyandhold.asp"
target="_blank"
>Buy and Hold →</a
>
</div>
</div>
</div>
</div>
<h2 class="h4 mb-3">Market</h2>
<div class="mb-5">
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Fear & Greed Index</h3>
<div class="mb-1">
The fear and greed index was developed by <i>CNNMoney</i> to
measure the primary emotions (fear and greed) that influence how
much investors are willing to pay for stocks.
</div>
<div class="mb-4 media">
<!--<img src="" class="mr-3" />-->
<div class="media-body">
<h5 class="mt-0">Dollar-Cost Averaging (DCA)</h5>
<div class="mb-1">
Dollar-cost averaging is an investment strategy where you
split the total amount to be invested across periodic
purchases of a target asset to reduce the impact of volatility
on the overall purchase.
</div>
<div>
<a
href="https://www.investopedia.com/terms/d/dollarcostaveraging.asp"
target="_blank"
>Dollar-Cost Averaging →</a
>
</div>
</div>
</div>
<div class="media">
<!--<img src="" class="mr-3" />-->
<div class="media-body">
<h5 class="mt-0">Financial Independence</h5>
<div class="mb-1">
Financial independence is the status of having enough income,
for example with a passive income like dividends, to cover
your living expenses for the rest of your life.
</div>
<div>
<a
href="https://en.wikipedia.org/wiki/Financial_independence"
target="_blank"
>Financial Independence →</a
>
</div>
</div>
<div>
<a
href="https://money.cnn.com/data/fear-and-greed/"
target="_blank"
>Fear & Greed Index →</a
>
</div>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="media">
<div class="media-body">
<h3 class="h5 mt-0">Inflation Chart</h3>
<div class="mb-1">
Inflation Chart helps you find the intrinsic value of stock
markets, stock prices, goods and services by adjusting them to the
amount of the money supply (M0, M1, M2) or price of other goods
(food or oil).
</div>
<div>
<a href="https://inflationchart.com" target="_blank"
>Inflation Chart →</a
>
</div>
</div>
</div>
</div>
<h2 class="h4 mb-3">Glossary</h2>
<div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Buy and Hold</h3>
<div class="mb-1">
Buy and hold is a passive investment strategy where you buy assets
and hold them for a long period regardless of fluctuations in the
market.
</div>
<div>
<a
href="https://www.investopedia.com/terms/b/buyandhold.asp"
target="_blank"
>Buy and Hold →</a
>
</div>
</div>
</div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Dollar-Cost Averaging (DCA)</h3>
<div class="mb-1">
Dollar-cost averaging is an investment strategy where you split
the total amount to be invested across periodic purchases of a
target asset to reduce the impact of volatility on the overall
purchase.
</div>
<div>
<a
href="https://www.investopedia.com/terms/d/dollarcostaveraging.asp"
target="_blank"
>Dollar-Cost Averaging →</a
>
</div>
</div>
</div>
<div class="media">
<div class="media-body">
<h3 class="h5 mt-0">Financial Independence</h3>
<div class="mb-1">
Financial independence is the status of having enough income, for
example with a passive income like dividends, to cover your living
expenses for the rest of your life.
</div>
<div>
<a
href="https://en.wikipedia.org/wiki/Financial_independence"
target="_blank"
>Financial Independence →</a
>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

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