Compare commits
55 Commits
Author | SHA1 | Date | |
---|---|---|---|
050c0a4da7 | |||
4908e6d35d | |||
fe4013830d | |||
11be6f630f | |||
85d123e1b1 | |||
c5e9804c25 | |||
1f042ee791 | |||
da6eaa0d77 | |||
3f31cec859 | |||
6c07759eb7 | |||
fcf07a0fd1 | |||
2f402c0c8e | |||
a24a094407 | |||
dc9b2ce194 | |||
72067459d6 | |||
705441ecf8 | |||
fbd1475402 | |||
4dc4f13f40 | |||
3b857aa8bb | |||
1c2ca5b96b | |||
572bfc59b8 | |||
147f0162b7 | |||
f6acf5207b | |||
80782f1098 | |||
bc58ee86ca | |||
0cb632b165 | |||
fca3a659d0 | |||
904dec040e | |||
fc6c81fe02 | |||
634171e4e3 | |||
f8f36e4f4e | |||
5e7cf9d0b6 | |||
e1932eb5a1 | |||
dba47d59e3 | |||
3032126508 | |||
a50b55da75 | |||
5422df05b3 | |||
d2fabe7ce4 | |||
a42700b9fe | |||
9df8541145 | |||
0b2252755c | |||
239bd09cbd | |||
cd76f89902 | |||
7425ba94f1 | |||
b9522307c4 | |||
e01e039a00 | |||
6d3513c17f | |||
d60b444324 | |||
2873130259 | |||
d999a27159 | |||
b6902e10ea | |||
7f3f75386d | |||
678544748a | |||
632f3e3872 | |||
87301ddbd5 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -23,6 +23,7 @@
|
|||||||
!.vscode/settings.json
|
!.vscode/settings.json
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
|
/.angular/cache
|
||||||
/.sass-cache
|
/.sass-cache
|
||||||
/connect.lock
|
/connect.lock
|
||||||
/coverage
|
/coverage
|
||||||
|
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@ -26,7 +26,8 @@
|
|||||||
"skipFiles": [
|
"skipFiles": [
|
||||||
"${workspaceFolder}/node_modules/**/*.js",
|
"${workspaceFolder}/node_modules/**/*.js",
|
||||||
"<node_internals>/**/*.js"
|
"<node_internals>/**/*.js"
|
||||||
]
|
],
|
||||||
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
182
CHANGELOG.md
182
CHANGELOG.md
@ -5,6 +5,174 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## 1.85.0 - 01.12.2021
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the data gathering of the _Fear & Greed Index_ (market mood)
|
||||||
|
|
||||||
|
## 1.84.0 - 30.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Exposed the data gathering by symbol as an endpoint
|
||||||
|
|
||||||
|
## 1.83.0 - 29.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Removed the experimental API
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Eliminated the redundant storage of historical exchange rates
|
||||||
|
|
||||||
|
## 1.82.0 - 28.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added tabs with routing to the admin control panel
|
||||||
|
- Added a new tab to manage historical data to the admin control panel
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Introduced tabs with routing to the home page
|
||||||
|
|
||||||
|
## 1.81.0 - 27.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the value to the position detail dialog
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded `angular` from version `12.2.4` to `13.0.2`
|
||||||
|
- Upgraded `angular-material-css-vars` from version `2.1.2` to `3.0.0`
|
||||||
|
- Upgraded `nestjs` from version `7.6.18` to `8.2.3`
|
||||||
|
- Upgraded `Nx` from version `12.8.0` to `13.2.2`
|
||||||
|
- Upgraded `rxjs` from version `6.6.7` to `7.4.0`
|
||||||
|
- Upgraded `storybook` from version `6.3.8` to `6.4.0-rc.3`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the broken line charts showing value labels if openend from the allocations page
|
||||||
|
- Fixed the click event for drafts in the transactions table
|
||||||
|
|
||||||
|
## 1.80.0 - 23.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Accentuated the all time high and the all time low
|
||||||
|
|
||||||
|
## 1.79.0 - 21.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the value column to the positions table
|
||||||
|
- Added support for cryptocurrency _Algorand_
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Locked the symbol input in the edit transaction dialog
|
||||||
|
- Filtered the account selector by account type (`SECURITIES`) in the create or edit transaction dialog
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the search functionality for cryptocurrency symbols (do not show unsupported symbols)
|
||||||
|
|
||||||
|
## 1.78.0 - 20.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a testimonial section to the landing page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the footer row border of the accounts table in dark mode
|
||||||
|
|
||||||
|
## 1.77.0 - 16.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Hid the _Get Started_ button on the registration page
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the footer row of the accounts table on mobile
|
||||||
|
- Fixed the transactions count calculation in the accounts table (exclude drafts)
|
||||||
|
|
||||||
|
## 1.76.0 - 14.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the footer row with buying power and net worth to the accounts table
|
||||||
|
|
||||||
|
## 1.75.0 - 13.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added a logo to the log on the server start
|
||||||
|
- Added the data gathering progress to the log and the admin control panel
|
||||||
|
- Added the value column to the accounts table
|
||||||
|
|
||||||
|
## 1.74.0 - 11.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Adapted the decimal places for cryptocurrencies in the position detail dialog
|
||||||
|
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
|
||||||
|
|
||||||
|
## 1.73.0 - 10.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the info messages to add the first transaction
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the skeleton loader of the portfolio holdings
|
||||||
|
|
||||||
|
## 1.72.0 - 08.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Cached the statistics section on the about page
|
||||||
|
|
||||||
|
## 1.71.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Changed the logger from `console.log()` to `Logger.log()`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed an exception in the scraper configuration
|
||||||
|
|
||||||
|
## 1.70.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the validation of `json` files in the import functionality for transactions
|
||||||
|
- Moved the scraper configuration to the symbol profile model
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
|
## 1.69.0 - 07.11.2021
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added the symbol mapping attribute to the symbol profile model
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Improved the registration page
|
||||||
|
|
||||||
|
### Todo
|
||||||
|
|
||||||
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.68.0 - 01.11.2021
|
## 1.68.0 - 01.11.2021
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@ -60,7 +228,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.62.0 - 17.10.2021
|
## 1.62.0 - 17.10.2021
|
||||||
|
|
||||||
@ -158,7 +326,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.55.0 - 20.09.2021
|
## 1.55.0 - 20.09.2021
|
||||||
|
|
||||||
@ -173,7 +341,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.54.0 - 18.09.2021
|
## 1.54.0 - 18.09.2021
|
||||||
|
|
||||||
@ -194,7 +362,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.53.0 - 13.09.2021
|
## 1.53.0 - 13.09.2021
|
||||||
|
|
||||||
@ -316,7 +484,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.41.0 - 21.08.2021
|
## 1.41.0 - 21.08.2021
|
||||||
|
|
||||||
@ -369,7 +537,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.38.0 - 14.08.2021
|
## 1.38.0 - 14.08.2021
|
||||||
|
|
||||||
@ -429,7 +597,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
### Todo
|
### Todo
|
||||||
|
|
||||||
- Apply data migration (`yarn prisma migrate deploy`)
|
- Apply data migration (`yarn database:migrate`)
|
||||||
|
|
||||||
## 1.34.0 - 07.08.2021
|
## 1.34.0 - 07.08.2021
|
||||||
|
|
||||||
|
12
README.md
12
README.md
@ -101,7 +101,7 @@ docker-compose -f docker/docker-compose-build-local.yml up
|
|||||||
Run the following command to setup the database once Ghostfolio is running:
|
Run the following command to setup the database once Ghostfolio is running:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn setup:database
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:setup
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fetch Historical Data
|
### Fetch Historical Data
|
||||||
@ -112,6 +112,14 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
1. Click _Sign out_ and check out the _Live Demo_
|
1. Click _Sign out_ and check out the _Live Demo_
|
||||||
|
|
||||||
|
### Migrate Database
|
||||||
|
|
||||||
|
With the following command you can keep your database schema in sync after a Ghostfolio version update:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
|
||||||
|
```
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
@ -126,7 +134,7 @@ Open http://localhost:3333 in your browser and accomplish these steps:
|
|||||||
1. Run `cd docker`
|
1. Run `cd docker`
|
||||||
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
1. Run `docker compose up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||||
1. Run `cd -` to go back to the project root directory
|
1. Run `cd -` to go back to the project root directory
|
||||||
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
|
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. Start server and client (see [_Development_](#Development))
|
||||||
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
|
||||||
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
|
||||||
|
37
angular.json
37
angular.json
@ -1,22 +1,5 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"cli": {
|
|
||||||
"defaultCollection": "@nrwl/nest"
|
|
||||||
},
|
|
||||||
"defaultProject": "api",
|
|
||||||
"schematics": {
|
|
||||||
"@nrwl/angular:application": {
|
|
||||||
"linter": "eslint",
|
|
||||||
"unitTestRunner": "jest",
|
|
||||||
"e2eTestRunner": "cypress"
|
|
||||||
},
|
|
||||||
"@nrwl/angular:library": {
|
|
||||||
"linter": "eslint",
|
|
||||||
"unitTestRunner": "jest"
|
|
||||||
},
|
|
||||||
"@nrwl/nest": {},
|
|
||||||
"@nrwl/angular:component": {}
|
|
||||||
},
|
|
||||||
"projects": {
|
"projects": {
|
||||||
"api": {
|
"api": {
|
||||||
"root": "apps/api",
|
"root": "apps/api",
|
||||||
@ -69,7 +52,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/api"]
|
"outputs": ["coverage/apps/api"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client": {
|
"client": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
@ -201,7 +185,8 @@
|
|||||||
},
|
},
|
||||||
"outputs": ["coverage/apps/client"]
|
"outputs": ["coverage/apps/client"]
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"client-e2e": {
|
"client-e2e": {
|
||||||
"root": "apps/client-e2e",
|
"root": "apps/client-e2e",
|
||||||
@ -221,7 +206,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["client"]
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"root": "libs/common",
|
"root": "libs/common",
|
||||||
@ -242,7 +229,8 @@
|
|||||||
"passWithNoTests": true
|
"passWithNoTests": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui": {
|
"ui": {
|
||||||
"projectType": "library",
|
"projectType": "library",
|
||||||
@ -300,7 +288,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": []
|
||||||
},
|
},
|
||||||
"ui-e2e": {
|
"ui-e2e": {
|
||||||
"root": "apps/ui-e2e",
|
"root": "apps/ui-e2e",
|
||||||
@ -326,7 +315,9 @@
|
|||||||
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"tags": [],
|
||||||
|
"implicitDependencies": ["ui"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,6 +1,11 @@
|
|||||||
|
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
import {
|
||||||
|
nullifyValuesInObject,
|
||||||
|
nullifyValuesInObjects
|
||||||
|
} from '@ghostfolio/api/helper/object.helper';
|
||||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||||
|
import { Accounts } from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
@ -34,6 +39,7 @@ export class AccountController {
|
|||||||
public constructor(
|
public constructor(
|
||||||
private readonly accountService: AccountService,
|
private readonly accountService: AccountService,
|
||||||
private readonly impersonationService: ImpersonationService,
|
private readonly impersonationService: ImpersonationService,
|
||||||
|
private readonly portfolioService: PortfolioService,
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||||
private readonly userService: UserService
|
private readonly userService: UserService
|
||||||
) {}
|
) {}
|
||||||
@ -85,30 +91,39 @@ export class AccountController {
|
|||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async getAllAccounts(
|
public async getAllAccounts(
|
||||||
@Headers('impersonation-id') impersonationId
|
@Headers('impersonation-id') impersonationId
|
||||||
): Promise<AccountModel[]> {
|
): Promise<Accounts> {
|
||||||
const impersonationUserId =
|
const impersonationUserId =
|
||||||
await this.impersonationService.validateImpersonationId(
|
await this.impersonationService.validateImpersonationId(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
this.request.user.id
|
this.request.user.id
|
||||||
);
|
);
|
||||||
|
|
||||||
let accounts = await this.accountService.getAccounts(
|
let accountsWithAggregations =
|
||||||
impersonationUserId || this.request.user.id
|
await this.portfolioService.getAccountsWithAggregations(
|
||||||
);
|
impersonationUserId || this.request.user.id
|
||||||
|
);
|
||||||
|
|
||||||
if (
|
if (
|
||||||
impersonationUserId ||
|
impersonationUserId ||
|
||||||
this.userService.isRestrictedView(this.request.user)
|
this.userService.isRestrictedView(this.request.user)
|
||||||
) {
|
) {
|
||||||
accounts = nullifyValuesInObjects(accounts, [
|
accountsWithAggregations = {
|
||||||
'balance',
|
...nullifyValuesInObject(accountsWithAggregations, [
|
||||||
'fee',
|
'totalBalance',
|
||||||
'quantity',
|
'totalValue'
|
||||||
'unitPrice'
|
]),
|
||||||
]);
|
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
|
||||||
|
'balance',
|
||||||
|
'convertedBalance',
|
||||||
|
'fee',
|
||||||
|
'quantity',
|
||||||
|
'unitPrice',
|
||||||
|
'value'
|
||||||
|
])
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return accounts;
|
return accountsWithAggregations;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||||
@ -11,16 +12,17 @@ import { AccountController } from './account.controller';
|
|||||||
import { AccountService } from './account.service';
|
import { AccountService } from './account.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
controllers: [AccountController],
|
||||||
imports: [
|
imports: [
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
RedisCacheModule,
|
PortfolioModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
RedisCacheModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [AccountController],
|
|
||||||
providers: [AccountService]
|
providers: [AccountService]
|
||||||
})
|
})
|
||||||
export class AccountModule {}
|
export class AccountModule {}
|
||||||
|
@ -85,7 +85,15 @@ export class AccountService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return accounts.map((account) => {
|
return accounts.map((account) => {
|
||||||
const result = { ...account, transactionCount: account.Order.length };
|
let transactionCount = 0;
|
||||||
|
|
||||||
|
for (const order of account.Order) {
|
||||||
|
if (!order.isDraft) {
|
||||||
|
transactionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { ...account, transactionCount };
|
||||||
|
|
||||||
delete result.Order;
|
delete result.Order;
|
||||||
|
|
||||||
|
@ -1,5 +1,9 @@
|
|||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AdminData,
|
||||||
|
AdminMarketData,
|
||||||
|
AdminMarketDataDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import {
|
import {
|
||||||
getPermissions,
|
getPermissions,
|
||||||
hasPermission,
|
hasPermission,
|
||||||
@ -11,11 +15,13 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Param,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { DataSource } from '@prisma/client';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import { AdminService } from './admin.service';
|
import { AdminService } from './admin.service';
|
||||||
@ -67,6 +73,29 @@ export class AdminController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('gather/:dataSource/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async gatherSymbol(
|
||||||
|
@Param('dataSource') dataSource: DataSource,
|
||||||
|
@Param('symbol') symbol: string
|
||||||
|
): Promise<void> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.dataGatheringService.gatherSymbol({ dataSource, symbol });
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
@Post('gather/profile-data')
|
@Post('gather/profile-data')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
@UseGuards(AuthGuard('jwt'))
|
||||||
public async gatherProfileData(): Promise<void> {
|
public async gatherProfileData(): Promise<void> {
|
||||||
@ -86,4 +115,42 @@ export class AdminController {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('market-data')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.getMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('market-data/:symbol')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
public async getMarketDataBySymbol(
|
||||||
|
@Param('symbol') symbol
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
if (
|
||||||
|
!hasPermission(
|
||||||
|
getPermissions(this.request.user.role),
|
||||||
|
permissions.accessAdminControl
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new HttpException(
|
||||||
|
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||||
|
StatusCodes.FORBIDDEN
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.adminService.getMarketDataBySymbol(symbol);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,6 +3,7 @@ import { ConfigurationModule } from '@ghostfolio/api/services/configuration.modu
|
|||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
@ -15,6 +16,7 @@ import { AdminService } from './admin.service';
|
|||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
|
MarketDataModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
SubscriptionModule
|
SubscriptionModule
|
||||||
],
|
],
|
||||||
|
@ -2,9 +2,14 @@ import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscripti
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
import {
|
||||||
|
AdminData,
|
||||||
|
AdminMarketData,
|
||||||
|
AdminMarketDataDetails
|
||||||
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
@ -14,12 +19,15 @@ export class AdminService {
|
|||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
|
private readonly marketDataService: MarketDataService,
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly subscriptionService: SubscriptionService
|
private readonly subscriptionService: SubscriptionService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<AdminData> {
|
public async get(): Promise<AdminData> {
|
||||||
return {
|
return {
|
||||||
|
dataGatheringProgress:
|
||||||
|
await this.dataGatheringService.getDataGatheringProgress(),
|
||||||
exchangeRates: this.exchangeRateDataService
|
exchangeRates: this.exchangeRateDataService
|
||||||
.getCurrencies()
|
.getCurrencies()
|
||||||
.filter((currency) => {
|
.filter((currency) => {
|
||||||
@ -43,6 +51,31 @@ export class AdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getMarketData(): Promise<AdminMarketData> {
|
||||||
|
return {
|
||||||
|
marketData: await (
|
||||||
|
await this.dataGatheringService.getSymbolsMax()
|
||||||
|
).map((symbol) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getMarketDataBySymbol(
|
||||||
|
aSymbol: string
|
||||||
|
): Promise<AdminMarketDataDetails> {
|
||||||
|
return {
|
||||||
|
marketData: await this.marketDataService.marketDataItems({
|
||||||
|
orderBy: {
|
||||||
|
date: 'asc'
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
symbol: aSymbol
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private async getLastDataGathering() {
|
private async getLastDataGathering() {
|
||||||
const lastDataGathering =
|
const lastDataGathering =
|
||||||
await this.dataGatheringService.getLastDataGathering();
|
await this.dataGatheringService.getLastDataGathering();
|
||||||
@ -58,7 +91,7 @@ export class AdminService {
|
|||||||
return 'IN_PROGRESS';
|
return 'IN_PROGRESS';
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||||
|
@ -19,7 +19,6 @@ import { AdminModule } from './admin/admin.module';
|
|||||||
import { AppController } from './app.controller';
|
import { AppController } from './app.controller';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { CacheModule } from './cache/cache.module';
|
import { CacheModule } from './cache/cache.module';
|
||||||
import { ExperimentalModule } from './experimental/experimental.module';
|
|
||||||
import { ExportModule } from './export/export.module';
|
import { ExportModule } from './export/export.module';
|
||||||
import { ImportModule } from './import/import.module';
|
import { ImportModule } from './import/import.module';
|
||||||
import { InfoModule } from './info/info.module';
|
import { InfoModule } from './info/info.module';
|
||||||
@ -42,7 +41,6 @@ import { UserModule } from './user/user.module';
|
|||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ExperimentalModule,
|
|
||||||
ExportModule,
|
ExportModule,
|
||||||
ImportModule,
|
ImportModule,
|
||||||
InfoModule,
|
InfoModule,
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Provider } from '@prisma/client';
|
import { Provider } from '@prisma/client';
|
||||||
import { Strategy } from 'passport-google-oauth20';
|
import { Strategy } from 'passport-google-oauth20';
|
||||||
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
|||||||
};
|
};
|
||||||
|
|
||||||
done(null, user);
|
done(null, user);
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error(err);
|
Logger.error(error);
|
||||||
done(err, false);
|
done(error, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,7 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
|||||||
import {
|
import {
|
||||||
Inject,
|
Inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
InternalServerErrorException
|
InternalServerErrorException,
|
||||||
|
Logger
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { REQUEST } from '@nestjs/core';
|
import { REQUEST } from '@nestjs/core';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
@ -94,7 +95,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = await verifyRegistrationResponse(opts);
|
verification = await verifyRegistrationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException(error.message);
|
throw new InternalServerErrorException(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,7 +193,7 @@ export class WebAuthService {
|
|||||||
};
|
};
|
||||||
verification = verifyAuthenticationResponse(opts);
|
verification = verifyAuthenticationResponse(opts);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
throw new InternalServerErrorException({ error: error.message });
|
throw new InternalServerErrorException({ error: error.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
4
apps/api/src/app/cache/cache.module.ts
vendored
4
apps/api/src/app/cache/cache.module.ts
vendored
@ -6,6 +6,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
|
|||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CacheController } from './cache.controller';
|
import { CacheController } from './cache.controller';
|
||||||
@ -15,7 +16,8 @@ import { CacheController } from './cache.controller';
|
|||||||
DataGatheringModule,
|
DataGatheringModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
RedisCacheModule
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [CacheController],
|
controllers: [CacheController],
|
||||||
providers: [
|
providers: [
|
||||||
|
@ -1,22 +0,0 @@
|
|||||||
import { Type } from '@prisma/client';
|
|
||||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
|
||||||
|
|
||||||
export class CreateOrderDto {
|
|
||||||
@IsString()
|
|
||||||
currency: string;
|
|
||||||
|
|
||||||
@IsISO8601()
|
|
||||||
date: string;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
quantity: number;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
symbol: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
type: Type;
|
|
||||||
|
|
||||||
@IsNumber()
|
|
||||||
unitPrice: number;
|
|
||||||
}
|
|
@ -1,69 +0,0 @@
|
|||||||
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
|
||||||
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
|
|
||||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
|
||||||
import {
|
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Get,
|
|
||||||
Headers,
|
|
||||||
HttpException,
|
|
||||||
Inject,
|
|
||||||
Param,
|
|
||||||
Post
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { REQUEST } from '@nestjs/core';
|
|
||||||
import { parse } from 'date-fns';
|
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
|
||||||
|
|
||||||
import { CreateOrderDto } from './create-order.dto';
|
|
||||||
import { ExperimentalService } from './experimental.service';
|
|
||||||
import { Data } from './interfaces/data.interface';
|
|
||||||
|
|
||||||
@Controller('experimental')
|
|
||||||
export class ExperimentalController {
|
|
||||||
public constructor(
|
|
||||||
private readonly experimentalService: ExperimentalService,
|
|
||||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get('benchmarks')
|
|
||||||
public async getBenchmarks(
|
|
||||||
@Headers('Authorization') apiToken: string
|
|
||||||
): Promise<string[]> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarks.map(({ symbol }) => {
|
|
||||||
return symbol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@Get('benchmarks/:symbol')
|
|
||||||
public async getBenchmark(
|
|
||||||
@Headers('Authorization') apiToken: string,
|
|
||||||
@Param('symbol') symbol: string
|
|
||||||
): Promise<{ date: Date; marketPrice: number }[]> {
|
|
||||||
if (!isApiTokenAuthorized(apiToken)) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
|
||||||
StatusCodes.FORBIDDEN
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const marketData = await this.experimentalService.getBenchmark(symbol);
|
|
||||||
|
|
||||||
if (marketData?.length === 0) {
|
|
||||||
throw new HttpException(
|
|
||||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
|
||||||
StatusCodes.NOT_FOUND
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return marketData;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
|
||||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
|
||||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|
||||||
import { Module } from '@nestjs/common';
|
|
||||||
|
|
||||||
import { ExperimentalController } from './experimental.controller';
|
|
||||||
import { ExperimentalService } from './experimental.service';
|
|
||||||
|
|
||||||
@Module({
|
|
||||||
imports: [
|
|
||||||
ConfigurationModule,
|
|
||||||
DataProviderModule,
|
|
||||||
ExchangeRateDataModule,
|
|
||||||
RedisCacheModule,
|
|
||||||
PrismaModule
|
|
||||||
],
|
|
||||||
controllers: [ExperimentalController],
|
|
||||||
providers: [AccountService, ExperimentalService]
|
|
||||||
})
|
|
||||||
export class ExperimentalModule {}
|
|
@ -1,23 +0,0 @@
|
|||||||
import { AccountService } from '@ghostfolio/api/app/account/account.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 { Injectable } from '@nestjs/common';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class ExperimentalService {
|
|
||||||
public constructor(
|
|
||||||
private readonly accountService: AccountService,
|
|
||||||
private readonly dataProviderService: DataProviderService,
|
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
|
||||||
private readonly prismaService: PrismaService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public async getBenchmark(aSymbol: string) {
|
|
||||||
return this.prismaService.marketData.findMany({
|
|
||||||
orderBy: { date: 'asc' },
|
|
||||||
select: { date: true, marketPrice: true },
|
|
||||||
where: { symbol: aSymbol }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,4 +0,0 @@
|
|||||||
export interface Data {
|
|
||||||
currency: string;
|
|
||||||
value: number;
|
|
||||||
}
|
|
@ -5,6 +5,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
UseGuards
|
UseGuards
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@ -39,7 +40,7 @@ export class ImportController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
{
|
{
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { JwtModule } from '@nestjs/jwt';
|
import { JwtModule } from '@nestjs/jwt';
|
||||||
|
|
||||||
@ -18,7 +20,9 @@ import { InfoService } from './info.service';
|
|||||||
JwtModule.register({
|
JwtModule.register({
|
||||||
secret: process.env.JWT_SECRET_KEY,
|
secret: process.env.JWT_SECRET_KEY,
|
||||||
signOptions: { expiresIn: '30 days' }
|
signOptions: { expiresIn: '30 days' }
|
||||||
})
|
}),
|
||||||
|
RedisCacheModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
controllers: [InfoController],
|
controllers: [InfoController],
|
||||||
providers: [
|
providers: [
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||||
import { permissions } from '@ghostfolio/common/permissions';
|
import { permissions } from '@ghostfolio/common/permissions';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { subDays } from 'date-fns';
|
import { subDays } from 'date-fns';
|
||||||
@ -14,6 +16,7 @@ import { subDays } from 'date-fns';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class InfoService {
|
export class InfoService {
|
||||||
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
|
||||||
|
private static CACHE_KEY_STATISTICS = 'STATISTICS';
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@ -21,7 +24,8 @@ export class InfoService {
|
|||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly dataGatheringService: DataGatheringService,
|
private readonly dataGatheringService: DataGatheringService,
|
||||||
private readonly jwtService: JwtService,
|
private readonly jwtService: JwtService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly redisCacheService: RedisCacheService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async get(): Promise<InfoItem> {
|
public async get(): Promise<InfoItem> {
|
||||||
@ -109,7 +113,7 @@ export class InfoService {
|
|||||||
const contributors = await get();
|
const contributors = await get();
|
||||||
return contributors?.length;
|
return contributors?.length;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -130,7 +134,7 @@ export class InfoService {
|
|||||||
const { stargazers_count } = await get();
|
const { stargazers_count } = await get();
|
||||||
return stargazers_count;
|
return stargazers_count;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@ -176,6 +180,18 @@ export class InfoService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let statistics: Statistics;
|
||||||
|
|
||||||
|
try {
|
||||||
|
statistics = JSON.parse(
|
||||||
|
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (statistics) {
|
||||||
|
return statistics;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const activeUsers1d = await this.countActiveUsers(1);
|
const activeUsers1d = await this.countActiveUsers(1);
|
||||||
const activeUsers7d = await this.countActiveUsers(7);
|
const activeUsers7d = await this.countActiveUsers(7);
|
||||||
const activeUsers30d = await this.countActiveUsers(30);
|
const activeUsers30d = await this.countActiveUsers(30);
|
||||||
@ -183,7 +199,7 @@ export class InfoService {
|
|||||||
const gitHubContributors = await this.countGitHubContributors();
|
const gitHubContributors = await this.countGitHubContributors();
|
||||||
const gitHubStargazers = await this.countGitHubStargazers();
|
const gitHubStargazers = await this.countGitHubStargazers();
|
||||||
|
|
||||||
return {
|
statistics = {
|
||||||
activeUsers1d,
|
activeUsers1d,
|
||||||
activeUsers7d,
|
activeUsers7d,
|
||||||
activeUsers30d,
|
activeUsers30d,
|
||||||
@ -191,6 +207,13 @@ export class InfoService {
|
|||||||
gitHubStargazers,
|
gitHubStargazers,
|
||||||
newUsers30d
|
newUsers30d
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await this.redisCacheService.set(
|
||||||
|
InfoService.CACHE_KEY_STATISTICS,
|
||||||
|
JSON.stringify(statistics)
|
||||||
|
);
|
||||||
|
|
||||||
|
return statistics;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getSubscriptions(): Promise<Subscription[]> {
|
private async getSubscriptions(): Promise<Subscription[]> {
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { MarketDataService } from './market-data.service';
|
|
||||||
|
|
||||||
jest.mock('./market-data.service', () => {
|
jest.mock('@ghostfolio/api/services/market-data.service', () => {
|
||||||
return {
|
return {
|
||||||
MarketDataService: jest.fn().mockImplementation(() => {
|
MarketDataService: jest.fn().mockImplementation(() => {
|
||||||
return {
|
return {
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
|
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { isBefore, isToday } from 'date-fns';
|
import { isBefore, isToday } from 'date-fns';
|
||||||
@ -8,7 +9,6 @@ import { flatten } from 'lodash';
|
|||||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||||
import { MarketDataService } from './market-data.service';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CurrentRateService {
|
export class CurrentRateService {
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
|
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||||
|
|
||||||
export interface PortfolioPositionDetail {
|
export interface PortfolioPositionDetail {
|
||||||
|
assetClass?: AssetClass;
|
||||||
|
assetSubClass?: AssetSubClass;
|
||||||
averagePrice: number;
|
averagePrice: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
firstBuyDate: string;
|
firstBuyDate: string;
|
||||||
@ -15,6 +19,13 @@ export interface PortfolioPositionDetail {
|
|||||||
quantity: number;
|
quantity: number;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
transactionCount: number;
|
transactionCount: number;
|
||||||
|
value: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HistoricalDataContainer {
|
||||||
|
isAllTimeHigh: boolean;
|
||||||
|
isAllTimeLow: boolean;
|
||||||
|
items: HistoricalDataItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface HistoricalDataItem {
|
export interface HistoricalDataItem {
|
||||||
|
@ -0,0 +1,8 @@
|
|||||||
|
import { TimelinePeriod } from '@ghostfolio/api/app/portfolio/interfaces/timeline-period.interface';
|
||||||
|
import Big from 'big.js';
|
||||||
|
|
||||||
|
export interface TimelineInfoInterface {
|
||||||
|
maxNetPerformance: Big;
|
||||||
|
minNetPerformance: Big;
|
||||||
|
timelinePeriods: TimelinePeriod[];
|
||||||
|
}
|
@ -1502,11 +1502,11 @@ describe('PortfolioCalculator', () => {
|
|||||||
accuracy: 'year'
|
accuracy: 'year'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const timeline: TimelinePeriod[] =
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
await portfolioCalculator.calculateTimeline(
|
timelineSpecification,
|
||||||
timelineSpecification,
|
'2021-06-30'
|
||||||
'2021-06-30'
|
);
|
||||||
);
|
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
expect(timeline).toEqual([
|
expect(timeline).toEqual([
|
||||||
{
|
{
|
||||||
@ -1622,11 +1622,11 @@ describe('PortfolioCalculator', () => {
|
|||||||
accuracy: 'year'
|
accuracy: 'year'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const timeline: TimelinePeriod[] =
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
await portfolioCalculator.calculateTimeline(
|
timelineSpecification,
|
||||||
timelineSpecification,
|
'2021-06-30'
|
||||||
'2021-06-30'
|
);
|
||||||
);
|
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
expect(timeline).toEqual([
|
expect(timeline).toEqual([
|
||||||
{
|
{
|
||||||
@ -1665,11 +1665,11 @@ describe('PortfolioCalculator', () => {
|
|||||||
accuracy: 'month'
|
accuracy: 'month'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const timeline: TimelinePeriod[] =
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
await portfolioCalculator.calculateTimeline(
|
timelineSpecification,
|
||||||
timelineSpecification,
|
'2021-06-30'
|
||||||
'2021-06-30'
|
);
|
||||||
);
|
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
expect(timeline).toEqual([
|
expect(timeline).toEqual([
|
||||||
{
|
{
|
||||||
@ -1883,6 +1883,9 @@ describe('PortfolioCalculator', () => {
|
|||||||
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
expect(timelineInfo.maxNetPerformance).toEqual(new Big('547.9'));
|
||||||
|
expect(timelineInfo.minNetPerformance).toEqual(new Big('0'));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('with yearly and monthly mixed', async () => {
|
it('with yearly and monthly mixed', async () => {
|
||||||
@ -1901,11 +1904,11 @@ describe('PortfolioCalculator', () => {
|
|||||||
accuracy: 'month'
|
accuracy: 'month'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const timeline: TimelinePeriod[] =
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
await portfolioCalculator.calculateTimeline(
|
timelineSpecification,
|
||||||
timelineSpecification,
|
'2021-06-30'
|
||||||
'2021-06-30'
|
);
|
||||||
);
|
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
expect(timeline).toEqual([
|
expect(timeline).toEqual([
|
||||||
{
|
{
|
||||||
@ -1987,11 +1990,11 @@ describe('PortfolioCalculator', () => {
|
|||||||
accuracy: 'day'
|
accuracy: 'day'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const timeline: TimelinePeriod[] =
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
await portfolioCalculator.calculateTimeline(
|
timelineSpecification,
|
||||||
timelineSpecification,
|
'2021-06-30'
|
||||||
'2021-06-30'
|
);
|
||||||
);
|
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
expect(timeline).toEqual(
|
expect(timeline).toEqual(
|
||||||
expect.objectContaining([
|
expect.objectContaining([
|
||||||
@ -2296,11 +2299,11 @@ describe('PortfolioCalculator', () => {
|
|||||||
accuracy: 'year'
|
accuracy: 'year'
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
const timeline: TimelinePeriod[] =
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
await portfolioCalculator.calculateTimeline(
|
timelineSpecification,
|
||||||
timelineSpecification,
|
'2020-01-01'
|
||||||
'2020-01-01'
|
);
|
||||||
);
|
const timeline: TimelinePeriod[] = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
expect(timeline).toEqual([
|
expect(timeline).toEqual([
|
||||||
{
|
{
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
|
||||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
import {
|
import {
|
||||||
addDays,
|
addDays,
|
||||||
@ -236,7 +238,7 @@ export class PortfolioCalculator {
|
|||||||
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
console.error(
|
Logger.error(
|
||||||
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
`Missing value for symbol ${item.symbol} at ${nextDate}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -269,7 +271,7 @@ export class PortfolioCalculator {
|
|||||||
if (!initialValue) {
|
if (!initialValue) {
|
||||||
invalidSymbols.push(item.symbol);
|
invalidSymbols.push(item.symbol);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
console.error(
|
Logger.error(
|
||||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
@ -364,16 +366,20 @@ export class PortfolioCalculator {
|
|||||||
public async calculateTimeline(
|
public async calculateTimeline(
|
||||||
timelineSpecification: TimelineSpecification[],
|
timelineSpecification: TimelineSpecification[],
|
||||||
endDate: string
|
endDate: string
|
||||||
): Promise<TimelinePeriod[]> {
|
): Promise<TimelineInfoInterface> {
|
||||||
if (timelineSpecification.length === 0) {
|
if (timelineSpecification.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
maxNetPerformance: new Big(0),
|
||||||
|
minNetPerformance: new Big(0),
|
||||||
|
timelinePeriods: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate = timelineSpecification[0].start;
|
const startDate = timelineSpecification[0].start;
|
||||||
const start = parseDate(startDate);
|
const start = parseDate(startDate);
|
||||||
const end = parseDate(endDate);
|
const end = parseDate(endDate);
|
||||||
|
|
||||||
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
|
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||||
let i = 0;
|
let i = 0;
|
||||||
let j = -1;
|
let j = -1;
|
||||||
for (
|
for (
|
||||||
@ -416,11 +422,40 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
|
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||||
timelinePeriodPromises
|
timelinePeriodPromises
|
||||||
);
|
);
|
||||||
|
const minNetPerformance = timelineInfoInterfaces
|
||||||
|
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||||
|
.filter((performance) => performance !== null)
|
||||||
|
.reduce((minPerformance, current) => {
|
||||||
|
if (minPerformance.lt(current)) {
|
||||||
|
return minPerformance;
|
||||||
|
} else {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return flatten(timelinePeriods);
|
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(
|
private calculateOverallPerformance(
|
||||||
@ -480,8 +515,8 @@ export class PortfolioCalculator {
|
|||||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||||
);
|
);
|
||||||
} else if (!currentPosition.quantity.eq(0)) {
|
} else if (!currentPosition.quantity.eq(0)) {
|
||||||
console.error(
|
Logger.error(
|
||||||
`Initial value is missing for symbol ${currentPosition.symbol}`
|
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||||
);
|
);
|
||||||
hasErrors = true;
|
hasErrors = true;
|
||||||
}
|
}
|
||||||
@ -512,7 +547,7 @@ export class PortfolioCalculator {
|
|||||||
j: number,
|
j: number,
|
||||||
startDate: Date,
|
startDate: Date,
|
||||||
endDate: Date
|
endDate: Date
|
||||||
): Promise<TimelinePeriod[]> {
|
): Promise<TimelineInfoInterface> {
|
||||||
let investment: Big = new Big(0);
|
let investment: Big = new Big(0);
|
||||||
let fees: Big = new Big(0);
|
let fees: Big = new Big(0);
|
||||||
|
|
||||||
@ -546,7 +581,7 @@ export class PortfolioCalculator {
|
|||||||
userCurrency: this.currency
|
userCurrency: this.currency
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
Logger.error(
|
||||||
`Failed to fetch info for date ${startDate} with exception`,
|
`Failed to fetch info for date ${startDate} with exception`,
|
||||||
error
|
error
|
||||||
);
|
);
|
||||||
@ -568,6 +603,8 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const results: TimelinePeriod[] = [];
|
const results: TimelinePeriod[] = [];
|
||||||
|
let maxNetPerformance: Big = null;
|
||||||
|
let minNetPerformance: Big = null;
|
||||||
for (
|
for (
|
||||||
let currentDate = startDate;
|
let currentDate = startDate;
|
||||||
isBefore(currentDate, endDate);
|
isBefore(currentDate, endDate);
|
||||||
@ -591,18 +628,36 @@ export class PortfolioCalculator {
|
|||||||
}
|
}
|
||||||
if (!invalid) {
|
if (!invalid) {
|
||||||
const grossPerformance = value.minus(investment);
|
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 = {
|
const result = {
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
investment,
|
investment,
|
||||||
|
netPerformance,
|
||||||
value,
|
value,
|
||||||
date: currentDateAsString,
|
date: currentDateAsString
|
||||||
netPerformance: grossPerformance.minus(fees)
|
|
||||||
};
|
};
|
||||||
results.push(result);
|
results.push(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results;
|
return {
|
||||||
|
maxNetPerformance,
|
||||||
|
minNetPerformance,
|
||||||
|
timelinePeriods: results
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private getFactor(type: OrderType) {
|
private getFactor(type: OrderType) {
|
||||||
|
@ -8,6 +8,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
|||||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import {
|
import {
|
||||||
|
PortfolioChart,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioPublicDetails,
|
PortfolioPublicDetails,
|
||||||
@ -32,10 +33,7 @@ import { AuthGuard } from '@nestjs/passport';
|
|||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||||
|
|
||||||
import {
|
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||||
HistoricalDataItem,
|
|
||||||
PortfolioPositionDetail
|
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
|
||||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
@ -92,12 +90,14 @@ export class PortfolioController {
|
|||||||
@Headers('impersonation-id') impersonationId,
|
@Headers('impersonation-id') impersonationId,
|
||||||
@Query('range') range,
|
@Query('range') range,
|
||||||
@Res() res: Response
|
@Res() res: Response
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<PortfolioChart> {
|
||||||
let chartData = await this.portfolioService.getChart(
|
const historicalDataContainer = await this.portfolioService.getChart(
|
||||||
impersonationId,
|
impersonationId,
|
||||||
range
|
range
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let chartData = historicalDataContainer.items;
|
||||||
|
|
||||||
let hasNullValue = false;
|
let hasNullValue = false;
|
||||||
|
|
||||||
chartData.forEach((chartDataItem) => {
|
chartData.forEach((chartDataItem) => {
|
||||||
@ -130,7 +130,11 @@ export class PortfolioController {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return <any>res.json(chartData);
|
return <any>res.json({
|
||||||
|
chart: chartData,
|
||||||
|
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||||
|
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('details')
|
@Get('details')
|
||||||
@ -366,7 +370,8 @@ export class PortfolioController {
|
|||||||
'grossPerformance',
|
'grossPerformance',
|
||||||
'investment',
|
'investment',
|
||||||
'netPerformance',
|
'netPerformance',
|
||||||
'quantity'
|
'quantity',
|
||||||
|
'value'
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,17 +7,18 @@ import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.mod
|
|||||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||||
|
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { CurrentRateService } from './current-rate.service';
|
import { CurrentRateService } from './current-rate.service';
|
||||||
import { MarketDataService } from './market-data.service';
|
|
||||||
import { PortfolioController } from './portfolio.controller';
|
import { PortfolioController } from './portfolio.controller';
|
||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
import { RulesService } from './rules.service';
|
import { RulesService } from './rules.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
exports: [PortfolioService],
|
||||||
imports: [
|
imports: [
|
||||||
AccessModule,
|
AccessModule,
|
||||||
ConfigurationModule,
|
ConfigurationModule,
|
||||||
@ -25,18 +26,18 @@ import { RulesService } from './rules.service';
|
|||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
ImpersonationModule,
|
ImpersonationModule,
|
||||||
|
MarketDataModule,
|
||||||
OrderModule,
|
OrderModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
SymbolProfileModule,
|
||||||
UserModule
|
UserModule
|
||||||
],
|
],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [
|
providers: [
|
||||||
AccountService,
|
AccountService,
|
||||||
CurrentRateService,
|
CurrentRateService,
|
||||||
MarketDataService,
|
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
RulesService,
|
RulesService
|
||||||
SymbolProfileService
|
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
export class PortfolioModule {}
|
export class PortfolioModule {}
|
||||||
|
@ -1,5 +1,3 @@
|
|||||||
// TODO ///////////
|
|
||||||
|
|
||||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||||
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||||
@ -30,6 +28,7 @@ import {
|
|||||||
} from '@ghostfolio/common/config';
|
} from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||||
import {
|
import {
|
||||||
|
Accounts,
|
||||||
PortfolioDetails,
|
PortfolioDetails,
|
||||||
PortfolioPerformance,
|
PortfolioPerformance,
|
||||||
PortfolioReport,
|
PortfolioReport,
|
||||||
@ -39,6 +38,7 @@ import {
|
|||||||
} from '@ghostfolio/common/interfaces';
|
} from '@ghostfolio/common/interfaces';
|
||||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||||
import type {
|
import type {
|
||||||
|
AccountWithValue,
|
||||||
DateRange,
|
DateRange,
|
||||||
OrderWithAccount,
|
OrderWithAccount,
|
||||||
RequestWithUser
|
RequestWithUser
|
||||||
@ -56,12 +56,14 @@ import {
|
|||||||
parse,
|
parse,
|
||||||
parseISO,
|
parseISO,
|
||||||
setDayOfYear,
|
setDayOfYear,
|
||||||
|
startOfDay,
|
||||||
subDays,
|
subDays,
|
||||||
subYears
|
subYears
|
||||||
} from 'date-fns';
|
} from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
HistoricalDataContainer,
|
||||||
HistoricalDataItem,
|
HistoricalDataItem,
|
||||||
PortfolioPositionDetail
|
PortfolioPositionDetail
|
||||||
} from './interfaces/portfolio-position-detail.interface';
|
} from './interfaces/portfolio-position-detail.interface';
|
||||||
@ -81,6 +83,59 @@ export class PortfolioService {
|
|||||||
private readonly symbolProfileService: SymbolProfileService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
|
||||||
|
const [accounts, details] = await Promise.all([
|
||||||
|
this.accountService.accounts({
|
||||||
|
include: { Order: true, Platform: true },
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
where: { userId: aUserId }
|
||||||
|
}),
|
||||||
|
this.getDetails(aUserId, aUserId)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const userCurrency = this.request.user.Settings.currency;
|
||||||
|
|
||||||
|
return accounts.map((account) => {
|
||||||
|
let transactionCount = 0;
|
||||||
|
|
||||||
|
for (const order of account.Order) {
|
||||||
|
if (!order.isDraft) {
|
||||||
|
transactionCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
...account,
|
||||||
|
transactionCount,
|
||||||
|
convertedBalance: this.exchangeRateDataService.toCurrency(
|
||||||
|
account.balance,
|
||||||
|
account.currency,
|
||||||
|
userCurrency
|
||||||
|
),
|
||||||
|
value: details.accounts[account.name]?.current ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
delete result.Order;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
|
||||||
|
const accounts = await this.getAccounts(aUserId);
|
||||||
|
let totalBalance = 0;
|
||||||
|
let totalValue = 0;
|
||||||
|
let transactionCount = 0;
|
||||||
|
|
||||||
|
for (const account of accounts) {
|
||||||
|
totalBalance += account.convertedBalance;
|
||||||
|
totalValue += account.value;
|
||||||
|
transactionCount += account.transactionCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { accounts, totalBalance, totalValue, transactionCount };
|
||||||
|
}
|
||||||
|
|
||||||
public async getInvestments(
|
public async getInvestments(
|
||||||
aImpersonationId: string
|
aImpersonationId: string
|
||||||
): Promise<InvestmentItem[]> {
|
): Promise<InvestmentItem[]> {
|
||||||
@ -111,7 +166,7 @@ export class PortfolioService {
|
|||||||
public async getChart(
|
public async getChart(
|
||||||
aImpersonationId: string,
|
aImpersonationId: string,
|
||||||
aDateRange: DateRange = 'max'
|
aDateRange: DateRange = 'max'
|
||||||
): Promise<HistoricalDataItem[]> {
|
): Promise<HistoricalDataContainer> {
|
||||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||||
|
|
||||||
const portfolioCalculator = new PortfolioCalculator(
|
const portfolioCalculator = new PortfolioCalculator(
|
||||||
@ -122,14 +177,21 @@ export class PortfolioService {
|
|||||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||||
if (transactionPoints.length === 0) {
|
if (transactionPoints.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false,
|
||||||
|
items: []
|
||||||
|
};
|
||||||
}
|
}
|
||||||
let portfolioStart = parse(
|
let portfolioStart = parse(
|
||||||
transactionPoints[0].date,
|
transactionPoints[0].date,
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
new Date()
|
new Date()
|
||||||
);
|
);
|
||||||
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
|
|
||||||
|
// Get start date for the full portfolio because of because of the
|
||||||
|
// min and max calculation
|
||||||
|
portfolioStart = this.getStartDate('max', portfolioStart);
|
||||||
|
|
||||||
const timelineSpecification: TimelineSpecification[] = [
|
const timelineSpecification: TimelineSpecification[] = [
|
||||||
{
|
{
|
||||||
@ -138,18 +200,52 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
const timeline = await portfolioCalculator.calculateTimeline(
|
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||||
timelineSpecification,
|
timelineSpecification,
|
||||||
format(new Date(), DATE_FORMAT)
|
format(new Date(), DATE_FORMAT)
|
||||||
);
|
);
|
||||||
|
|
||||||
return timeline
|
const timeline = timelineInfo.timelinePeriods;
|
||||||
|
|
||||||
|
const items = timeline
|
||||||
.filter((timelineItem) => timelineItem !== null)
|
.filter((timelineItem) => timelineItem !== null)
|
||||||
.map((timelineItem) => ({
|
.map((timelineItem) => ({
|
||||||
date: timelineItem.date,
|
date: timelineItem.date,
|
||||||
marketPrice: timelineItem.value,
|
marketPrice: timelineItem.value,
|
||||||
value: timelineItem.netPerformance.toNumber()
|
value: timelineItem.netPerformance.toNumber()
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
let lastItem = null;
|
||||||
|
if (timeline.length > 0) {
|
||||||
|
lastItem = timeline[timeline.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
|
||||||
|
lastItem?.netPerformance
|
||||||
|
);
|
||||||
|
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
|
||||||
|
lastItem?.netPerformance
|
||||||
|
);
|
||||||
|
if (isAllTimeHigh && isAllTimeLow) {
|
||||||
|
isAllTimeHigh = false;
|
||||||
|
isAllTimeLow = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
portfolioStart = startOfDay(
|
||||||
|
this.getStartDate(
|
||||||
|
aDateRange,
|
||||||
|
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAllTimeHigh,
|
||||||
|
isAllTimeLow,
|
||||||
|
items: items.filter((item) => {
|
||||||
|
// Filter items of date range
|
||||||
|
return !isAfter(portfolioStart, parseDate(item.date));
|
||||||
|
})
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getDetails(
|
public async getDetails(
|
||||||
@ -258,7 +354,7 @@ export class PortfolioService {
|
|||||||
value: totalValue
|
value: totalValue
|
||||||
});
|
});
|
||||||
|
|
||||||
const accounts = await this.getAccounts(
|
const accounts = await this.getValueOfAccounts(
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
userCurrency,
|
userCurrency,
|
||||||
@ -295,10 +391,13 @@ export class PortfolioService {
|
|||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
quantity: undefined,
|
quantity: undefined,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined
|
transactionCount: undefined,
|
||||||
|
value: undefined
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const assetClass = orders[0].SymbolProfile?.assetClass;
|
||||||
|
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
|
||||||
const positionCurrency = orders[0].currency;
|
const positionCurrency = orders[0].currency;
|
||||||
const name = orders[0].SymbolProfile?.name ?? '';
|
const name = orders[0].SymbolProfile?.name ?? '';
|
||||||
|
|
||||||
@ -412,6 +511,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
currency,
|
currency,
|
||||||
firstBuyDate,
|
firstBuyDate,
|
||||||
grossPerformance,
|
grossPerformance,
|
||||||
@ -427,7 +528,12 @@ export class PortfolioService {
|
|||||||
historicalData: historicalDataArray,
|
historicalData: historicalDataArray,
|
||||||
netPerformancePercent: position.netPerformancePercentage.toNumber(),
|
netPerformancePercent: position.netPerformancePercentage.toNumber(),
|
||||||
quantity: quantity.toNumber(),
|
quantity: quantity.toNumber(),
|
||||||
symbol: aSymbol
|
symbol: aSymbol,
|
||||||
|
value: this.exchangeRateDataService.toCurrency(
|
||||||
|
quantity.mul(marketPrice).toNumber(),
|
||||||
|
currency,
|
||||||
|
userCurrency
|
||||||
|
)
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const currentData = await this.dataProviderService.get([
|
const currentData = await this.dataProviderService.get([
|
||||||
@ -467,6 +573,8 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
assetClass,
|
||||||
|
assetSubClass,
|
||||||
marketPrice,
|
marketPrice,
|
||||||
maxPrice,
|
maxPrice,
|
||||||
minPrice,
|
minPrice,
|
||||||
@ -482,7 +590,8 @@ export class PortfolioService {
|
|||||||
netPerformancePercent: undefined,
|
netPerformancePercent: undefined,
|
||||||
quantity: 0,
|
quantity: 0,
|
||||||
symbol: aSymbol,
|
symbol: aSymbol,
|
||||||
transactionCount: undefined
|
transactionCount: undefined,
|
||||||
|
value: 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -580,7 +689,9 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent: 0,
|
currentGrossPerformancePercent: 0,
|
||||||
currentNetPerformance: 0,
|
currentNetPerformance: 0,
|
||||||
currentNetPerformancePercent: 0,
|
currentNetPerformancePercent: 0,
|
||||||
currentValue: 0
|
currentValue: 0,
|
||||||
|
isAllTimeHigh: false,
|
||||||
|
isAllTimeLow: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -613,7 +724,9 @@ export class PortfolioService {
|
|||||||
currentGrossPerformancePercent,
|
currentGrossPerformancePercent,
|
||||||
currentNetPerformance,
|
currentNetPerformance,
|
||||||
currentNetPerformancePercent,
|
currentNetPerformancePercent,
|
||||||
currentValue: currentValue
|
currentValue,
|
||||||
|
isAllTimeHigh: true, // TODO
|
||||||
|
isAllTimeLow: false // TODO
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -663,7 +776,7 @@ export class PortfolioService {
|
|||||||
for (const position of currentPositions.positions) {
|
for (const position of currentPositions.positions) {
|
||||||
portfolioItemsNow[position.symbol] = position;
|
portfolioItemsNow[position.symbol] = position;
|
||||||
}
|
}
|
||||||
const accounts = await this.getAccounts(
|
const accounts = await this.getValueOfAccounts(
|
||||||
orders,
|
orders,
|
||||||
portfolioItemsNow,
|
portfolioItemsNow,
|
||||||
currency,
|
currency,
|
||||||
@ -863,7 +976,7 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getAccounts(
|
private async getValueOfAccounts(
|
||||||
orders: OrderWithAccount[],
|
orders: OrderWithAccount[],
|
||||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||||
userCurrency: string,
|
userCurrency: string,
|
||||||
@ -878,32 +991,20 @@ export class PortfolioService {
|
|||||||
return accountId === account.id;
|
return accountId === account.id;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (ordersByAccount.length <= 0) {
|
const convertedBalance = this.exchangeRateDataService.toCurrency(
|
||||||
// Add account without orders
|
account.balance,
|
||||||
const balance = this.exchangeRateDataService.toCurrency(
|
account.currency,
|
||||||
account.balance,
|
userCurrency
|
||||||
account.currency,
|
);
|
||||||
userCurrency
|
accounts[account.name] = {
|
||||||
);
|
current: convertedBalance,
|
||||||
accounts[account.name] = {
|
original: convertedBalance
|
||||||
current: balance,
|
};
|
||||||
original: balance
|
|
||||||
};
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const order of ordersByAccount) {
|
for (const order of ordersByAccount) {
|
||||||
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
let currentValueOfSymbol =
|
||||||
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
|
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
|
||||||
order.currency,
|
let originalValueOfSymbol = order.quantity * order.unitPrice;
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
|
|
||||||
order.quantity * order.unitPrice,
|
|
||||||
order.currency,
|
|
||||||
userCurrency
|
|
||||||
);
|
|
||||||
|
|
||||||
if (order.type === 'SELL') {
|
if (order.type === 'SELL') {
|
||||||
currentValueOfSymbol *= -1;
|
currentValueOfSymbol *= -1;
|
||||||
|
@ -21,9 +21,9 @@ export class RedisCacheService {
|
|||||||
await this.cache.reset();
|
await this.cache.reset();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async set(key: string, value: string) {
|
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||||
await this.cache.set(key, value, {
|
await this.cache.set(key, value, {
|
||||||
ttl: this.configurationService.get('CACHE_TTL')
|
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpException,
|
HttpException,
|
||||||
Inject,
|
Inject,
|
||||||
|
Logger,
|
||||||
Post,
|
Post,
|
||||||
Req,
|
Req,
|
||||||
Res,
|
Res,
|
||||||
@ -46,7 +47,7 @@ export class SubscriptionController {
|
|||||||
userId: this.request.user.id
|
userId: this.request.user.id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw new HttpException(
|
throw new HttpException(
|
||||||
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
getReasonPhrase(StatusCodes.BAD_REQUEST),
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Subscription } from '@prisma/client';
|
import { Subscription } from '@prisma/client';
|
||||||
import { addDays, isBefore } from 'date-fns';
|
import { addDays, isBefore } from 'date-fns';
|
||||||
import Stripe from 'stripe';
|
import Stripe from 'stripe';
|
||||||
@ -85,7 +85,7 @@ export class SubscriptionService {
|
|||||||
description: session.client_reference_id
|
description: session.client_reference_id
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
|
|
||||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||||
@ -67,7 +67,7 @@ export class SymbolService {
|
|||||||
|
|
||||||
return results;
|
return results;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
|
||||||
import { AppModule } from './app/app.module';
|
import { AppModule } from './app/app.module';
|
||||||
|
import { environment } from './environments/environment';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
@ -18,8 +19,21 @@ async function bootstrap() {
|
|||||||
|
|
||||||
const port = process.env.PORT || 3333;
|
const port = process.env.PORT || 3333;
|
||||||
await app.listen(port, () => {
|
await app.listen(port, () => {
|
||||||
|
logLogo();
|
||||||
Logger.log(`Listening at http://localhost:${port}`);
|
Logger.log(`Listening at http://localhost:${port}`);
|
||||||
|
Logger.log('');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function logLogo() {
|
||||||
|
Logger.log(' ________ __ ____ ___');
|
||||||
|
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___');
|
||||||
|
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\');
|
||||||
|
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /');
|
||||||
|
Logger.log(
|
||||||
|
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`
|
||||||
|
);
|
||||||
|
Logger.log('');
|
||||||
|
}
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"1INCH": "1inch",
|
"1INCH": "1inch",
|
||||||
|
"ALGO": "Algorand",
|
||||||
"AVAX": "Avalanche",
|
"AVAX": "Avalanche",
|
||||||
"MATIC": "Polygon",
|
"MATIC": "Polygon",
|
||||||
"SHIB": "Shiba Inu"
|
"SHIB": "Shiba Inu"
|
||||||
|
@ -6,6 +6,7 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||||
|
import { SymbolProfileModule } from './symbol-profile.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -13,7 +14,8 @@ import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
|||||||
DataEnhancerModule,
|
DataEnhancerModule,
|
||||||
DataProviderModule,
|
DataProviderModule,
|
||||||
ExchangeRateDataModule,
|
ExchangeRateDataModule,
|
||||||
PrismaModule
|
PrismaModule,
|
||||||
|
SymbolProfileModule
|
||||||
],
|
],
|
||||||
providers: [DataGatheringService],
|
providers: [DataGatheringService],
|
||||||
exports: [DataEnhancerModule, DataGatheringService]
|
exports: [DataEnhancerModule, DataGatheringService]
|
||||||
|
@ -1,9 +1,7 @@
|
|||||||
import {
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
benchmarks,
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
ghostfolioFearAndGreedIndexSymbol
|
|
||||||
} from '@ghostfolio/common/config';
|
|
||||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
differenceInHours,
|
differenceInHours,
|
||||||
@ -17,7 +15,6 @@ import {
|
|||||||
|
|
||||||
import { ConfigurationService } from './configuration.service';
|
import { ConfigurationService } from './configuration.service';
|
||||||
import { DataProviderService } from './data-provider/data-provider.service';
|
import { DataProviderService } from './data-provider/data-provider.service';
|
||||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
|
||||||
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
|
||||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||||
@ -25,21 +22,23 @@ import { PrismaService } from './prisma.service';
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DataGatheringService {
|
export class DataGatheringService {
|
||||||
|
private dataGatheringProgress: number;
|
||||||
|
|
||||||
public constructor(
|
public constructor(
|
||||||
private readonly configurationService: ConfigurationService,
|
private readonly configurationService: ConfigurationService,
|
||||||
@Inject('DataEnhancers')
|
@Inject('DataEnhancers')
|
||||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||||
private readonly dataProviderService: DataProviderService,
|
private readonly dataProviderService: DataProviderService,
|
||||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly prismaService: PrismaService
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public async gather7Days() {
|
public async gather7Days() {
|
||||||
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
|
||||||
|
|
||||||
if (isDataGatheringNeeded) {
|
if (isDataGatheringNeeded) {
|
||||||
console.log('7d data gathering has been started.');
|
Logger.log('7d data gathering has been started.');
|
||||||
console.time('data-gathering-7d');
|
console.time('data-gathering-7d');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
@ -63,7 +62,7 @@ export class DataGatheringService {
|
|||||||
where: { key: 'LAST_DATA_GATHERING' }
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -72,7 +71,7 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('7d data gathering has been completed.');
|
Logger.log('7d data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering-7d');
|
console.timeEnd('data-gathering-7d');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -83,7 +82,7 @@ export class DataGatheringService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!isDataGatheringLocked) {
|
if (!isDataGatheringLocked) {
|
||||||
console.log('Max data gathering has been started.');
|
Logger.log('Max data gathering has been started.');
|
||||||
console.time('data-gathering-max');
|
console.time('data-gathering-max');
|
||||||
|
|
||||||
await this.prismaService.property.create({
|
await this.prismaService.property.create({
|
||||||
@ -107,7 +106,7 @@ export class DataGatheringService {
|
|||||||
where: { key: 'LAST_DATA_GATHERING' }
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.prismaService.property.delete({
|
await this.prismaService.property.delete({
|
||||||
@ -116,13 +115,70 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('Max data gathering has been completed.');
|
Logger.log('Max data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering-max');
|
console.timeEnd('data-gathering-max');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async gatherSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||||
|
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!isDataGatheringLocked) {
|
||||||
|
Logger.log(`Symbol data gathering for ${symbol} has been started.`);
|
||||||
|
console.time('data-gathering-symbol');
|
||||||
|
|
||||||
|
await this.prismaService.property.create({
|
||||||
|
data: {
|
||||||
|
key: 'LOCKED_DATA_GATHERING',
|
||||||
|
value: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const symbols = (await this.getSymbolsMax()).filter(
|
||||||
|
(dataGatheringItem) => {
|
||||||
|
return (
|
||||||
|
dataGatheringItem.dataSource === dataSource &&
|
||||||
|
dataGatheringItem.symbol === symbol
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.gatherSymbols(symbols);
|
||||||
|
|
||||||
|
await this.prismaService.property.upsert({
|
||||||
|
create: {
|
||||||
|
key: 'LAST_DATA_GATHERING',
|
||||||
|
value: new Date().toISOString()
|
||||||
|
},
|
||||||
|
update: { value: new Date().toISOString() },
|
||||||
|
where: { key: 'LAST_DATA_GATHERING' }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prismaService.property.delete({
|
||||||
|
where: {
|
||||||
|
key: 'LOCKED_DATA_GATHERING'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.log(`Symbol data gathering for ${symbol} has been completed.`);
|
||||||
|
console.timeEnd('data-gathering-symbol');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||||
console.log('Profile data gathering has been started.');
|
Logger.log('Profile data gathering has been started.');
|
||||||
console.time('data-gathering-profile');
|
console.time('data-gathering-profile');
|
||||||
|
|
||||||
let dataGatheringItems = aDataGatheringItems;
|
let dataGatheringItems = aDataGatheringItems;
|
||||||
@ -132,16 +188,25 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
||||||
|
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||||
|
dataGatheringItems.map(({ symbol }) => {
|
||||||
|
return symbol;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
for (const [symbol, response] of Object.entries(currentData)) {
|
for (const [symbol, response] of Object.entries(currentData)) {
|
||||||
|
const symbolMapping = symbolProfiles.find((symbolProfile) => {
|
||||||
|
return symbolProfile.symbol === symbol;
|
||||||
|
})?.symbolMapping;
|
||||||
|
|
||||||
for (const dataEnhancer of this.dataEnhancers) {
|
for (const dataEnhancer of this.dataEnhancers) {
|
||||||
try {
|
try {
|
||||||
currentData[symbol] = await dataEnhancer.enhance({
|
currentData[symbol] = await dataEnhancer.enhance({
|
||||||
response,
|
response,
|
||||||
symbol
|
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to enhance data for symbol ${symbol}`, error);
|
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -183,18 +248,21 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`${symbol}: ${error?.meta?.cause}`);
|
Logger.error(`${symbol}: ${error?.meta?.cause}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Profile data gathering has been completed.');
|
Logger.log('Profile data gathering has been completed.');
|
||||||
console.timeEnd('data-gathering-profile');
|
console.timeEnd('data-gathering-profile');
|
||||||
}
|
}
|
||||||
|
|
||||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||||
let hasError = false;
|
let hasError = false;
|
||||||
|
let symbolCounter = 0;
|
||||||
|
|
||||||
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
|
||||||
|
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
const historicalData = await this.dataProviderService.getHistoricalRaw(
|
||||||
[{ dataSource, symbol }],
|
[{ dataSource, symbol }],
|
||||||
@ -250,8 +318,18 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
hasError = true;
|
hasError = true;
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
|
||||||
|
Logger.log(
|
||||||
|
`Data gathering progress: ${(
|
||||||
|
this.dataGatheringProgress * 100
|
||||||
|
).toFixed(2)}%`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
symbolCounter += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.exchangeRateDataService.initialize();
|
await this.exchangeRateDataService.initialize();
|
||||||
@ -261,19 +339,14 @@ export class DataGatheringService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCustomSymbolsToGather(
|
public async getDataGatheringProgress() {
|
||||||
startDate?: Date
|
const isInProgress = await this.getIsInProgress();
|
||||||
): Promise<IDataGatheringItem[]> {
|
|
||||||
const scraperConfigurations =
|
|
||||||
await this.ghostfolioScraperApi.getScraperConfigurations();
|
|
||||||
|
|
||||||
return scraperConfigurations.map((scraperConfiguration) => {
|
if (isInProgress) {
|
||||||
return {
|
return this.dataGatheringProgress;
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
}
|
||||||
date: startDate,
|
|
||||||
symbol: scraperConfiguration.symbol
|
return undefined;
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getIsInProgress() {
|
public async getIsInProgress() {
|
||||||
@ -294,76 +367,7 @@ export class DataGatheringService {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async reset() {
|
public async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||||
console.log('Data gathering has been reset.');
|
|
||||||
|
|
||||||
await this.prismaService.property.deleteMany({
|
|
||||||
where: {
|
|
||||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
|
||||||
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
|
||||||
benchmarksToGather.push({
|
|
||||||
dataSource: DataSource.RAKUTEN,
|
|
||||||
date: startDate,
|
|
||||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return benchmarksToGather;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
|
||||||
const startDate = subDays(resetHours(new Date()), 7);
|
|
||||||
|
|
||||||
const symbolProfilesToGather = (
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
orderBy: [{ symbol: 'asc' }],
|
|
||||||
select: {
|
|
||||||
dataSource: true,
|
|
||||||
symbol: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
).map((symbolProfile) => {
|
|
||||||
return {
|
|
||||||
...symbolProfile,
|
|
||||||
date: startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const currencyPairsToGather = this.exchangeRateDataService
|
|
||||||
.getCurrencyPairs()
|
|
||||||
.map(({ dataSource, symbol }) => {
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const customSymbolsToGather =
|
|
||||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...this.getBenchmarksToGather(startDate),
|
|
||||||
...customSymbolsToGather,
|
|
||||||
...currencyPairsToGather,
|
|
||||||
...symbolProfilesToGather
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
|
||||||
const startDate =
|
const startDate =
|
||||||
(
|
(
|
||||||
await this.prismaService.order.findFirst({
|
await this.prismaService.order.findFirst({
|
||||||
@ -371,9 +375,6 @@ export class DataGatheringService {
|
|||||||
})
|
})
|
||||||
)?.date ?? new Date();
|
)?.date ?? new Date();
|
||||||
|
|
||||||
const customSymbolsToGather =
|
|
||||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
|
||||||
|
|
||||||
const currencyPairsToGather = this.exchangeRateDataService
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
.getCurrencyPairs()
|
.getCurrencyPairs()
|
||||||
.map(({ dataSource, symbol }) => {
|
.map(({ dataSource, symbol }) => {
|
||||||
@ -394,20 +395,79 @@ export class DataGatheringService {
|
|||||||
select: { date: true },
|
select: { date: true },
|
||||||
take: 1
|
take: 1
|
||||||
},
|
},
|
||||||
|
scraperConfiguration: true,
|
||||||
symbol: true
|
symbol: true
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
).map((item) => {
|
).map((symbolProfile) => {
|
||||||
return {
|
return {
|
||||||
dataSource: item.dataSource,
|
...symbolProfile,
|
||||||
date: item.Order?.[0]?.date ?? startDate,
|
date: symbolProfile.Order?.[0]?.date ?? startDate
|
||||||
symbol: item.symbol
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return [
|
return [
|
||||||
...this.getBenchmarksToGather(startDate),
|
...this.getBenchmarksToGather(startDate),
|
||||||
...customSymbolsToGather,
|
...currencyPairsToGather,
|
||||||
|
...symbolProfilesToGather
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reset() {
|
||||||
|
Logger.log('Data gathering has been reset.');
|
||||||
|
|
||||||
|
await this.prismaService.property.deleteMany({
|
||||||
|
where: {
|
||||||
|
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
|
||||||
|
const benchmarksToGather: IDataGatheringItem[] = [];
|
||||||
|
|
||||||
|
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
|
||||||
|
benchmarksToGather.push({
|
||||||
|
dataSource: DataSource.RAKUTEN,
|
||||||
|
date: startDate,
|
||||||
|
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return benchmarksToGather;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||||
|
const startDate = subDays(resetHours(new Date()), 7);
|
||||||
|
|
||||||
|
const symbolProfilesToGather = (
|
||||||
|
await this.prismaService.symbolProfile.findMany({
|
||||||
|
orderBy: [{ symbol: 'asc' }],
|
||||||
|
select: {
|
||||||
|
dataSource: true,
|
||||||
|
scraperConfiguration: true,
|
||||||
|
symbol: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
).map((symbolProfile) => {
|
||||||
|
return {
|
||||||
|
...symbolProfile,
|
||||||
|
date: startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const currencyPairsToGather = this.exchangeRateDataService
|
||||||
|
.getCurrencyPairs()
|
||||||
|
.map(({ dataSource, symbol }) => {
|
||||||
|
return {
|
||||||
|
dataSource,
|
||||||
|
symbol,
|
||||||
|
date: startDate
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
...this.getBenchmarksToGather(startDate),
|
||||||
...currencyPairsToGather,
|
...currencyPairsToGather,
|
||||||
...symbolProfilesToGather
|
...symbolProfilesToGather
|
||||||
];
|
];
|
||||||
|
@ -2,7 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import { isAfter, isBefore, parse } from 'date-fns';
|
import { isAfter, isBefore, parse } from 'date-fns';
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ export class AlphaVantageService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error, symbol);
|
Logger.error(error, symbol);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
@ -70,4 +70,8 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
|||||||
|
|
||||||
return Promise.resolve(response);
|
return Promise.resolve(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getName() {
|
||||||
|
return 'TRACKINSIGHT';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -4,13 +4,19 @@ import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provi
|
|||||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.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 { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
||||||
import { DataProviderService } from './data-provider.service';
|
import { DataProviderService } from './data-provider.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [ConfigurationModule, CryptocurrencyModule, PrismaModule],
|
imports: [
|
||||||
|
ConfigurationModule,
|
||||||
|
CryptocurrencyModule,
|
||||||
|
PrismaModule,
|
||||||
|
SymbolProfileModule
|
||||||
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AlphaVantageService,
|
AlphaVantageService,
|
||||||
DataProviderService,
|
DataProviderService,
|
||||||
|
@ -9,7 +9,7 @@ import {
|
|||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource, MarketData } from '@prisma/client';
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
@ -109,7 +109,7 @@ export class DataProviderService {
|
|||||||
return r;
|
return r;
|
||||||
}, {});
|
}, {});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
} finally {
|
} finally {
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
@ -1,31 +1,33 @@
|
|||||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
|
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||||
import {
|
import {
|
||||||
DATE_FORMAT,
|
DATE_FORMAT,
|
||||||
getYesterday,
|
getYesterday,
|
||||||
isGhostfolioScraperApiSymbol
|
isGhostfolioScraperApiSymbol
|
||||||
} from '@ghostfolio/common/helper';
|
} from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import * as cheerio from 'cheerio';
|
import * as cheerio from 'cheerio';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IDataGatheringItem,
|
|
||||||
IDataProviderHistoricalResponse,
|
IDataProviderHistoricalResponse,
|
||||||
IDataProviderResponse,
|
IDataProviderResponse,
|
||||||
MarketState
|
MarketState
|
||||||
} from '../../interfaces/interfaces';
|
} from '../../interfaces/interfaces';
|
||||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||||
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||||
|
|
||||||
public constructor(private readonly prismaService: PrismaService) {}
|
public constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly symbolProfileService: SymbolProfileService
|
||||||
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return isGhostfolioScraperApiSymbol(symbol);
|
return isGhostfolioScraperApiSymbol(symbol);
|
||||||
@ -39,9 +41,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const [symbol] = aSymbols;
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
|
[symbol]
|
||||||
|
);
|
||||||
|
|
||||||
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
||||||
orderBy: {
|
orderBy: {
|
||||||
@ -55,37 +58,18 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return {
|
return {
|
||||||
[symbol]: {
|
[symbol]: {
|
||||||
marketPrice,
|
marketPrice,
|
||||||
currency: scraperConfig?.currency,
|
currency: symbolProfile?.currency,
|
||||||
dataSource: DataSource.GHOSTFOLIO,
|
dataSource: DataSource.GHOSTFOLIO,
|
||||||
marketState: MarketState.delayed
|
marketState: MarketState.delayed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCustomSymbolsToGather(
|
|
||||||
startDate?: Date
|
|
||||||
): Promise<IDataGatheringItem[]> {
|
|
||||||
const ghostfolioSymbolProfiles =
|
|
||||||
await this.prismaService.symbolProfile.findMany({
|
|
||||||
where: {
|
|
||||||
dataSource: DataSource.GHOSTFOLIO
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
|
|
||||||
return {
|
|
||||||
dataSource,
|
|
||||||
symbol,
|
|
||||||
date: startDate
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public async getHistorical(
|
public async getHistorical(
|
||||||
aSymbols: string[],
|
aSymbols: string[],
|
||||||
aGranularity: Granularity = 'day',
|
aGranularity: Granularity = 'day',
|
||||||
@ -99,11 +83,11 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const symbol = aSymbols[0];
|
const [symbol] = aSymbols;
|
||||||
|
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||||
const scraperConfiguration = await this.getScraperConfigurationBySymbol(
|
[symbol]
|
||||||
symbol
|
|
||||||
);
|
);
|
||||||
|
const scraperConfiguration = symbolProfile?.scraperConfiguration;
|
||||||
|
|
||||||
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
|
||||||
|
|
||||||
@ -122,28 +106,12 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
|
||||||
try {
|
|
||||||
const { value: scraperConfigString } =
|
|
||||||
await this.prismaService.property.findFirst({
|
|
||||||
select: {
|
|
||||||
value: true
|
|
||||||
},
|
|
||||||
where: { key: 'SCRAPER_CONFIG' }
|
|
||||||
});
|
|
||||||
|
|
||||||
return JSON.parse(scraperConfigString);
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public getName(): DataSource {
|
public getName(): DataSource {
|
||||||
return DataSource.GHOSTFOLIO;
|
return DataSource.GHOSTFOLIO;
|
||||||
}
|
}
|
||||||
@ -162,11 +130,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getScraperConfigurationBySymbol(aSymbol: string) {
|
|
||||||
const scraperConfigurations = await this.getScraperConfigurations();
|
|
||||||
return scraperConfigurations.find((scraperConfiguration) => {
|
|
||||||
return scraperConfiguration.symbol === aSymbol;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,6 +0,0 @@
|
|||||||
export interface ScraperConfig {
|
|
||||||
currency: string;
|
|
||||||
selector: string;
|
|
||||||
symbol: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
@ -0,0 +1,4 @@
|
|||||||
|
export interface ScraperConfiguration {
|
||||||
|
selector: string;
|
||||||
|
url: string;
|
||||||
|
}
|
@ -8,4 +8,6 @@ export interface DataEnhancerInterface {
|
|||||||
response: IDataProviderResponse;
|
response: IDataProviderResponse;
|
||||||
symbol: string;
|
symbol: string;
|
||||||
}): Promise<IDataProviderResponse>;
|
}): Promise<IDataProviderResponse>;
|
||||||
|
|
||||||
|
getName(): string;
|
||||||
}
|
}
|
||||||
|
@ -2,14 +2,9 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
|
|||||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
import {
|
import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||||
DATE_FORMAT,
|
|
||||||
getToday,
|
|
||||||
getYesterday,
|
|
||||||
isRakutenRapidApiSymbol
|
|
||||||
} from '@ghostfolio/common/helper';
|
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
import { DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||||
@ -31,10 +26,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public canHandle(symbol: string) {
|
public canHandle(symbol: string) {
|
||||||
return (
|
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
||||||
isRakutenRapidApiSymbol(symbol) &&
|
|
||||||
!!this.configurationService.get('RAKUTEN_RAPID_API_KEY')
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(
|
public async get(
|
||||||
@ -61,7 +53,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
@ -166,7 +158,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
|||||||
const { fgi } = await get();
|
const { fgi } = await get();
|
||||||
return fgi;
|
return fgi;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/c
|
|||||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||||
import { Granularity } from '@ghostfolio/common/types';
|
import { Granularity } from '@ghostfolio/common/types';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
import * as bent from 'bent';
|
import * as bent from 'bent';
|
||||||
import Big from 'big.js';
|
import Big from 'big.js';
|
||||||
@ -117,7 +117,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -169,7 +169,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
|
|
||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
Logger.error(error);
|
||||||
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -197,16 +197,20 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
// filter out undefined symbols
|
// filter out undefined symbols
|
||||||
return quote.symbol;
|
return quote.symbol;
|
||||||
})
|
})
|
||||||
.filter(({ quoteType }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
return (
|
return (
|
||||||
quoteType === 'CRYPTOCURRENCY' ||
|
(quoteType === 'CRYPTOCURRENCY' &&
|
||||||
|
this.cryptocurrencyService.isCrypto(
|
||||||
|
symbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
|
||||||
|
)) ||
|
||||||
quoteType === 'EQUITY' ||
|
quoteType === 'EQUITY' ||
|
||||||
quoteType === 'ETF'
|
quoteType === 'ETF'
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
.filter(({ quoteType, symbol }) => {
|
.filter(({ quoteType, symbol }) => {
|
||||||
if (quoteType === 'CRYPTOCURRENCY') {
|
if (quoteType === 'CRYPTOCURRENCY') {
|
||||||
// Only allow cryptocurrencies in USD
|
// Only allow cryptocurrencies in USD to avoid having redundancy in the database.
|
||||||
|
// Trades need to be converted manually before to USD (or a UI converter needs to be developed)
|
||||||
return symbol.includes('USD');
|
return symbol.includes('USD');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,14 +258,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
|||||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||||
return `${aSymbol}=X`;
|
return `${aSymbol}=X`;
|
||||||
} else if (
|
} else if (
|
||||||
this.cryptocurrencyService.isCrypto(aSymbol) ||
|
this.cryptocurrencyService.isCrypto(
|
||||||
this.cryptocurrencyService.isCrypto(aSymbol.replace('1', ''))
|
aSymbol.replace(new RegExp('-USD$'), 'USD').replace('1', '')
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
// Add a dash before the last three characters
|
// Add a dash before the last three characters
|
||||||
// BTCUSD -> BTC-USD
|
// BTCUSD -> BTC-USD
|
||||||
// DOGEUSD -> DOGE-USD
|
// DOGEUSD -> DOGE-USD
|
||||||
// SOL1USD -> SOL1-USD
|
// SOL1USD -> SOL1-USD
|
||||||
return aSymbol.replace('USD', '-USD');
|
return aSymbol.replace(new RegExp('-?USD$'), '-USD');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { baseCurrency } from '@ghostfolio/common/config';
|
import { baseCurrency } from '@ghostfolio/common/config';
|
||||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { DataSource } from '@prisma/client';
|
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { isEmpty, isNumber, uniq } from 'lodash';
|
import { isEmpty, isNumber, uniq } from 'lodash';
|
||||||
|
|
||||||
@ -40,7 +39,10 @@ export class ExchangeRateDataService {
|
|||||||
currency2,
|
currency2,
|
||||||
dataSource
|
dataSource
|
||||||
} of this.prepareCurrencyPairs(this.currencies)) {
|
} of this.prepareCurrencyPairs(this.currencies)) {
|
||||||
this.addCurrencyPairs({ currency1, currency2, dataSource });
|
this.currencyPairs.push({
|
||||||
|
dataSource,
|
||||||
|
symbol: `${currency1}${currency2}`
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.loadCurrencies();
|
await this.loadCurrencies();
|
||||||
@ -86,7 +88,7 @@ export class ExchangeRateDataService {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
this.currencyPairs.forEach(({ symbol }) => {
|
Object.keys(resultExtended).forEach((symbol) => {
|
||||||
const [currency1, currency2] = symbol.match(/.{1,3}/g);
|
const [currency1, currency2] = symbol.match(/.{1,3}/g);
|
||||||
const date = format(getYesterday(), DATE_FORMAT);
|
const date = format(getYesterday(), DATE_FORMAT);
|
||||||
|
|
||||||
@ -140,31 +142,12 @@ export class ExchangeRateDataService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback with error, if currencies are not available
|
// Fallback with error, if currencies are not available
|
||||||
console.error(
|
Logger.error(
|
||||||
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
|
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
|
||||||
);
|
);
|
||||||
return aValue;
|
return aValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
private addCurrencyPairs({
|
|
||||||
currency1,
|
|
||||||
currency2,
|
|
||||||
dataSource
|
|
||||||
}: {
|
|
||||||
currency1: string;
|
|
||||||
currency2: string;
|
|
||||||
dataSource: DataSource;
|
|
||||||
}) {
|
|
||||||
this.currencyPairs.push({
|
|
||||||
dataSource,
|
|
||||||
symbol: `${currency1}${currency2}`
|
|
||||||
});
|
|
||||||
this.currencyPairs.push({
|
|
||||||
dataSource,
|
|
||||||
symbol: `${currency2}${currency1}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async prepareCurrencies(): Promise<string[]> {
|
private async prepareCurrencies(): Promise<string[]> {
|
||||||
const currencies: string[] = [];
|
const currencies: string[] = [];
|
||||||
|
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||||
@ -5,13 +6,15 @@ import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
|||||||
export interface EnhancedSymbolProfile {
|
export interface EnhancedSymbolProfile {
|
||||||
assetClass: AssetClass;
|
assetClass: AssetClass;
|
||||||
assetSubClass: AssetSubClass;
|
assetSubClass: AssetSubClass;
|
||||||
|
countries: Country[];
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
dataSource: DataSource;
|
dataSource: DataSource;
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
updatedAt: Date;
|
scraperConfiguration?: ScraperConfiguration | null;
|
||||||
symbol: string;
|
|
||||||
countries: Country[];
|
|
||||||
sectors: Sector[];
|
sectors: Sector[];
|
||||||
|
symbol: string;
|
||||||
|
symbolMapping?: { [key: string]: string };
|
||||||
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
11
apps/api/src/services/market-data.module.ts
Normal file
11
apps/api/src/services/market-data.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MarketDataService } from './market-data.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
exports: [MarketDataService],
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [MarketDataService]
|
||||||
|
})
|
||||||
|
export class MarketDataModule {}
|
@ -1,9 +1,8 @@
|
|||||||
|
import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.interface';
|
||||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||||
import { resetHours } from '@ghostfolio/common/helper';
|
import { resetHours } from '@ghostfolio/common/helper';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { MarketData } from '@prisma/client';
|
import { MarketData, Prisma } from '@prisma/client';
|
||||||
|
|
||||||
import { DateQuery } from './interfaces/date-query.interface';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MarketDataService {
|
export class MarketDataService {
|
||||||
@ -48,4 +47,22 @@ export class MarketDataService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async marketDataItems(params: {
|
||||||
|
skip?: number;
|
||||||
|
take?: number;
|
||||||
|
cursor?: Prisma.MarketDataWhereUniqueInput;
|
||||||
|
where?: Prisma.MarketDataWhereInput;
|
||||||
|
orderBy?: Prisma.MarketDataOrderByInput;
|
||||||
|
}): Promise<MarketData[]> {
|
||||||
|
const { skip, take, cursor, where, orderBy } = params;
|
||||||
|
|
||||||
|
return this.prismaService.marketData.findMany({
|
||||||
|
cursor,
|
||||||
|
orderBy,
|
||||||
|
skip,
|
||||||
|
take,
|
||||||
|
where
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
11
apps/api/src/services/symbol-profile.module.ts
Normal file
11
apps/api/src/services/symbol-profile.module.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { SymbolProfileService } from './symbol-profile.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [SymbolProfileService],
|
||||||
|
exports: [SymbolProfileService]
|
||||||
|
})
|
||||||
|
export class SymbolProfileModule {}
|
@ -7,6 +7,8 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { Prisma, SymbolProfile } from '@prisma/client';
|
import { Prisma, SymbolProfile } from '@prisma/client';
|
||||||
import { continents, countries } from 'countries-list';
|
import { continents, countries } from 'countries-list';
|
||||||
|
|
||||||
|
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SymbolProfileService {
|
export class SymbolProfileService {
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
@ -29,7 +31,9 @@ export class SymbolProfileService {
|
|||||||
return symbolProfiles.map((symbolProfile) => ({
|
return symbolProfiles.map((symbolProfile) => ({
|
||||||
...symbolProfile,
|
...symbolProfile,
|
||||||
countries: this.getCountries(symbolProfile),
|
countries: this.getCountries(symbolProfile),
|
||||||
sectors: this.getSectors(symbolProfile)
|
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||||
|
sectors: this.getSectors(symbolProfile),
|
||||||
|
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -49,6 +53,22 @@ export class SymbolProfileService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getScraperConfiguration(
|
||||||
|
symbolProfile: SymbolProfile
|
||||||
|
): ScraperConfiguration {
|
||||||
|
const scraperConfiguration =
|
||||||
|
symbolProfile.scraperConfiguration as Prisma.JsonObject;
|
||||||
|
|
||||||
|
if (scraperConfiguration) {
|
||||||
|
return {
|
||||||
|
selector: scraperConfiguration.selector as string,
|
||||||
|
url: scraperConfiguration.url as string
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private getSectors(symbolProfile: SymbolProfile): Sector[] {
|
private getSectors(symbolProfile: SymbolProfile): Sector[] {
|
||||||
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
|
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
|
||||||
(sector) => {
|
(sector) => {
|
||||||
@ -61,4 +81,12 @@ export class SymbolProfileService {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSymbolMapping(symbolProfile: SymbolProfile) {
|
||||||
|
return (
|
||||||
|
(symbolProfile['symbolMapping'] as {
|
||||||
|
[key: string]: string;
|
||||||
|
}) ?? {}
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,6 @@
|
|||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
"target": "es2015"
|
"target": "es2015"
|
||||||
},
|
},
|
||||||
"exclude": ["**/*.spec.ts"],
|
"exclude": ["**/*.spec.ts", "**/*.test.ts"],
|
||||||
"include": ["**/*.ts"]
|
"include": ["**/*.ts"]
|
||||||
}
|
}
|
||||||
|
@ -5,5 +5,5 @@
|
|||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"types": ["jest", "node"]
|
"types": ["jest", "node"]
|
||||||
},
|
},
|
||||||
"include": ["**/*.spec.ts", "**/*.d.ts"]
|
"include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"]
|
||||||
}
|
}
|
||||||
|
@ -14,5 +14,8 @@ module.exports = {
|
|||||||
'jest-preset-angular/build/serializers/ng-snapshot',
|
'jest-preset-angular/build/serializers/ng-snapshot',
|
||||||
'jest-preset-angular/build/serializers/html-comment'
|
'jest-preset-angular/build/serializers/html-comment'
|
||||||
],
|
],
|
||||||
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
|
transform: {
|
||||||
|
'^.+.(ts|mjs|js|html)$': 'jest-preset-angular'
|
||||||
|
},
|
||||||
|
transformIgnorePatterns: ['node_modules/(?!.*.mjs$)']
|
||||||
};
|
};
|
||||||
|
@ -15,6 +15,24 @@
|
|||||||
>(Default)</span
|
>(Default)</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="currency">
|
||||||
|
<th
|
||||||
|
*matHeaderCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
i18n
|
||||||
|
mat-header-cell
|
||||||
|
>
|
||||||
|
Currency
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||||
|
{{ element.currency }}
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||||
|
{{ baseCurrency }}
|
||||||
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="platform">
|
<ng-container matColumnDef="platform">
|
||||||
@ -37,6 +55,11 @@
|
|||||||
<span>{{ element.Platform?.name }}</span>
|
<span>{{ element.Platform?.name }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td
|
||||||
|
*matFooterCellDef
|
||||||
|
class="d-none d-lg-table-cell px-1"
|
||||||
|
mat-footer-cell
|
||||||
|
></td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="transactions">
|
<ng-container matColumnDef="transactions">
|
||||||
@ -45,7 +68,12 @@
|
|||||||
<span class="d-none d-sm-block" i18n>Transactions</span>
|
<span class="d-none d-sm-block" i18n>Transactions</span>
|
||||||
</th>
|
</th>
|
||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
{{ element.transactionCount }}
|
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{
|
||||||
|
element.transactionCount
|
||||||
|
}}</ng-container>
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
|
||||||
|
{{ transactionCount }}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
@ -56,9 +84,39 @@
|
|||||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
<gf-value
|
<gf-value
|
||||||
class="d-inline-block justify-content-end"
|
class="d-inline-block justify-content-end"
|
||||||
[currency]="element.currency"
|
[isCurrency]="true"
|
||||||
[locale]="locale"
|
[locale]="locale"
|
||||||
[value]="element.balance"
|
[value]="element.convertedBalance"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="totalBalance"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="value">
|
||||||
|
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="element.value"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
|
||||||
|
<gf-value
|
||||||
|
class="d-inline-block justify-content-end"
|
||||||
|
[isCurrency]="true"
|
||||||
|
[locale]="locale"
|
||||||
|
[value]="totalValue"
|
||||||
></gf-value>
|
></gf-value>
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@ -88,10 +146,16 @@
|
|||||||
</button>
|
</button>
|
||||||
</mat-menu>
|
</mat-menu>
|
||||||
</td>
|
</td>
|
||||||
|
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
|
||||||
|
<tr
|
||||||
|
*matFooterRowDef="displayedColumns"
|
||||||
|
mat-footer-row
|
||||||
|
[ngClass]="{ 'd-none': isLoading }"
|
||||||
|
></tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<ngx-skeleton-loader
|
<ngx-skeleton-loader
|
||||||
|
@ -10,6 +10,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.mat-table {
|
.mat-table {
|
||||||
|
td {
|
||||||
|
&.mat-footer-cell {
|
||||||
|
border-top: 1px solid
|
||||||
|
rgba(
|
||||||
|
var(--palette-foreground-divider),
|
||||||
|
var(--palette-foreground-divider-alpha)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
th {
|
th {
|
||||||
::ng-deep {
|
::ng-deep {
|
||||||
.mat-sort-header-container {
|
.mat-sort-header-container {
|
||||||
@ -21,7 +31,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
:host-context(.is-dark-theme) {
|
:host-context(.is-dark-theme) {
|
||||||
.mat-form-field {
|
.mat-table {
|
||||||
color: rgba(var(--light-primary-text));
|
td {
|
||||||
|
&.mat-footer-cell {
|
||||||
|
border-top-color: rgba(
|
||||||
|
var(--palette-foreground-divider-dark),
|
||||||
|
var(--palette-foreground-divider-dark-alpha)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,6 +24,9 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
@Input() locale: string;
|
@Input() locale: string;
|
||||||
@Input() showActions: boolean;
|
@Input() showActions: boolean;
|
||||||
|
@Input() totalBalance: number;
|
||||||
|
@Input() totalValue: number;
|
||||||
|
@Input() transactionCount: number;
|
||||||
|
|
||||||
@Output() accountDeleted = new EventEmitter<string>();
|
@Output() accountDeleted = new EventEmitter<string>();
|
||||||
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
@Output() accountToUpdate = new EventEmitter<AccountModel>();
|
||||||
@ -41,7 +44,14 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
|||||||
public ngOnInit() {}
|
public ngOnInit() {}
|
||||||
|
|
||||||
public ngOnChanges() {
|
public ngOnChanges() {
|
||||||
this.displayedColumns = ['account', 'platform', 'transactions', 'balance'];
|
this.displayedColumns = [
|
||||||
|
'account',
|
||||||
|
'currency',
|
||||||
|
'platform',
|
||||||
|
'transactions',
|
||||||
|
'balance',
|
||||||
|
'value'
|
||||||
|
];
|
||||||
|
|
||||||
if (this.showActions) {
|
if (this.showActions) {
|
||||||
this.displayedColumns.push('actions');
|
this.displayedColumns.push('actions');
|
||||||
|
@ -0,0 +1,23 @@
|
|||||||
|
<div class="py-2">
|
||||||
|
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||||
|
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||||
|
<div class="align-items-center d-flex flex-grow-1 px-1">
|
||||||
|
<div
|
||||||
|
*ngFor="let dayItem of days; let i = index"
|
||||||
|
class="day"
|
||||||
|
[title]="
|
||||||
|
(marketDataByMonth[itemByMonth.key][i + 1]?.date
|
||||||
|
| date: defaultDateFormat) ?? ''
|
||||||
|
"
|
||||||
|
[ngClass]="{
|
||||||
|
'available cursor-pointer':
|
||||||
|
marketDataByMonth[itemByMonth.key][i + 1]?.day == i + 1
|
||||||
|
}"
|
||||||
|
(click)="
|
||||||
|
marketDataByMonth[itemByMonth.key][i + 1] &&
|
||||||
|
onOpenMarketDataDetail(marketDataByMonth[itemByMonth.key][i + 1])
|
||||||
|
"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,22 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
.date {
|
||||||
|
font-feature-settings: 'tnum';
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day {
|
||||||
|
background-color: var(--danger);
|
||||||
|
height: 0.5rem;
|
||||||
|
margin-right: 0.25rem;
|
||||||
|
width: 0.5rem;
|
||||||
|
|
||||||
|
&.available {
|
||||||
|
background-color: var(--success);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
Input,
|
||||||
|
OnChanges,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatDialog } from '@angular/material/dialog';
|
||||||
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
|
import { MarketData } from '@prisma/client';
|
||||||
|
import { format } from 'date-fns';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { Subject, takeUntil } from 'rxjs';
|
||||||
|
|
||||||
|
import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-detail-dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-admin-market-data-detail',
|
||||||
|
styleUrls: ['./admin-market-data-detail.component.scss'],
|
||||||
|
templateUrl: './admin-market-data-detail.component.html'
|
||||||
|
})
|
||||||
|
export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||||
|
@Input() marketData: MarketData[];
|
||||||
|
|
||||||
|
public days = Array(31);
|
||||||
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
|
public deviceType: string;
|
||||||
|
public marketDataByMonth: {
|
||||||
|
[yearMonth: string]: { [day: string]: MarketData & { day: number } };
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private dialog: MatDialog
|
||||||
|
) {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnInit() {}
|
||||||
|
|
||||||
|
public ngOnChanges() {
|
||||||
|
this.marketDataByMonth = {};
|
||||||
|
|
||||||
|
for (const marketDataItem of this.marketData) {
|
||||||
|
const currentDay = parseInt(format(marketDataItem.date, 'd'), 10);
|
||||||
|
const key = format(marketDataItem.date, 'yyyy-MM');
|
||||||
|
|
||||||
|
if (!this.marketDataByMonth[key]) {
|
||||||
|
this.marketDataByMonth[key] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.marketDataByMonth[key][currentDay] = {
|
||||||
|
...marketDataItem,
|
||||||
|
day: currentDay
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onOpenMarketDataDetail({ date, marketPrice, symbol }: MarketData) {
|
||||||
|
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||||
|
data: {
|
||||||
|
marketPrice,
|
||||||
|
symbol,
|
||||||
|
date: format(date, DEFAULT_DATE_FORMAT)
|
||||||
|
},
|
||||||
|
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||||
|
});
|
||||||
|
|
||||||
|
dialogRef
|
||||||
|
.afterClosed()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,14 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
|
||||||
|
import { AdminMarketDataDetailComponent } from './admin-market-data-detail.component';
|
||||||
|
import { GfMarketDataDetailDialogModule } from './market-data-detail-dialog/market-data-detail-dialog.module';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminMarketDataDetailComponent],
|
||||||
|
exports: [AdminMarketDataDetailComponent],
|
||||||
|
imports: [CommonModule, GfMarketDataDetailDialogModule],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminMarketDataDetailModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
export interface MarketDataDetailDialogParams {
|
||||||
|
date: string;
|
||||||
|
marketPrice: number;
|
||||||
|
symbol: string;
|
||||||
|
}
|
@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
Inject,
|
||||||
|
OnDestroy
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
import { MarketDataDetailDialogParams } from './interfaces/interfaces';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
host: { class: 'h-100' },
|
||||||
|
selector: 'gf-market-data-detail-dialog',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
styleUrls: ['./market-data-detail-dialog.scss'],
|
||||||
|
templateUrl: 'market-data-detail-dialog.html'
|
||||||
|
})
|
||||||
|
export class MarketDataDetailDialog implements OnDestroy {
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
|
||||||
|
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public ngOnInit() {}
|
||||||
|
|
||||||
|
public onCancel(): void {
|
||||||
|
this.dialogRef.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
<form class="d-flex flex-column h-100">
|
||||||
|
<h1 mat-dialog-title i18n>Details for {{ data.symbol }}</h1>
|
||||||
|
<div class="flex-grow-1" mat-dialog-content>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>Date</mat-label>
|
||||||
|
<input matInput name="date" readonly [(ngModel)]="data.date" />
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<mat-form-field appearance="outline" class="w-100">
|
||||||
|
<mat-label i18n>MarketPrice</mat-label>
|
||||||
|
<input
|
||||||
|
matInput
|
||||||
|
name="marketPrice"
|
||||||
|
readonly
|
||||||
|
[(ngModel)]="data.marketPrice"
|
||||||
|
/>
|
||||||
|
</mat-form-field>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="justify-content-end" mat-dialog-actions>
|
||||||
|
<button i18n mat-button (click)="onCancel()">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
@ -0,0 +1,26 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatDialogModule } from '@angular/material/dialog';
|
||||||
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
|
import { MatInputModule } from '@angular/material/input';
|
||||||
|
|
||||||
|
import { MarketDataDetailDialog } from './market-data-detail-dialog.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [MarketDataDetailDialog],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatDialogModule,
|
||||||
|
MatFormFieldModule,
|
||||||
|
MatInputModule,
|
||||||
|
ReactiveFormsModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfMarketDataDetailDialogModule {}
|
@ -0,0 +1,7 @@
|
|||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-dialog-content {
|
||||||
|
max-height: unset;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit
|
||||||
|
} from '@angular/core';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
|
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
|
||||||
|
import { DataSource, MarketData } from '@prisma/client';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
selector: 'gf-admin-market-data',
|
||||||
|
styleUrls: ['./admin-market-data.scss'],
|
||||||
|
templateUrl: './admin-market-data.html'
|
||||||
|
})
|
||||||
|
export class AdminMarketDataComponent implements OnDestroy, OnInit {
|
||||||
|
public currentSymbol: string;
|
||||||
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
|
public marketData: AdminMarketDataItem[] = [];
|
||||||
|
public marketDataDetails: MarketData[] = [];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.fetchAdminMarketData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherSymbol({
|
||||||
|
dataSource,
|
||||||
|
symbol
|
||||||
|
}: {
|
||||||
|
dataSource: DataSource;
|
||||||
|
symbol: string;
|
||||||
|
}) {
|
||||||
|
this.adminService
|
||||||
|
.gatherSymbol({ dataSource, symbol })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public setCurrentSymbol(aSymbol: string) {
|
||||||
|
this.marketDataDetails = [];
|
||||||
|
|
||||||
|
if (this.currentSymbol === aSymbol) {
|
||||||
|
this.currentSymbol = '';
|
||||||
|
} else {
|
||||||
|
this.currentSymbol = aSymbol;
|
||||||
|
|
||||||
|
this.fetchAdminMarketDataBySymbol(this.currentSymbol);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminMarketData() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAdminMarketData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.marketData = marketData;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminMarketDataBySymbol(aSymbol: string) {
|
||||||
|
this.dataService
|
||||||
|
.fetchAdminMarketDataBySymbol(aSymbol)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ marketData }) => {
|
||||||
|
this.marketDataDetails = marketData;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<table class="gf-table w-100">
|
||||||
|
<thead>
|
||||||
|
<tr class="mat-header-row">
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||||
|
<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"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<ng-container *ngFor="let item of marketData; let i = index">
|
||||||
|
<tr
|
||||||
|
class="cursor-pointer mat-row"
|
||||||
|
(click)="setCurrentSymbol(item.symbol)"
|
||||||
|
>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</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">
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
(click)="onGatherSymbol({dataSource: item.dataSource, symbol: item.symbol})"
|
||||||
|
>
|
||||||
|
Gather Data
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr *ngIf="currentSymbol === item.symbol" class="mat-row">
|
||||||
|
<td></td>
|
||||||
|
<td colspan="4">
|
||||||
|
<gf-admin-market-data-detail
|
||||||
|
[marketData]="marketDataDetails"
|
||||||
|
></gf-admin-market-data-detail>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</ng-container>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,19 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
|
||||||
|
|
||||||
|
import { AdminMarketDataComponent } from './admin-market-data.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminMarketDataComponent],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfAdminMarketDataDetailModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatMenuModule
|
||||||
|
],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminMarketDataModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -0,0 +1,150 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
differenceInSeconds,
|
||||||
|
formatDistanceToNowStrict,
|
||||||
|
isValid,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-admin-overview',
|
||||||
|
styleUrls: ['./admin-overview.scss'],
|
||||||
|
templateUrl: './admin-overview.html'
|
||||||
|
})
|
||||||
|
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||||
|
public dataGatheringInProgress: boolean;
|
||||||
|
public dataGatheringProgress: number;
|
||||||
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
|
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||||
|
public lastDataGathering: string;
|
||||||
|
public transactionCount: number;
|
||||||
|
public userCount: number;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private cacheService: CacheService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private userService: UserService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.fetchAdminData();
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onFlushCache() {
|
||||||
|
this.cacheService
|
||||||
|
.flush()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherMax() {
|
||||||
|
const confirmation = confirm(
|
||||||
|
'This action may take some time. Do you want to proceed?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation === true) {
|
||||||
|
this.adminService
|
||||||
|
.gatherMax()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherProfileData() {
|
||||||
|
this.adminService
|
||||||
|
.gatherProfileData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public formatDistanceToNow(aDateString: string) {
|
||||||
|
if (aDateString) {
|
||||||
|
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||||
|
addSuffix: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||||
|
60
|
||||||
|
? 'just now'
|
||||||
|
: distanceString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminData() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAdminData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(
|
||||||
|
({
|
||||||
|
dataGatheringProgress,
|
||||||
|
exchangeRates,
|
||||||
|
lastDataGathering,
|
||||||
|
transactionCount,
|
||||||
|
userCount
|
||||||
|
}) => {
|
||||||
|
this.dataGatheringProgress = dataGatheringProgress;
|
||||||
|
this.exchangeRates = exchangeRates;
|
||||||
|
|
||||||
|
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
||||||
|
this.lastDataGathering = formatDistanceToNowStrict(
|
||||||
|
new Date(lastDataGathering),
|
||||||
|
{
|
||||||
|
addSuffix: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (lastDataGathering === 'IN_PROGRESS') {
|
||||||
|
this.dataGatheringInProgress = true;
|
||||||
|
} else {
|
||||||
|
this.lastDataGathering = 'Starting soon...';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transactionCount = transactionCount;
|
||||||
|
this.userCount = userCount;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="mb-5 row">
|
||||||
|
<div class="col">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-content>
|
||||||
|
<div
|
||||||
|
*ngIf="exchangeRates?.length > 0"
|
||||||
|
class="align-items-start d-flex my-3"
|
||||||
|
>
|
||||||
|
<div class="w-50" i18n>Exchange Rates</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<table>
|
||||||
|
<tr *ngFor="let exchangeRate of exchangeRates">
|
||||||
|
<td class="d-flex">
|
||||||
|
<gf-value
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="1"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
||||||
|
<td class="px-1">=</td>
|
||||||
|
<td class="d-flex justify-content-end">
|
||||||
|
<gf-value
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="4"
|
||||||
|
[value]="exchangeRate.value"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Data Gathering</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<div>
|
||||||
|
<ng-container *ngIf="lastDataGathering"
|
||||||
|
>{{ lastDataGathering }}</ng-container
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="dataGatheringInProgress" i18n
|
||||||
|
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
|
||||||
|
}})</ng-container
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 overflow-hidden">
|
||||||
|
<div class="mb-2">
|
||||||
|
<button
|
||||||
|
class="mw-100"
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onFlushCache()"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="close-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Reset Data Gathering</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<button
|
||||||
|
class="mw-100"
|
||||||
|
color="warn"
|
||||||
|
mat-flat-button
|
||||||
|
[disabled]="dataGatheringInProgress"
|
||||||
|
(click)="onGatherMax()"
|
||||||
|
>
|
||||||
|
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
||||||
|
<span i18n>Gather All Data</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="mb-2 mr-2 mw-100"
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onGatherProfileData()"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="cloud-download-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Gather Profile Data</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>User Count</div>
|
||||||
|
<div class="w-50">{{ userCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Transaction Count</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<ng-container *ngIf="transactionCount">
|
||||||
|
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||||
|
: '1.2-2' }} <span i18n>per User</span>)
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,17 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import { AdminOverviewComponent } from './admin-overview.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminOverviewComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [CommonModule, GfValueModule, MatButtonModule, MatCardModule],
|
||||||
|
providers: [CacheService],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminOverviewModule {}
|
@ -0,0 +1,17 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-flat-button {
|
||||||
|
::ng-deep {
|
||||||
|
.mat-button-wrapper {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
differenceInSeconds,
|
||||||
|
formatDistanceToNowStrict,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-admin-users',
|
||||||
|
styleUrls: ['./admin-users.scss'],
|
||||||
|
templateUrl: './admin-users.html'
|
||||||
|
})
|
||||||
|
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||||
|
public users: AdminData['users'];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.fetchAdminData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public formatDistanceToNow(aDateString: string) {
|
||||||
|
if (aDateString) {
|
||||||
|
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||||
|
addSuffix: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||||
|
60
|
||||||
|
? 'just now'
|
||||||
|
: distanceString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteUser(aId: string) {
|
||||||
|
const confirmation = confirm('Do you really want to delete this user?');
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.dataService
|
||||||
|
.deleteUser(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.fetchAdminData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminData() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAdminData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ users }) => {
|
||||||
|
this.users = users;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
86
apps/client/src/app/components/admin-users/admin-users.html
Normal file
86
apps/client/src/app/components/admin-users/admin-users.html
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="users">
|
||||||
|
<table class="gf-table">
|
||||||
|
<thead>
|
||||||
|
<tr class="mat-header-row">
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Registration
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Accounts
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Transactions
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Engagement per Day
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="d-none d-sm-inline-block"
|
||||||
|
>{{ userItem.alias || userItem.id }}</span
|
||||||
|
>
|
||||||
|
<span class="d-inline-block d-sm-none"
|
||||||
|
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
||||||
|
'...' }}</span
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||||
|
class="ml-1 text-muted"
|
||||||
|
name="diamond-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ userItem.accountCount }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ userItem.transactionCount }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ userItem.engagement | number: '1.0-0' }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
{{ formatDistanceToNow(userItem.lastActivity) }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="userItem.id === user?.id"
|
||||||
|
(click)="onDeleteUser(userItem.id)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,14 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
|
||||||
|
import { AdminUsersComponent } from './admin-users.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminUsersComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [CommonModule, MatButtonModule, MatMenuModule],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfAdminUsersModule {}
|
18
apps/client/src/app/components/admin-users/admin-users.scss
Normal file
18
apps/client/src/app/components/admin-users/admin-users.scss
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.users {
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 100%;
|
||||||
|
|
||||||
|
.mat-row,
|
||||||
|
.mat-header-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,4 +1,8 @@
|
|||||||
<span class="flex-grow-1 text-truncate">{{ title }}</span>
|
<span
|
||||||
|
class="flex-grow-1 text-truncate"
|
||||||
|
[ngClass]="{ 'text-center': position === 'center' }"
|
||||||
|
>{{ title }}</span
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
*ngIf="deviceType !== 'mobile'"
|
*ngIf="deviceType !== 'mobile'"
|
||||||
class="no-min-width px-0"
|
class="no-min-width px-0"
|
||||||
|
@ -16,6 +16,7 @@ import {
|
|||||||
})
|
})
|
||||||
export class DialogHeaderComponent implements OnInit {
|
export class DialogHeaderComponent implements OnInit {
|
||||||
@Input() deviceType: string;
|
@Input() deviceType: string;
|
||||||
|
@Input() position: 'center' | 'left' = 'left';
|
||||||
@Input() title: string;
|
@Input() title: string;
|
||||||
|
|
||||||
@Output() closeButtonClicked = new EventEmitter<void>();
|
@Output() closeButtonClicked = new EventEmitter<void>();
|
||||||
|
@ -2,7 +2,7 @@
|
|||||||
<ng-container *ngIf="user">
|
<ng-container *ngIf="user">
|
||||||
<a
|
<a
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
class="align-items-center d-flex h-100 mx-2 no-min-width px-2 rounded-0"
|
class="align-items-center d-flex h-100 no-min-width px-2 rounded-0"
|
||||||
mat-button
|
mat-button
|
||||||
>
|
>
|
||||||
<gf-logo></gf-logo>
|
<gf-logo></gf-logo>
|
||||||
@ -229,7 +229,13 @@
|
|||||||
mat-button
|
mat-button
|
||||||
[routerLink]="['/']"
|
[routerLink]="['/']"
|
||||||
>
|
>
|
||||||
<gf-logo [hideName]="!currentRoute || currentRoute === 'start'"></gf-logo>
|
<gf-logo
|
||||||
|
[hideName]="
|
||||||
|
!currentRoute ||
|
||||||
|
currentRoute === 'register' ||
|
||||||
|
currentRoute === 'start'
|
||||||
|
"
|
||||||
|
></gf-logo>
|
||||||
</a>
|
</a>
|
||||||
<span class="spacer"></span>
|
<span class="spacer"></span>
|
||||||
<a
|
<a
|
||||||
@ -264,6 +270,7 @@
|
|||||||
Sign In
|
Sign In
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
|
*ngIf="currentRoute !== 'register'"
|
||||||
class="d-none d-sm-block"
|
class="d-none d-sm-block"
|
||||||
color="primary"
|
color="primary"
|
||||||
i18n
|
i18n
|
||||||
|
@ -0,0 +1,84 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import {
|
||||||
|
RANGE,
|
||||||
|
SettingsStorageService
|
||||||
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||||
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-home-holdings',
|
||||||
|
styleUrls: ['./home-holdings.scss'],
|
||||||
|
templateUrl: './home-holdings.html'
|
||||||
|
})
|
||||||
|
export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||||
|
public dateRange: DateRange;
|
||||||
|
public deviceType: string;
|
||||||
|
public hasPermissionToCreateOrder: boolean;
|
||||||
|
public positions: Position[];
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private settingsStorageService: SettingsStorageService,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToCreateOrder = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.createOrder
|
||||||
|
);
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.dateRange =
|
||||||
|
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.dataService
|
||||||
|
.fetchPositions({ range: this.dateRange })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.positions = response.positions;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
<div class="container justify-content-center pb-3 px-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="align-items-center col-xs-12 col-md-8 offset-md-2">
|
||||||
|
<mat-card class="p-0">
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-positions
|
||||||
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[deviceType]="deviceType"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[positions]="positions"
|
||||||
|
[range]="dateRange"
|
||||||
|
></gf-positions>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
|
||||||
|
<a
|
||||||
|
class="mt-3"
|
||||||
|
i18n
|
||||||
|
mat-button
|
||||||
|
[routerLink]="['/portfolio', 'transactions']"
|
||||||
|
>Manage Transactions...</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,23 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
||||||
|
|
||||||
|
import { HomeHoldingsComponent } from './home-holdings.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [HomeHoldingsComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfPositionsModule,
|
||||||
|
MatButtonModule,
|
||||||
|
MatCardModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfHomeHoldingsModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -0,0 +1,74 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
|
||||||
|
import { 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';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-home-market',
|
||||||
|
styleUrls: ['./home-market.scss'],
|
||||||
|
templateUrl: './home-market.html'
|
||||||
|
})
|
||||||
|
export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||||
|
public fearAndGreedIndex: number;
|
||||||
|
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||||
|
public isLoading = true;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
|
||||||
|
this.user.permissions,
|
||||||
|
permissions.accessFearAndGreedIndex
|
||||||
|
);
|
||||||
|
|
||||||
|
if (this.hasPermissionToAccessFearAndGreedIndex) {
|
||||||
|
this.dataService
|
||||||
|
.fetchSymbolItem({
|
||||||
|
dataSource: DataSource.RAKUTEN,
|
||||||
|
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||||
|
})
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ marketPrice }) => {
|
||||||
|
this.fearAndGreedIndex = marketPrice;
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
}
|
25
apps/client/src/app/components/home-market/home-market.html
Normal file
25
apps/client/src/app/components/home-market/home-market.html
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
<div
|
||||||
|
class="
|
||||||
|
align-items-center
|
||||||
|
container
|
||||||
|
d-flex
|
||||||
|
flex-grow-1
|
||||||
|
h-100
|
||||||
|
justify-content-center
|
||||||
|
w-100
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="row w-100">
|
||||||
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
|
<mat-card class="h-100">
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-fear-and-greed-index
|
||||||
|
class="d-flex justify-content-center"
|
||||||
|
[fearAndGreedIndex]="fearAndGreedIndex"
|
||||||
|
[hidden]="isLoading"
|
||||||
|
></gf-fear-and-greed-index>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,15 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
|
||||||
|
|
||||||
|
import { HomeMarketComponent } from './home-market.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [HomeMarketComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [CommonModule, GfFearAndGreedIndexModule, MatCardModule],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfHomeMarketModule {}
|
@ -0,0 +1,5 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
}
|
@ -0,0 +1,127 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||||
|
import {
|
||||||
|
RANGE,
|
||||||
|
SettingsStorageService
|
||||||
|
} from '@ghostfolio/client/services/settings-storage.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { DateRange } from '@ghostfolio/common/types';
|
||||||
|
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||||
|
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-home-overview',
|
||||||
|
styleUrls: ['./home-overview.scss'],
|
||||||
|
templateUrl: './home-overview.html'
|
||||||
|
})
|
||||||
|
export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||||
|
public dateRange: DateRange;
|
||||||
|
public dateRangeOptions: ToggleOption[] = [
|
||||||
|
{ label: 'Today', value: '1d' },
|
||||||
|
{ label: 'YTD', value: 'ytd' },
|
||||||
|
{ label: '1Y', value: '1y' },
|
||||||
|
{ label: '5Y', value: '5y' },
|
||||||
|
{ label: 'Max', value: 'max' }
|
||||||
|
];
|
||||||
|
public deviceType: string;
|
||||||
|
public hasImpersonationId: boolean;
|
||||||
|
public historicalDataItems: LineChartItem[];
|
||||||
|
public isAllTimeHigh: boolean;
|
||||||
|
public isAllTimeLow: boolean;
|
||||||
|
public isLoadingPerformance = true;
|
||||||
|
public performance: PortfolioPerformance;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private deviceService: DeviceDetectorService,
|
||||||
|
private impersonationStorageService: ImpersonationStorageService,
|
||||||
|
private settingsStorageService: SettingsStorageService,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||||
|
|
||||||
|
this.impersonationStorageService
|
||||||
|
.onChangeHasImpersonation()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((aId) => {
|
||||||
|
this.hasImpersonationId = !!aId;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dateRange =
|
||||||
|
<DateRange>this.settingsStorageService.getSetting(RANGE) || 'max';
|
||||||
|
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public onChangeDateRange(aDateRange: DateRange) {
|
||||||
|
this.dateRange = aDateRange;
|
||||||
|
this.settingsStorageService.setSetting(RANGE, this.dateRange);
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.isLoadingPerformance = true;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchChart({ range: this.dateRange })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((chartData) => {
|
||||||
|
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
|
||||||
|
return {
|
||||||
|
date: chartDataItem.date,
|
||||||
|
value: chartDataItem.value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.isAllTimeHigh = chartData.isAllTimeHigh;
|
||||||
|
this.isAllTimeLow = chartData.isAllTimeLow;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPortfolioPerformance({ range: this.dateRange })
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.performance = response;
|
||||||
|
this.isLoadingPerformance = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
<div
|
||||||
|
class="
|
||||||
|
align-items-center
|
||||||
|
container
|
||||||
|
d-flex
|
||||||
|
flex-column
|
||||||
|
h-100
|
||||||
|
justify-content-center
|
||||||
|
overview
|
||||||
|
p-0
|
||||||
|
position-relative
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<div class="row w-100">
|
||||||
|
<div class="chart-container col">
|
||||||
|
<gf-line-chart
|
||||||
|
symbol="Performance"
|
||||||
|
[historicalDataItems]="historicalDataItems"
|
||||||
|
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||||
|
[showGradient]="true"
|
||||||
|
[showLoader]="false"
|
||||||
|
[showXAxis]="false"
|
||||||
|
[showYAxis]="false"
|
||||||
|
></gf-line-chart>
|
||||||
|
<div
|
||||||
|
*ngIf="historicalDataItems?.length === 0"
|
||||||
|
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||||
|
>
|
||||||
|
<div class="d-flex justify-content-center">
|
||||||
|
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="overview-container row mt-1">
|
||||||
|
<div class="col">
|
||||||
|
<gf-portfolio-performance
|
||||||
|
class="pb-4"
|
||||||
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[isAllTimeHigh]="isAllTimeHigh"
|
||||||
|
[isAllTimeLow]="isAllTimeLow"
|
||||||
|
[isLoading]="isLoadingPerformance"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[performance]="performance"
|
||||||
|
[showDetails]="!hasImpersonationId && !user.settings.isRestrictedView"
|
||||||
|
></gf-portfolio-performance>
|
||||||
|
<div class="text-center">
|
||||||
|
<gf-toggle
|
||||||
|
[defaultValue]="dateRange"
|
||||||
|
[isLoading]="isLoadingPerformance"
|
||||||
|
[options]="dateRangeOptions"
|
||||||
|
(change)="onChangeDateRange($event.value)"
|
||||||
|
></gf-toggle>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,25 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { RouterModule } from '@angular/router';
|
||||||
|
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
|
||||||
|
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||||
|
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||||
|
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||||
|
|
||||||
|
import { HomeOverviewComponent } from './home-overview.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [HomeOverviewComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
GfLineChartModule,
|
||||||
|
GfNoTransactionsInfoModule,
|
||||||
|
GfPortfolioPerformanceModule,
|
||||||
|
GfToggleModule,
|
||||||
|
RouterModule
|
||||||
|
],
|
||||||
|
providers: [],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class GfHomeOverviewModule {}
|
@ -0,0 +1,34 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.chart-container {
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
max-height: 50vh;
|
||||||
|
|
||||||
|
// Fallback for aspect-ratio (using padding hack)
|
||||||
|
@supports not (aspect-ratio: 16 / 9) {
|
||||||
|
&::before {
|
||||||
|
float: left;
|
||||||
|
padding-top: 56.25%;
|
||||||
|
content: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: block;
|
||||||
|
content: '';
|
||||||
|
clear: both;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gf-line-chart {
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,66 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-home-summary',
|
||||||
|
styleUrls: ['./home-summary.scss'],
|
||||||
|
templateUrl: './home-summary.html'
|
||||||
|
})
|
||||||
|
export class HomeSummaryComponent implements OnDestroy, OnInit {
|
||||||
|
public isLoading = true;
|
||||||
|
public summary: PortfolioSummary;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private userService: UserService
|
||||||
|
) {
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.isLoading = true;
|
||||||
|
|
||||||
|
this.dataService
|
||||||
|
.fetchPortfolioSummary()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((response) => {
|
||||||
|
this.summary = response;
|
||||||
|
this.isLoading = false;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
<div class="container pb-3 px-3">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||||
|
<mat-card class="h-100">
|
||||||
|
<mat-card-header>
|
||||||
|
<mat-card-title i18n>Summary</mat-card-title>
|
||||||
|
</mat-card-header>
|
||||||
|
<mat-card-content>
|
||||||
|
<gf-portfolio-summary
|
||||||
|
[baseCurrency]="user?.settings?.baseCurrency"
|
||||||
|
[isLoading]="isLoading"
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[summary]="summary"
|
||||||
|
></gf-portfolio-summary>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user