Compare commits
342 Commits
Author | SHA1 | Date | |
---|---|---|---|
671e4e316b | |||
473136e9aa | |||
9a3db91982 | |||
d23cb5f190 | |||
7a364472c8 | |||
59c064e3c8 | |||
e792924606 | |||
d32dd5e860 | |||
bb86f85203 | |||
0bca8897d6 | |||
ba73f6de2e | |||
eb75be8535 | |||
6d2a897366 | |||
d8bfb23f20 | |||
d9d71e7827 | |||
b642ce08e5 | |||
bc8d8309d4 | |||
1f2f9f22f2 | |||
7a3237f1ff | |||
07661d9262 | |||
77358eed65 | |||
c641c28b12 | |||
c54392b7bb | |||
f3a8822a77 | |||
f1dc075c36 | |||
144d831954 | |||
c37ad9bad4 | |||
4ab3f81384 | |||
b932bac9aa | |||
bcdd873222 | |||
25b3de5828 | |||
40b454d2f3 | |||
5596e5f03b | |||
66992ef915 | |||
7f67430685 | |||
8a49a04324 | |||
5d7c19b0ed | |||
cde74b6c62 | |||
633c65e33c | |||
d1617f2d87 | |||
68e558f198 | |||
12ca01c862 | |||
2115745471 | |||
2cabd21315 | |||
3615e2f057 | |||
d3679d41b3 | |||
f2d431a6b8 | |||
2bc8bebfb8 | |||
5b20ba3382 | |||
15cc294581 | |||
b060b81204 | |||
a8d557eb1b | |||
6ae3a47b54 | |||
88c19eb45e | |||
7728706bc8 | |||
2e9d40c201 | |||
c002e37285 | |||
6be38a1c19 | |||
a3178fb213 | |||
e7158f6e16 | |||
dbea0456bc | |||
fefee11301 | |||
40836b745b | |||
07eabac059 | |||
48b412cfb8 | |||
b62488628c | |||
982c71c728 | |||
5aa16a3779 | |||
93de25e5b6 | |||
9acdb41aa2 | |||
ffbdfb86ec | |||
be7f6bb657 | |||
6f7cbc93b9 | |||
0b5c71130d | |||
0578c645d1 | |||
67ae86763e | |||
266c0a9a2c | |||
a3cdb23776 | |||
e1371a8d2b | |||
448cea0b69 | |||
ad42c0bf28 | |||
f50670c7fe | |||
c0029d3b1d | |||
2518a8fd9d | |||
572dcf075a | |||
29cb83d469 | |||
cac73ac111 | |||
02cf4295a9 | |||
78b3328bf7 | |||
e0d6d9e8ca | |||
54310f2214 | |||
1fec49fbc2 | |||
d00489b547 | |||
2985dd67c5 | |||
5eba764c04 | |||
cc0ce18627 | |||
b758654158 | |||
d5d40c0ea1 | |||
fd294d4d2b | |||
e82cf2e7d0 | |||
446c7cb517 | |||
e921ed7f52 | |||
865402be3a | |||
6eb659d7e6 | |||
37430b7bdc | |||
ef9d77312e | |||
ccaf06360a | |||
f83e75df44 | |||
00a2b60eb5 | |||
fcbf2f1645 | |||
460266a501 | |||
9fe90273c7 | |||
4078229fe6 | |||
609c03f174 | |||
e7d4641d13 | |||
cc1d9811e0 | |||
35450ac004 | |||
9c18f48a32 | |||
87529490c3 | |||
893e76f83f | |||
06ba7a4b1b | |||
c68d113d27 | |||
69e3bee52c | |||
cea569c987 | |||
2a38a16f6b | |||
0f9455cf02 | |||
d4afa03505 | |||
c9237146e2 | |||
faad65b6f3 | |||
e459c72100 | |||
a8add30125 | |||
b535aee91d | |||
4434d0315f | |||
8b10695353 | |||
e82dcc8ace | |||
6dcb0d8583 | |||
40b6777814 | |||
25deba16df | |||
be93ca8968 | |||
0436cc6487 | |||
857708dc4d | |||
1ca4f885b0 | |||
c9368c5cf2 | |||
29423efea3 | |||
f3ee99fb2b | |||
3df8810412 | |||
b8ca88c6df | |||
2c068c412d | |||
9fdbd22cb5 | |||
8f5f4c5875 | |||
50fb82a6e6 | |||
2c10cd7edf | |||
bbde86c66e | |||
73c0843d51 | |||
04fc2cd3e1 | |||
b39c97ab9f | |||
1dd5e9c787 | |||
a9985b65b8 | |||
0a35d5f236 | |||
09ce8b1cd0 | |||
a5ed49fe4c | |||
5c23ece62c | |||
4e9e3f7b6b | |||
5fc84a06cc | |||
12186e1c6c | |||
f2803aecbc | |||
5ba5b86d5f | |||
6167f105fe | |||
8d5f2fd91d | |||
4ac661fb94 | |||
e763bfb2e2 | |||
88c7e34cc3 | |||
0ee632470e | |||
c918deeb1c | |||
1877b31f00 | |||
00895b7bb1 | |||
bff60ddbe0 | |||
d46de0a15e | |||
7b45a8b3fc | |||
693791d113 | |||
1b2d2a9860 | |||
bde8be1385 | |||
74ca058364 | |||
ba3cf82c6e | |||
217bb6aa5a | |||
440dc470fa | |||
165ca94f5b | |||
c418e75139 | |||
76bf839010 | |||
3bdc4c9b4a | |||
005890d785 | |||
256c020e88 | |||
5fa3388609 | |||
be801b481e | |||
a72e98f73c | |||
f5df970685 | |||
edfdc0c346 | |||
fcfe7b1787 | |||
170b8acc65 | |||
a47829082e | |||
48ab5fcf08 | |||
dc8b60eeb1 | |||
ee67432ffc | |||
7755a6b655 | |||
d7f72819de | |||
2a4d7bf14f | |||
d49287922f | |||
ac0f6f40cf | |||
d91f947ab0 | |||
af71274ea9 | |||
0feba4b8d9 | |||
62f85293e2 | |||
6a048cee85 | |||
0d93612d16 | |||
9bf68b0d20 | |||
371f1dc451 | |||
5cb2ec6411 | |||
3723a1d8b8 | |||
4c30e9459d | |||
23d323073d | |||
0ad734262a | |||
0649f9fd2c | |||
d089662dab | |||
8c1c336fc6 | |||
43b4f14ace | |||
3717e38845 | |||
265d4d0450 | |||
726e727c7d | |||
cb664774c0 | |||
b89bf1d5e8 | |||
53ce37a83a | |||
e9ac9057ff | |||
7020fc2a93 | |||
efcd9539dd | |||
61ecc48d0e | |||
e465f1b791 | |||
01b6c14bcc | |||
34b02210df | |||
0034776b34 | |||
b183c45027 | |||
7d68905f1b | |||
0953c072fe | |||
d152187ee8 | |||
3c5affce88 | |||
f27e21f9a0 | |||
337ca328c3 | |||
beb9e2c43f | |||
4d79df90a7 | |||
aa72d9b730 | |||
80e899a5d3 | |||
7c33120546 | |||
7f3c86038f | |||
c1446f8559 | |||
88d5dfe435 | |||
7dc8f80fdf | |||
96f90c7259 | |||
a10d9cb6ba | |||
4547c5da1d | |||
28706d7b26 | |||
492bc5e17b | |||
6c37737051 | |||
8677d20c2c | |||
4d905065ad | |||
5599b41b83 | |||
8d5a60d777 | |||
695acf4f3f | |||
67dbef3b7a | |||
0e94112dc7 | |||
b22edff16b | |||
ffb7cbff50 | |||
25424ad280 | |||
a768902b00 | |||
2c7ece50fe | |||
51a0ede3e4 | |||
531964636b | |||
e461fff1d7 | |||
4f9a5f0340 | |||
8d80e840b8 | |||
833982a9de | |||
c85966e5ed | |||
43f67ba832 | |||
cbea8ac9d3 | |||
d4c939e41d | |||
c1f129501a | |||
377ba75e4c | |||
77b13b88f0 | |||
813e73a0a3 | |||
1d796a9597 | |||
4eedf64a3c | |||
ed4dd79c72 | |||
6f4fd0826c | |||
8e3a144a37 | |||
07b0a2c40a | |||
c5dc3d4272 | |||
73e69273b4 | |||
e0b74ef418 | |||
2b491dc732 | |||
79fc22b5ae | |||
0a83bcd697 | |||
52540d460b | |||
6ff2e0f952 | |||
b3e72383bc | |||
bdfba4d509 | |||
8a411b707d | |||
e21601202e | |||
8f66040df1 | |||
5ad248a643 | |||
fa36c42af4 | |||
d4ddc781e1 | |||
386dd56590 | |||
f28b13604a | |||
d827858d0b | |||
c758ca4bfa | |||
37183a07bd | |||
fb294fc6e2 | |||
8898d02442 | |||
232d30234c | |||
e2234c4966 | |||
272a34195b | |||
8c25294da7 | |||
6f11627006 | |||
215098e418 | |||
781496383b | |||
f0f304c012 | |||
4bf97c104b | |||
0b35a3c7a7 | |||
1586cd3a59 | |||
ae763cbb87 | |||
aa72287d54 | |||
d155ab6f28 | |||
913ca71aa5 | |||
1ffde2a27e | |||
fcf0cea982 | |||
ae1968aadf | |||
3e6333ef95 | |||
c69686651e | |||
93b6011ddc | |||
f567e25f27 | |||
5dc538bafb | |||
b4de06fcf0 | |||
27da0eb26e | |||
8ff80c10e5 |
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
11
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@ -6,7 +6,13 @@ labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
**Important Notice**
|
||||
|
||||
The issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
Incomplete or non-reproducible issues may be closed, but we are here to help! If you encounter difficulties reproducing the bug or need assistance, please reach out to our community channels mentioned above.
|
||||
|
||||
Thank you for your understanding and cooperation!
|
||||
|
||||
**Bug Description**
|
||||
|
||||
@ -36,8 +42,9 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Cloud or Self-hosted
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Cloud or Self-hosted
|
||||
- Experimental Features enabled or disabled
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
|
7
.github/workflows/build-code.yml
vendored
7
.github/workflows/build-code.yml
vendored
@ -4,6 +4,9 @@ on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
@ -13,12 +16,12 @@ jobs:
|
||||
- 18
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js ${{ matrix.node_version }}
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
cache: 'yarn'
|
||||
|
2
.github/workflows/docker-image.yml
vendored
2
.github/workflows/docker-image.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
|
@ -1,3 +1,4 @@
|
||||
/.nx/cache
|
||||
/apps/client/src/polyfills.ts
|
||||
/dist
|
||||
/test/import
|
||||
|
21
.prettierrc
21
.prettierrc
@ -9,7 +9,26 @@
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-organize-attributes"],
|
||||
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.ts",
|
||||
"options": {
|
||||
"importOrderParserPlugins": ["decorators-legacy", "typescript"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"plugins": [
|
||||
"prettier-plugin-organize-attributes",
|
||||
"@trivago/prettier-plugin-sort-imports"
|
||||
],
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
|
647
CHANGELOG.md
647
CHANGELOG.md
@ -5,6 +5,619 @@ 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).
|
||||
|
||||
## 2.63.2 - 2024-03-12
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page
|
||||
- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0`
|
||||
- Upgraded `countries-list` from version `2.6.1` to `3.1.0`
|
||||
- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day
|
||||
- Fixed an issue in the calculation on the allocations page caused by liabilities
|
||||
- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_
|
||||
|
||||
## 2.62.0 - 2024-03-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the calculation of the accounts table
|
||||
- Optimized the calculation of the portfolio holdings
|
||||
- Integrated dividend into the transaction point concept in the portfolio service
|
||||
- Removed the environment variable `WEB_AUTH_RP_ID`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
|
||||
- Fixed an issue with removing a linked account from a (wealth) item activity
|
||||
|
||||
## 2.61.1 - 2024-03-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the account value calculation caused by liabilities
|
||||
|
||||
## 2.61.0 - 2024-03-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the calculation of the portfolio summary
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the activities import (query parameter handling)
|
||||
|
||||
## 2.60.0 - 2024-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for the cryptocurrency _Uniswap_ (`UNI7083-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the benchmarks in the markets overview
|
||||
- Integrated (wealth) items into the transaction point concept in the portfolio service
|
||||
- Refreshed the cryptocurrencies list
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a missing value in the activities table on mobile
|
||||
- Fixed a missing value on the public page
|
||||
- Displayed the button to fetch the current market price only if the activity is from today
|
||||
|
||||
## 2.59.0 - 2024-02-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added an index for `isExcluded` to the account database table
|
||||
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the activities import by `isin` in the _Yahoo Finance_ service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the exchange rate calculation of (wealth) items in accounts
|
||||
|
||||
## 2.58.0 - 2024-02-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the handling of activities without account
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the query to filter activities of excluded accounts
|
||||
- Improved the asset profile validation in the activities import
|
||||
|
||||
## 2.57.0 - 2024-02-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the break down of the performance into asset and currency on the analysis page from experimental to general availability
|
||||
- Restructured the `copy-assets` `Nx` target
|
||||
|
||||
### Fixed
|
||||
|
||||
- Changed the performances of the _Top 3_ and _Bottom 3_ performers on the analysis page to take the currency effects into account
|
||||
|
||||
## 2.56.0 - 2024-02-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Switched the performance calculations to take the currency effects into account
|
||||
- Removed the `isDefault` flag from the `Account` database schema
|
||||
- Exposed the database index of _Redis_ as an environment variable (`REDIS_DB`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `prisma` from version `5.9.1` to `5.10.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added the missing default currency to the prepare currencies function in the exchange rate data service
|
||||
|
||||
## 2.55.0 - 2024-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added indexes for `alias`, `granteeUserId` and `userId` to the access database table
|
||||
- Added indexes for `currency`, `name` and `userId` to the account database table
|
||||
- Added indexes for `accountId`, `date` and `updatedAt` to the account balance database table
|
||||
- Added an index for `userId` to the auth device database table
|
||||
- Added indexes for `marketPrice` and `state` to the market data database table
|
||||
- Added indexes for `date`, `isDraft` and `userId` to the order database table
|
||||
- Added an index for `name` to the platform database table
|
||||
- Added indexes for `assetClass`, `currency`, `dataSource`, `isin`, `name` and `symbol` to the symbol profile database table
|
||||
- Added an index for `userId` to the subscription database table
|
||||
- Added an index for `name` to the tag database table
|
||||
- Added indexes for `accessToken`, `createdAt`, `provider`, `role` and `thirdPartyId` to the user database table
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the validation for `currency` in various endpoints
|
||||
- Harmonized the setting of a default locale in various components
|
||||
- Set the parser to `angular` in the `prettier` options
|
||||
|
||||
## 2.54.0 - 2024-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added an index for `id` to the account database table
|
||||
- Added indexes for `dataSource` and `date` to the market data database table
|
||||
- Added an index for `accountId` to the order database table
|
||||
|
||||
## 2.53.1 - 2024-02-18
|
||||
|
||||
### Added
|
||||
|
||||
- Added an accounts tab to the position detail dialog
|
||||
- Added `INACTIVE` as a new user role
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the holdings table
|
||||
- Refactored the query to filter activities of excluded accounts
|
||||
- Eliminated the search request to get quotes in the _EOD Historical Data_ service
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.9.1` to `2.10.0`
|
||||
|
||||
## 2.52.0 - 2024-02-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added a loading indicator to the dividend timeline on the analysis page
|
||||
- Added a loading indicator to the investment timeline on the analysis page
|
||||
- Added support for the cryptocurrency _Jupiter_ (`JUP29210-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Divided the content of the Frequently Asked Questions (FAQ) page into three sections: _General_, _Cloud (SaaS)_ and _Self-Hosting_
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the X-axis scale of the dividend timeline on the analysis page
|
||||
- Fixed an issue with the X-axis scale of the investment timeline on the analysis page
|
||||
|
||||
## 2.51.0 - 2024-02-12
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the ordered list of the _Top 3_ and _Bottom 3_ performers on the analysis page in Safari
|
||||
- Replaced `import-sort` with `prettier-plugin-sort-imports`
|
||||
- Upgraded `eslint` dependencies
|
||||
- Upgraded `Nx` from version `17.2.8` to `18.0.4`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the date conversion of the import of historical market data in the admin control panel
|
||||
|
||||
## 2.50.0 - 2024-02-11
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced a setting to disable the data gathering in the admin control
|
||||
|
||||
### Changed
|
||||
|
||||
- Harmonized the environment variables of various API keys
|
||||
- Upgraded `prisma` from version `5.8.1` to `5.9.1`
|
||||
|
||||
### Todo
|
||||
|
||||
- Rename the environment variable from `ALPHA_VANTAGE_API_KEY` to `API_KEY_ALPHA_VANTAGE`
|
||||
- Rename the environment variable from `BETTER_UPTIME_API_KEY` to `API_KEY_BETTER_UPTIME`
|
||||
- Rename the environment variable from `EOD_HISTORICAL_DATA_API_KEY` to `API_KEY_EOD_HISTORICAL_DATA`
|
||||
- Rename the environment variable from `FINANCIAL_MODELING_PREP_API_KEY` to `API_KEY_FINANCIAL_MODELING_PREP`
|
||||
- Rename the environment variable from `OPEN_FIGI_API_KEY` to `API_KEY_OPEN_FIGI`
|
||||
- Rename the environment variable from `RAPID_API_API_KEY` to `API_KEY_RAPID_API`
|
||||
|
||||
## 2.49.0 - 2024-02-09
|
||||
|
||||
### Added
|
||||
|
||||
- Added a button to apply the active filters in the assistant
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the assistant from experimental to general availability
|
||||
- Improved the usability by reloading the content with a logo click on the home page
|
||||
- Upgraded `yahoo-finance2` from version `2.9.0` to `2.9.1`
|
||||
|
||||
## 2.48.1 - 2024-02-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added the missing data provider information to the _CoinGecko_ service
|
||||
|
||||
## 2.48.0 - 2024-02-05
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by an asset class selector (experimental)
|
||||
- Added the data provider information to the search endpoint
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the account selector in the assistant (experimental)
|
||||
- Improved the usability of the tag selector in the assistant (experimental)
|
||||
- Improved the error logs for a timeout in the data provider services
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Upgraded `prettier` from version `3.2.4` to `3.2.5`
|
||||
|
||||
## 2.47.0 - 2024-02-02
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the tag selector to only show used tags in the assistant (experimental)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `prettier` from version `3.2.1` to `3.2.4`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a rendering issue caused by the date range selector in the assistant (experimental)
|
||||
- Fixed an issue with the currency conversion in the investment timeline
|
||||
- Fixed the export in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
|
||||
## 2.46.0 - 2024-01-28
|
||||
|
||||
### Added
|
||||
|
||||
- Added a button to reset the active filters in the assistant (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the portfolio allocations to work with the filters of the assistant (experimental)
|
||||
- Migrated the portfolio holdings to work with the filters of the assistant (experimental)
|
||||
|
||||
## 2.45.0 - 2024-01-27
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by an account selector (experimental)
|
||||
- Added support to grant private access with permissions (experimental)
|
||||
- Added `permissions` to the `Access` model
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the tag selector to a form group in the assistant (experimental)
|
||||
- Formatted the name in the _EOD Historical Data_ service
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the import for activities with `MANUAL` data source and type `FEE`, `INTEREST`, `ITEM` or `LIABILITY`
|
||||
- Removed holdings with incomplete data from the _Top 3_ and _Bottom 3_ performers on the analysis page
|
||||
|
||||
## 2.44.0 - 2024-01-24
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the validation for non-numeric results in the _EOD Historical Data_ service
|
||||
|
||||
## 2.43.1 - 2024-01-23
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the date range support by week to date (`WTD`) and month to date (`MTD`) in the assistant (experimental)
|
||||
- Added support for importing dividends from _EOD Historical Data_
|
||||
- Added `healthcheck` for the _Ghostfolio_ service to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the link to manage the benchmarks in the benchmark comparator with an icon
|
||||
|
||||
## 2.42.0 - 2024-01-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to edit countries in the asset profile details dialog of the admin control
|
||||
- Added support to edit sectors in the asset profile details dialog of the admin control
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the handling of derived currencies
|
||||
- Improved the labels in the portfolio evolution chart and investment timeline on the analysis page
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `prisma` from version `5.7.1` to `5.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the performance calculation with the currency conversion of fees
|
||||
|
||||
## 2.41.0 - 2024-01-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added the holdings table to the account detail dialog
|
||||
- Validated the currency of the search results in the _EOD Historical Data_ service
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the timeout to load historical data in the data provider service
|
||||
- Improved the asset profile validation for `MANUAL` data source in the activities import
|
||||
|
||||
## 2.40.0 - 2024-01-15
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the robustness of the exchange rates by always getting quotes in the exchange rate data service
|
||||
|
||||
## 2.39.0 - 2024-01-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the alignment in the portfolio performance chart
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the currency in the error log of the exchange rate data service
|
||||
- Fixed an issue with the currency inconsistency in the _EOD Historical Data_ service (convert from `ZAR` to `ZAc`)
|
||||
|
||||
## 2.38.0 - 2024-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- Broken down the performance into asset and currency on the analysis page (experimental)
|
||||
- Added support for international formatted numbers in the scraper configuration
|
||||
- Added the attribute `locale` to the scraper configuration to parse the number
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the indicator for delayed market data in the client
|
||||
- Prepared the portfolio calculation for exchange rate effects
|
||||
- Upgraded `prettier` from version `3.1.1` to `3.2.1`
|
||||
|
||||
## 2.37.0 - 2024-01-11
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the chart size in the asset profile details dialog of the admin control
|
||||
- Updated the `docker compose` instructions to _Compose V2_ in the documentation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the hidden fifth tab on mobile
|
||||
|
||||
## 2.36.0 - 2024-01-07
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by a tag selector (experimental)
|
||||
- Added support to set a _CoinGecko_ Demo API key via environment variable (`API_KEY_COINGECKO_DEMO`)
|
||||
- Added support to set a _CoinGecko_ Pro API key via environment variable (`API_KEY_COINGECKO_PRO`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
- Removed the `AccountType` enum
|
||||
- Refreshed the cryptocurrencies list
|
||||
|
||||
## 2.35.0 - 2024-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to grant private access
|
||||
- Added a hint for _Time-Weighted Rate of Return_ (TWR) to the portfolio summary tab on the home page
|
||||
- Added support for REST APIs (`JSON`) via the scraper configuration
|
||||
- Enabled the _Redis_ authentication in the `docker-compose` files
|
||||
- Set up a git-hook to format the code before any commit
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the user interface of the access table to share the portfolio
|
||||
- Improved the style of the assistant (experimental)
|
||||
|
||||
## 2.34.0 - 2024-01-02
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the assistant by a date range selector (experimental)
|
||||
- Added a button to test the scraper configuration in the asset profile details dialog of the admin control
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the style of the _Top 3_ and _Bottom 3_ performers on the analysis page
|
||||
- Upgraded `Nx` from version `17.2.7` to `17.2.8`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the time-weighted performance calculation for `1D`
|
||||
- Improved the tabs on iOS (_Add to Home Screen_)
|
||||
|
||||
## 2.33.0 - 2023-12-31
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to edit the currency of asset profiles with `MANUAL` data source in the asset profile details dialog of the admin control panel
|
||||
- Added a hint for the community languages in the user settings
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the performance calculation to a time-weighted approach
|
||||
- Normalized the benchmark by currency in the benchmark comparator
|
||||
- Increased the timeout to load currencies in the exchange rate data service
|
||||
- Exposed the environment variable `REQUEST_TIMEOUT`
|
||||
- Used the `HasPermission` annotation in endpoints
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.9.0` to `2.9.1`
|
||||
- Upgraded `Nx` from version `17.2.5` to `17.2.7`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the handling of derived currencies (`USX`)
|
||||
|
||||
## 2.32.0 - 2023-12-26
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to search for an asset profile by `id` as an administrator
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the select column of the lazy-loaded activities table to stick at the end (experimental)
|
||||
- Dropped the activity id in the activities import
|
||||
- Improved the validation of the currency management in the admin control panel
|
||||
- Improved the performance of the value redaction interceptor for the impersonation mode by eliminating `cloneDeep`
|
||||
- Modernized the `Nx` executors
|
||||
- `@nx/eslint:lint`
|
||||
- `@nx/webpack:webpack`
|
||||
- Upgraded `prettier` from version `3.1.0` to `3.1.1`
|
||||
- Upgraded `prisma` from version `5.7.0` to `5.7.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Reset the letter spacing in buttons
|
||||
|
||||
## 2.31.0 - 2023-12-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Introduced the lazy-loaded activities table to the account detail dialog (experimental)
|
||||
- Introduced the lazy-loaded activities table to the import activities dialog (experimental)
|
||||
- Introduced the lazy-loaded activities table to the position detail dialog (experimental)
|
||||
- Improved the font weight in the value component
|
||||
- Improved the language localization for Türkçe (`tr`)
|
||||
- Upgraded `angular` from version `17.0.4` to `17.0.7`
|
||||
- Upgraded to _Inter_ 4 font family
|
||||
- Upgraded `Nx` from version `17.0.2` to `17.2.5`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the loading state in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
- Fixed the edit of activity in the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
|
||||
## 2.30.0 - 2023-12-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for column sorting to the lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
- Extended the benchmarks of the markets overview by the current market condition (all time high)
|
||||
|
||||
### Changed
|
||||
|
||||
- Adjusted the threshold to skip the data enhancement (_Trackinsight_) if data is inaccurate
|
||||
- Upgraded `prisma` from version `5.6.0` to `5.7.0`
|
||||
|
||||
## 2.29.0 - 2023-12-09
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced a lazy-loaded activities table on the portfolio activities page (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the actions columns of various tables to stick at the end
|
||||
- Increased the height of the tabs on mobile
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Türkçe (`tr`)
|
||||
- Upgraded `marked` from version `4.2.12` to `9.1.6`
|
||||
- Upgraded `ngx-markdown` from version `15.1.0` to `17.1.1`
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.8.3` to `2.9.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the biometric authentication registration
|
||||
|
||||
## 2.28.0 - 2023-12-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a historical cash balances table to the account detail dialog
|
||||
- Introduced a `HasPermission` annotation for endpoints
|
||||
|
||||
### Changed
|
||||
|
||||
- Relaxed the check for duplicates in the preview step of the activities import (allow same day)
|
||||
- Respected the `withExcludedAccounts` flag in the account balance time series
|
||||
|
||||
### Fixed
|
||||
|
||||
- Changed the mechanism of the `INTRADAY` data gathering to operate synchronously avoiding database deadlocks
|
||||
|
||||
## 2.27.1 - 2023-11-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Reverted `Nx` from version `17.1.3` to `17.0.2`
|
||||
|
||||
## 2.27.0 - 2023-11-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the chart in the account detail dialog by historical cash balances
|
||||
- Improved the error log for a timeout in the data source request
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `angular` from version `16.2.12` to `17.0.4`
|
||||
- Upgraded `Nx` from version `17.0.2` to `17.1.3`
|
||||
|
||||
## 2.26.0 - 2023-11-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `prisma` from version `5.5.2` to `5.6.0`
|
||||
- Upgraded `yahoo-finance2` from version `2.8.1` to `2.9.0`
|
||||
|
||||
## 2.25.1 - 2023-11-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Black Friday 2023_
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `http-status-codes` from version `2.2.0` to `2.3.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Handled reading items from missing transaction point while getting the position (`getPosition()`) in the portfolio service
|
||||
|
||||
## 2.24.0 - 2023-11-16
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the "too many bind variables in prepared statement" issue of the data range functionality (`getRange()`) in the market data service
|
||||
|
||||
## 2.23.0 - 2023-11-15
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the benchmarks in the markets overview by 50-Day and 200-Day trends (experimental)
|
||||
- Set up the language localization for Polski (`pl`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the data source validation in the activities import
|
||||
- Changed _Twitter_ to _𝕏_
|
||||
- Improved the selection in the twitter bot service
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.7.0` to `2.8.3`
|
||||
- Upgraded `prettier` from version `3.0.3` to `3.1.0`
|
||||
|
||||
## 2.22.0 - 2023-11-11
|
||||
|
||||
### Added
|
||||
|
||||
- Added the platform icon to the account selectors in the cash balance transfer from one to another account
|
||||
- Added the platform icon to the account selector of the create or edit activity dialog
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the style of the carousel component on mobile for the testimonial section on the landing page
|
||||
- Introduced action menus in the overview of the admin control panel
|
||||
- Harmonized the name column in the historical market data table of the admin control panel
|
||||
- Refactored the implementation of the data range functionality (`getRange()`) in the market data service
|
||||
|
||||
## 2.21.0 - 2023-11-09
|
||||
|
||||
### Changed
|
||||
@ -78,7 +691,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the check for duplicates in the preview step of the activities import (allow different accounts)
|
||||
- Relaxed the check for duplicates in the preview step of the activities import (allow different accounts)
|
||||
- Improved the usability and validation in the cash balance transfer from one to another account
|
||||
- Changed the checkboxes to slide toggles in the overview of the admin control panel
|
||||
- Switched from the deprecated (`PUT`) to the new endpoint (`POST`) to manage historical market data in the asset profile details dialog of the admin control panel
|
||||
@ -139,7 +752,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Changed the users table in the admin control panel to an `@angular/material` data table
|
||||
- Improved the styling of the membership status
|
||||
- Improved the style of the membership status
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -170,7 +783,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Added support to transfer a part of the cash balance from one to another account
|
||||
- Extended the markets overview by benchmarks (date of last all time high)
|
||||
- Extended the benchmarks in the markets overview by the date of the last all time high
|
||||
- Added support to import historical market data in the admin control panel
|
||||
|
||||
### Changed
|
||||
@ -1314,7 +1927,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling in the admin control panel
|
||||
- Improved the style in the admin control panel
|
||||
- Removed the _Google Play_ badge from the landing page
|
||||
- Upgraded `eslint` dependencies
|
||||
|
||||
@ -2069,7 +2682,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
- Simplified the initialization of the exchange rate service
|
||||
- Improved the orders query for `assetClass` with symbol profile overrides
|
||||
- Improved the styling of the benchmarks in the markets overview
|
||||
- Improved the style of the benchmarks in the markets overview
|
||||
|
||||
### Todo
|
||||
|
||||
@ -2403,14 +3016,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a styling issue in the benchmark component on mobile
|
||||
- Fixed a style issue in the benchmark component on mobile
|
||||
|
||||
## 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)
|
||||
- Extended the benchmarks in the markets overview by the current change to the all time high
|
||||
|
||||
## 1.151.0 - 24.05.2022
|
||||
|
||||
@ -2734,7 +3347,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the user currency of the public page
|
||||
- Fixed an issue of the performance calculation with recent activities in the new calculation engine
|
||||
- Fixed an issue in the performance calculation with recent activities in the new calculation engine
|
||||
|
||||
## 1.127.0 - 16.03.2022
|
||||
|
||||
@ -3010,7 +3623,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the styling in the footer row of the activities table
|
||||
- Fixed the style in the footer row of the activities table
|
||||
|
||||
## 1.106.0 - 23.01.2022
|
||||
|
||||
@ -3778,7 +4391,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the wording for the _Restricted View_: _Presenter View_
|
||||
- Improved the styling of the tables
|
||||
- Improved the style of the tables
|
||||
- Ignored cash assets in the allocation chart by sector, continent and country
|
||||
|
||||
### Fixed
|
||||
@ -3981,8 +4594,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling of the current pricing plan
|
||||
- Improved the styling of the transaction type badge
|
||||
- Improved the style of the current pricing plan
|
||||
- Improved the style of the transaction type badge
|
||||
- Set the public _Stripe_ key dynamically
|
||||
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
|
||||
|
||||
@ -4342,7 +4955,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the users table styling of the admin control panel
|
||||
- Improved the users table style of the admin control panel
|
||||
- Improved the background colors in the dark mode
|
||||
|
||||
## 0.92.0 - 25.04.2021
|
||||
@ -4366,7 +4979,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling of the rules in the _X-ray_ section
|
||||
- Improved the style of the rules in the _X-ray_ section
|
||||
|
||||
## 0.90.0 - 22.04.2021
|
||||
|
||||
@ -4561,7 +5174,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Improved the alignment of the _Why Ghostfolio?_ section
|
||||
- Improved the styling of the _Fear & Greed Index_ (market mood)
|
||||
- Improved the style of the _Fear & Greed Index_ (market mood)
|
||||
|
||||
## 0.73.0 - 31.03.2021
|
||||
|
||||
@ -4607,7 +5220,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the styling in the _X-ray_ section
|
||||
- Improved the style in the _X-ray_ section
|
||||
|
||||
## 0.70.0 - 27.03.2021
|
||||
|
||||
@ -4902,7 +5515,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Fixed
|
||||
|
||||
- Only show relevant data in the position detail dialog
|
||||
- Improved the performance chart styling in Safari
|
||||
- Improved the performance chart style in Safari
|
||||
|
||||
## 0.40.0 - 01.03.2021
|
||||
|
||||
|
@ -32,7 +32,7 @@ Use `*ngIf="user?.settings?.isExperimentalFeatures"` in HTML template
|
||||
|
||||
1. Run `yarn nx migrate latest`
|
||||
1. Make sure `package.json` changes make sense and then run `yarn install`
|
||||
1. Run `yarn nx migrate --run-migrations`
|
||||
1. Run `yarn nx migrate --run-migrations` (Run `YARN_NODE_LINKER="node-modules" NX_MIGRATE_SKIP_INSTALL=1 yarn nx migrate --run-migrations` due to https://github.com/nrwl/nx/issues/16338)
|
||||
|
||||
### Prisma
|
||||
|
||||
|
@ -13,8 +13,8 @@ COPY ./.yarnrc .yarnrc
|
||||
COPY ./prisma/schema.prisma prisma/schema.prisma
|
||||
|
||||
RUN apt update && apt install -y \
|
||||
git \
|
||||
g++ \
|
||||
git \
|
||||
make \
|
||||
openssl \
|
||||
python3 \
|
||||
@ -52,6 +52,7 @@ RUN yarn database:generate-typings
|
||||
# Image to run, copy everything needed from builder
|
||||
FROM node:18-slim
|
||||
RUN apt update && apt install -y \
|
||||
curl \
|
||||
openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
55
README.md
55
README.md
@ -49,7 +49,7 @@ Ghostfolio is for you if you are...
|
||||
|
||||
- ✅ Create, update and delete transactions
|
||||
- ✅ Multi account management
|
||||
- ✅ Portfolio performance for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
|
||||
- ✅ Various charts
|
||||
- ✅ Static analysis to identify potential risks in your portfolio
|
||||
- ✅ Import and export transactions
|
||||
@ -87,19 +87,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| Name | Default Value | Description |
|
||||
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -115,7 +119,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
docker compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
```
|
||||
|
||||
#### b. Build and run environment
|
||||
@ -123,8 +127,8 @@ docker-compose --env-file ./.env -f docker/docker-compose.yml up -d
|
||||
Run the following commands to build and start the Docker images:
|
||||
|
||||
```bash
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml build
|
||||
docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
```
|
||||
|
||||
#### Setup
|
||||
@ -135,12 +139,12 @@ docker-compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
#### Upgrade Version
|
||||
|
||||
1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
|
||||
1. Run the following command to start the new Docker image: `docker-compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||
1. Run the following command to start the new Docker image: `docker compose --env-file ./.env -f docker/docker-compose.yml up -d`
|
||||
At each start, the container will automatically apply the database schema migrations if needed.
|
||||
|
||||
### Home Server Systems (Community)
|
||||
|
||||
Ghostfolio is available for various home server systems, including [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||
|
||||
## Development
|
||||
|
||||
@ -155,8 +159,9 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `docker compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema
|
||||
1. Run `git config core.hooksPath ./git-hooks/` to setup git hooks
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
1. Open http://localhost:4200/en in your browser
|
||||
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
|
||||
@ -165,7 +170,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
||||
|
||||
#### Debug
|
||||
|
||||
Run `yarn watch:server` and click _Launch Program_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
Run `yarn watch:server` and click _Debug API_ in [Visual Studio Code](https://code.visualstudio.com)
|
||||
|
||||
#### Serve
|
||||
|
||||
@ -272,12 +277,16 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_). We would love to hear from you.
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
## Analytics
|
||||
|
||||

|
||||
|
||||
## License
|
||||
|
||||
© 2021 - 2023 [Ghostfolio](https://ghostfol.io)
|
||||
© 2021 - 2024 [Ghostfolio](https://ghostfol.io)
|
||||
|
||||
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).
|
||||
|
@ -13,7 +13,6 @@ export default {
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node',
|
||||
preset: '../../jest.preset.js'
|
||||
};
|
||||
|
@ -7,14 +7,16 @@
|
||||
"generators": {},
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nrwl/webpack:webpack",
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"compiler": "tsc",
|
||||
"deleteOutputPath": false,
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"outputPath": "dist/apps/api",
|
||||
"sourceMap": true,
|
||||
"target": "node",
|
||||
"compiler": "tsc"
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"webpackConfig": "apps/api/webpack.config.js"
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
@ -32,6 +34,26 @@
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"copy-assets": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "shx rm -rf dist/apps/api"
|
||||
},
|
||||
{
|
||||
"command": "shx mkdir -p dist/apps/api/assets/locales"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
|
||||
}
|
||||
],
|
||||
"parallel": false
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/js:node",
|
||||
"options": {
|
||||
@ -39,7 +61,7 @@
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"executor": "@nrwl/linter:eslint",
|
||||
"executor": "@nx/eslint:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": ["apps/api/**/*.ts"]
|
||||
}
|
||||
@ -47,8 +69,7 @@
|
||||
"test": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"options": {
|
||||
"jestConfig": "apps/api/jest.config.ts",
|
||||
"passWithNoTests": true
|
||||
"jestConfig": "apps/api/jest.config.ts"
|
||||
},
|
||||
"outputs": ["{workspaceRoot}/coverage/apps/api"]
|
||||
}
|
||||
|
@ -1,6 +1,10 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { Access } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -17,7 +21,6 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { Access as AccessModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccessModule } from './access.module';
|
||||
import { AccessService } from './access.service';
|
||||
import { CreateAccessDto } from './create-access.dto';
|
||||
|
||||
@ -25,11 +28,12 @@ import { CreateAccessDto } from './create-access.dto';
|
||||
export class AccessController {
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getAllAccesses(): Promise<Access[]> {
|
||||
const accessesWithGranteeUser = await this.accessService.accesses({
|
||||
include: {
|
||||
@ -39,32 +43,38 @@ export class AccessController {
|
||||
where: { userId: this.request.user.id }
|
||||
});
|
||||
|
||||
return accessesWithGranteeUser.map((access) => {
|
||||
if (access.GranteeUser) {
|
||||
return accessesWithGranteeUser.map(
|
||||
({ alias, GranteeUser, id, permissions }) => {
|
||||
if (GranteeUser) {
|
||||
return {
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: GranteeUser?.id,
|
||||
type: 'PRIVATE'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: access.alias,
|
||||
grantee: access.GranteeUser?.id,
|
||||
id: access.id,
|
||||
type: 'RESTRICTED_VIEW'
|
||||
alias,
|
||||
id,
|
||||
permissions,
|
||||
grantee: 'Public',
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
alias: access.alias,
|
||||
grantee: 'Public',
|
||||
id: access.id,
|
||||
type: 'PUBLIC'
|
||||
};
|
||||
});
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createAccess)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccess(
|
||||
@Body() data: CreateAccessDto
|
||||
): Promise<AccessModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccess)
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -72,25 +82,30 @@ export class AccessController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
try {
|
||||
return await this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
: undefined,
|
||||
permissions: data.permissions,
|
||||
User: { connect: { id: this.request.user.id } }
|
||||
});
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||
StatusCodes.BAD_REQUEST
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModule> {
|
||||
@HasPermission(permissions.deleteAccess)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccess(@Param('id') id: string): Promise<AccessModel> {
|
||||
const access = await this.accessService.access({ id });
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccess) ||
|
||||
!access ||
|
||||
access.userId !== this.request.user.id
|
||||
) {
|
||||
if (!access || access.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccessController } from './access.controller';
|
||||
@ -7,7 +9,7 @@ import { AccessService } from './access.service';
|
||||
@Module({
|
||||
controllers: [AccessController],
|
||||
exports: [AccessService],
|
||||
imports: [PrismaModule],
|
||||
imports: [ConfigurationModule, PrismaModule],
|
||||
providers: [AccessService]
|
||||
})
|
||||
export class AccessModule {}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccessWithGranteeUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Access, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { AccessPermission } from '@prisma/client';
|
||||
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccessDto {
|
||||
@IsOptional()
|
||||
@ -6,10 +7,10 @@ export class CreateAccessDto {
|
||||
alias?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsUUID()
|
||||
granteeUserId?: string;
|
||||
|
||||
@IsEnum(AccessPermission, { each: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
type?: 'PUBLIC';
|
||||
permissions?: AccessPermission[];
|
||||
}
|
||||
|
@ -0,0 +1,49 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AccountBalance } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
|
||||
@Controller('account-balance')
|
||||
export class AccountBalanceController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.deleteAccountBalance)
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccountBalance(
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalance> {
|
||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||
id
|
||||
});
|
||||
|
||||
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountBalanceService.deleteAccountBalance({
|
||||
id
|
||||
});
|
||||
}
|
||||
}
|
15
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
15
apps/api/src/app/account-balance/account-balance.module.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountBalanceController } from './account-balance.controller';
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AccountBalanceController],
|
||||
exports: [AccountBalanceService],
|
||||
imports: [ExchangeRateDataModule, PrismaModule],
|
||||
providers: [AccountBalanceService]
|
||||
})
|
||||
export class AccountBalanceModule {}
|
92
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
92
apps/api/src/app/account-balance/account-balance.service.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccountBalanceService {
|
||||
public constructor(
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async accountBalance(
|
||||
accountBalanceWhereInput: Prisma.AccountBalanceWhereInput
|
||||
): Promise<AccountBalance | null> {
|
||||
return this.prismaService.accountBalance.findFirst({
|
||||
include: {
|
||||
Account: true
|
||||
},
|
||||
where: accountBalanceWhereInput
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccountBalance(
|
||||
data: Prisma.AccountBalanceCreateInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteAccountBalance(
|
||||
where: Prisma.AccountBalanceWhereUniqueInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccountBalances({
|
||||
filters,
|
||||
user,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
user: UserWithSettings;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<AccountBalancesResponse> {
|
||||
const where: Prisma.AccountBalanceWhereInput = { userId: user.id };
|
||||
|
||||
const accountFilter = filters?.find(({ type }) => {
|
||||
return type === 'ACCOUNT';
|
||||
});
|
||||
|
||||
if (accountFilter) {
|
||||
where.accountId = accountFilter.id;
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.Account = { isExcluded: false };
|
||||
}
|
||||
|
||||
const balances = await this.prismaService.accountBalance.findMany({
|
||||
where,
|
||||
orderBy: {
|
||||
date: 'asc'
|
||||
},
|
||||
select: {
|
||||
Account: true,
|
||||
date: true,
|
||||
id: true,
|
||||
value: true
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
balances: balances.map((balance) => {
|
||||
return {
|
||||
...balance,
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
balance.value,
|
||||
balance.Account.currency,
|
||||
user.Settings.settings.baseCurrency
|
||||
)
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
@ -1,17 +1,20 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AccountBalancesResponse,
|
||||
Accounts
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
AccountWithValue,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -47,17 +50,9 @@ export class AccountController {
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteAccount)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAccount(@Param('id') id: string): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const account = await this.accountService.accountWithOrders(
|
||||
{
|
||||
id_userId: {
|
||||
@ -68,7 +63,7 @@ export class AccountController {
|
||||
{ Order: true }
|
||||
);
|
||||
|
||||
if (account?.isDefault || account?.Order.length > 0) {
|
||||
if (!account || account?.Order.length > 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -87,7 +82,7 @@ export class AccountController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAllAccounts(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId
|
||||
@ -102,7 +97,7 @@ export class AccountController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountById(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@ -122,31 +117,23 @@ export class AccountController {
|
||||
}
|
||||
|
||||
@Get(':id/balances')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
public async getAccountBalancesById(
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalancesResponse> {
|
||||
return this.accountBalanceService.getAccountBalances({
|
||||
accountId: id,
|
||||
userId: this.request.user.id
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
user: this.request.user
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createAccount)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccount(
|
||||
@Body() data: CreateAccountDto
|
||||
): Promise<AccountModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
if (data.platformId) {
|
||||
const platformId = data.platformId;
|
||||
delete data.platformId;
|
||||
@ -172,20 +159,12 @@ export class AccountController {
|
||||
}
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateAccount)
|
||||
@Post('transfer-balance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async transferAccountBalance(
|
||||
@Body() { accountIdFrom, accountIdTo, balance }: TransferBalanceDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const accountsOfUser = await this.accountService.getAccounts(
|
||||
this.request.user.id
|
||||
);
|
||||
@ -234,18 +213,10 @@ export class AccountController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateAccount)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateAccountDto) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalAccount = await this.accountService.account({
|
||||
id_userId: {
|
||||
id,
|
||||
|
@ -1,12 +1,13 @@
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AccountController } from './account.controller';
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/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';
|
||||
@ -20,10 +21,8 @@ export class AccountService {
|
||||
public async account({
|
||||
id_userId
|
||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||
const { id, userId } = id_userId;
|
||||
|
||||
const [account] = await this.accounts({
|
||||
where: { id, userId }
|
||||
where: id_userId
|
||||
});
|
||||
|
||||
return account;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -19,7 +20,7 @@ export class CreateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -19,7 +20,7 @@ export class UpdateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
|
||||
import {
|
||||
@ -17,11 +20,12 @@ import {
|
||||
AdminMarketDataDetails,
|
||||
EnhancedSymbolProfile
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
MarketDataPreset,
|
||||
RequestWithUser
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -29,6 +33,7 @@ import {
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Logger,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
@ -54,61 +59,29 @@ export class AdminController {
|
||||
private readonly adminService: AdminService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly manualService: ManualService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getAdminData(): Promise<AdminData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.get();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gather7Days(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gather7Days();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/max')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherMax(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
@ -130,21 +103,10 @@ export class AdminController {
|
||||
this.dataGatheringService.gatherMax();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/profile-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileData(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const uniqueAssets = await this.dataGatheringService.getUniqueAssets();
|
||||
|
||||
await this.dataGatheringService.addJobsToQueue(
|
||||
@ -164,24 +126,13 @@ export class AdminController {
|
||||
);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherProfileDataForSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.dataGatheringService.addJobToQueue({
|
||||
data: {
|
||||
dataSource,
|
||||
@ -196,47 +147,25 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Post('gather/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
public async gatherSymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('gather/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async gatherSymbolForDate(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<MarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseISO(dateString);
|
||||
|
||||
if (!isDate(date)) {
|
||||
@ -254,7 +183,8 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('market-data')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketData(
|
||||
@Query('assetSubClasses') filterByAssetSubClasses?: string,
|
||||
@Query('presetId') presetId?: MarketDataPreset,
|
||||
@ -264,18 +194,6 @@ export class AdminController {
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('take') take?: number
|
||||
): Promise<AdminMarketData> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAssetSubClasses,
|
||||
filterBySearchQuery
|
||||
@ -292,51 +210,53 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Get('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<AdminMarketDataDetails> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('market-data/:dataSource/:symbol/test')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async testMarketData(
|
||||
@Body() data: { scraperConfiguration: string },
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<{ price: number }> {
|
||||
try {
|
||||
const scraperConfiguration = JSON.parse(data.scraperConfiguration);
|
||||
const price = await this.manualService.test(scraperConfiguration);
|
||||
|
||||
if (price) {
|
||||
return { price };
|
||||
}
|
||||
|
||||
throw new Error('Could not parse the current market price');
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
|
||||
throw new HttpException(error.message, StatusCodes.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('market-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateMarketData(
|
||||
@Body() data: UpdateBulkMarketDataDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const dataBulkUpdate: Prisma.MarketDataUpdateInput[] = data.marketData.map(
|
||||
({ date, marketPrice }) => ({
|
||||
dataSource,
|
||||
marketPrice,
|
||||
symbol,
|
||||
date: resetHours(parseISO(date)),
|
||||
date: parseISO(date),
|
||||
state: 'CLOSE'
|
||||
})
|
||||
);
|
||||
@ -349,26 +269,15 @@ export class AdminController {
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Put('market-data/:dataSource/:symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async update(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string,
|
||||
@Body() data: UpdateMarketDataDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const date = parseISO(dateString);
|
||||
|
||||
return this.marketDataService.updateMarketData({
|
||||
@ -383,24 +292,14 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async addProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolProfile | never> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
return this.adminService.addAssetProfile({
|
||||
dataSource,
|
||||
symbol,
|
||||
@ -409,45 +308,23 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteProfileData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.deleteProfileData({ dataSource, symbol });
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Patch('profile-data/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async patchAssetProfileData(
|
||||
@Body() assetProfileData: UpdateAssetProfileDto,
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<EnhancedSymbolProfile> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.adminService.patchAssetProfileData({
|
||||
...assetProfileData,
|
||||
dataSource,
|
||||
@ -455,24 +332,13 @@ export class AdminController {
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Put('settings/:key')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updateProperty(
|
||||
@Param('key') key: string,
|
||||
@Body() data: PropertyDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return await this.adminService.putSetting(key, data.value);
|
||||
}
|
||||
}
|
||||
|
@ -8,6 +8,7 @@ import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-da
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AdminController } from './admin.controller';
|
||||
|
@ -22,6 +22,7 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { MarketDataPreset } from '@ghostfolio/common/types';
|
||||
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AssetSubClass,
|
||||
@ -176,6 +177,7 @@ export class AdminService {
|
||||
|
||||
if (searchQuery) {
|
||||
where.OR = [
|
||||
{ id: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ isin: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ name: { mode: 'insensitive', startsWith: searchQuery } },
|
||||
{ symbol: { mode: 'insensitive', startsWith: searchQuery } }
|
||||
@ -224,7 +226,7 @@ export class AdminService {
|
||||
this.prismaService.symbolProfile.count({ where })
|
||||
]);
|
||||
|
||||
let marketData = assetProfiles.map(
|
||||
let marketData: AdminMarketDataItem[] = assetProfiles.map(
|
||||
({
|
||||
_count,
|
||||
assetClass,
|
||||
@ -320,9 +322,12 @@ export class AdminService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
|
||||
@ -330,9 +335,12 @@ export class AdminService {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
symbolMapping
|
||||
});
|
||||
@ -432,13 +440,14 @@ export class AdminService {
|
||||
},
|
||||
createdAt: true,
|
||||
id: true,
|
||||
role: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics
|
||||
@ -448,13 +457,17 @@ export class AdminService {
|
||||
const subscription = this.configurationService.get(
|
||||
'ENABLE_FEATURE_SUBSCRIPTION'
|
||||
)
|
||||
? this.subscriptionService.getSubscription(Subscription)
|
||||
? this.subscriptionService.getSubscription({
|
||||
createdAt,
|
||||
subscriptions: Subscription
|
||||
})
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
role,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics?.country,
|
||||
|
@ -1,87 +1,49 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { JobStatus } from 'bull';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { QueueService } from './queue.service';
|
||||
|
||||
@Controller('admin/queue')
|
||||
export class QueueController {
|
||||
public constructor(
|
||||
private readonly queueService: QueueService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly queueService: QueueService) {}
|
||||
|
||||
@Delete('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
return this.queueService.deleteJobs({ status });
|
||||
}
|
||||
|
||||
@Get('job')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getJobs(
|
||||
@Query('status') filterByStatus?: string
|
||||
): Promise<AdminJobs> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const status = <JobStatus[]>filterByStatus?.split(',') ?? undefined;
|
||||
return this.queueService.getJobs({ status });
|
||||
}
|
||||
|
||||
@Delete('job/:id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteJob(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.queueService.deleteJob(id);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { QueueController } from './queue.controller';
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
QUEUE_JOB_STATUS_LIST
|
||||
} from '@ghostfolio/common/config';
|
||||
import { AdminJobs } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { InjectQueue } from '@nestjs/bull';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JobStatus, Queue } from 'bull';
|
||||
|
@ -1,5 +1,12 @@
|
||||
import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import { IsEnum, IsObject, IsOptional, IsString } from 'class-validator';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateAssetProfileDto {
|
||||
@IsEnum(AssetClass, { each: true })
|
||||
@ -14,6 +21,14 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
comment?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
countries?: Prisma.InputJsonArray;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
@ -22,6 +37,10 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
scraperConfiguration?: Prisma.InputJsonObject;
|
||||
|
||||
@IsArray()
|
||||
@IsOptional()
|
||||
sectors?: Prisma.InputJsonArray;
|
||||
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
symbolMapping?: {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
@Controller()
|
||||
|
@ -1,22 +1,23 @@
|
||||
import { join } from 'path';
|
||||
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
|
||||
import {
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
SUPPORTED_LANGUAGE_CODES
|
||||
} from '@ghostfolio/common/config';
|
||||
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
import { join } from 'path';
|
||||
|
||||
import { AccessModule } from './access/access.module';
|
||||
import { AccountModule } from './account/account.module';
|
||||
@ -52,6 +53,7 @@ import { UserModule } from './user/user.module';
|
||||
BenchmarkModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
@ -73,6 +75,7 @@ import { UserModule } from './user/user.module';
|
||||
PlatformModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
|
@ -1,40 +1,19 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Controller, Delete, Param, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('auth-device')
|
||||
export class AuthDeviceController {
|
||||
public constructor(
|
||||
private readonly authDeviceService: AuthDeviceService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly authDeviceService: AuthDeviceService) {}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteAuthDevice)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteAuthDevice(@Param('id') id: string): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.deleteAuthDevice
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
await this.authDeviceService.deleteAuthDevice({ id });
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-devic
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { OAuthResponse } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -118,13 +120,13 @@ export class AuthController {
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-registration-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async generateRegistrationOptions() {
|
||||
return this.webAuthService.generateRegistrationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async verifyAttestation(
|
||||
@Body() body: { deviceName: string; credential: AttestationCredentialJSON }
|
||||
) {
|
||||
|
@ -5,6 +5,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
|
||||
import { Provider } from '@prisma/client';
|
||||
|
||||
export interface AuthDeviceDialogParams {
|
||||
|
@ -2,6 +2,7 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
|
@ -3,6 +3,7 @@ import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.s
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
@ -40,7 +41,7 @@ export class WebAuthService {
|
||||
) {}
|
||||
|
||||
get rpID() {
|
||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
||||
}
|
||||
|
||||
get expectedOrigin() {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
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 type {
|
||||
@ -5,8 +7,9 @@ import type {
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -33,21 +36,10 @@ export class BenchmarkController {
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async addBenchmark(@Body() { dataSource, symbol }: UniqueAsset) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.addBenchmark({
|
||||
dataSource,
|
||||
@ -71,23 +63,12 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Delete(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteBenchmark(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
) {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const benchmark = await this.benchmarkService.deleteBenchmark({
|
||||
dataSource,
|
||||
@ -120,7 +101,7 @@ export class BenchmarkController {
|
||||
}
|
||||
|
||||
@Get(':dataSource/:symbol/:startDateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async getBenchmarkMarketDataBySymbol(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@ -128,11 +109,13 @@ export class BenchmarkController {
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<BenchmarkMarketDataDetails> {
|
||||
const startDate = new Date(startDateString);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
return this.benchmarkService.getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
symbol,
|
||||
userCurrency
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2,10 +2,12 @@ import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.mo
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BenchmarkController } from './benchmark.controller';
|
||||
@ -17,6 +19,7 @@ import { BenchmarkService } from './benchmark.service';
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
PrismaModule,
|
||||
PropertyModule,
|
||||
|
@ -11,6 +11,7 @@ describe('BenchmarkService', () => {
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
@ -9,18 +10,25 @@ import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
calculateBenchmarkTrend,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Benchmark,
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkProperty,
|
||||
BenchmarkResponse,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BenchmarkTrend } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { format, isSameDay, subDays } from 'date-fns';
|
||||
import { isNumber, last, uniqBy } from 'lodash';
|
||||
import ms from 'ms';
|
||||
|
||||
@Injectable()
|
||||
@ -29,6 +37,7 @@ export class BenchmarkService {
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@ -45,9 +54,34 @@ export class BenchmarkService {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||
BenchmarkResponse['benchmarks']
|
||||
> {
|
||||
public async getBenchmarkTrends({ dataSource, symbol }: UniqueAsset) {
|
||||
const historicalData = await this.marketDataService.marketDataItems({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
where: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: { gte: subDays(new Date(), 400) }
|
||||
}
|
||||
});
|
||||
|
||||
const fiftyDayAverage = calculateBenchmarkTrend({
|
||||
historicalData,
|
||||
days: 50
|
||||
});
|
||||
const twoHundredDayAverage = calculateBenchmarkTrend({
|
||||
historicalData,
|
||||
days: 200
|
||||
});
|
||||
|
||||
return { trend50d: fiftyDayAverage, trend200d: twoHundredDayAverage };
|
||||
}
|
||||
|
||||
public async getBenchmarks({
|
||||
enableSharing = false,
|
||||
useCache = true
|
||||
} = {}): Promise<BenchmarkResponse['benchmarks']> {
|
||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||
|
||||
if (useCache) {
|
||||
@ -62,9 +96,16 @@ export class BenchmarkService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles({
|
||||
enableSharing
|
||||
});
|
||||
|
||||
const promises: Promise<{ date: Date; marketPrice: number }>[] = [];
|
||||
const promisesAllTimeHighs: Promise<{ date: Date; marketPrice: number }>[] =
|
||||
[];
|
||||
const promisesBenchmarkTrends: Promise<{
|
||||
trend50d: BenchmarkTrend;
|
||||
trend200d: BenchmarkTrend;
|
||||
}>[] = [];
|
||||
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
@ -73,10 +114,18 @@ export class BenchmarkService {
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
promisesAllTimeHighs.push(
|
||||
this.marketDataService.getMax({ dataSource, symbol })
|
||||
);
|
||||
promisesBenchmarkTrends.push(
|
||||
this.getBenchmarkTrends({ dataSource, symbol })
|
||||
);
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
const [allTimeHighs, benchmarkTrends] = await Promise.all([
|
||||
Promise.all(promisesAllTimeHighs),
|
||||
Promise.all(promisesBenchmarkTrends)
|
||||
]);
|
||||
let storeInCache = true;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
@ -93,6 +142,7 @@ export class BenchmarkService {
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
@ -100,10 +150,12 @@ export class BenchmarkService {
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
date: allTimeHigh.date,
|
||||
date: allTimeHigh?.date,
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
}
|
||||
}
|
||||
},
|
||||
trend50d: benchmarkTrends[index].trend50d,
|
||||
trend200d: benchmarkTrends[index].trend200d
|
||||
};
|
||||
});
|
||||
|
||||
@ -118,14 +170,24 @@ export class BenchmarkService {
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
public async getBenchmarkAssetProfiles({
|
||||
enableSharing = false
|
||||
} = {}): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as BenchmarkProperty[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
)
|
||||
.filter((benchmark) => {
|
||||
if (enableSharing) {
|
||||
return benchmark.enableSharing;
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||
@ -145,8 +207,14 @@ export class BenchmarkService {
|
||||
public async getMarketDataBySymbol({
|
||||
dataSource,
|
||||
startDate,
|
||||
symbol
|
||||
}: { startDate: Date } & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
symbol,
|
||||
userCurrency
|
||||
}: {
|
||||
startDate: Date;
|
||||
userCurrency: string;
|
||||
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
|
||||
const marketData: { date: string; value: number }[] = [];
|
||||
|
||||
const [currentSymbolItem, marketDataItems] = await Promise.all([
|
||||
this.symbolService.get({
|
||||
dataGatheringItem: {
|
||||
@ -168,44 +236,96 @@ export class BenchmarkService {
|
||||
})
|
||||
]);
|
||||
|
||||
const exchangeRates =
|
||||
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||
startDate,
|
||||
currencies: [currentSymbolItem.currency],
|
||||
targetCurrency: userCurrency
|
||||
});
|
||||
|
||||
const exchangeRateAtStartDate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(startDate, DATE_FORMAT)
|
||||
];
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.find(({ date }) => {
|
||||
return isSameDay(date, startDate);
|
||||
})?.marketPrice;
|
||||
|
||||
if (!marketPriceAtStartDate) {
|
||||
Logger.error(
|
||||
`No historical market data has been found for ${symbol} (${dataSource}) at ${format(
|
||||
startDate,
|
||||
DATE_FORMAT
|
||||
)}`,
|
||||
'BenchmarkService'
|
||||
);
|
||||
|
||||
return { marketData };
|
||||
}
|
||||
|
||||
const step = Math.round(
|
||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||
const response = {
|
||||
marketData: [
|
||||
...marketDataItems
|
||||
.filter((marketDataItem, index) => {
|
||||
return index % step === 0;
|
||||
})
|
||||
.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
let i = 0;
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
for (let marketDataItem of marketDataItems) {
|
||||
if (i % step !== 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const exchangeRate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(marketDataItem.date, DATE_FORMAT)
|
||||
];
|
||||
|
||||
const exchangeRateFactor =
|
||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||
? exchangeRate / exchangeRateAtStartDate
|
||||
: 1;
|
||||
|
||||
marketData.push({
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice * exchangeRateFactor
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
const includesToday = isSameDay(
|
||||
parseDate(last(marketData).date),
|
||||
new Date()
|
||||
);
|
||||
|
||||
if (currentSymbolItem?.marketPrice && !includesToday) {
|
||||
const exchangeRate =
|
||||
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
|
||||
format(new Date(), DATE_FORMAT)
|
||||
];
|
||||
|
||||
const exchangeRateFactor =
|
||||
isNumber(exchangeRateAtStartDate) && isNumber(exchangeRate)
|
||||
? exchangeRate / exchangeRateAtStartDate
|
||||
: 1;
|
||||
|
||||
marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
currentSymbolItem.marketPrice * exchangeRateFactor
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
return {
|
||||
marketData
|
||||
};
|
||||
}
|
||||
|
||||
public async addBenchmark({
|
||||
@ -282,7 +402,15 @@ export class BenchmarkService {
|
||||
};
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
return aPerformanceInPercent <= -0.2 ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
private getMarketCondition(
|
||||
aPerformanceInPercent: number
|
||||
): Benchmark['marketCondition'] {
|
||||
if (aPerformanceInPercent === 0) {
|
||||
return 'ALL_TIME_HIGH';
|
||||
} else if (aPerformanceInPercent <= -0.2) {
|
||||
return 'BEAR_MARKET';
|
||||
} else {
|
||||
return 'NEUTRAL_MARKET';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
36
apps/api/src/app/cache/cache.controller.ts
vendored
36
apps/api/src/app/cache/cache.controller.ts
vendored
@ -1,39 +1,19 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
HttpException,
|
||||
Inject,
|
||||
Post,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Controller, Post, UseGuards } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@Controller('cache')
|
||||
export class CacheController {
|
||||
public constructor(
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||
|
||||
@HasPermission(permissions.accessAdminControl)
|
||||
@Post('flush')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async flushCache(): Promise<void> {
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.accessAdminControl
|
||||
)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.redisCacheService.reset();
|
||||
}
|
||||
}
|
||||
|
1
apps/api/src/app/cache/cache.module.ts
vendored
1
apps/api/src/app/cache/cache.module.ts
vendored
@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheController } from './cache.controller';
|
||||
|
@ -1,4 +1,6 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
@ -19,7 +21,7 @@ export class ExchangeRateController {
|
||||
) {}
|
||||
|
||||
@Get(':symbol/:dateString')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getExchangeRate(
|
||||
@Param('dateString') dateString: string,
|
||||
@Param('symbol') symbol: string
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExchangeRateController } from './exchange-rate.controller';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
@ -9,17 +12,29 @@ import { ExportService } from './export.service';
|
||||
@Controller('export')
|
||||
export class ExportController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly exportService: ExportService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async export(
|
||||
@Query('activityIds') activityIds?: string[]
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('activityIds') activityIds?: string[],
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Export> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
return this.exportService.export({
|
||||
activityIds,
|
||||
filters,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
@ -1,9 +1,11 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
@ -12,6 +14,7 @@ import { ExportService } from './export.service';
|
||||
@Module({
|
||||
imports: [
|
||||
AccountModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { Export } from '@ghostfolio/common/interfaces';
|
||||
import { Filter, Export } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -13,9 +14,13 @@ export class ExportService {
|
||||
|
||||
public async export({
|
||||
activityIds,
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activityIds?: string[];
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Export> {
|
||||
const accounts = (
|
||||
@ -39,10 +44,14 @@ export class ExportService {
|
||||
}
|
||||
);
|
||||
|
||||
let activities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
let { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
sortColumn: 'date',
|
||||
sortDirection: 'asc',
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (activityIds) {
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { HealthController } from './health.controller';
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { DataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsOptional, ValidateNested } from 'class-validator';
|
||||
|
||||
|
@ -1,9 +1,12 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
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 { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -34,19 +37,18 @@ export class ImportController {
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@HasPermission(permissions.createOrder)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
@Query('dryRun') isDryRunParam = 'false'
|
||||
): Promise<ImportResponse> {
|
||||
const isDryRun = isDryRunParam === 'true';
|
||||
|
||||
if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
permissions.createAccount
|
||||
) ||
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
@ -65,16 +67,13 @@ export class ImportController {
|
||||
maxActivitiesToImport = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
try {
|
||||
const activities = await this.importService.import({
|
||||
isDryRun,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
accountsDto: importData.accounts ?? [],
|
||||
activitiesDto: importData.activities,
|
||||
userId: this.request.user.id
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
return { activities };
|
||||
@ -92,7 +91,7 @@ export class ImportController {
|
||||
}
|
||||
|
||||
@Get('dividends/:dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async gatherDividends(
|
||||
|
@ -10,6 +10,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
@ -20,12 +21,14 @@ import {
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
AccountWithPlatform,
|
||||
OrderWithAccount
|
||||
OrderWithAccount,
|
||||
UserWithSettings
|
||||
} from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, format, isAfter, isSameDay, parseISO } from 'date-fns';
|
||||
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@ -33,6 +36,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
export class ImportService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
@ -81,12 +85,13 @@ export class ImportService {
|
||||
|
||||
const value = new Big(quantity).mul(marketPrice).toNumber();
|
||||
|
||||
const date = parseDate(dateString);
|
||||
const isDuplicate = orders.some((activity) => {
|
||||
return (
|
||||
activity.accountId === Account?.id &&
|
||||
activity.SymbolProfile.currency === assetProfile.currency &&
|
||||
activity.SymbolProfile.dataSource === assetProfile.dataSource &&
|
||||
isSameDay(activity.date, parseDate(dateString)) &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === assetProfile.symbol &&
|
||||
activity.type === 'DIVIDEND' &&
|
||||
@ -100,6 +105,7 @@ export class ImportService {
|
||||
|
||||
return {
|
||||
Account,
|
||||
date,
|
||||
error,
|
||||
quantity,
|
||||
value,
|
||||
@ -107,7 +113,6 @@ export class ImportService {
|
||||
accountUserId: undefined,
|
||||
comment: undefined,
|
||||
createdAt: undefined,
|
||||
date: parseDate(dateString),
|
||||
fee: 0,
|
||||
feeInBaseCurrency: 0,
|
||||
id: assetProfile.id,
|
||||
@ -135,17 +140,16 @@ export class ImportService {
|
||||
activitiesDto,
|
||||
isDryRun = false,
|
||||
maxActivitiesToImport,
|
||||
userCurrency,
|
||||
userId
|
||||
user
|
||||
}: {
|
||||
accountsDto: Partial<CreateAccountDto>[];
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
isDryRun?: boolean;
|
||||
maxActivitiesToImport: number;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
user: UserWithSettings;
|
||||
}): Promise<Activity[]> {
|
||||
const accountIdMapping: { [oldAccountId: string]: string } = {};
|
||||
const userCurrency = user.Settings.settings.baseCurrency;
|
||||
|
||||
if (!isDryRun && accountsDto?.length) {
|
||||
const [existingAccounts, existingPlatforms] = await Promise.all([
|
||||
@ -168,7 +172,7 @@ export class ImportService {
|
||||
);
|
||||
|
||||
// If there is no account or if the account belongs to a different user then create a new account
|
||||
if (!accountWithSameId || accountWithSameId.userId !== userId) {
|
||||
if (!accountWithSameId || accountWithSameId.userId !== user.id) {
|
||||
let oldAccountId: string;
|
||||
const platformId = account.platformId;
|
||||
|
||||
@ -181,7 +185,7 @@ export class ImportService {
|
||||
|
||||
let accountObject: Prisma.AccountCreateInput = {
|
||||
...account,
|
||||
User: { connect: { id: userId } }
|
||||
User: { connect: { id: user.id } }
|
||||
};
|
||||
|
||||
if (
|
||||
@ -197,7 +201,7 @@ export class ImportService {
|
||||
|
||||
const newAccount = await this.accountService.createAccount(
|
||||
accountObject,
|
||||
userId
|
||||
user.id
|
||||
);
|
||||
|
||||
// Store the new to old account ID mappings for updating activities
|
||||
@ -228,15 +232,17 @@ export class ImportService {
|
||||
|
||||
const assetProfiles = await this.validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport
|
||||
maxActivitiesToImport,
|
||||
user
|
||||
});
|
||||
|
||||
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userId
|
||||
userCurrency,
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
const accounts = (await this.accountService.getAccounts(userId)).map(
|
||||
const accounts = (await this.accountService.getAccounts(user.id)).map(
|
||||
({ id, name }) => {
|
||||
return { id, name };
|
||||
}
|
||||
@ -341,7 +347,6 @@ export class ImportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccount?.id,
|
||||
accountUserId: undefined,
|
||||
createdAt: new Date(),
|
||||
@ -370,7 +375,8 @@ export class ImportService {
|
||||
},
|
||||
Account: validatedAccount,
|
||||
symbolProfileId: undefined,
|
||||
updatedAt: new Date()
|
||||
updatedAt: new Date(),
|
||||
userId: user.id
|
||||
};
|
||||
} else {
|
||||
if (error) {
|
||||
@ -384,7 +390,6 @@ export class ImportService {
|
||||
quantity,
|
||||
type,
|
||||
unitPrice,
|
||||
userId,
|
||||
accountId: validatedAccount?.id,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
@ -402,7 +407,8 @@ export class ImportService {
|
||||
}
|
||||
},
|
||||
updateAccountBalance: false,
|
||||
User: { connect: { id: userId } }
|
||||
User: { connect: { id: user.id } },
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
|
||||
@ -456,15 +462,18 @@ export class ImportService {
|
||||
|
||||
private async extendActivitiesWithErrors({
|
||||
activitiesDto,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<Partial<Activity>[]> {
|
||||
const existingActivities = await this.orderService.orders({
|
||||
include: { SymbolProfile: true },
|
||||
orderBy: { date: 'desc' },
|
||||
where: { userId }
|
||||
let { activities: existingActivities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
includeDrafts: true,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
return activitiesDto.map(
|
||||
@ -480,13 +489,13 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
const date = parseISO(<string>(<unknown>dateString));
|
||||
const date = parseISO(dateString);
|
||||
const isDuplicate = existingActivities.some((activity) => {
|
||||
return (
|
||||
activity.accountId === accountId &&
|
||||
activity.SymbolProfile.currency === currency &&
|
||||
activity.SymbolProfile.dataSource === dataSource &&
|
||||
isSameDay(activity.date, date) &&
|
||||
isSameSecond(activity.date, date) &&
|
||||
activity.fee === fee &&
|
||||
activity.quantity === quantity &&
|
||||
activity.SymbolProfile.symbol === symbol &&
|
||||
@ -546,10 +555,12 @@ export class ImportService {
|
||||
|
||||
private async validateActivities({
|
||||
activitiesDto,
|
||||
maxActivitiesToImport
|
||||
maxActivitiesToImport,
|
||||
user
|
||||
}: {
|
||||
activitiesDto: Partial<CreateOrderDto>[];
|
||||
maxActivitiesToImport: number;
|
||||
user: UserWithSettings;
|
||||
}) {
|
||||
if (activitiesDto?.length > maxActivitiesToImport) {
|
||||
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
|
||||
@ -559,40 +570,53 @@ export class ImportService {
|
||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
|
||||
const uniqueActivitiesDto = uniqBy(
|
||||
activitiesDto,
|
||||
({ dataSource, symbol }) => {
|
||||
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||
}
|
||||
);
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, symbol }
|
||||
] of uniqueActivitiesDto.entries()) {
|
||||
if (dataSource !== 'MANUAL') {
|
||||
const assetProfile = (
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol];
|
||||
{ currency, dataSource, symbol, type }
|
||||
] of activitiesDto.entries()) {
|
||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||
throw new Error(
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
);
|
||||
}
|
||||
|
||||
if (!assetProfile?.name) {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
user.subscription.type === 'Basic'
|
||||
) {
|
||||
const dataProvider = this.dataProviderService.getDataProvider(
|
||||
DataSource[dataSource]
|
||||
);
|
||||
|
||||
if (dataProvider.getDataProviderInfo().isPremium) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
assetProfile.currency !== currency &&
|
||||
!this.exchangeRateDataService.hasCurrencyPair(
|
||||
currency,
|
||||
assetProfile.currency
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||
);
|
||||
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol]
|
||||
};
|
||||
|
||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||
|
||||
import { InfoService } from './info.service';
|
||||
|
@ -10,6 +10,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
import { TagModule } from '@ghostfolio/api/services/tag/tag.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
|
@ -8,7 +8,6 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_REQUEST_TIMEOUT,
|
||||
PROPERTY_BETTER_UPTIME_MONITOR_ID,
|
||||
PROPERTY_COUNTRIES_OF_SUBSCRIBERS,
|
||||
PROPERTY_DEMO_USER_ID,
|
||||
@ -29,6 +28,7 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { SubscriptionOffer } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import * as cheerio from 'cheerio';
|
||||
@ -60,10 +60,6 @@ export class InfoService {
|
||||
|
||||
const globalPermissions: string[] = [];
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
|
||||
globalPermissions.push(permissions.enableBlog);
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
info.fearAndGreedDataSource = encodeDataSource(
|
||||
@ -162,7 +158,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { pull_count } = await got(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
@ -187,7 +183,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { body } = await got('https://github.com/ghostfolio/ghostfolio', {
|
||||
// @ts-ignore
|
||||
@ -196,11 +192,11 @@ export class InfoService {
|
||||
|
||||
const $ = cheerio.load(body);
|
||||
|
||||
return extractNumberFromString(
|
||||
$(
|
||||
return extractNumberFromString({
|
||||
value: $(
|
||||
`a[href="/ghostfolio/ghostfolio/graphs/contributors"] .Counter`
|
||||
).text()
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService - GitHub');
|
||||
|
||||
@ -214,7 +210,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { stargazers_count } = await got(
|
||||
`https://api.github.com/repos/ghostfolio/ghostfolio`,
|
||||
@ -342,7 +338,7 @@ export class InfoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { data } = await got(
|
||||
`https://uptime.betterstack.com/api/v2/monitors/${monitorId}/sla?from=${format(
|
||||
@ -352,7 +348,7 @@ export class InfoService {
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.configurationService.get(
|
||||
'BETTER_UPTIME_API_KEY'
|
||||
'API_KEY_BETTER_UPTIME'
|
||||
)}`
|
||||
},
|
||||
// @ts-ignore
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { LogoController } from './logo.controller';
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import { DEFAULT_REQUEST_TIMEOUT } from '@ghostfolio/common/config';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import got from 'got';
|
||||
@ -9,6 +10,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@Injectable()
|
||||
export class LogoService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
@ -46,7 +48,7 @@ export class LogoService {
|
||||
|
||||
setTimeout(() => {
|
||||
abortController.abort();
|
||||
}, DEFAULT_REQUEST_TIMEOUT);
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
return got(
|
||||
`https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=${aUrl}&size=64`,
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -38,7 +39,7 @@ export class CreateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -2,6 +2,7 @@ import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
export interface Activities {
|
||||
activities: Activity[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
@ -7,6 +9,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation/imp
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
@ -24,7 +27,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Order as OrderModel } from '@prisma/client';
|
||||
import { Order as OrderModel, Prisma } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -44,24 +47,16 @@ export class OrderController {
|
||||
) {}
|
||||
|
||||
@Delete()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOrders(): Promise<number> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.orderService.deleteOrders({
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
const order = await this.orderService.order({ id });
|
||||
|
||||
@ -82,7 +77,7 @@ export class OrderController {
|
||||
}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@ -90,6 +85,8 @@ export class OrderController {
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('take') take?: number
|
||||
): Promise<Activities> {
|
||||
@ -103,8 +100,10 @@ export class OrderController {
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const activities = await this.orderService.getOrders({
|
||||
const { activities, count } = await this.orderService.getOrders({
|
||||
filters,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
skip: isNaN(skip) ? undefined : skip,
|
||||
@ -113,22 +112,14 @@ export class OrderController {
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
return { activities };
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createOrder)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createOrder)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const order = await this.orderService.createOrder({
|
||||
...data,
|
||||
date: parseISO(data.date),
|
||||
@ -166,19 +157,16 @@ export class OrderController {
|
||||
return order;
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updateOrder)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
|
||||
const originalOrder = await this.orderService.order({
|
||||
id
|
||||
});
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updateOrder) ||
|
||||
!originalOrder ||
|
||||
originalOrder.userId !== this.request.user.id
|
||||
) {
|
||||
if (!originalOrder || originalOrder.userId !== this.request.user.id) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
@ -11,6 +11,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { OrderController } from './order.controller';
|
||||
|
@ -8,8 +8,9 @@ import {
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
@ -18,14 +19,14 @@ import {
|
||||
Order,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
Type as ActivityType
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
@ -37,34 +38,6 @@ export class OrderService {
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async order(
|
||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order | null> {
|
||||
return this.prismaService.order.findUnique({
|
||||
where: orderWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async orders(params: {
|
||||
include?: Prisma.OrderInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.OrderOrderByWithRelationInput;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.order.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput & {
|
||||
accountId?: string;
|
||||
@ -97,12 +70,7 @@ export class OrderService {
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
@ -157,13 +125,9 @@ export class OrderService {
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
const isDraft =
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
@ -207,12 +171,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (
|
||||
order.type === 'FEE' ||
|
||||
order.type === 'INTEREST' ||
|
||||
order.type === 'ITEM' ||
|
||||
order.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -227,10 +186,23 @@ export class OrderService {
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.order.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
where: {
|
||||
SymbolProfile: { dataSource, symbol }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getOrders({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
skip,
|
||||
sortColumn,
|
||||
sortDirection,
|
||||
take = Number.MAX_SAFE_INTEGER,
|
||||
types,
|
||||
userCurrency,
|
||||
@ -240,12 +212,17 @@ export class OrderService {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
skip?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
take?: number;
|
||||
types?: TypeOfOrder[];
|
||||
types?: ActivityType[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activity[]> {
|
||||
}): Promise<Activities> {
|
||||
let orderBy: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput> = [
|
||||
{ date: 'asc' }
|
||||
];
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
const {
|
||||
@ -307,18 +284,24 @@ export class OrderService {
|
||||
};
|
||||
}
|
||||
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
if (sortColumn) {
|
||||
orderBy = [{ [sortColumn]: sortDirection }];
|
||||
}
|
||||
|
||||
return (
|
||||
await this.orders({
|
||||
if (types) {
|
||||
where.type = { in: types };
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.OR = [
|
||||
{ Account: null },
|
||||
{ Account: { NOT: { isExcluded: true } } }
|
||||
];
|
||||
}
|
||||
|
||||
const [orders, count] = await Promise.all([
|
||||
this.orders({
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where,
|
||||
@ -332,35 +315,41 @@ export class OrderService {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
SymbolProfile: true,
|
||||
tags: true
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
)
|
||||
.filter((order) => {
|
||||
return (
|
||||
withExcludedAccounts ||
|
||||
!order.Account ||
|
||||
order.Account?.isExcluded === false
|
||||
);
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
}
|
||||
}),
|
||||
this.prismaService.order.count({ where })
|
||||
]);
|
||||
|
||||
return {
|
||||
...order,
|
||||
const activities = orders.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
// TODO: Use exchange rate of date
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
// TODO: Use exchange rate of date
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
|
||||
return { activities, count };
|
||||
}
|
||||
|
||||
public async order(
|
||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order | null> {
|
||||
return this.prismaService.order.findUnique({
|
||||
where: orderWhereUniqueInput
|
||||
});
|
||||
}
|
||||
|
||||
public async updateOrder({
|
||||
@ -374,13 +363,10 @@ export class OrderService {
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
type?: ActivityType;
|
||||
};
|
||||
where: Prisma.OrderWhereUniqueInput;
|
||||
}): Promise<Order> {
|
||||
if (data.Account.connect.id_userId.id === null) {
|
||||
delete data.Account;
|
||||
}
|
||||
|
||||
if (!data.comment) {
|
||||
data.comment = null;
|
||||
}
|
||||
@ -389,13 +375,12 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
delete data.SymbolProfile.connect;
|
||||
|
||||
if (data.Account?.connect?.id_userId?.id === null) {
|
||||
data.Account = { disconnect: true };
|
||||
}
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
||||
@ -439,4 +424,24 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
private async orders(params: {
|
||||
include?: Prisma.OrderInclude;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
cursor?: Prisma.OrderWhereUniqueInput;
|
||||
where?: Prisma.OrderWhereInput;
|
||||
orderBy?: Prisma.Enumerable<Prisma.OrderOrderByWithRelationInput>;
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prismaService.order.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
skip,
|
||||
take,
|
||||
where
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -37,7 +38,7 @@ export class UpdateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -1,18 +1,18 @@
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Platform } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -23,49 +23,30 @@ import { UpdatePlatformDto } from './update-platform.dto';
|
||||
|
||||
@Controller('platform')
|
||||
export class PlatformController {
|
||||
public constructor(
|
||||
private readonly platformService: PlatformService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly platformService: PlatformService) {}
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getPlatforms() {
|
||||
return this.platformService.getPlatformsWithAccountCount();
|
||||
}
|
||||
|
||||
@HasPermission(permissions.createPlatform)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createPlatform(
|
||||
@Body() data: CreatePlatformDto
|
||||
): Promise<Platform> {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createPlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.platformService.createPlatform(data);
|
||||
}
|
||||
|
||||
@HasPermission(permissions.updatePlatform)
|
||||
@Put(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async updatePlatform(
|
||||
@Param('id') id: string,
|
||||
@Body() data: UpdatePlatformDto
|
||||
) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.updatePlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlatform = await this.platformService.getPlatform({
|
||||
id
|
||||
});
|
||||
@ -88,17 +69,9 @@ export class PlatformController {
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@HasPermission(permissions.deletePlatform)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deletePlatform(@Param('id') id: string) {
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deletePlatform)
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
const originalPlatform = await this.platformService.getPlatform({
|
||||
id
|
||||
});
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PlatformController } from './platform.controller';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Platform, Prisma } from '@prisma/client';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
|
||||
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
@ -33,6 +34,26 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'GOOGL':
|
||||
if (isSameDay(parseDate('2023-01-03'), date)) {
|
||||
return { marketPrice: 89.12 };
|
||||
} else if (isSameDay(parseDate('2023-07-10'), date)) {
|
||||
return { marketPrice: 116.45 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'MSFT':
|
||||
if (isSameDay(parseDate('2021-09-16'), date)) {
|
||||
return { marketPrice: 89.12 };
|
||||
} else if (isSameDay(parseDate('2021-11-16'), date)) {
|
||||
return { marketPrice: 339.51 };
|
||||
} else if (isSameDay(parseDate('2023-07-10'), date)) {
|
||||
return { marketPrice: 331.83 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
@ -61,10 +82,9 @@ export const CurrentRateServiceMock = {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
date
|
||||
).marketPrice,
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||
.marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
@ -74,10 +94,9 @@ export const CurrentRateServiceMock = {
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
values.push({
|
||||
date,
|
||||
marketPriceInBaseCurrency: mockGetValue(
|
||||
dataGatheringItem.symbol,
|
||||
date
|
||||
).marketPrice,
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
|
||||
.marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
@ -25,30 +26,30 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
|
||||
getRange: ({
|
||||
dateRangeEnd,
|
||||
dateRangeStart,
|
||||
symbols
|
||||
uniqueAssets
|
||||
}: {
|
||||
dateRangeEnd: Date;
|
||||
dateRangeStart: Date;
|
||||
symbols: string[];
|
||||
uniqueAssets: UniqueAsset[];
|
||||
}) => {
|
||||
return Promise.resolve<MarketData[]>([
|
||||
{
|
||||
createdAt: dateRangeStart,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: uniqueAssets[0].dataSource,
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
state: 'CLOSE',
|
||||
symbol: symbols[0]
|
||||
symbol: uniqueAssets[0].symbol
|
||||
},
|
||||
{
|
||||
createdAt: dateRangeEnd,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: uniqueAssets[0].dataSource,
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
state: 'CLOSE',
|
||||
symbol: symbols[0]
|
||||
symbol: uniqueAssets[0].symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
@ -66,7 +67,8 @@ jest.mock(
|
||||
initialize: () => Promise.resolve(),
|
||||
toCurrency: (value: number) => {
|
||||
return 1 * value;
|
||||
}
|
||||
},
|
||||
getExchangeRates: () => Promise.resolve()
|
||||
};
|
||||
})
|
||||
};
|
||||
@ -86,7 +88,6 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => {
|
||||
describe('CurrentRateService', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let dataProviderService: DataProviderService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let marketDataService: MarketDataService;
|
||||
let propertyService: PropertyService;
|
||||
|
||||
@ -101,41 +102,34 @@ describe('CurrentRateService', () => {
|
||||
propertyService,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
currentRateService = new CurrentRateService(
|
||||
dataProviderService,
|
||||
exchangeRateDataService,
|
||||
marketDataService
|
||||
marketDataService,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
currencies: { AMZN: 'USD' },
|
||||
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
||||
dateQuery: {
|
||||
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||
},
|
||||
userCurrency: 'CHF'
|
||||
}
|
||||
})
|
||||
).toMatchObject<GetValuesObject>({
|
||||
dataProviderInfos: [],
|
||||
errors: [],
|
||||
values: [
|
||||
{
|
||||
dataSource: 'YAHOO',
|
||||
date: undefined,
|
||||
marketPriceInBaseCurrency: 1841.823902,
|
||||
marketPrice: 1841.823902,
|
||||
symbol: 'AMZN'
|
||||
}
|
||||
]
|
||||
|
@ -1,9 +1,16 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataProviderInfo, ResponseError } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
DataProviderInfo,
|
||||
ResponseError,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten, isEmpty, uniqBy } from 'lodash';
|
||||
|
||||
@ -15,17 +22,18 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
export class CurrentRateService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly marketDataService: MarketDataService
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
// TODO: Pass user instead of using this.request.user
|
||||
public async getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
userCurrency
|
||||
dateQuery
|
||||
}: GetValuesParams): Promise<GetValuesObject> {
|
||||
const dataProviderInfos: DataProviderInfo[] = [];
|
||||
|
||||
const includeToday =
|
||||
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
|
||||
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
|
||||
@ -38,9 +46,10 @@ export class CurrentRateService {
|
||||
if (includeToday) {
|
||||
promises.push(
|
||||
this.dataProviderService
|
||||
.getQuotes({ items: dataGatheringItems })
|
||||
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
|
||||
.then((dataResultProvider) => {
|
||||
const result: GetValueObject[] = [];
|
||||
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
if (
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.dataProviderInfo
|
||||
@ -52,14 +61,10 @@ export class CurrentRateService {
|
||||
|
||||
if (dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice) {
|
||||
result.push({
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
date: today,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]
|
||||
?.marketPrice,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
marketPrice:
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice,
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
} else {
|
||||
@ -75,27 +80,25 @@ export class CurrentRateService {
|
||||
);
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
const uniqueAssets: UniqueAsset[] = dataGatheringItems.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
}
|
||||
);
|
||||
|
||||
promises.push(
|
||||
this.marketDataService
|
||||
.getRange({
|
||||
dateQuery,
|
||||
symbols
|
||||
uniqueAssets
|
||||
})
|
||||
.then((data) => {
|
||||
return data.map((marketDataItem) => {
|
||||
return data.map(({ dataSource, date, marketPrice, symbol }) => {
|
||||
return {
|
||||
date: marketDataItem.date,
|
||||
marketPriceInBaseCurrency:
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
marketDataItem.marketPrice,
|
||||
currencies[marketDataItem.symbol],
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketDataItem.symbol
|
||||
dataSource,
|
||||
date,
|
||||
marketPrice,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
})
|
||||
@ -112,7 +115,7 @@ export class CurrentRateService {
|
||||
};
|
||||
|
||||
if (!isEmpty(quoteErrors)) {
|
||||
for (const { symbol } of quoteErrors) {
|
||||
for (const { dataSource, symbol } of quoteErrors) {
|
||||
try {
|
||||
// If missing quote, fallback to the latest available historical market price
|
||||
let value: GetValueObject = response.values.find((currentValue) => {
|
||||
@ -120,10 +123,17 @@ export class CurrentRateService {
|
||||
});
|
||||
|
||||
if (!value) {
|
||||
// Fallback to unit price of latest activity
|
||||
const latestActivity = await this.orderService.getLatestOrder({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
value = {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: today,
|
||||
marketPriceInBaseCurrency: 0
|
||||
marketPrice: latestActivity?.unitPrice ?? 0
|
||||
};
|
||||
|
||||
response.values.push(value);
|
||||
@ -131,10 +141,7 @@ export class CurrentRateService {
|
||||
|
||||
const [latestValue] = response.values
|
||||
.filter((currentValue) => {
|
||||
return (
|
||||
currentValue.symbol === symbol &&
|
||||
currentValue.marketPriceInBaseCurrency
|
||||
);
|
||||
return currentValue.symbol === symbol && currentValue.marketPrice;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.date < b.date) {
|
||||
@ -148,8 +155,7 @@ export class CurrentRateService {
|
||||
return 0;
|
||||
});
|
||||
|
||||
value.marketPriceInBaseCurrency =
|
||||
latestValue.marketPriceInBaseCurrency;
|
||||
value.marketPrice = latestValue.marketPrice;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface CurrentPositions extends ResponseError {
|
||||
positions: TimelinePosition[];
|
||||
currentValueInBaseCurrency: Big;
|
||||
grossPerformance: Big;
|
||||
grossPerformanceWithCurrencyEffect: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
grossPerformancePercentageWithCurrencyEffect: Big;
|
||||
netAnnualizedPerformance?: Big;
|
||||
netAnnualizedPerformanceWithCurrencyEffect?: Big;
|
||||
netPerformance: Big;
|
||||
netPerformanceWithCurrencyEffect: Big;
|
||||
netPerformancePercentage: Big;
|
||||
currentValue: Big;
|
||||
netPerformancePercentageWithCurrencyEffect: Big;
|
||||
positions: TimelinePosition[];
|
||||
totalInvestment: Big;
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
export interface GetValueObject {
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface GetValueObject extends UniqueAsset {
|
||||
date: Date;
|
||||
marketPriceInBaseCurrency: number;
|
||||
symbol: string;
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -3,8 +3,6 @@ import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfac
|
||||
import { DateQuery } from './date-query.interface';
|
||||
|
||||
export interface GetValuesParams {
|
||||
currencies: { [symbol: string]: string };
|
||||
dataGatheringItems: IDataGatheringItem[];
|
||||
dateQuery: DateQuery;
|
||||
userCurrency: string;
|
||||
}
|
||||
|
@ -1,5 +1,11 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
import { PortfolioOrder } from './portfolio-order.interface';
|
||||
|
||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||
itemType?: '' | 'start' | 'end';
|
||||
feeInBaseCurrency?: Big;
|
||||
feeInBaseCurrencyWithCurrencyEffect?: Big;
|
||||
itemType?: 'end' | 'start';
|
||||
unitPriceInBaseCurrency?: Big;
|
||||
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { DataSource, Tag, Type as ActivityType } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
@ -10,6 +10,6 @@ export interface PortfolioOrder {
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
tags?: Tag[];
|
||||
type: TypeOfOrder;
|
||||
type: ActivityType;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -4,9 +4,11 @@ import {
|
||||
HistoricalDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Tag } from '@prisma/client';
|
||||
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
accounts: Account[];
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
||||
dividendInBaseCurrency: number;
|
||||
@ -14,6 +16,8 @@ export interface PortfolioPositionDetail {
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
grossPerformancePercentWithCurrencyEffect: number;
|
||||
grossPerformanceWithCurrencyEffect: number;
|
||||
historicalData: HistoricalDataItem[];
|
||||
investment: number;
|
||||
marketPrice: number;
|
||||
@ -21,6 +25,8 @@ export interface PortfolioPositionDetail {
|
||||
minPrice: number;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
netPerformancePercentWithCurrencyEffect: number;
|
||||
netPerformanceWithCurrencyEffect: number;
|
||||
orders: OrderWithAccount[];
|
||||
quantity: number;
|
||||
SymbolProfile: EnhancedSymbolProfile;
|
||||
|
@ -1,8 +0,0 @@
|
||||
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelineInfoInterface {
|
||||
maxNetPerformance: Big;
|
||||
minNetPerformance: Big;
|
||||
timelinePeriods: TimelinePeriod[];
|
||||
}
|
@ -1,9 +0,0 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TimelinePeriod {
|
||||
date: string;
|
||||
grossPerformance: Big;
|
||||
investment: Big;
|
||||
netPerformance: Big;
|
||||
value: Big;
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
export type Accuracy = 'day' | 'month' | 'year';
|
||||
|
||||
export interface TimelineSpecification {
|
||||
accuracy: Accuracy;
|
||||
start: string;
|
||||
}
|
@ -2,8 +2,10 @@ import { DataSource, Tag } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
averagePrice: Big;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
dividend: Big;
|
||||
fee: Big;
|
||||
firstBuyDate: string;
|
||||
investment: Big;
|
||||
|
@ -0,0 +1,166 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell in two activities', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-22',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(142.9)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.65),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-22')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.04408677396780965649'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.05528341497550734703'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.04408677396780965649'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||
investment: new Big('0'),
|
||||
investmentWithCurrencyEffect: new Big('0'),
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.05528341497550734703'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||
marketPrice: 148.9,
|
||||
marketPriceInBaseCurrency: 148.9,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'285.80000000000000396627'
|
||||
),
|
||||
transactionCount: 3,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||
{ date: '2021-11-30', investment: new Big('0') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: 0 },
|
||||
{ date: '2021-12-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -1,5 +1,7 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
@ -16,15 +18,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
@ -58,44 +69,74 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-22')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.0440867739678096571'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.0552834149755073478'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.0440867739678096571'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||
investment: new Big('0'),
|
||||
investmentWithCurrencyEffect: new Big('0'),
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.0552834149755073478'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.0552834149755073478'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||
marketPrice: 148.9,
|
||||
marketPriceInBaseCurrency: 148.9,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'BALN.SW',
|
||||
transactionCount: 2
|
||||
timeWeightedInvestment: new Big('285.8'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0')
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
@ -104,7 +145,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: new Big('12.6') }
|
||||
{ date: '2021-11-01', investment: 0 },
|
||||
{ date: '2021-12-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
@ -16,15 +18,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
@ -47,44 +58,74 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-30')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-30')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('297.8'),
|
||||
currentValueInBaseCurrency: new Big('297.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.09004392386530014641'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('23.05'),
|
||||
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.08437042459736456808'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('136.6'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.09004392386530014641'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('24.6'),
|
||||
investment: new Big('273.2'),
|
||||
investmentWithCurrencyEffect: new Big('273.2'),
|
||||
netPerformance: new Big('23.05'),
|
||||
netPerformancePercentage: new Big('0.08437042459736456808'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.08437042459736456808'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('23.05'),
|
||||
marketPrice: 148.9,
|
||||
marketPriceInBaseCurrency: 148.9,
|
||||
quantity: new Big('2'),
|
||||
symbol: 'BALN.SW',
|
||||
transactionCount: 1
|
||||
timeWeightedInvestment: new Big('273.2'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('297.8')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('273.2')
|
||||
totalInvestment: new Big('273.2'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('273.2')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
@ -92,7 +133,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: new Big('273.2') }
|
||||
{ date: '2021-11-01', investment: 273.2 },
|
||||
{ date: '2021-12-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,8 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
@ -14,21 +17,42 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return ExchangeRateDataServiceMock;
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BTCUSD buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
currency: 'USD',
|
||||
date: '2015-01-01',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
@ -39,7 +63,7 @@ describe('PortfolioCalculator', () => {
|
||||
unitPrice: new Big(320.43)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
currency: 'USD',
|
||||
date: '2017-12-31',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
@ -58,44 +82,81 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2018-01-01').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2015-01-01')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2015-01-01')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('13657.2'),
|
||||
currentValueInBaseCurrency: new Big('13298.425356'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'41.6401219622042072686'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'41.6401219622042072686'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('26516.208701400000064086'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'CHF',
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'41.6401219622042072686'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big(
|
||||
'26516.208701400000064086'
|
||||
),
|
||||
investment: new Big('320.43'),
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.40043067128546016291'),
|
||||
investmentWithCurrencyEffect: new Big('318.542667299999967957'),
|
||||
marketPrice: 13657.2,
|
||||
marketPriceInBaseCurrency: 13298.425356,
|
||||
netPerformance: new Big('27172.74'),
|
||||
netPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'41.6401219622042072686'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big(
|
||||
'26516.208701400000064086'
|
||||
),
|
||||
quantity: new Big('1'),
|
||||
symbol: 'BTCUSD',
|
||||
transactionCount: 2
|
||||
tags: undefined,
|
||||
timeWeightedInvestment: new Big('640.56763686131386861314'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'636.79469348020066587024'
|
||||
),
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('13298.425356')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('320.43')
|
||||
totalInvestment: new Big('320.43'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('318.542667299999967957')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
@ -104,42 +165,43 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2015-01-01', investment: new Big('640.86') },
|
||||
{ date: '2015-02-01', investment: new Big('0') },
|
||||
{ date: '2015-03-01', investment: new Big('0') },
|
||||
{ date: '2015-04-01', investment: new Big('0') },
|
||||
{ date: '2015-05-01', investment: new Big('0') },
|
||||
{ date: '2015-06-01', investment: new Big('0') },
|
||||
{ date: '2015-07-01', investment: new Big('0') },
|
||||
{ date: '2015-08-01', investment: new Big('0') },
|
||||
{ date: '2015-09-01', investment: new Big('0') },
|
||||
{ date: '2015-10-01', investment: new Big('0') },
|
||||
{ date: '2015-11-01', investment: new Big('0') },
|
||||
{ date: '2015-12-01', investment: new Big('0') },
|
||||
{ date: '2016-01-01', investment: new Big('0') },
|
||||
{ date: '2016-02-01', investment: new Big('0') },
|
||||
{ date: '2016-03-01', investment: new Big('0') },
|
||||
{ date: '2016-04-01', investment: new Big('0') },
|
||||
{ date: '2016-05-01', investment: new Big('0') },
|
||||
{ date: '2016-06-01', investment: new Big('0') },
|
||||
{ date: '2016-07-01', investment: new Big('0') },
|
||||
{ date: '2016-08-01', investment: new Big('0') },
|
||||
{ date: '2016-09-01', investment: new Big('0') },
|
||||
{ date: '2016-10-01', investment: new Big('0') },
|
||||
{ date: '2016-11-01', investment: new Big('0') },
|
||||
{ date: '2016-12-01', investment: new Big('0') },
|
||||
{ date: '2017-01-01', investment: new Big('0') },
|
||||
{ date: '2017-02-01', investment: new Big('0') },
|
||||
{ date: '2017-03-01', investment: new Big('0') },
|
||||
{ date: '2017-04-01', investment: new Big('0') },
|
||||
{ date: '2017-05-01', investment: new Big('0') },
|
||||
{ date: '2017-06-01', investment: new Big('0') },
|
||||
{ date: '2017-07-01', investment: new Big('0') },
|
||||
{ date: '2017-08-01', investment: new Big('0') },
|
||||
{ date: '2017-09-01', investment: new Big('0') },
|
||||
{ date: '2017-10-01', investment: new Big('0') },
|
||||
{ date: '2017-11-01', investment: new Big('0') },
|
||||
{ date: '2017-12-01', investment: new Big('-14156.4') }
|
||||
{ date: '2015-01-01', investment: 637.0853345999999 },
|
||||
{ date: '2015-02-01', investment: 0 },
|
||||
{ date: '2015-03-01', investment: 0 },
|
||||
{ date: '2015-04-01', investment: 0 },
|
||||
{ date: '2015-05-01', investment: 0 },
|
||||
{ date: '2015-06-01', investment: 0 },
|
||||
{ date: '2015-07-01', investment: 0 },
|
||||
{ date: '2015-08-01', investment: 0 },
|
||||
{ date: '2015-09-01', investment: 0 },
|
||||
{ date: '2015-10-01', investment: 0 },
|
||||
{ date: '2015-11-01', investment: 0 },
|
||||
{ date: '2015-12-01', investment: 0 },
|
||||
{ date: '2016-01-01', investment: 0 },
|
||||
{ date: '2016-02-01', investment: 0 },
|
||||
{ date: '2016-03-01', investment: 0 },
|
||||
{ date: '2016-04-01', investment: 0 },
|
||||
{ date: '2016-05-01', investment: 0 },
|
||||
{ date: '2016-06-01', investment: 0 },
|
||||
{ date: '2016-07-01', investment: 0 },
|
||||
{ date: '2016-08-01', investment: 0 },
|
||||
{ date: '2016-09-01', investment: 0 },
|
||||
{ date: '2016-10-01', investment: 0 },
|
||||
{ date: '2016-11-01', investment: 0 },
|
||||
{ date: '2016-12-01', investment: 0 },
|
||||
{ date: '2017-01-01', investment: 0 },
|
||||
{ date: '2017-02-01', investment: 0 },
|
||||
{ date: '2017-03-01', investment: 0 },
|
||||
{ date: '2017-04-01', investment: 0 },
|
||||
{ date: '2017-05-01', investment: 0 },
|
||||
{ date: '2017-06-01', investment: 0 },
|
||||
{ date: '2017-07-01', investment: 0 },
|
||||
{ date: '2017-08-01', investment: 0 },
|
||||
{ date: '2017-09-01', investment: 0 },
|
||||
{ date: '2017-10-01', investment: 0 },
|
||||
{ date: '2017-11-01', investment: 0 },
|
||||
{ date: '2017-12-01', investment: -318.54266729999995 },
|
||||
{ date: '2018-01-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -0,0 +1,178 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return ExchangeRateDataServiceMock;
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with GOOGL buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'USD',
|
||||
date: '2023-01-03',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1),
|
||||
name: 'Alphabet Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'GOOGL',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(89.12)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2023-01-03')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2023-01-03')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValueInBaseCurrency: new Big('103.10483'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27.33'),
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.25235044599563974109'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('26.33'),
|
||||
netPerformancePercentage: new Big('0.29544434470377019749'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.24112962014285697628'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('89.12'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1'),
|
||||
firstBuyDate: '2023-01-03',
|
||||
grossPerformance: new Big('27.33'),
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.25235044599563974109'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('20.775774'),
|
||||
investment: new Big('89.12'),
|
||||
investmentWithCurrencyEffect: new Big('82.329056'),
|
||||
netPerformance: new Big('26.33'),
|
||||
netPerformancePercentage: new Big('0.29544434470377019749'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.24112962014285697628'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('19.851974'),
|
||||
marketPrice: 116.45,
|
||||
marketPriceInBaseCurrency: 103.10483,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'GOOGL',
|
||||
tags: undefined,
|
||||
timeWeightedInvestment: new Big('89.12'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('103.10483')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('89.12'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('82.329056')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2023-01-03', investment: new Big('89.12') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2023-01-01', investment: 82.329056 },
|
||||
{
|
||||
date: '2023-02-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-03-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-04-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-05-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-06-01',
|
||||
investment: 0
|
||||
},
|
||||
{
|
||||
date: '2023-07-01',
|
||||
investment: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -0,0 +1,118 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return ExchangeRateDataServiceMock;
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with MSFT buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'USD',
|
||||
orders: [
|
||||
{
|
||||
currency: 'USD',
|
||||
date: '2021-09-16',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(19),
|
||||
name: 'Microsoft Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'MSFT',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(298.58)
|
||||
},
|
||||
{
|
||||
currency: 'USD',
|
||||
date: '2021-11-16',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Microsoft Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'MSFT',
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: new Big(0.62)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2023-07-10')
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toMatchObject({
|
||||
errors: [],
|
||||
hasErrors: false,
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('298.58'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0.62'),
|
||||
dividendInBaseCurrency: new Big('0.62'),
|
||||
fee: new Big('19'),
|
||||
firstBuyDate: '2021-09-16',
|
||||
investment: new Big('298.58'),
|
||||
investmentWithCurrencyEffect: new Big('298.58'),
|
||||
marketPrice: 331.83,
|
||||
marketPriceInBaseCurrency: 331.83,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'MSFT',
|
||||
tags: undefined,
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('298.58'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('298.58')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1,5 +1,7 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
@ -16,15 +18,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it('with no orders', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: []
|
||||
});
|
||||
@ -35,24 +46,34 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: new Date()
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
new Date()
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big(0),
|
||||
currentValueInBaseCurrency: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
grossPerformanceWithCurrencyEffect: new Big(0),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
netPerformanceWithCurrencyEffect: new Big(0),
|
||||
positions: [],
|
||||
totalInvestment: new Big(0)
|
||||
});
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
@ -16,15 +18,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell partially', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
@ -58,44 +69,76 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2022-03-07')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('87.8'),
|
||||
currentValueInBaseCurrency: new Big('87.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.15113417083448194384'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('21.93'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('17.68'),
|
||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.12184460284330327256'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('17.68'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('75.80'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.14465699208443271768'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.15113417083448194384'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('21.93'),
|
||||
investment: new Big('75.80'),
|
||||
investmentWithCurrencyEffect: new Big('75.80'),
|
||||
netPerformance: new Big('17.68'),
|
||||
netPerformancePercentage: new Big('0.11662269129287598945'),
|
||||
netPerformancePercentage: new Big('0.12184460284330327256'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.12184460284330327256'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('17.68'),
|
||||
marketPrice: 87.8,
|
||||
marketPriceInBaseCurrency: 87.8,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'NOVN.SW',
|
||||
transactionCount: 2
|
||||
timeWeightedInvestment: new Big('145.10285714285714285714'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'145.10285714285714285714'
|
||||
),
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('87.8')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('75.80')
|
||||
totalInvestment: new Big('75.80'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('75.80')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
@ -104,8 +147,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-85.73') }
|
||||
{ date: '2022-03-01', investment: 151.6 },
|
||||
{ date: '2022-04-01', investment: -75.8 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
@ -16,15 +18,24 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with NOVN.SW buy and sell', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
@ -58,9 +69,9 @@ describe('PortfolioCalculator', () => {
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2022-04-11').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData(
|
||||
parseDate('2022-03-07')
|
||||
);
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2022-03-07')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2022-03-07')
|
||||
@ -68,54 +79,90 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth =
|
||||
portfolioCalculator.getInvestmentsByGroup('month');
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(chartData[0]).toEqual({
|
||||
date: '2022-03-07',
|
||||
netPerformanceInPercentage: 0,
|
||||
investmentValueWithCurrencyEffect: 151.6,
|
||||
netPerformance: 0,
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
totalInvestment: 151.6,
|
||||
value: 151.6
|
||||
totalInvestmentValueWithCurrencyEffect: 151.6,
|
||||
value: 151.6,
|
||||
valueWithCurrencyEffect: 151.6
|
||||
});
|
||||
|
||||
expect(chartData[chartData.length - 1]).toEqual({
|
||||
date: '2022-04-11',
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
investmentValueWithCurrencyEffect: 0,
|
||||
netPerformance: 19.86,
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
||||
netPerformanceWithCurrencyEffect: 19.86,
|
||||
totalInvestment: 0,
|
||||
value: 0
|
||||
totalInvestmentValueWithCurrencyEffect: 0,
|
||||
value: 0,
|
||||
valueWithCurrencyEffect: 0
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.13100263852242744063'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.13100263852242744063'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.13100263852242744063'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||
investment: new Big('0'),
|
||||
investmentWithCurrencyEffect: new Big('0'),
|
||||
netPerformance: new Big('19.86'),
|
||||
netPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'0.13100263852242744063'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('19.86'),
|
||||
marketPrice: 87.8,
|
||||
marketPriceInBaseCurrency: 87.8,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'NOVN.SW',
|
||||
transactionCount: 2
|
||||
timeWeightedInvestment: new Big('151.6'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0')
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
@ -124,8 +171,8 @@ describe('PortfolioCalculator', () => {
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2022-03-01', investment: new Big('151.6') },
|
||||
{ date: '2022-04-01', investment: new Big('-171.46') }
|
||||
{ date: '2022-03-01', investment: 151.6 },
|
||||
{ date: '2022-04-01', investment: -151.6 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
@ -5,14 +7,23 @@ import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'USD',
|
||||
orders: []
|
||||
});
|
||||
|
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user