Compare commits

...

85 Commits

Author SHA1 Message Date
06d5ec9182 Release 1.117.0 (#713) 2022-02-19 19:48:51 +01:00
122107c8a1 Feature/distinguish today s data point in admin panel (#711)
* Distinguish today's data point

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

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

* Refactor calculations with big.js

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

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

* Update changelog
2022-02-17 20:40:28 +01:00
5a869a90da Release 1.116.0 (#703) 2022-02-16 21:23:13 +01:00
280030ae7f Feature/add twitter bot for fear and greed index (#702)
* Add twitter bot for fear and greed index

* Update changelog
2022-02-16 21:17:11 +01:00
52e4504de9 Fix time in market (#700)
* Fix time in market

* Update changelog
2022-02-15 09:47:56 +01:00
20356f6931 Bugfix/fix max items attribute (#698)
* Fix maxItems

* Update changelog
2022-02-15 09:36:55 +01:00
e0bb2b1c78 Feature/improve position detail dialog (#697)
* Improve mobile layout

* Update changelog
2022-02-14 21:20:07 +01:00
ec806be45f Add detached mode (#696) 2022-02-14 18:51:16 +01:00
809ee97f6f Add tests (#693) 2022-02-14 18:50:47 +01:00
893ca83d3a Release 1.115.0 (#695) 2022-02-13 18:14:55 +01:00
23da1bd293 Feature/add feature page (#694)
* Add feature page

* Update changelog
2022-02-13 18:12:59 +01:00
fa66cd5bce Feature/add countries and sectors to position detail dialog (#692)
* Add asset and asset sub class

* Add countries and sectors to position detail dialog

* Update changelog
2022-02-12 11:22:03 +01:00
9344dcd26e Feature/upgrade nx to 13.8.1 (#691)
* Upgrade angular, nx and storybook dependencies

* Update changelog
2022-02-11 10:01:15 +01:00
90ad22cccf Add import and export (#690) 2022-02-11 10:00:49 +01:00
dcc7ef89fe Release/1.114.1 (#689)
* Fix creation of wealth items

* Release 1.114.1
2022-02-10 11:16:25 +01:00
e355847f40 Release 1.114.0 (#688) 2022-02-10 10:33:58 +01:00
76f70598e2 Feature/add support for wealth items (#666)
* Add support for wealth items

* Update changelog
2022-02-10 09:39:10 +01:00
7af5cd244a Release 1.113.0 (#687) 2022-02-09 09:58:46 +01:00
86943a5f5b Feature/harmonize big.js operators (#686)
* Harmonize big.js operators

* Update changelog
2022-02-09 09:36:54 +01:00
6eb4eae4a9 Feature/fix twr performance 2 (#684)
* Fix TWR performance
* Weight holding period returns according to their investment value

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-02-09 09:29:43 +01:00
6ac693dd39 Feature/improve position of currency column (#685)
* Move position of currency column

* Update changelog
2022-02-09 09:25:22 +01:00
e29f7f8976 Release 1.112.1 (#683) 2022-02-06 21:41:51 +01:00
82069da4e2 Bugfix/fix user account creation (#682)
* Fix the user account creation

* Update changelog
2022-02-06 21:40:26 +01:00
07656c6a95 Release 1.112.0 (#681) 2022-02-06 17:18:28 +01:00
16f0743353 Bugfix/fix total value of activities table (#680)
* Fix total value (absolute value)

* Update changelog
2022-02-06 17:14:04 +01:00
9b5ec0c56d Feature/fix twr performance (#679)
* Fix TWR performance

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-02-06 16:54:14 +01:00
8d2fcc6b42 Feature/upgrade prisma to version 3.9.1 (#677)
* Upgrade prisma to version 3.9.1

* Update changelog
2022-02-06 15:47:08 +01:00
e625e55784 Move currency column (#678) 2022-02-06 15:46:14 +01:00
bed3e5aae2 Bugfix/fix horizontal overflow in activities table (#676)
* Fix horizontal overflow in tables

* Update changelog
2022-02-06 15:45:39 +01:00
65bfe52db4 Feature/simplify admin user sign up (#675)
* Simplify admin user sign up

* Update changelog
2022-02-06 09:32:41 +01:00
48b524de5a Feature/add export functionality to position detail dialog (#672)
* Add export functionality to the position detail dialog

* Respect filters in activities export

* Update changelog
2022-02-05 20:26:10 +01:00
67d40333f6 Move currency column (#674) 2022-02-05 10:17:09 +01:00
48f6b8d353 Release 1.111.0 (#671) 2022-02-03 21:00:53 +01:00
f369996912 Bugfix/fix symbol selection of 7d data gathering (#670)
* Fix symbol selection of 7d data gathering

* Update changelog
2022-02-03 20:58:59 +01:00
dc424a86ec Feature/support deleting symbol profile data (#669)
* Add support for deleting symbol profile data

* Update changelog
2022-02-03 20:56:39 +01:00
5d8bde5a70 Feature/access data source and symbol from symbol profile (#668)
* Access dataSource and symbol from SymbolProfile

* Update changelog
2022-02-03 19:21:55 +01:00
16360c0c67 Feature/minor code cleanup (#667)
* Sort imports

* Update changelog
2022-02-02 22:06:34 +01:00
526a6b2030 Release 1.110.0 (#665) 2022-02-02 20:31:23 +01:00
5000e9c79b Feature/update database schema of order (#664)
* Add schema migrations

* Update changelog
2022-02-02 20:29:19 +01:00
161cb82820 Bugfix/fix data source of fear and greed index (#663)
* Encode data source

* Update changelog
2022-02-02 20:07:33 +01:00
fed28f29d1 Release 1.109.0 (#662) 2022-02-01 21:05:14 +01:00
8bd9330acc Feature/improve usability of create or edit transaction dialog (#661)
* Move the fee to the bottom

* Update changelog
2022-02-01 20:35:44 +01:00
155c08d665 Transform data source (#658)
* Transform data source

* Update changelog
2022-02-01 20:35:25 +01:00
b8ad6d6662 Feature/improve import (#657)
* Improve import

* Update changelog

Co-Authored-By: Ronald Konjer <ronaldkonjer@gmail.com>
2022-02-01 19:12:00 +01:00
9d6977e3f7 Feature/support cryptocurrency mina protocol (#659)
* Support Mina Protocol (MINA-USD)

* Update changelog
2022-02-01 10:58:34 +01:00
919b20197f import csv with account name or id (#654)
* import csv with account id
2022-01-29 17:27:33 +01:00
62885ea890 Feature/improve consistent use of symbol with data source (#656)
* Improve the consistent use of symbol with dataSource

* Update changelog
2022-01-29 16:51:37 +01:00
035d8ad9eb Update copyright (#655) 2022-01-29 09:15:11 +01:00
9676f96e97 Release 1.108.0 (#653) 2022-01-27 21:33:59 +01:00
65e151151b Feature/increase fear and greed index to 90 days (#652)
* Increase fear and greed index to 90 days

* Update changelog
2022-01-27 21:01:38 +01:00
5d3bbb8f30 Feature/improve annualized performance (#651)
* Improve annualized performance calculation

* Update changelog
2022-01-27 20:56:20 +01:00
b464fefc57 Release 1.107.0 (#650) 2022-01-24 21:43:37 +01:00
bcb7f5f522 Feature/add feature toggle for new calculation engine (#649)
* Add feature toggle for new calculation engine

* Update changelog
2022-01-24 21:38:59 +01:00
f15b33e950 Portfolio calculator rework (#632)
* Portfolio calculator rework

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-01-24 20:35:13 +01:00
ca64492e77 Bugfix/fix styling in activities table footer (#648)
* Fix styling

* Update changelog
2022-01-24 20:12:54 +01:00
761376d72d Release 1.106.0 (#647) 2022-01-23 17:41:54 +01:00
9c086edffe Feature/extend historical data view in admin control (#646)
* Extend market data view

* Update changelog
2022-01-23 17:02:12 +01:00
585f99e4df Feature/add summary row to activities table (#645)
* Add summary row to activities table

* Update changelog
2022-01-23 11:39:30 +01:00
9d907b5eb5 Bugfix/improve the redirection on logout (#642)
* Improve logout

* Update changelog
2022-01-22 09:38:01 +01:00
ba05f5ba30 Feature/upgrade prisma to version 3.8.1 (#640)
* Upgrade prisma to version 3.8.1

* Update changelog
2022-01-22 09:36:58 +01:00
3261e3ee59 Feature/upgrade stripe dependencies (#641)
* Upgrade stripe dependencies

* Update changelog
2022-01-21 20:30:41 +01:00
5607c6bb52 Update blog url (#639) 2022-01-21 20:07:56 +01:00
1c6050d3e3 Release 1.105.0 (#638) 2022-01-20 21:35:56 +01:00
38f2930ec6 Feature/improve data provider service (#637)
* Improve data provider service

* Update changelog
2022-01-20 21:34:23 +01:00
556be61fff Bugfix/fix unresolved account names in reports (#636)
* Fix unresolved account names

* Update changelog
2022-01-19 21:28:15 +01:00
651b4bcff7 Release 1.104.0 (#631) 2022-01-16 15:45:28 +01:00
0a8d159f78 Bugfix/fix missing symbol profile data connection in import (#630)
* Fix missing symbol profile data connection in import

* Update changelog
2022-01-16 15:31:56 +01:00
1a4109ebaa Bugfix/fix fallback to load currencies directly from data provider (#629)
* Fix fallback

* Update changelog
2022-01-16 13:46:00 +01:00
92e502e1c2 Release 1.103.0 (#628) 2022-01-13 20:33:31 +01:00
e344c43a5a Bugfix/fix currency of value in position detail dialog (#627)
* Fix currency

* Update changelog
2022-01-13 20:25:21 +01:00
d6b78f3457 Feature/add links to statistics section (#626)
* Add links and clean up style

* Update changelog
2022-01-13 19:07:23 +01:00
9bbb856f66 Release 1.102.0 (#625) 2022-01-11 19:53:23 +01:00
d3707bbb87 Bugfix/fix preselected default account in create activity dialog (#624)
* Fix preselected default account

* Update changelog
2022-01-11 19:50:22 +01:00
7df53896f3 Feature/start eliminating data source from order (#622)
* Start eliminating dataSource from order

* Update changelog
2022-01-11 19:49:45 +01:00
b2b3fde80e Bugfix/support multiple accounts with the same name (#623)
* Support multiple accounts with the same name

* Update changelog
2022-01-10 21:23:47 +01:00
a83441b3ba Release 1.101.0 (#621) 2022-01-08 18:21:33 +01:00
075431d868 Feature/add google sheets as data source (#620)
* Add google sheets as data source

* Update changelog
2022-01-08 18:19:25 +01:00
0168c1c4e8 Feature/exclude url pattern of shared portfolios in robots.txt (#619)
* Exclude shared portfolios

* Update changelog
2022-01-08 09:37:54 +01:00
07de8f87fc Set market prices explicitly (#618)
* Set market prices explicitly

* Set comments explicitly
2022-01-07 08:09:12 +01:00
177 changed files with 8555 additions and 3668 deletions

View File

@ -5,6 +5,238 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.117.0 - 19.02.2022
### Changed
- Moved the countries and sectors charts in the position detail dialog
- Distinguished today's data point of historical data in the admin control panel
- Restructured the server modules
### Fixed
- Fixed the allocations by account for non-unique account names
- Added a fallback to the default account if the `accountId` is invalid in the import functionality for activities
## 1.116.0 - 16.02.2022
### Added
- Added a service to tweet the current _Fear & Greed Index_ (market mood)
### Changed
- Improved the mobile layout of the position detail dialog (countries and sectors charts)
### Fixed
- Fixed the `maxItems` attribute of the portfolio proportion chart component
- Fixed the time in market display of the portfolio summary tab on the home page
## 1.115.0 - 13.02.2022
### Added
- Added a feature overview page
- Added the asset and asset sub class to the position detail dialog
- Added the countries and sectors to the position detail dialog
### Changed
- Upgraded `angular` from version `13.1.2` to `13.2.3`
- Upgraded `Nx` from version `13.4.1` to `13.8.1`
- Upgraded `storybook` from version `6.4.9` to `6.4.18`
## 1.114.1 - 10.02.2022
### Fixed
- Fixed the creation of (wealth) items
## 1.114.0 - 10.02.2022
### Added
- Added support for (wealth) items
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.113.0 - 09.02.2022
### Changed
- Improved the position of the currency column in the accounts table
- Improved the position of the currency column in the activities table
### Fixed
- Fixed an issue with the performance calculation in connection with fees in the new calculation engine
## 1.112.1 - 06.02.2022
### Fixed
- Fixed the creation of the user account (missing access token)
## 1.112.0 - 06.02.2022
### Added
- Added the export functionality to the position detail dialog
### Changed
- Improved the export functionality for activities (respect filtering)
- Removed the _Admin_ user from the database seeding
- Assigned the role `ADMIN` on sign up (only if there is no admin yet)
- Upgraded `prisma` from version `3.8.1` to `3.9.1`
### Fixed
- Fixed an issue with the performance calculation in connection with a sell activity in the new calculation engine
- Fixed the horizontal overflow in the accounts table
- Fixed the horizontal overflow in the activities table
- Fixed the total value of the activities table in the position detail dialog (absolute value)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.111.0 - 03.02.2022
### Added
- Added support for deleting symbol profile data in the admin control panel
### Changed
- Used `dataSource` and `symbol` from `SymbolProfile` instead of the `order` object (in `ExportService` and `PortfolioService`)
### Fixed
- Fixed the symbol selection of the 7d data gathering
## 1.110.0 - 02.02.2022
### Fixed
- Fixed the data source of the _Fear & Greed Index_ (market mood)
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.109.0 - 01.02.2022
### Added
- Added support for the (optional) `accountId` in the import functionality for activities
- Added support for the (optional) `dataSource` in the import functionality for activities
- Added support for the data source transformation
- Added support for the cryptocurrency _Mina Protocol_ (`MINA-USD`)
### Changed
- Improved the usability of the form in the create or edit transaction dialog
- Improved the consistent use of `symbol` in combination with `dataSource`
- Removed the primary data source from the client
### Removed
- Removed the unused endpoint `GET api/order/:id`
## 1.108.0 - 27.01.2022
### Changed
- Improved the annualized performance in the new calculation engine
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
## 1.107.0 - 24.01.2022
### Added
- Added a new calculation engine (experimental)
### Fixed
- Fixed the styling in the footer row of the activities table
## 1.106.0 - 23.01.2022
### Added
- Added the footer row with total fees and total value to the activities table
### Changed
- Extended the historical data view in the admin control panel
- Upgraded _Stripe_ dependencies
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
### Fixed
- Improved the redirection on logout
## 1.105.0 - 20.01.2022
### Added
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
### Changed
- Improved the data provider with grouping by data source and thereby reducing the number of requests
### Fixed
- Fixed the unresolved account names in the _X-ray_ section
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
## 1.104.0 - 16.01.2022
### Fixed
- Fixed the fallback to load currencies directly from the data provider
- Fixed the missing symbol profile data connection in the import functionality for activities
## 1.103.0 - 13.01.2022
### Changed
- Added links to the statistics section on the about page
### Fixed
- Fixed the currency of the value in the position detail dialog
## 1.102.0 - 11.01.2022
### Changed
- Start eliminating `dataSource` from activity
### Fixed
- Fixed the support for multiple accounts with the same name
- Fixed the preselected default account of the create activity dialog
## 1.101.0 - 08.01.2022
### Added
- Added `GOOGLE_SHEETS` as a new data source type
### Changed
- Excluded the url pattern of shared portfolios in the `robots.txt` file
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.100.0 - 05.01.2022
### Added
@ -110,7 +342,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support for cryptocurrency _Solana_ (`SOL-USD`)
- Added support for the cryptocurrency _Solana_ (`SOL-USD`)
- Extended the documentation for self-hosting with the [official Ghostfolio Docker image](https://hub.docker.com/r/ghostfolio/ghostfolio)
### Fixed

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p>
<p>
<a href="#contributing">
@ -41,21 +41,13 @@ If you prefer to run Ghostfolio on your own infrastructure (self-hosting), pleas
Ghostfolio is for you if you are...
- 💼 trading stocks, ETFs or cryptocurrencies on multiple platforms
- 🏦 pursuing a buy & hold strategy
- 🎯 interested in getting insights of your portfolio composition
- 👻 valuing privacy and data ownership
- 🧘 into minimalism
- 🧺 caring about diversifying your financial resources
- 🆓 interested in financial independence
- 🙅 saying no to spreadsheets in 2021
- 😎 still reading this list
## Features
@ -65,6 +57,7 @@ Ghostfolio is for you if you are...
- ✅ 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
- ✅ Dark Mode
- ✅ Zen Mode
- ✅ Mobile-first design
@ -92,7 +85,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
Run the following command to start the Docker images from [Docker Hub](https://hub.docker.com/r/ghostfolio/ghostfolio):
```bash
docker-compose -f docker/docker-compose.yml up
docker-compose -f docker/docker-compose.yml up -d
```
#### Setup Database
@ -109,7 +102,7 @@ Run the following commands to build and start the Docker images:
```bash
docker-compose -f docker/docker-compose.build.yml build
docker-compose -f docker/docker-compose.build.yml up
docker-compose -f docker/docker-compose.build.yml up -d
```
#### Setup Database
@ -124,16 +117,10 @@ docker-compose -f docker/docker-compose.build.yml exec ghostfolio yarn database:
Open http://localhost:3333 in your browser and accomplish these steps:
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
### Finalization
1. Create a new user via _Get Started_
1. Assign the role `ADMIN` to this user (directly in the database)
1. Delete the original _Admin_ (directly in the database)
### Migrate Database
With the following command you can keep your database schema in sync after a Ghostfolio version update:
@ -155,8 +142,8 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
1. Run `yarn install`
1. Run `docker-compose -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 and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Start the server and the client (see [_Development_](#Development))
1. Create a new user via _Get Started_ (this first user will get the role `ADMIN`)
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
@ -187,6 +174,6 @@ Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Sl
## License
© 2021 [Ghostfolio](https://ghostfol.io)
© 2022 [Ghostfolio](https://ghostfol.io)
Licensed under the [AGPLv3 License](https://www.gnu.org/licenses/agpl-3.0.html).

View File

@ -264,7 +264,8 @@
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {
@ -280,7 +281,8 @@
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"projectBuildConfig": "ui:build-storybook"
},
"configurations": {
"ci": {

View File

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

View File

@ -1,4 +1,4 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import {
nullifyValuesInObject,
@ -35,7 +35,7 @@ export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -91,10 +91,9 @@ export class AccountController {
this.request.user.id
);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id
);
let accountsWithAggregations = await this.portfolioServiceStrategy
.get()
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
if (
impersonationUserId ||

View File

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

View File

@ -11,6 +11,7 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
HttpException,
Inject,
@ -195,9 +196,10 @@ export class AdminController {
return this.adminService.getMarketData();
}
@Get('market-data/:symbol')
@Get('market-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getMarketDataBySymbol(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<AdminMarketDataDetails> {
if (
@ -212,7 +214,7 @@ export class AdminController {
);
}
return this.adminService.getMarketDataBySymbol(symbol);
return this.adminService.getMarketDataBySymbol({ dataSource, symbol });
}
@Put('market-data/:dataSource/:symbol/:dateString')
@ -248,6 +250,27 @@ export class AdminController {
});
}
@Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
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 });
}
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(

View File

@ -6,6 +6,7 @@ import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-d
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller';
@ -20,7 +21,8 @@ import { AdminService } from './admin.service';
MarketDataModule,
PrismaModule,
PropertyModule,
SubscriptionModule
SubscriptionModule,
SymbolProfileModule
],
controllers: [AdminController],
providers: [AdminService],

View File

@ -5,14 +5,16 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails
AdminMarketDataDetails,
AdminMarketDataItem
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client';
import { DataSource, Property } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@Injectable()
@ -24,9 +26,21 @@ export class AdminService {
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async deleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
}
public async get(): Promise<AdminData> {
return {
dataGatheringProgress:
@ -56,25 +70,85 @@ export class AdminService {
}
public async getMarketData(): Promise<AdminMarketData> {
return {
marketData: await (
await this.dataGatheringService.getSymbolsMax()
).map((symbol) => {
return symbol;
const marketData = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
const currencyPairsToGather: AdminMarketDataItem[] =
this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol
};
});
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
},
dataSource: true,
Order: {
orderBy: [{ date: 'asc' }],
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((symbolProfile) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === symbolProfile.dataSource &&
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
return {
marketDataItemCount,
activityCount: symbolProfile._count.Order,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
};
});
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
};
}
public async getMarketDataBySymbol(
aSymbol: string
): Promise<AdminMarketDataDetails> {
public async getMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}): Promise<AdminMarketDataDetails> {
return {
marketData: await this.marketDataService.marketDataItems({
orderBy: {
date: 'asc'
},
where: {
symbol: aSymbol
dataSource,
symbol
}
})
};

View File

@ -8,6 +8,7 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { TwitterBotModule } from '@ghostfolio/api/services/twitter-bot/twitter-bot.module';
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule';
@ -65,6 +66,7 @@ import { UserModule } from './user/user.module';
}),
SubscriptionModule,
SymbolModule,
TwitterBotModule,
UserModule
],
controllers: [AppController],

View File

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

View File

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

View File

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

View File

@ -1,6 +1,13 @@
import { Export } from '@ghostfolio/common/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import {
Controller,
Get,
Headers,
Inject,
Query,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -15,8 +22,11 @@ export class ExportController {
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
public async export(
@Query('activityIds') activityIds?: string[]
): Promise<Export> {
return this.exportService.export({
activityIds,
userId: this.request.user.id
});
}

View File

@ -7,25 +7,62 @@ import { Injectable } from '@nestjs/common';
export class ExportService {
public constructor(private readonly prismaService: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prismaService.order.findMany({
public async export({
activityIds,
userId
}: {
activityIds?: string[];
userId: string;
}): Promise<Export> {
let orders = await this.prismaService.order.findMany({
orderBy: { date: 'desc' },
select: {
accountId: true,
currency: true,
dataSource: true,
date: true,
fee: true,
id: true,
quantity: true,
symbol: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId }
});
if (activityIds) {
orders = orders.filter((order) => {
return activityIds.includes(order.id);
});
}
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
orders: orders.map(
({
accountId,
currency,
date,
fee,
quantity,
SymbolProfile,
type,
unitPrice
}) => {
return {
accountId,
currency,
date,
fee,
quantity,
type,
unitPrice,
dataSource: SymbolProfile.dataSource,
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
};
}
)
};
}
}

View File

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

View File

@ -1,3 +1,4 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -8,6 +9,7 @@ import { isSameDay, parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly orderService: OrderService
@ -20,8 +22,24 @@ export class ImportService {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const order of orders) {
if (!order.dataSource) {
if (order.type === 'ITEM') {
order.dataSource = 'MANUAL';
} else {
order.dataSource = this.dataProviderService.getPrimaryDataSource();
}
}
}
await this.validateOrders({ orders, userId });
const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => {
return account.id;
}
);
for (const {
accountId,
currency,
@ -34,11 +52,6 @@ export class ImportService {
unitPrice
} of orders) {
await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency,
dataSource,
fee,
@ -46,7 +59,23 @@ export class ImportService {
symbol,
type,
unitPrice,
userId,
accountId: accountIds.includes(accountId) ? accountId : undefined,
date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
User: { connect: { id: userId } }
});
}
@ -95,20 +124,22 @@ export class ImportService {
throw new Error(`orders.${index} is a duplicate transaction`);
}
const result = await this.dataProviderService.get([
{ dataSource, symbol }
]);
if (dataSource !== 'MANUAL') {
const result = await this.dataProviderService.get([
{ dataSource, symbol }
]);
if (result[symbol] === undefined) {
throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (result[symbol] === undefined) {
throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
);
}
if (result[symbol].currency !== currency) {
throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
);
if (result[symbol].currency !== currency) {
throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${result[symbol].currency}"`
);
}
}
}
}

View File

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

View File

@ -1,7 +1,6 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
@ -10,8 +9,10 @@ import {
PROPERTY_IS_READ_ONLY_MODE,
PROPERTY_SLACK_COMMUNITY_USERS,
PROPERTY_STRIPE_CONFIG,
PROPERTY_SYSTEM_MESSAGE
PROPERTY_SYSTEM_MESSAGE,
ghostfolioFearAndGreedIndexDataSource
} from '@ghostfolio/common/config';
import { encodeDataSource } from '@ghostfolio/common/helper';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -27,7 +28,6 @@ export class InfoService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
@ -51,6 +51,12 @@ export class InfoService {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource
);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
@ -92,7 +98,6 @@ export class InfoService {
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),
primaryDataSource: this.dataProviderService.getPrimaryDataSource(),
statistics: await this.getStatistics(),
subscriptions: await this.getSubscriptions()
};

View File

@ -1,14 +1,22 @@
import { DataSource, Type } from '@prisma/client';
import { IsEnum, IsISO8601, IsNumber, IsString } from 'class-validator';
import {
IsEnum,
IsISO8601,
IsNumber,
IsOptional,
IsString
} from 'class-validator';
export class CreateOrderDto {
@IsString()
@IsOptional()
accountId: string;
@IsString()
currency: string;
@IsEnum(DataSource, { each: true })
@IsOptional()
dataSource: DataSource;
@IsISO8601()

View File

@ -0,0 +1,10 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
export interface Activities {
activities: Activity[];
}
export interface Activity extends OrderWithAccount {
feeInBaseCurrency: number;
valueInBaseCurrency: number;
}

View File

@ -1,5 +1,7 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
@ -14,7 +16,8 @@ import {
Param,
Post,
Put,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
@ -23,6 +26,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto';
@ -57,16 +61,19 @@ export class OrderController {
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> {
): Promise<Activities> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const userCurrency = this.request.user.Settings.currency;
let orders = await this.orderService.getOrders({
let activities = await this.orderService.getOrders({
userCurrency,
includeDrafts: true,
userId: impersonationUserId || this.request.user.id
});
@ -75,30 +82,22 @@ export class OrderController {
impersonationUserId ||
this.userService.isRestrictedView(this.request.user)
) {
orders = nullifyValuesInObjects(orders, [
activities = nullifyValuesInObjects(activities, [
'fee',
'feeInBaseCurrency',
'quantity',
'unitPrice',
'value'
'value',
'valueInBaseCurrency'
]);
}
return orders;
}
@Get(':id')
@UseGuards(AuthGuard('jwt'))
public async getOrderById(@Param('id') id: string): Promise<OrderModel> {
return this.orderService.order({
id_userId: {
id,
userId: this.request.user.id
}
});
return { activities };
}
@Post()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async createOrder(@Body() data: CreateOrderDto): Promise<OrderModel> {
if (
!hasPermission(this.request.user.permissions, permissions.createOrder)
@ -109,39 +108,31 @@ export class OrderController {
);
}
const date = parseISO(data.date);
const accountId = data.accountId;
delete data.accountId;
return this.orderService.createOrder({
...data,
Account: {
connect: {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
date,
date: parseISO(data.date),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource: data.dataSource,
symbol: data.symbol
},
where: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
User: { connect: { id: this.request.user.id } },
userId: this.request.user.id
});
}
@Put(':id')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async update(@Param('id') id: string, @Body() data: UpdateOrderDto) {
if (
!hasPermission(this.request.user.permissions, permissions.updateOrder)

View File

@ -1,28 +1,34 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.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 { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { OrderController } from './order.controller';
import { OrderService } from './order.service';
@Module({
controllers: [OrderController],
exports: [OrderService],
imports: [
CacheModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
PrismaModule,
RedisCacheModule,
SymbolProfileModule,
UserModule
],
controllers: [OrderController],
providers: [CacheService, OrderService],
exports: [OrderService]
providers: [AccountService, OrderService]
})
export class OrderModule {}

View File

@ -1,18 +1,27 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
import { v4 as uuidv4 } from 'uuid';
import { Activity } from './interfaces/activities.interface';
@Injectable()
export class OrderService {
public constructor(
private readonly accountService: AccountService,
private readonly cacheService: CacheService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async order(
@ -43,34 +52,79 @@ export class OrderService {
});
}
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
const isDraft = isAfter(data.date as Date, endOfToday());
public async createOrder(
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
).find((account) => {
return account.isDefault === true;
});
// Convert the symbol to uppercase to avoid case-sensitive duplicates
const symbol = data.symbol.toUpperCase();
let Account = {
connect: {
id_userId: {
userId: data.userId,
id: data.accountId ?? defaultAccount?.id
}
}
};
if (data.type === 'ITEM') {
const currency = data.currency;
const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.dataSource = dataSource;
data.id = id;
data.symbol = null;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name;
data.SymbolProfile.connectOrCreate.create.symbol = id;
data.SymbolProfile.connectOrCreate.where.dataSource_symbol = {
dataSource,
symbol: id
};
} else {
data.SymbolProfile.connectOrCreate.create.symbol =
data.SymbolProfile.connectOrCreate.create.symbol.toUpperCase();
}
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
symbol,
dataSource: data.dataSource,
date: <Date>data.date
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
this.dataGatheringService.gatherProfileData([
{ symbol, dataSource: data.dataSource }
{
dataSource: data.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
await this.cacheService.flush();
delete data.accountId;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
return this.prismaService.order.create({
data: {
...data,
isDraft,
symbol
...orderData,
Account,
isDraft
}
});
}
@ -78,20 +132,28 @@ export class OrderService {
public async deleteOrder(
where: Prisma.OrderWhereUniqueInput
): Promise<Order> {
return this.prismaService.order.delete({
const order = await this.prismaService.order.delete({
where
});
if (order.type === 'ITEM') {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
return order;
}
public async getOrders({
includeDrafts = false,
types,
userCurrency,
userId
}: {
includeDrafts?: boolean;
types?: TypeOfOrder[];
userCurrency: string;
userId: string;
}) {
}): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) {
@ -124,12 +186,21 @@ export class OrderService {
orderBy: { date: 'asc' }
})
).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value: new Big(order.quantity)
.mul(order.unitPrice)
.plus(order.fee)
.toNumber()
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
userCurrency
)
};
});
}
@ -140,6 +211,17 @@ export class OrderService {
}): Promise<Order> {
const { data, where } = params;
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
if (data.type === 'ITEM') {
const name = data.symbol;
data.symbol = null;
data.SymbolProfile = { update: { name } };
}
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {

View File

@ -1,7 +1,8 @@
import { DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
import { IsISO8601, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateOrderDto {
@IsOptional()
@IsString()
accountId: string;

View File

@ -0,0 +1,60 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
switch (symbol) {
case 'BALN.SW':
if (isSameDay(parseDate('2021-11-12'), date)) {
return { marketPrice: 146 };
} else if (isSameDay(parseDate('2021-11-22'), date)) {
return { marketPrice: 142.9 };
} else if (isSameDay(parseDate('2021-11-26'), date)) {
return { marketPrice: 139.9 };
} else if (isSameDay(parseDate('2021-11-30'), date)) {
return { marketPrice: 136.6 };
} else if (isSameDay(parseDate('2021-12-18'), date)) {
return { marketPrice: 148.9 };
}
return { marketPrice: 0 };
default:
return { marketPrice: 0 };
}
}
export const CurrentRateServiceMock = {
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
const result = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
isBefore(date, endOfDay(dateQuery.lt));
date = addDays(date, 1)
) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
symbol: dataGatheringItem.symbol
});
}
}
} else {
for (const date of dateQuery.in) {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
symbol: dataGatheringItem.symbol
});
}
}
}
return Promise.resolve(result);
}
};

View File

@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
);
});
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: 'USD',
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: 'CHF'
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => {
expect(
await currentRateService.getValues({

View File

@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable()
@ -18,46 +17,6 @@ export class CurrentRateService {
private readonly marketDataService: MarketDataService
) {}
public async getValue({
currency,
date,
symbol,
userCurrency
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([
{
symbol,
dataSource: this.dataProviderService.getPrimaryDataSource()
}
]);
return {
symbol,
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({
currencies,
dataGatheringItems,

View File

@ -6,7 +6,7 @@ export interface CurrentPositions {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
netAnnualizedPerformance: Big;
netAnnualizedPerformance?: Big;
netPerformance: Big;
netPerformancePercentage: Big;
currentValue: Big;

View File

@ -1,6 +0,0 @@
export interface GetValueParams {
currency: string;
date: Date;
symbol: string;
userCurrency: string;
}

View File

@ -0,0 +1,5 @@
import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder {
itemType?: '' | 'start' | 'end';
}

View File

@ -1,11 +1,8 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
averagePrice: number;
currency: string;
firstBuyDate: string;
grossPerformance: number;
grossPerformancePercent: number;
@ -14,12 +11,11 @@ export interface PortfolioPositionDetail {
marketPrice: number;
maxPrice: number;
minPrice: number;
name: string;
netPerformance: number;
netPerformancePercent: number;
orders: OrderWithAccount[];
quantity: number;
symbol: string;
SymbolProfile: EnhancedSymbolProfile;
transactionCount: number;
value: number;
}

View File

@ -0,0 +1,95 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
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('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
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(2),
symbol: 'BALN.SW',
type: 'SELL',
unitPrice: new Big(136.6)
}
]
});
portfolioCalculatorNew.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
parseDate('2021-11-22')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('0'),
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
hasErrors: false,
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
positions: [
{
averagePrice: new Big('0'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2021-11-22',
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
investment: new Big('0'),
netPerformance: new Big('-15.8'),
netPerformancePercentage: new Big('-0.0552834149755073478'),
marketPrice: 148.9,
quantity: new Big('0'),
symbol: 'BALN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('0')
});
});
});
});

View File

@ -0,0 +1,84 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
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('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
type: 'BUY',
unitPrice: new Big(136.6)
}
]
});
portfolioCalculatorNew.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
parseDate('2021-11-30')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('297.8'),
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
hasErrors: false,
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
positions: [
{
averagePrice: new Big('136.6'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2021-11-30',
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
investment: new Big('273.2'),
netPerformance: new Big('23.05'),
netPerformancePercentage: new Big('0.08437042459736456808'),
marketPrice: 148.9,
quantity: new Big('2'),
symbol: 'BALN.SW',
transactionCount: 1
}
],
totalInvestment: new Big('273.2')
});
});
});
});

View File

@ -0,0 +1,56 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
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('PortfolioCalculatorNew', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it('with no orders', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
currentRateService,
currency: 'CHF',
orders: []
});
portfolioCalculatorNew.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
new Date()
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
hasErrors: false,
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0)
});
});
});
});

View File

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

View File

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

View File

@ -1,16 +1,9 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { DataSource } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
differenceInCalendarDays,
endOfDay,
isBefore,
isSameDay
} from 'date-fns';
import { addDays, endOfDay, format, isBefore, isSameDay } from 'date-fns';
import { CurrentRateService } from './current-rate.service';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
@ -67,15 +60,202 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 };
case 'VTI':
return {
marketPrice: new Big('144.38')
.plus(
new Big('0.08').mul(
differenceInCalendarDays(date, parseDate('2019-02-01'))
)
)
.toNumber()
};
switch (format(date, DATE_FORMAT)) {
case '2019-01-01':
return { marketPrice: 144.38 };
case '2019-02-01':
return { marketPrice: 144.38 };
case '2019-03-01':
return { marketPrice: 146.62 };
case '2019-04-01':
return { marketPrice: 149.1 };
case '2019-05-01':
return { marketPrice: 151.5 };
case '2019-06-01':
return { marketPrice: 153.98 };
case '2019-07-01':
return { marketPrice: 156.38 };
case '2019-08-01':
return { marketPrice: 158.86 };
case '2019-08-03':
return { marketPrice: 159.02 };
case '2019-09-01':
return { marketPrice: 161.34 };
case '2019-10-01':
return { marketPrice: 163.74 };
case '2019-11-01':
return { marketPrice: 166.22 };
case '2019-12-01':
return { marketPrice: 168.62 };
case '2020-01-01':
return { marketPrice: 171.1 };
case '2020-02-01':
return { marketPrice: 173.58 };
case '2020-02-02':
return { marketPrice: 173.66 };
case '2020-03-01':
return { marketPrice: 175.9 };
case '2020-04-01':
return { marketPrice: 178.38 };
case '2020-05-01':
return { marketPrice: 180.78 };
case '2020-06-01':
return { marketPrice: 183.26 };
case '2020-07-01':
return { marketPrice: 185.66 };
case '2020-08-01':
return { marketPrice: 188.14 };
case '2020-08-02':
return { marketPrice: 188.22 };
case '2020-08-03':
return { marketPrice: 188.3 };
case '2020-09-01':
return { marketPrice: 190.62 };
case '2020-10-01':
return { marketPrice: 193.02 };
case '2020-11-01':
return { marketPrice: 195.5 };
case '2020-12-01':
return { marketPrice: 197.9 };
case '2021-01-01':
return { marketPrice: 200.38 };
case '2021-02-01':
return { marketPrice: 202.86 };
case '2021-03-01':
return { marketPrice: 205.1 };
case '2021-04-01':
return { marketPrice: 207.58 };
case '2021-05-01':
return { marketPrice: 209.98 };
case '2021-06-01':
return { marketPrice: 212.46 };
case '2021-06-02':
return { marketPrice: 212.54 };
case '2021-06-03':
return { marketPrice: 212.62 };
case '2021-06-04':
return { marketPrice: 212.7 };
case '2021-06-05':
return { marketPrice: 212.78 };
case '2021-06-06':
return { marketPrice: 212.86 };
case '2021-06-07':
return { marketPrice: 212.94 };
case '2021-06-08':
return { marketPrice: 213.02 };
case '2021-06-09':
return { marketPrice: 213.1 };
case '2021-06-10':
return { marketPrice: 213.18 };
case '2021-06-11':
return { marketPrice: 213.26 };
case '2021-06-12':
return { marketPrice: 213.34 };
case '2021-06-13':
return { marketPrice: 213.42 };
case '2021-06-14':
return { marketPrice: 213.5 };
case '2021-06-15':
return { marketPrice: 213.58 };
case '2021-06-16':
return { marketPrice: 213.66 };
case '2021-06-17':
return { marketPrice: 213.74 };
case '2021-06-18':
return { marketPrice: 213.82 };
case '2021-06-19':
return { marketPrice: 213.9 };
case '2021-06-20':
return { marketPrice: 213.98 };
case '2021-06-21':
return { marketPrice: 214.06 };
case '2021-06-22':
return { marketPrice: 214.14 };
case '2021-06-23':
return { marketPrice: 214.22 };
case '2021-06-24':
return { marketPrice: 214.3 };
case '2021-06-25':
return { marketPrice: 214.38 };
case '2021-06-26':
return { marketPrice: 214.46 };
case '2021-06-27':
return { marketPrice: 214.54 };
case '2021-06-28':
return { marketPrice: 214.62 };
case '2021-06-29':
return { marketPrice: 214.7 };
case '2021-06-30':
return { marketPrice: 214.78 };
case '2021-07-01':
return { marketPrice: 214.86 };
case '2021-07-02':
return { marketPrice: 214.94 };
case '2021-07-03':
return { marketPrice: 215.02 };
case '2021-07-04':
return { marketPrice: 215.1 };
case '2021-07-05':
return { marketPrice: 215.18 };
case '2021-07-06':
return { marketPrice: 215.26 };
case '2021-07-07':
return { marketPrice: 215.34 };
case '2021-07-08':
return { marketPrice: 215.42 };
case '2021-07-09':
return { marketPrice: 215.5 };
case '2021-07-10':
return { marketPrice: 215.58 };
case '2021-07-11':
return { marketPrice: 215.66 };
case '2021-07-12':
return { marketPrice: 215.74 };
case '2021-07-13':
return { marketPrice: 215.82 };
case '2021-07-14':
return { marketPrice: 215.9 };
case '2021-07-15':
return { marketPrice: 215.98 };
case '2021-07-16':
return { marketPrice: 216.06 };
case '2021-07-17':
return { marketPrice: 216.14 };
case '2021-07-18':
return { marketPrice: 216.22 };
case '2021-07-19':
return { marketPrice: 216.3 };
case '2021-07-20':
return { marketPrice: 216.38 };
case '2021-07-21':
return { marketPrice: 216.46 };
case '2021-07-22':
return { marketPrice: 216.54 };
case '2021-07-23':
return { marketPrice: 216.62 };
case '2021-07-24':
return { marketPrice: 216.7 };
case '2021-07-25':
return { marketPrice: 216.78 };
case '2021-07-26':
return { marketPrice: 216.86 };
case '2021-07-27':
return { marketPrice: 216.94 };
case '2021-07-28':
return { marketPrice: 217.02 };
case '2021-07-29':
return { marketPrice: 217.1 };
case '2021-07-30':
return { marketPrice: 217.18 };
case '2021-07-31':
return { marketPrice: 217.26 };
case '2021-08-01':
return { marketPrice: 217.34 };
case '2020-10-24':
return { marketPrice: 194.86 };
default:
return { marketPrice: 0 };
}
default:
return { marketPrice: 0 };
@ -87,9 +267,6 @@ jest.mock('./current-rate.service', () => {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return {
getValue: ({ date, symbol }: GetValueParams) => {
return Promise.resolve(mockGetValue(symbol, date));
},
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
const result = [];
if (dateQuery.lt) {
@ -1645,14 +1822,14 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
}
]);
});
@ -1765,14 +1942,14 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('398.3'), // 100 fees
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('199.35'), // 150 fees
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
}
]);
});
@ -1808,203 +1985,203 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('0'),
netPerformance: new Big('0'),
investment: new Big('1443.8'),
value: new Big('1443.8') // 10 * (144.38 + days=0 * 0.08)
value: new Big('1443.8') // 10 * 144.38
},
{
date: '2019-03-01',
grossPerformance: new Big('22.4'),
netPerformance: new Big('22.4'),
investment: new Big('1443.8'),
value: new Big('1466.2') // 10 * (144.38 + days=28 * 0.08)
value: new Big('1466.2') // 10 * 146.62
},
{
date: '2019-04-01',
grossPerformance: new Big('47.2'),
netPerformance: new Big('47.2'),
investment: new Big('1443.8'),
value: new Big('1491') // 10 * (144.38 + days=59 * 0.08)
value: new Big('1491') // 10 * 149.1
},
{
date: '2019-05-01',
grossPerformance: new Big('71.2'),
netPerformance: new Big('71.2'),
investment: new Big('1443.8'),
value: new Big('1515') // 10 * (144.38 + days=89 * 0.08)
value: new Big('1515') // 10 * 151.5
},
{
date: '2019-06-01',
grossPerformance: new Big('96'),
netPerformance: new Big('96'),
investment: new Big('1443.8'),
value: new Big('1539.8') // 10 * (144.38 + days=120 * 0.08)
value: new Big('1539.8') // 10 * 153.98
},
{
date: '2019-07-01',
grossPerformance: new Big('120'),
netPerformance: new Big('120'),
investment: new Big('1443.8'),
value: new Big('1563.8') // 10 * (144.38 + days=150 * 0.08)
value: new Big('1563.8') // 10 * 156.38
},
{
date: '2019-08-01',
grossPerformance: new Big('144.8'),
netPerformance: new Big('144.8'),
investment: new Big('1443.8'),
value: new Big('1588.6') // 10 * (144.38 + days=181 * 0.08)
value: new Big('1588.6') // 10 * 158.86
},
{
date: '2019-09-01',
grossPerformance: new Big('303.1'),
netPerformance: new Big('303.1'),
investment: new Big('2923.7'),
value: new Big('3226.8') // 20 * (144.38 + days=212 * 0.08)
value: new Big('3226.8') // 20 * 161.34
},
{
date: '2019-10-01',
grossPerformance: new Big('351.1'),
netPerformance: new Big('351.1'),
investment: new Big('2923.7'),
value: new Big('3274.8') // 20 * (144.38 + days=242 * 0.08)
value: new Big('3274.8') // 20 * 163.74
},
{
date: '2019-11-01',
grossPerformance: new Big('400.7'),
netPerformance: new Big('400.7'),
investment: new Big('2923.7'),
value: new Big('3324.4') // 20 * (144.38 + days=273 * 0.08)
value: new Big('3324.4') // 20 * 166.22
},
{
date: '2019-12-01',
grossPerformance: new Big('448.7'),
netPerformance: new Big('448.7'),
investment: new Big('2923.7'),
value: new Big('3372.4') // 20 * (144.38 + days=303 * 0.08)
value: new Big('3372.4') // 20 * 168.62
},
{
date: '2020-01-01',
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2020-02-01',
grossPerformance: new Big('547.9'),
netPerformance: new Big('547.9'),
investment: new Big('2923.7'),
value: new Big('3471.6') // 20 * (144.38 + days=365 * 0.08)
value: new Big('3471.6') // 20 * 173.58
},
{
date: '2020-03-01',
grossPerformance: new Big('226.95'),
netPerformance: new Big('226.95'),
investment: new Big('652.55'),
value: new Big('879.5') // 5 * (144.38 + days=394 * 0.08)
value: new Big('879.5') // 5 * 175.9
},
{
date: '2020-04-01',
grossPerformance: new Big('239.35'),
netPerformance: new Big('239.35'),
investment: new Big('652.55'),
value: new Big('891.9') // 5 * (144.38 + days=425 * 0.08)
value: new Big('891.9') // 5 * 178.38
},
{
date: '2020-05-01',
grossPerformance: new Big('251.35'),
netPerformance: new Big('251.35'),
investment: new Big('652.55'),
value: new Big('903.9') // 5 * (144.38 + days=455 * 0.08)
value: new Big('903.9') // 5 * 180.78
},
{
date: '2020-06-01',
grossPerformance: new Big('263.75'),
netPerformance: new Big('263.75'),
investment: new Big('652.55'),
value: new Big('916.3') // 5 * (144.38 + days=486 * 0.08)
value: new Big('916.3') // 5 * 183.26
},
{
date: '2020-07-01',
grossPerformance: new Big('275.75'),
netPerformance: new Big('275.75'),
investment: new Big('652.55'),
value: new Big('928.3') // 5 * (144.38 + days=516 * 0.08)
value: new Big('928.3') // 5 * 185.66
},
{
date: '2020-08-01',
grossPerformance: new Big('288.15'),
netPerformance: new Big('288.15'),
investment: new Big('652.55'),
value: new Big('940.7') // 5 * (144.38 + days=547 * 0.08)
value: new Big('940.7') // 5 * 188.14
},
{
date: '2020-09-01',
grossPerformance: new Big('300.55'),
netPerformance: new Big('300.55'),
investment: new Big('652.55'),
value: new Big('953.1') // 5 * (144.38 + days=578 * 0.08)
value: new Big('953.1') // 5 * 190.62
},
{
date: '2020-10-01',
grossPerformance: new Big('312.55'),
netPerformance: new Big('312.55'),
investment: new Big('652.55'),
value: new Big('965.1') // 5 * (144.38 + days=608 * 0.08)
value: new Big('965.1') // 5 * 193.02
},
{
date: '2020-11-01',
grossPerformance: new Big('324.95'),
netPerformance: new Big('324.95'),
investment: new Big('652.55'),
value: new Big('977.5') // 5 * (144.38 + days=639 * 0.08)
value: new Big('977.5') // 5 * 195.5
},
{
date: '2020-12-01',
grossPerformance: new Big('336.95'),
netPerformance: new Big('336.95'),
investment: new Big('652.55'),
value: new Big('989.5') // 5 * (144.38 + days=669 * 0.08)
value: new Big('989.5') // 5 * 197.9
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
},
{
date: '2021-02-01',
grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'),
investment: new Big('2684.05'),
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
value: new Big('3042.9') // 15 * 202.86
},
{
date: '2021-03-01',
grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'),
investment: new Big('2684.05'),
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
value: new Big('3076.5') // 15 * 205.1
},
{
date: '2021-04-01',
grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'),
investment: new Big('2684.05'),
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
value: new Big('3113.7') // 15 * 207.58
},
{
date: '2021-05-01',
grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'),
investment: new Big('2684.05'),
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
value: new Big('3149.7') // 15 * 209.98
},
{
date: '2021-06-01',
grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'),
investment: new Big('2684.05'),
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
value: new Big('3186.9') // 15 * 212.46
}
]);
@ -2047,49 +2224,49 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
},
{
date: '2021-02-01',
grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'),
investment: new Big('2684.05'),
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
value: new Big('3042.9') // 15 * 202.86
},
{
date: '2021-03-01',
grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'),
investment: new Big('2684.05'),
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
value: new Big('3076.5') // 15 * 205.1
},
{
date: '2021-04-01',
grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'),
investment: new Big('2684.05'),
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
value: new Big('3113.7') // 15 * 207.58
},
{
date: '2021-05-01',
grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'),
investment: new Big('2684.05'),
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
value: new Big('3149.7') // 15 * 209.98
},
{
date: '2021-06-01',
grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'),
investment: new Big('2684.05'),
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
value: new Big('3186.9') // 15 * 212.46
}
]);
});
@ -2134,252 +2311,252 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'),
investment: new Big('2923.7'),
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
value: new Big('3422') // 20 * 171.1
},
{
date: '2021-01-01',
grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'),
investment: new Big('652.55'),
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
value: new Big('1001.9') // 5 * 200.38
},
{
date: '2021-02-01',
grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'),
investment: new Big('2684.05'),
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
value: new Big('3042.9') // 15 * 202.86
},
{
date: '2021-03-01',
grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'),
investment: new Big('2684.05'),
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
value: new Big('3076.5') // 15 * 205.1
},
{
date: '2021-04-01',
grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'),
investment: new Big('2684.05'),
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
value: new Big('3113.7') // 15 * 207.58
},
{
date: '2021-05-01',
grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'),
investment: new Big('2684.05'),
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
value: new Big('3149.7') // 15 * 209.98
},
{
date: '2021-06-01',
grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'),
investment: new Big('2684.05'),
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
value: new Big('3186.9') // 15 * 212.46
},
{
date: '2021-06-02',
grossPerformance: new Big('504.05'),
netPerformance: new Big('504.05'),
investment: new Big('2684.05'),
value: new Big('3188.1') // 15 * (144.38 + days=852 * 0.08) / +1.2
value: new Big('3188.1') // 15 * 212.54
},
{
date: '2021-06-03',
grossPerformance: new Big('505.25'),
netPerformance: new Big('505.25'),
investment: new Big('2684.05'),
value: new Big('3189.3') // +1.2
value: new Big('3189.3') // 15 * 212.62
},
{
date: '2021-06-04',
grossPerformance: new Big('506.45'),
netPerformance: new Big('506.45'),
investment: new Big('2684.05'),
value: new Big('3190.5') // +1.2
value: new Big('3190.5') // 15 * 212.7
},
{
date: '2021-06-05',
grossPerformance: new Big('507.65'),
netPerformance: new Big('507.65'),
investment: new Big('2684.05'),
value: new Big('3191.7') // +1.2
value: new Big('3191.7') // 15 * 212.78
},
{
date: '2021-06-06',
grossPerformance: new Big('508.85'),
netPerformance: new Big('508.85'),
investment: new Big('2684.05'),
value: new Big('3192.9') // +1.2
value: new Big('3192.9') // 15 * 212.86
},
{
date: '2021-06-07',
grossPerformance: new Big('510.05'),
netPerformance: new Big('510.05'),
investment: new Big('2684.05'),
value: new Big('3194.1') // +1.2
value: new Big('3194.1') // 15 * 212.94
},
{
date: '2021-06-08',
grossPerformance: new Big('511.25'),
netPerformance: new Big('511.25'),
investment: new Big('2684.05'),
value: new Big('3195.3') // +1.2
value: new Big('3195.3') // 15 * 213.02
},
{
date: '2021-06-09',
grossPerformance: new Big('512.45'),
netPerformance: new Big('512.45'),
investment: new Big('2684.05'),
value: new Big('3196.5') // +1.2
value: new Big('3196.5') // 15 * 213.1
},
{
date: '2021-06-10',
grossPerformance: new Big('513.65'),
netPerformance: new Big('513.65'),
investment: new Big('2684.05'),
value: new Big('3197.7') // +1.2
value: new Big('3197.7') // 15 * 213.18
},
{
date: '2021-06-11',
grossPerformance: new Big('514.85'),
netPerformance: new Big('514.85'),
investment: new Big('2684.05'),
value: new Big('3198.9') // +1.2
value: new Big('3198.9') // 15 * 213.26
},
{
date: '2021-06-12',
grossPerformance: new Big('516.05'),
netPerformance: new Big('516.05'),
investment: new Big('2684.05'),
value: new Big('3200.1') // +1.2
value: new Big('3200.1') // 15 * 213.34
},
{
date: '2021-06-13',
grossPerformance: new Big('517.25'),
netPerformance: new Big('517.25'),
investment: new Big('2684.05'),
value: new Big('3201.3') // +1.2
value: new Big('3201.3') // 15 * 213.42
},
{
date: '2021-06-14',
grossPerformance: new Big('518.45'),
netPerformance: new Big('518.45'),
investment: new Big('2684.05'),
value: new Big('3202.5') // +1.2
value: new Big('3202.5') // 15 * 213.5
},
{
date: '2021-06-15',
grossPerformance: new Big('519.65'),
netPerformance: new Big('519.65'),
investment: new Big('2684.05'),
value: new Big('3203.7') // +1.2
value: new Big('3203.7') // 15 * 213.58
},
{
date: '2021-06-16',
grossPerformance: new Big('520.85'),
netPerformance: new Big('520.85'),
investment: new Big('2684.05'),
value: new Big('3204.9') // +1.2
value: new Big('3204.9') // 15 * 213.66
},
{
date: '2021-06-17',
grossPerformance: new Big('522.05'),
netPerformance: new Big('522.05'),
investment: new Big('2684.05'),
value: new Big('3206.1') // +1.2
value: new Big('3206.1') // 15 * 213.74
},
{
date: '2021-06-18',
grossPerformance: new Big('523.25'),
netPerformance: new Big('523.25'),
investment: new Big('2684.05'),
value: new Big('3207.3') // +1.2
value: new Big('3207.3') // 15 * 213.82
},
{
date: '2021-06-19',
grossPerformance: new Big('524.45'),
netPerformance: new Big('524.45'),
investment: new Big('2684.05'),
value: new Big('3208.5') // +1.2
value: new Big('3208.5') // 15 * 213.9
},
{
date: '2021-06-20',
grossPerformance: new Big('525.65'),
netPerformance: new Big('525.65'),
investment: new Big('2684.05'),
value: new Big('3209.7') // +1.2
value: new Big('3209.7') // 15 * 213.98
},
{
date: '2021-06-21',
grossPerformance: new Big('526.85'),
netPerformance: new Big('526.85'),
investment: new Big('2684.05'),
value: new Big('3210.9') // +1.2
value: new Big('3210.9') // 15 * 214.06
},
{
date: '2021-06-22',
grossPerformance: new Big('528.05'),
netPerformance: new Big('528.05'),
investment: new Big('2684.05'),
value: new Big('3212.1') // +1.2
value: new Big('3212.1') // 15 * 214.14
},
{
date: '2021-06-23',
grossPerformance: new Big('529.25'),
netPerformance: new Big('529.25'),
investment: new Big('2684.05'),
value: new Big('3213.3') // +1.2
value: new Big('3213.3') // 15 * 214.22
},
{
date: '2021-06-24',
grossPerformance: new Big('530.45'),
netPerformance: new Big('530.45'),
investment: new Big('2684.05'),
value: new Big('3214.5') // +1.2
value: new Big('3214.5') // 15 * 214.3
},
{
date: '2021-06-25',
grossPerformance: new Big('531.65'),
netPerformance: new Big('531.65'),
investment: new Big('2684.05'),
value: new Big('3215.7') // +1.2
value: new Big('3215.7') // 15 * 214.38
},
{
date: '2021-06-26',
grossPerformance: new Big('532.85'),
netPerformance: new Big('532.85'),
investment: new Big('2684.05'),
value: new Big('3216.9') // +1.2
value: new Big('3216.9') // 15 * 214.46
},
{
date: '2021-06-27',
grossPerformance: new Big('534.05'),
netPerformance: new Big('534.05'),
investment: new Big('2684.05'),
value: new Big('3218.1') // +1.2
value: new Big('3218.1') // 15 * 214.54
},
{
date: '2021-06-28',
grossPerformance: new Big('535.25'),
netPerformance: new Big('535.25'),
investment: new Big('2684.05'),
value: new Big('3219.3') // +1.2
value: new Big('3219.3') // 15 * 214.62
},
{
date: '2021-06-29',
grossPerformance: new Big('536.45'),
netPerformance: new Big('536.45'),
investment: new Big('2684.05'),
value: new Big('3220.5') // +1.2
value: new Big('3220.5') // 15 * 214.7
},
{
date: '2021-06-30',
grossPerformance: new Big('537.65'),
netPerformance: new Big('537.65'),
investment: new Big('2684.05'),
value: new Big('3221.7') // +1.2
value: new Big('3221.7') // 15 * 214.78
}
])
);
@ -2442,7 +2619,7 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('267.2'),
netPerformance: new Big('267.2'),
investment: new Big('11553.75'),
value: new Big('11820.95') // 10 * (144.38 + days=334 * 0.08) + 5 * 2021.99
value: new Big('11820.95') // 10 * 171.1 + 5 * 2021.99
}
]);
});

View File

@ -69,7 +69,7 @@ export class PortfolioCalculator {
: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
@ -354,7 +354,7 @@ export class PortfolioCalculator {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.add(transactionPointSymbol.investment),
investment.plus(transactionPointSymbol.investment),
new Big(0)
)
};
@ -475,13 +475,13 @@ export class PortfolioCalculator {
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.add(
currentValue = currentValue.plus(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.add(currentPosition.investment);
totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
@ -562,8 +562,8 @@ export class PortfolioCalculator {
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.add(item.investment);
fees = fees.add(item.fee);
investment = investment.plus(item.investment);
fees = fees.plus(item.fee);
}
let marketSymbols: GetValueObject[] = [];
@ -619,7 +619,7 @@ export class PortfolioCalculator {
invalid = true;
break;
}
value = value.add(
value = value.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}

View File

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

View File

@ -4,9 +4,12 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
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.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
PortfolioChart,
PortfolioDetails,
@ -25,17 +28,16 @@ import {
Inject,
Param,
Query,
Res,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
@Controller('portfolio')
export class PortfolioController {
@ -43,7 +45,7 @@ export class PortfolioController {
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -52,13 +54,11 @@ export class PortfolioController {
@UseGuards(AuthGuard('jwt'))
public async getChart(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
range
);
const historicalDataContainer = await this.portfolioServiceStrategy
.get()
.getChart(impersonationId, range);
let chartData = historicalDataContainer.items;
@ -90,37 +90,37 @@ export class PortfolioController {
});
}
return <any>res.json({
return {
hasError,
chart: chartData,
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
isAllTimeLow: historicalDataContainer.isAllTimeLow
});
};
}
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioDetails> {
@Query('range') range
): Promise<PortfolioDetails & { hasError: boolean }> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ accounts: {}, holdings: {} });
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let hasError = false;
const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
);
await this.portfolioServiceStrategy
.get()
.getDetails(impersonationId, this.request.user.id, range);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true;
@ -161,26 +161,27 @@ export class PortfolioController {
}
}
return <any>res.json({ accounts, hasError, holdings });
return { accounts, hasError, holdings };
}
@Get('investments')
@UseGuards(AuthGuard('jwt'))
public async getInvestments(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioInvestments> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({});
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let investments = await this.portfolioService.getInvestments(
impersonationId
);
let investments = await this.portfolioServiceStrategy
.get()
.getInvestments(impersonationId);
if (
impersonationId ||
@ -197,20 +198,18 @@ export class PortfolioController {
}));
}
return <any>res.json({ firstOrderDate: investments[0]?.date, investments });
return { firstOrderDate: parseDate(investments[0]?.date), investments };
}
@Get('performance')
@UseGuards(AuthGuard('jwt'))
public async getPerformance(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
range
);
const performanceInformation = await this.portfolioServiceStrategy
.get()
.getPerformance(impersonationId, range);
if (
impersonationId ||
@ -222,20 +221,19 @@ export class PortfolioController {
);
}
return <any>res.json(performanceInformation);
return performanceInformation;
}
@Get('positions')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPositions(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range,
@Res() res: Response
@Query('range') range
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
const result = await this.portfolioServiceStrategy
.get()
.getPositions(impersonationId, range);
if (
impersonationId ||
@ -251,13 +249,12 @@ export class PortfolioController {
});
}
return <any>res.json(result);
return result;
}
@Get('public/:accessId')
public async getPublic(
@Param('accessId') accessId,
@Res() res: Response
@Param('accessId') accessId
): Promise<PortfolioPublicDetails> {
const access = await this.accessService.access({ id: accessId });
const user = await this.userService.user({
@ -265,8 +262,10 @@ export class PortfolioController {
});
if (!access) {
res.status(StatusCodes.NOT_FOUND);
return <any>res.json({ accounts: {}, holdings: {} });
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
let hasDetails = true;
@ -274,10 +273,9 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
);
const { holdings } = await this.portfolioServiceStrategy
.get()
.getDetails(access.userId, access.userId);
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
@ -310,7 +308,7 @@ export class PortfolioController {
}
}
return <any>res.json(portfolioPublicDetails);
return portfolioPublicDetails;
}
@Get('summary')
@ -318,7 +316,9 @@ export class PortfolioController {
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId);
let summary = await this.portfolioServiceStrategy
.get()
.getSummary(impersonationId);
if (
impersonationId ||
@ -332,6 +332,7 @@ export class PortfolioController {
'currentValue',
'dividend',
'fees',
'items',
'netWorth',
'totalBuy',
'totalSell'
@ -341,16 +342,18 @@ export class PortfolioController {
return summary;
}
@Get('position/:symbol')
@Get('position/:dataSource/:symbol')
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@Headers('impersonation-id') impersonationId: string,
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition(
impersonationId,
symbol
);
let position = await this.portfolioServiceStrategy
.get()
.getPosition(dataSource, impersonationId, symbol);
if (position) {
if (
@ -379,19 +382,18 @@ export class PortfolioController {
@Get('report')
@UseGuards(AuthGuard('jwt'))
public async getReport(
@Headers('impersonation-id') impersonationId: string,
@Res() res: Response
@Headers('impersonation-id') impersonationId: string
): Promise<PortfolioReport> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
res.status(StatusCodes.FORBIDDEN);
return <any>res.json({ rules: [] });
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return <any>(
res.json(await this.portfolioService.getReport(impersonationId))
);
return await this.portfolioServiceStrategy.get().getReport(impersonationId);
}
}

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -107,7 +107,7 @@ export class PortfolioService {
account.currency,
userCurrency
),
value: details.accounts[account.name]?.current ?? 0
value: details.accounts[account.id]?.current ?? 0
};
delete result.Order;
@ -345,6 +345,7 @@ export class PortfolioService {
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries,
currency: item.currency,
dataSource: symbolProfile.dataSource,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent:
@ -385,19 +386,25 @@ export class PortfolioService {
}
public async getPosition(
aDataSource: DataSource,
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter(
(order) => order.symbol === aSymbol
);
const orders = (
await this.orderService.getOrders({ userCurrency, userId })
).filter(({ SymbolProfile }) => {
return (
SymbolProfile.dataSource === aDataSource &&
SymbolProfile.symbol === aSymbol
);
});
if (orders.length <= 0) {
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -406,21 +413,20 @@ export class PortfolioService {
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
name: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
orders: [],
quantity: undefined,
symbol: aSymbol,
SymbolProfile: undefined,
transactionCount: undefined,
value: undefined
};
}
const assetClass = orders[0].SymbolProfile?.assetClass;
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
@ -428,7 +434,7 @@ export class PortfolioService {
})
.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
@ -466,7 +472,6 @@ export class PortfolioService {
} = position;
// Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency(
position.investment?.toNumber(),
currency,
@ -536,25 +541,21 @@ export class PortfolioService {
}
return {
assetClass,
assetSubClass,
currency,
firstBuyDate,
grossPerformance,
investment,
marketPrice,
maxPrice,
minPrice,
name,
netPerformance,
orders,
SymbolProfile,
transactionCount,
averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(),
quantity: quantity.toNumber(),
symbol: aSymbol,
value: this.exchangeRateDataService.toCurrency(
quantity.mul(marketPrice).toNumber(),
currency,
@ -599,15 +600,12 @@ export class PortfolioService {
}
return {
assetClass,
assetSubClass,
marketPrice,
maxPrice,
minPrice,
name,
orders,
SymbolProfile,
averagePrice: 0,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
@ -616,7 +614,6 @@ export class PortfolioService {
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: 0,
symbol: aSymbol,
transactionCount: undefined,
value: 0
};
@ -846,29 +843,32 @@ export class PortfolioService {
}
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency;
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
userId,
currency
userCurrency
);
const orders = await this.orderService.getOrders({
userCurrency,
userId
});
const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date;
const items = this.getItems(orders).toNumber();
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
const totalSell = this.getTotalByType(orders, currency, 'SELL');
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const committedFunds = new Big(totalBuy).sub(totalSell);
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balance)
.plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber();
return {
@ -876,9 +876,12 @@ export class PortfolioService {
dividend,
fees,
firstOrderDate,
items,
netWorth,
totalBuy,
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balance,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.filter((order) => {
@ -895,8 +898,8 @@ export class PortfolioService {
}: {
cashDetails: CashDetails;
investment: Big;
value: Big;
userCurrency: string;
value: Big;
}) {
const cashPositions = {};
@ -997,6 +1000,28 @@ export class PortfolioService {
);
}
private getItems(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date and type item
return (
isBefore(date, new Date(order.date)) &&
order.type === TypeOfOrder.ITEM
);
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
this.request.user.Settings.currency
);
})
.reduce(
(previous, current) => new Big(previous).plus(current),
new Big(0)
);
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
@ -1025,8 +1050,11 @@ export class PortfolioService {
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
}> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({
includeDrafts,
userCurrency,
userId,
types: ['BUY', 'SELL']
});
@ -1035,10 +1063,9 @@ export class PortfolioService {
return { transactionPoints: [], orders: [] };
}
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.dataSource,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
@ -1091,10 +1118,11 @@ export class PortfolioService {
account.currency,
userCurrency
);
accounts[account.name] = {
accounts[account.id] = {
balance: convertedBalance,
currency: account.currency,
current: convertedBalance,
name: account.name,
original: convertedBalance
};
@ -1108,16 +1136,17 @@ export class PortfolioService {
originalValueOfSymbol *= -1;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol,
name: account.name,
original: originalValueOfSymbol
};
}

View File

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

View File

@ -7,6 +7,7 @@ import {
Body,
Controller,
Get,
HttpCode,
HttpException,
Inject,
Logger,
@ -17,7 +18,6 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Response } from 'express';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { SubscriptionService } from './subscription.service';
@ -32,11 +32,9 @@ export class SubscriptionController {
) {}
@Post('redeem-coupon')
@HttpCode(StatusCodes.OK)
@UseGuards(AuthGuard('jwt'))
public async redeemCoupon(
@Body() { couponCode }: { couponCode: string },
@Res() res: Response
) {
public async redeemCoupon(@Body() { couponCode }: { couponCode: string }) {
if (!this.request.user) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
@ -74,12 +72,10 @@ export class SubscriptionController {
`Subscription for user '${this.request.user.id}' has been created with coupon`
);
res.status(StatusCodes.OK);
return <any>res.json({
return {
message: getReasonPhrase(StatusCodes.OK),
statusCode: StatusCodes.OK
});
};
}
@Get('stripe/callback')

View File

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

View File

@ -1,19 +1,17 @@
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 { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Controller,
DefaultValuePipe,
Get,
HttpException,
Inject,
Param,
ParseBoolPipe,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash';
@ -23,22 +21,19 @@ import { SymbolService } from './symbol.service';
@Controller('symbol')
export class SymbolController {
public constructor(
private readonly symbolService: SymbolService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
public constructor(private readonly symbolService: SymbolService) {}
/**
* Must be before /:symbol
*/
@Get('lookup')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query() { query = '' }
): Promise<{ items: LookupItem[] }> {
try {
const encodedQuery = encodeURIComponent(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
return this.symbolService.lookup(query.toLowerCase());
} catch {
throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
@ -52,11 +47,12 @@ export class SymbolController {
*/
@Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getSymbolData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
includeHistoricalData: boolean
@Query('includeHistoricalData') includeHistoricalData?: number
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(

View File

@ -8,13 +8,14 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service';
@Module({
controllers: [SymbolController],
exports: [SymbolService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
controllers: [SymbolController],
providers: [SymbolService]
})
export class SymbolModule {}

View File

@ -5,7 +5,6 @@ import {
IDataProviderHistoricalResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -18,25 +17,24 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService
private readonly marketDataService: MarketDataService
) {}
public async get({
dataGatheringItem,
includeHistoricalData = false
includeHistoricalData
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[];
let historicalData: HistoricalDataItem[] = [];
if (includeHistoricalData) {
const days = 30;
if (includeHistoricalData > 0) {
const days = includeHistoricalData;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
@ -93,32 +91,6 @@ export class SymbolService {
try {
const { items } = await this.dataProviderService.search(aQuery);
results.items = items;
// Add custom symbols
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results;
} catch (error) {
Logger.error(error);

View File

@ -1,4 +1,7 @@
import { Role } from '@prisma/client';
export interface UserItem {
accessToken?: string;
authToken: string;
role: Role;
}

View File

@ -1,6 +1,11 @@
import { IsBoolean } from 'class-validator';
import { IsBoolean, IsOptional } from 'class-validator';
export class UpdateUserSettingDto {
@IsBoolean()
@IsOptional()
isNewCalculationEngine?: boolean;
@IsBoolean()
@IsOptional()
isRestrictedView?: boolean;
}

View File

@ -83,12 +83,15 @@ export class UserController {
}
}
const { accessToken, id } = await this.userService.createUser({
provider: Provider.ANONYMOUS
const hasAdmin = await this.userService.hasAdmin();
const { accessToken, id, role } = await this.userService.createUser({
role: hasAdmin ? 'USER' : 'ADMIN'
});
return {
accessToken,
role,
authToken: this.jwtService.sign({
id
})
@ -115,6 +118,12 @@ export class UserController {
...data
};
for (const key in userSettings) {
if (userSettings[key] === false) {
delete userSettings[key];
}
}
return await this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id

View File

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

View File

@ -70,6 +70,18 @@ export class UserService {
};
}
public async hasAdmin() {
const usersWithAdminRole = await this.users({
where: {
role: {
equals: 'ADMIN'
}
}
});
return usersWithAdminRole.length > 0;
}
public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
}
@ -168,7 +180,11 @@ export class UserService {
return hash.digest('hex');
}
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
public async createUser(data: Prisma.UserCreateInput): Promise<User> {
if (!data?.provider) {
data.provider = 'ANONYMOUS';
}
let user = await this.prismaService.user.create({
data: {
...data,
@ -187,7 +203,7 @@ export class UserService {
}
});
if (data.provider === Provider.ANONYMOUS) {
if (data.provider === 'ANONYMOUS') {
const accessToken = this.createAccessToken(
user.id,
this.getRandomString(10)

View File

@ -0,0 +1,39 @@
import { decodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInRequestInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
const http = context.switchToHttp();
const request = http.getRequest();
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true) {
if (request.body.dataSource) {
request.body.dataSource = decodeDataSource(request.body.dataSource);
}
if (request.params.dataSource) {
request.params.dataSource = decodeDataSource(request.params.dataSource);
}
}
return next.handle();
}
}

View File

@ -0,0 +1,86 @@
import { encodeDataSource } from '@ghostfolio/common/helper';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { ConfigurationService } from '../services/configuration.service';
@Injectable()
export class TransformDataSourceInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor(
private readonly configurationService: ConfigurationService
) {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
if (data.activities) {
data.activities.map((activity) => {
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
activity.dataSource = encodeDataSource(activity.dataSource);
return activity;
});
}
if (data.dataSource) {
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
data.holdings[symbol].dataSource = encodeDataSource(
data.holdings[symbol].dataSource
);
}
}
}
if (data.items) {
data.items.map((item) => {
item.dataSource = encodeDataSource(item.dataSource);
return item;
});
}
if (data.orders) {
data.orders.map((order) => {
order.dataSource = encodeDataSource(order.dataSource);
return order;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);
return position;
});
}
if (data.SymbolProfile) {
data.SymbolProfile.dataSource = encodeDataSource(
data.SymbolProfile.dataSource
);
}
}
return data;
})
);
}
}

View File

@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
};
} = {};
for (const account of Object.keys(this.accounts)) {
accounts[account] = {
name: account,
investment: this.accounts[account].current
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.current
};
}
let maxItem;
let totalInvestment = 0;
Object.values(accounts).forEach((account) => {
for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = account;
}
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
if (account.investment > maxItem?.investment) {
maxItem = account;
}
});
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
}
public evaluate(ruleSettings?: Settings) {
const platforms: {
const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
for (const account of Object.keys(this.accounts)) {
platforms[account] = {
name: account,
investment: this.accounts[account].original
for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[accountId] = {
name: account.name,
investment: account.original
};
}
let maxItem;
let totalInvestment = 0;
Object.values(platforms).forEach((platform) => {
for (const account of Object.values(accounts)) {
if (!maxItem) {
maxItem = platform;
maxItem = account;
}
// Calculate total investment
totalInvestment += platform.investment;
totalInvestment += account.investment;
// Find maximum
if (platform.investment > maxItem?.investment) {
maxItem = platform;
if (account.investment > maxItem?.investment) {
maxItem = account;
}
});
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -13,6 +13,7 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
@ -25,6 +26,9 @@ export class ConfigurationService {
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }),
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
GOOGLE_SHEETS_ID: str({ default: '' }),
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
@ -35,6 +39,10 @@ export class ConfigurationService {
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
});
}

View File

@ -3,12 +3,14 @@ import { Cron, CronExpression } from '@nestjs/schedule';
import { DataGatheringService } from './data-gathering.service';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { TwitterBotService } from './twitter-bot/twitter-bot.service';
@Injectable()
export class CronService {
public constructor(
private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly twitterBotService: TwitterBotService
) {}
@Cron(CronExpression.EVERY_MINUTE)
@ -21,6 +23,11 @@ export class CronService {
await this.exchangeRateDataService.loadCurrencies();
}
@Cron(CronExpression.EVERY_DAY_AT_5PM)
public async runEveryDayAtFivePM() {
this.twitterBotService.tweetFearAndGreedIndex();
}
@Cron(CronExpression.EVERY_WEEKEND)
public async runEveryWeekend() {
await this.dataGatheringService.gatherProfileData();

View File

@ -5,6 +5,7 @@
"AVAX": "Avalanche",
"DOT": "Polkadot",
"MATIC": "Polygon",
"MINA": "Mina Protocol",
"SHIB": "Shiba Inu",
"SOL": "Solana",
"UNI3": "Uniswap"

View File

@ -445,6 +445,11 @@ export class DataGatheringService {
},
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
})
).map((symbolProfile) => {
@ -473,9 +478,23 @@ export class DataGatheringService {
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
},
where: {
dataSource: {
not: 'MANUAL'
}
}
});
// Only consider symbols with incomplete market data for the last
// 7 days
const symbolsToGather = (
const symbolsNotToGather = (
await this.prismaService.marketData.groupBy({
_count: true,
by: ['symbol'],
@ -485,24 +504,15 @@ export class DataGatheringService {
})
)
.filter((group) => {
return group._count < 6;
return group._count >= 6;
})
.map((group) => {
return group.symbol;
});
const symbolProfilesToGather = (
await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
}
})
)
const symbolProfilesToGather = symbolProfiles
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
return !symbolsNotToGather.includes(symbol);
})
.map((symbolProfile) => {
return {
@ -514,7 +524,7 @@ export class DataGatheringService {
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.filter(({ symbol }) => {
return symbolsToGather.includes(symbol);
return !symbolsNotToGather.includes(symbol);
})
.map(({ dataSource, symbol }) => {
return {
@ -537,6 +547,7 @@ export class DataGatheringService {
return distinctOrders.filter((distinctOrder) => {
return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.MANUAL &&
distinctOrder.dataSource !== DataSource.RAKUTEN
);
});

View File

@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol);
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery);
return {
items: result?.bestMatches?.map((bestMatch) => {
return {
dataSource: DataSource.ALPHA_VANTAGE,
dataSource: this.getName(),
name: bestMatch['2. name'],
symbol: bestMatch['1. symbol']
};

View File

@ -1,6 +1,8 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@ -21,12 +23,16 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService,
DataProviderService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RakutenRapidApiService,
YahooFinanceService,
{
inject: [
AlphaVantageService,
GhostfolioScraperApiService,
GoogleSheetsService,
ManualService,
RakutenRapidApiService,
YahooFinanceService
],
@ -34,11 +40,15 @@ import { DataProviderService } from './data-provider.service';
useFactory: (
alphaVantageService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rakutenRapidApiService,
yahooFinanceService
) => [
alphaVantageService,
ghostfolioScraperApiService,
googleSheetsService,
manualService,
rakutenRapidApiService,
yahooFinanceService
]

View File

@ -12,7 +12,7 @@ import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client';
import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash';
import { groupBy, isEmpty } from 'lodash';
@Injectable()
export class DataProviderService {
@ -30,18 +30,27 @@ export class DataProviderService {
[symbol: string]: IDataProviderResponse;
} = {};
for (const item of items) {
const dataProvider = this.getDataProvider(item.dataSource);
response[item.symbol] = (await dataProvider.get([item.symbol]))[
item.symbol
];
}
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const promises = [];
for (const symbol of Object.keys(response)) {
const promise = Promise.resolve(response[symbol]);
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
)) {
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
const promise = Promise.resolve(
this.getDataProvider(DataSource[dataSource]).get(symbols)
);
promises.push(
promise.then((currentResponse) => (response[symbol] = currentResponse))
promise.then((result) => {
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
response[symbol] = dataProviderResponse;
}
})
);
}
@ -149,13 +158,13 @@ export class DataProviderService {
return result;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
this.getDataProvider(DataSource[dataSource]).search(aQuery)
);
}
@ -176,7 +185,7 @@ export class DataProviderService {
}
public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
}
private getDataProvider(providerName: DataSource) {
@ -185,6 +194,7 @@ export class DataProviderService {
return dataProviderInterface;
}
}
throw new Error('No data provider has been found.');
}
}

View File

@ -1,4 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
@ -13,13 +19,6 @@ import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { format } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: {
marketPrice,
currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO,
dataSource: this.getName(),
marketState: MarketState.delayed
}
};
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
private extractNumberFromString(aString: string): number {

View File

@ -0,0 +1,181 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns';
import { GoogleSpreadsheet } from 'google-spreadsheet';
@Injectable()
export class GoogleSheetsService implements DataProviderInterface {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return true;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) {
return {};
}
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
symbol: 'Overview'
});
const rows = await sheet.getRows();
for (const row of rows) {
const marketPrice = parseFloat(row['marketPrice']);
const symbol = row['symbol'];
if (aSymbols.includes(symbol)) {
response[symbol] = {
marketPrice,
currency: symbolProfiles.find((symbolProfile) => {
return symbolProfile.symbol === symbol;
})?.currency,
dataSource: this.getName(),
marketState: MarketState.delayed
};
}
}
return response;
} catch (error) {
Logger.error(error);
}
return {};
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
if (aSymbols.length <= 0) {
return {};
}
try {
const [symbol] = aSymbols;
const sheet = await this.getSheet({
symbol,
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
});
const rows = await sheet.getRows();
const historicalData: {
[date: string]: IDataProviderHistoricalResponse;
} = {};
rows
.filter((row, index) => {
return index >= 1;
})
.forEach((row) => {
const date = parseDate(row._rawData[0]);
const close = parseFloat(row._rawData[1]);
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
});
return {
[symbol]: historicalData
};
} catch (error) {
Logger.error(error);
}
return {};
}
public getName(): DataSource {
return DataSource.GOOGLE_SHEETS;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
currency: true,
dataSource: true,
name: true,
symbol: true
},
where: {
OR: [
{
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
}
}
]
}
});
return { items };
}
private async getSheet({
sheetId,
symbol
}: {
sheetId: string;
symbol: string;
}) {
const doc = new GoogleSpreadsheet(sheetId);
await doc.useServiceAccountAuth({
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
private_key: this.configurationService
.get('GOOGLE_SHEETS_PRIVATE_KEY')
.replace(/\\n/g, '\n')
});
await doc.loadInfo();
const sheet = doc.sheetsByTitle[symbol];
await sheet.loadCells();
return sheet;
}
}

View File

@ -1,11 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '../../interfaces/interfaces';
} from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
export interface DataProviderInterface {
canHandle(symbol: string): boolean;
@ -23,5 +22,5 @@ export interface DataProviderInterface {
getName(): DataSource;
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
search(aQuery: string): Promise<{ items: LookupItem[] }>;
}

View File

@ -0,0 +1,43 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@Injectable()
export class ManualService implements DataProviderInterface {
public constructor() {}
public canHandle(symbol: string) {
return false;
}
public async get(
aSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> {
return {};
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
from: Date,
to: Date
): Promise<{
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
}> {
return {};
}
public getName(): DataSource {
return DataSource.MANUAL;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}
}

View File

@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {
[ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
marketPrice: fgi.now.value,
marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value
}
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value
}
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({
data: {
symbol,
dataSource: DataSource.RAKUTEN,
dataSource: this.getName(),
date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value
}
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] };
}

View File

@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass,
assetSubClass,
currency: value.price?.currency,
dataSource: DataSource.YAHOO,
dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName),
marketState:
value.price?.marketState === 'REGULAR' ||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO;
}
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = [];
try {
const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
items.push({
symbol,
currency: value.currency,
dataSource: DataSource.YAHOO,
dataSource: this.getName(),
name: value.name
});
}

View File

@ -58,9 +58,9 @@ export class ExchangeRateDataService {
getYesterday()
);
if (isEmpty(result)) {
if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback
// if historical data is not yet available
// if historical data is not fully available
const historicalData = await this.dataProviderService.get(
this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number;
@ -26,5 +30,9 @@ export interface Environment extends CleanedEnvAccessors {
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string;
TWITTER_API_KEY: string;
TWITTER_API_SECRET: string;
WEB_AUTH_RP_ID: string;
}

View File

@ -9,6 +9,21 @@ import { DataSource, MarketData, Prisma } from '@prisma/client';
export class MarketDataService {
public constructor(private readonly prismaService: PrismaService) {}
public async deleteMany({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.marketData.deleteMany({
where: {
dataSource,
symbol
}
});
}
public async get({
date,
symbol

View File

@ -4,14 +4,32 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile } from '@prisma/client';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable()
export class SymbolProfileService {
constructor(private readonly prismaService: PrismaService) {}
public constructor(private readonly prismaService: PrismaService) {}
public async delete({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
});
}
public async deleteById(id: string) {
return this.prismaService.symbolProfile.delete({
where: { id }
});
}
public async getSymbolProfiles(
symbols: string[]

View File

@ -0,0 +1,11 @@
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common';
@Module({
exports: [TwitterBotService],
imports: [ConfigurationModule, SymbolModule],
providers: [TwitterBotService]
})
export class TwitterBotModule {}

View File

@ -0,0 +1,64 @@
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import {
ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { isSunday } from 'date-fns';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable()
export class TwitterBotService {
private twitterClient: TwitterApiReadWrite;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly symbolService: SymbolService
) {
this.twitterClient = new TwitterApi({
accessSecret: this.configurationService.get(
'TWITTER_ACCESS_TOKEN_SECRET'
),
accessToken: this.configurationService.get('TWITTER_ACCESS_TOKEN'),
appKey: this.configurationService.get('TWITTER_API_KEY'),
appSecret: this.configurationService.get('TWITTER_API_SECRET')
}).readWrite;
}
public async tweetFearAndGreedIndex() {
if (
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
isSunday(new Date())
) {
return;
}
try {
const symbolItem = await this.symbolService.get({
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSource,
symbol: ghostfolioFearAndGreedIndexSymbol
}
});
if (symbolItem?.marketPrice) {
const { emoji, text } = resolveFearAndGreedIndex(
symbolItem.marketPrice
);
const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`;
const { data: createdTweet } = await this.twitterClient.v2.tweet(
status
);
Logger.log(
`Fear & Greed Index has been tweeted: https://twitter.com/ghostfolio_/status/${createdTweet.id}`
);
}
} catch (error) {
Logger.error(error);
}
}
}

View File

@ -66,6 +66,13 @@ const routes: Routes = [
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'features',
loadChildren: () =>
import('./pages/features/features-page.module').then(
(m) => m.FeaturesPageModule
)
},
{
path: 'home',
loadChildren: () =>

View File

@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut();
this.userService.remove();
this.router.navigate(['/']);
document.location.href = '/';
}
public ngOnDestroy() {

View File

@ -15,7 +15,7 @@
>(Default)</span
>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
</ng-container>
<ng-container matColumnDef="currency">

View File

@ -46,11 +46,11 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() {
this.displayedColumns = [
'account',
'currency',
'platform',
'transactions',
'balance',
'value'
'value',
'currency'
];
if (this.showActions) {

View File

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

View File

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

View File

@ -12,7 +12,7 @@ import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client';
import { format, isBefore, isValid, parse } from 'date-fns';
import { format, isBefore, isSameDay, isValid, parse } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs';
@ -82,6 +82,11 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
return isValid(date) && isBefore(date, new Date());
}
public isToday(aDateString: string) {
const date = parse(aDateString, DATE_FORMAT, new Date());
return isValid(date) && isSameDay(date, new Date());
}
public onOpenMarketDataDetail({
day,
yearMonth
@ -89,13 +94,18 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string;
yearMonth: string;
}) {
const date = new Date(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
if (isSameDay(date, new Date())) {
return;
}
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: {
date,
marketPrice,
dataSource: this.dataSource,
date: new Date(`${yearMonth}-${day}`),
symbol: this.symbol
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -20,6 +20,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-market-data.html'
})
export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentDataSource: DataSource;
public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public marketData: AdminMarketDataItem[] = [];
@ -43,6 +44,19 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.fetchAdminMarketData();
}
public onDeleteProfileData({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public onGatherProfileDataBySymbol({
dataSource,
symbol
@ -69,22 +83,33 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
.subscribe(() => {});
}
public setCurrentSymbol(aSymbol: string) {
this.marketDataDetails = [];
if (this.currentSymbol === aSymbol) {
this.currentSymbol = '';
} else {
this.currentSymbol = aSymbol;
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
}
}
public onMarketDataChanged(withRefresh: boolean = false) {
if (withRefresh) {
this.fetchAdminMarketData();
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
this.fetchAdminMarketDataBySymbol({
dataSource: this.currentDataSource,
symbol: this.currentSymbol
});
}
}
public setCurrentProfile({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.marketDataDetails = [];
if (this.currentSymbol === symbol) {
this.currentDataSource = undefined;
this.currentSymbol = '';
} else {
this.currentDataSource = dataSource;
this.currentSymbol = symbol;
this.fetchAdminMarketDataBySymbol({ dataSource, symbol });
}
}
@ -104,9 +129,15 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
});
}
private fetchAdminMarketDataBySymbol(aSymbol: string) {
this.dataService
.fetchAdminMarketDataBySymbol(aSymbol)
private fetchAdminMarketDataBySymbol({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.adminService
.fetchAdminMarketDataBySymbol({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketData }) => {
this.marketDataDetails = marketData;

View File

@ -6,7 +6,9 @@
<tr class="mat-header-row">
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
<th class="mat-header-cell px-1 py-2"></th>
</tr>
</thead>
@ -14,13 +16,15 @@
<ng-container *ngFor="let item of marketData; let i = index">
<tr
class="cursor-pointer mat-row"
(click)="setCurrentSymbol(item.symbol)"
(click)="setCurrentProfile({ dataSource: item.dataSource, symbol: item.symbol })"
>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
<td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }}
</td>
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
<td class="mat-cell px-1 py-2">
<button
class="mx-1 no-min-width px-2"
@ -45,11 +49,19 @@
>
Gather Profile Data
</button>
<button
i18n
mat-menu-item
[disabled]="item.activityCount !== 0"
(click)="onDeleteProfileData({dataSource: item.dataSource, symbol: item.symbol})"
>
Delete Profile Data
</button>
</mat-menu>
</td>
</tr>
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
<td class="p-1" colspan="4">
<td class="p-1" colspan="6">
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[marketData]="marketDataDetails"

View File

@ -24,12 +24,9 @@ export class FearAndGreedIndexComponent implements OnChanges, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.fearAndGreedIndexEmoji = resolveFearAndGreedIndex(
this.fearAndGreedIndex
).emoji;
const { emoji, text } = resolveFearAndGreedIndex(this.fearAndGreedIndex);
this.fearAndGreedIndexText = resolveFearAndGreedIndex(
this.fearAndGreedIndex
).text;
this.fearAndGreedIndexEmoji = emoji;
this.fearAndGreedIndexText = text;
}
}

View File

@ -238,6 +238,17 @@
></gf-logo>
</a>
<span class="spacer"></span>
<a
class="d-none d-sm-block mx-1"
i18n
mat-flat-button
[ngClass]="{
'font-weight-bold': currentRoute === 'features',
'text-decoration-underline': currentRoute === 'features'
}"
[routerLink]="['/features']"
>Features</a
>
<a
class="d-none d-sm-block mx-1"
i18n

View File

@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import {
RANGE,
SettingsStorageService
@ -12,6 +13,7 @@ import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { Position, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -25,6 +27,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public positions: Position[];
public user: User;
@ -39,6 +42,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
@ -47,8 +51,15 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({ symbol: params['symbol'] });
if (
params['dataSource'] &&
params['positionDetailDialog'] &&
params['symbol']
) {
this.openPositionDialog({
dataSource: params['dataSource'],
symbol: params['symbol']
});
}
});
@ -74,6 +85,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.dateRange =
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
@ -91,7 +109,13 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openPositionDialog({ symbol }: { symbol: string }) {
private openPositionDialog({
dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
@ -101,9 +125,11 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -27,7 +27,7 @@
i18n
mat-button
[routerLink]="['/portfolio', 'activities']"
>Manage Activities...</a
>Manage Activities</a
>
</div>
</div>

View File

@ -4,9 +4,8 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource } from '@prisma/client';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -19,7 +18,9 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
public info: InfoItem;
public isLoading = true;
public readonly numberOfDays = 90;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -32,6 +33,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
this.info = this.dataService.fetchInfo();
this.isLoading = true;
this.userService.stateChanged
@ -48,8 +50,8 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService
.fetchSymbolItem({
dataSource: DataSource.RAKUTEN,
includeHistoricalData: true,
dataSource: this.info.fearAndGreedDataSource,
includeHistoricalData: this.numberOfDays,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject))

View File

@ -1,18 +1,10 @@
<div
class="
align-items-center
container
d-flex
flex-grow-1
h-100
justify-content-center
w-100
"
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
>
<div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted">
<small i18n>Last 30 Days</small>
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-5"

View File

@ -13,7 +13,7 @@
[showYAxis]="false"
></gf-line-chart>
<div
*ngIf="hasPermissionToCreateOrder&& historicalDataItems?.length === 0"
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
>
<div class="d-flex justify-content-center">

View File

@ -1,6 +0,0 @@
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
export interface PositionDetailDialogParams {
deviceType: string;
historicalDataItems: LineChartItem[];
}

View File

@ -1,12 +0,0 @@
:host {
display: block;
.mat-dialog-content {
max-height: unset;
gf-line-chart {
aspect-ratio: 16 / 9;
margin: 0 -1rem;
}
}
}

View File

@ -1,94 +0,0 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { isToday, parse } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@Component({
selector: 'gf-performance-chart-dialog',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: 'performance-chart-dialog.html',
styleUrls: ['./performance-chart-dialog.component.scss']
})
export class PerformanceChartDialog {
public benchmarkDataItems: LineChartItem[];
public benchmarkSymbol = 'VOO';
public currency: string;
public firstBuyDate: string;
public marketPrice: number;
public historicalDataItems: LineChartItem[];
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
public dialogRef: MatDialogRef<PerformanceChartDialog>,
@Inject(MAT_DIALOG_DATA) public data: PositionDetailDialogParams
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
this.firstBuyDate = firstBuyDate;
this.historicalDataItems = [];
this.marketPrice = marketPrice;
let coefficient = 1;
this.historicalDataItems = this.data.historicalDataItems;
this.historicalDataItems?.forEach((historicalDataItem) => {
const benchmarkItem = historicalData.find((item) => {
return item.date === historicalDataItem.date;
});
if (benchmarkItem) {
if (coefficient === 1) {
coefficient = historicalDataItem.value / benchmarkItem.value || 1;
}
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: benchmarkItem.value * coefficient
});
} else if (
isToday(parse(historicalDataItem.date, DATE_FORMAT, new Date()))
) {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: marketPrice * coefficient
});
} else {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
value: undefined
});
}
});
this.changeDetectorRef.markForCheck();
});
}
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,27 +0,0 @@
<gf-dialog-header
mat-dialog-title
title="Performance"
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
<div mat-dialog-content>
<div class="container p-0">
<gf-line-chart
class="mb-4"
symbol="Performance"
[benchmarkDataItems]="benchmarkDataItems"
[historicalDataItems]="historicalDataItems"
[showGradient]="true"
[showLegend]="true"
[showXAxis]="true"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
<gf-dialog-footer
mat-dialog-actions
[deviceType]="data.deviceType"
(closeButtonClicked)="onClose()"
></gf-dialog-footer>

View File

@ -1,28 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({
declarations: [PerformanceChartDialog],
exports: [],
imports: [
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfLineChartModule,
GfValueModule,
MatButtonModule,
MatDialogModule,
NgxSkeletonLoaderModule
],
providers: []
})
export class GfPerformanceChartDialogModule {}

View File

@ -2,7 +2,6 @@
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Time in Market</div>
<div class="d-flex justify-content-end">
{{ timeInMarket }}
<gf-value class="justify-content-end" [value]="timeInMarket"></gf-value>
</div>
</div>
@ -142,6 +141,17 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Items</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.items"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>

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