Compare commits

..

52 Commits

Author SHA1 Message Date
5cd6edaf3a Release 1.55.0 (#383) 2021-09-20 11:58:48 +02:00
98be8745d9 Bugfix/fix create or edit transaction dialog (#382)
* Fix create or edit transaction dialog

* Update changelog
2021-09-20 11:49:50 +02:00
861dff9210 Feature/upgrade storybook dependencies (#381)
* Upgrade storybook dependencies
2021-09-19 18:01:41 +02:00
f2364eed10 Feature/remove default value of data source (#379)
* Remove default value of dataSource

* Update changelog
2021-09-19 17:16:29 +02:00
d5392de7c9 Release 1.54.0 (#378) 2021-09-18 20:21:43 +02:00
0f72673ef4 Feature/respect data source in symbol data endpoint (#370)
* Respect data source in symbol data endpoint

* Respect data source in the data provider service

* Combine symbol with data source in get() of data provider service

* Improve search functionality for multiple data sources

* Update changelog
2021-09-18 19:32:22 +02:00
641fe4e8f4 Bugfix/net performance in positions endpoint (#377)
* Nullify net performance

* Update changelog
2021-09-18 19:20:14 +02:00
18e06bb6e6 Feature/hide sign if performance is zero (#376)
* Hide sign if performance is zero

* Update changelog
2021-09-18 13:36:49 +02:00
5b588c2000 Bugfix/hide the current net performance (#373)
* Hide the current net performance

* Update changelog
2021-09-15 22:27:18 +02:00
162d19fa44 Release 1.53.0 (#369) 2021-09-13 21:37:18 +02:00
4a815d2031 Feature/change data gathering selection (#368)
* Change data gathering selection from distinct orders to symbol profiles

* Update changelog
2021-09-13 21:26:23 +02:00
d2aeeb3e88 optimize annual performance calculation (#367)
* Optimize annual performance calculation

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-12 20:08:42 +02:00
ba926ffcf2 Release 1.52.0 (#366) 2021-09-11 21:36:22 +02:00
5ea455b98b Feature/upgrade simplewebauthn dependencies to version 4.1.0 (#365)
* Upgrade @simplewebauthn dependencies to version 4.1.0
  * @simplewebauthn/browser
  * @simplewebauthn/server

* Update changelog
2021-09-11 21:23:06 +02:00
39f315aba0 Feature/add annualized performance (#364)
* Add annualized performance

* Update changelog
2021-09-11 20:16:53 +02:00
df2dfc20a1 Feature/add slack channel (#363)
* Add Slack channel

* Update changelog
2021-09-11 12:06:28 +02:00
81e83d4cea Release 1.51.0 (#362) 2021-09-11 11:25:07 +02:00
5d4156ecec Feature/refactor position detail dialog (#355)
* Add name to portfolio position endpoint

* Update changelog
2021-09-11 11:23:47 +02:00
4693a8baa2 Release 1.50.0 (#361) 2021-09-11 11:21:53 +02:00
773444b1e2 Bugfix/fix home button overlap on ios (#360)
* Fix overlap

* Update changelog
2021-09-11 11:17:49 +02:00
3c46bde8d5 Bugfix/fix fear and greed index (#359)
* Fix fear and greed index
* Refactor fear and greed index symbol
   * GF.FEAR_AND_GREED_INDEX -> _GF_FEAR_AND_GREED_INDEX

* Update changelog
2021-09-11 11:14:55 +02:00
63ee33b685 Use 'import type' to import types, eliminate webpack warnings (#358) 2021-09-11 09:27:22 +02:00
bc87c0a3e1 Add slack (#357) 2021-09-10 18:11:35 +02:00
caa9fc3efa Release 1.49.0 (#354) 2021-09-08 22:19:53 +02:00
9ed82ac82b Feature/improve labels of allocation chart by symbol (#353)
* Improve labels

* Update changelog
2021-09-08 22:03:33 +02:00
9c9ca4ab1e Add labels to allocation piecharts (#337) 2021-09-08 21:31:34 +02:00
b0b0942162 Release 1.48.0 (#352)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:23:07 +02:00
9cbf789c22 Bugfix/fix values in position detail dialog (#351)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:11:38 +02:00
ee5ab05d8a Release 1.47.1 (#350) 2021-09-06 22:55:08 +02:00
20731c67cb Release 1.47.0 (#349) 2021-09-06 22:34:17 +02:00
bf8856ad19 Bugfix/fix search for cryptocurrencies (#348)
* Fix the search for cryptocurrency symbols

* Update changelog
2021-09-06 22:02:49 +02:00
a31d79821d Release 1.46.0 (#347) 2021-09-05 22:15:21 +02:00
48ab862bb6 net performance for current positions (#330)
* implement fees for transaction points #324

* add net performance to current positions #324

* add net performance to calculate timeline #324

* make timeline fee accumulated by default #324

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-05 21:21:22 +02:00
ba234a470e Feature/add storybook story for trend indicator component (#346)
* Add storybook story for trend indicator component

* Update changelog
2021-09-05 08:49:06 +02:00
ccae660104 Feature/add storybook story for no transactions info component (#345)
* Add storybook story for no transactions info component

* Update changelog
2021-09-05 08:34:02 +02:00
21ed91d184 Feature/add storybook story for logo component (#344)
* Add storybook story for logo component

* Update changelog
2021-09-05 08:03:32 +02:00
5fd413e57e Feature/setup storybook (#332)
* Setup ui library with storybook

* Add value component with story

* Update changelog
2021-09-04 22:12:54 +02:00
4c194c938a Feature/add contributors count to statistics (#342)
* Add contributors count to statistics

* Update changelog
2021-09-04 19:46:24 +02:00
a4d049e53d Release 1.45.0 (#340) 2021-09-04 19:20:09 +02:00
f9c4408126 Update yarn start:server to watch for changes (#338) 2021-09-04 19:05:04 +02:00
d046f1d498 Feature/upgrade nx to version 12.8.0 (#331)
* Upgrade angular and Nx

* Update changelog
2021-09-04 11:25:40 +02:00
ad96d6e53e Feature/upgrade prisma from version 2.24.1 to 2.30.2 (#325)
* Upgrade prisma

* Update changelog
2021-09-04 10:49:09 +02:00
747e5b63fa Feature/restructure allocations page including allocations by symbol (#333)
* Restructure allocations page

* Update changelog
2021-09-03 23:20:30 +02:00
b1187cf880 Add a new symbol allocation chart (#326)
* Add a new symbol allocation chart

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-03 17:48:35 +02:00
ba9e6eab58 Feature/add link to transactions below holdings (#329)
* Add link: Manage transactions

* Update changelog
2021-09-02 21:17:01 +02:00
01feead017 Show decimal transactionCount and singular for 1 transaction (#327)
* Show decimal `transactionCount` and singular for 1 transaction

* Update changelog

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-09-02 20:47:27 +02:00
6a0cfb8f77 Release 1.44.0 (#323) 2021-08-30 18:22:41 +02:00
6386786ac0 Bugfix/improve symbol lookup (#322)
* Improve symbol lookup

* Update changelog
2021-08-30 18:08:21 +02:00
d3be6577c8 Add feature: time-weighted rate of return (TWR) (#321) 2021-08-29 17:09:11 +02:00
73a967a7e5 Feature/add cash as asset sub class (#319)
* Add cash as asset sub class

* Update changelog
2021-08-26 21:49:02 +02:00
836ff6ec13 Feature/upgrade svgmap to version 2.6.0 (#318)
* Upgrade svgmap

* Update changelog
2021-08-26 21:23:49 +02:00
c5bb3023d3 Bugfix/filter out positions without quantity (#317)
* Filter out positions without any quantity

* Update changelog
2021-08-26 17:45:04 +02:00
145 changed files with 9388 additions and 3709 deletions

11
.storybook/main.js Normal file
View File

@ -0,0 +1,11 @@
module.exports = {
stories: [],
addons: ['@storybook/addon-essentials']
// uncomment the property below if you want to apply some webpack config globally
// webpackFinal: async (config, { configType }) => {
// // Make whatever fine-grained changes you need that should apply to all storybook configs
// // Return the altered config
// return config;
// },
};

10
.storybook/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"exclude": [
"../**/*.spec.js",
"../**/*.spec.ts",
"../**/*.spec.tsx",
"../**/*.spec.jsx"
],
"include": ["../**/*"]
}

View File

@ -5,6 +5,146 @@ 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.55.0 - 20.09.2021
### Changed
- Removed the default value of the data source attribute
- Upgraded `@storybook` dependencies
### Fixed
- Fixed an issue in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn prisma migrate deploy`)
## 1.54.0 - 18.09.2021
### Added
- Added the data source attribute to the symbol profile model
### Changed
- Respected the data source attribute in the data provider service
- Respected the data source attribute in the symbol data endpoint
- Improved the search functionality of the data management (multiple data sources)
### Fixed
- Hid the net performance in the _Presenter View_ (portfolio holdings and summary tab on the home page)
- Hid the sign if the performance is zero in the value component
### Todo
- Apply data migration (`yarn prisma migrate deploy`)
## 1.53.0 - 13.09.2021
### Changed
- Optimized the annualized performance calculation
- Changed the data gathering selection from distinct orders to symbol profiles
## 1.52.0 - 11.09.2021
### Added
- Added the annualized performance to the portfolio summary tab on the home page
- Added the Ghostfolio Slack channel to the about page
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `3.0.0` to `4.1.0`
### Fixed
- Fixed the sign in with fingerprint for some android devices
## 1.51.0 - 11.09.2021
### Changed
- Provided the name in the portfolio position endpoint
## 1.50.0 - 11.09.2021
### Fixed
- Fixed the _Fear & Greed Index_ (market mood)
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
## 1.49.0 - 08.09.2021
### Added
- Added labels to the allocation chart by symbol on desktop
## 1.48.0 - 07.09.2021
### Added
- Added the attribute `precision` in the value component
### Fixed
- Hid the performance in the _Presenter View_
## 1.47.1 - 06.09.2021
### Fixed
- Fixed the search functionality for cryptocurrency symbols
## 1.46.0 - 05.09.2021
### Added
- Extended the statistics section on the about page by the _GitHub_ contributors count
- Set up _Storybook_
- Added a story for the logo component
- Added a story for the no transactions info component
- Added a story for the trend indicator component
- Added a story for the value component
### Changed
- Switched from gross to net performance
- Restructured the portfolio summary tab on the home page (fees and net performance)
## 1.45.0 - 04.09.2021
### Added
- Added a link below the holdings to manage the transactions
- Added the allocation chart by symbol
### Changed
- Restructured the allocations page
- Upgraded `angular` from version `12.0.4` to `12.2.4`
- Upgraded `@angular/cdk` and `@angular/material` from version `12.0.6` to `12.2.4`
- Upgraded `Nx` from version `12.5.4` to `12.8.0`
- Upgraded `prisma` from version `2.24.1` to `2.30.2`
### Fixed
- Fixed the value formatting for integers (transactions count)
## 1.44.0 - 30.08.2021
### Changed
- Extended the sub classification of assets by cash
- Upgraded `svgmap` from version `2.1.1` to `2.6.0`
### Fixed
- Filtered out positions without any quantity in the positions table
- Improved the symbol lookup: allow saving with valid symbol in create or edit transaction dialog
## 1.43.0 - 24.08.2021 ## 1.43.0 - 24.08.2021
### Added ### Added
@ -21,7 +161,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn database:push`) - Apply data migration (`yarn prisma migrate deploy`)
## 1.41.0 - 21.08.2021 ## 1.41.0 - 21.08.2021
@ -74,7 +214,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn database:push`) - Apply data migration (`yarn prisma migrate deploy`)
## 1.38.0 - 14.08.2021 ## 1.38.0 - 14.08.2021
@ -134,7 +274,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Todo ### Todo
- Apply data migration (`yarn database:push`) - Apply data migration (`yarn prisma migrate deploy`)
## 1.34.0 - 07.08.2021 ## 1.34.0 - 07.08.2021

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software made for Humans</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -62,7 +62,7 @@ Ghostfolio is for you if you are...
- ✅ Create, update and delete transactions - ✅ Create, update and delete transactions
- ✅ Multi account management - ✅ Multi account management
- ✅ Portfolio performance (`Today`, `YTD`, `1Y`, `5Y`, `Max`) - ✅ Portfolio performance: Time-weighted rate of return (TWR) for `Today`, `YTD`, `1Y`, `5Y`, `Max`
- ✅ Various charts - ✅ Various charts
- ✅ Static analysis to identify potential risks in your portfolio - ✅ Static analysis to identify potential risks in your portfolio
- ✅ Dark Mode - ✅ Dark Mode
@ -116,6 +116,10 @@ Please make sure you have completed the instructions from [_Setup_](#Setup).
Run `yarn start:client` Run `yarn start:client`
### Start _Storybook_
Run `yarn start:storybook`
## Testing ## Testing
Run `yarn test` Run `yarn test`
@ -124,7 +128,7 @@ Run `yarn test`
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
## License ## License

View File

@ -6,13 +6,16 @@
"defaultProject": "api", "defaultProject": "api",
"schematics": { "schematics": {
"@nrwl/angular:application": { "@nrwl/angular:application": {
"linter": "eslint",
"unitTestRunner": "jest", "unitTestRunner": "jest",
"e2eTestRunner": "cypress" "e2eTestRunner": "cypress"
}, },
"@nrwl/angular:library": { "@nrwl/angular:library": {
"linter": "eslint",
"unitTestRunner": "jest" "unitTestRunner": "jest"
}, },
"@nrwl/nest": {} "@nrwl/nest": {},
"@nrwl/angular:component": {}
}, },
"projects": { "projects": {
"api": { "api": {
@ -239,6 +242,90 @@
} }
} }
} }
},
"ui": {
"projectType": "library",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "libs/ui",
"sourceRoot": "libs/ui/src",
"prefix": "gf",
"architect": {
"test": {
"builder": "@nrwl/jest:jest",
"outputs": ["coverage/libs/ui"],
"options": {
"jestConfig": "libs/ui/jest.config.js",
"passWithNoTests": true
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["libs/ui/src/**/*.ts", "libs/ui/src/**/*.html"]
}
},
"storybook": {
"builder": "@nrwl/storybook:storybook",
"options": {
"uiFramework": "@storybook/angular",
"port": 4400,
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
},
"build-storybook": {
"builder": "@nrwl/storybook:build",
"outputs": ["{options.outputPath}"],
"options": {
"uiFramework": "@storybook/angular",
"outputPath": "dist/storybook/ui",
"config": {
"configFolder": "libs/ui/.storybook"
}
},
"configurations": {
"ci": {
"quiet": true
}
}
}
}
},
"ui-e2e": {
"root": "apps/ui-e2e",
"sourceRoot": "apps/ui-e2e/src",
"projectType": "application",
"architect": {
"e2e": {
"builder": "@nrwl/cypress:cypress",
"options": {
"cypressConfig": "apps/ui-e2e/cypress.json",
"devServerTarget": "ui:storybook",
"tsConfig": "apps/ui-e2e/tsconfig.json"
},
"configurations": {
"ci": {
"devServerTarget": "ui:storybook:ci"
}
}
},
"lint": {
"builder": "@nrwl/linter:eslint",
"options": {
"lintFilePatterns": ["apps/ui-e2e/**/*.{js,ts}"]
}
}
}
} }
} }
} }

View File

@ -1,5 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -6,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -5,7 +5,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Delete, Delete,

View File

@ -62,10 +62,10 @@ export class AuthController {
} }
} }
@Get('webauthn/generate-attestation-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() { public async generateRegistrationOptions() {
return this.webAuthService.generateAttestationOptions(); return this.webAuthService.generateRegistrationOptions();
} }
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')

View File

@ -2,7 +2,7 @@ import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Inject, Inject,
Injectable, Injectable,
@ -11,16 +11,16 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { import {
GenerateAssertionOptionsOpts, GenerateAuthenticationOptionsOpts,
GenerateAttestationOptionsOpts, GenerateRegistrationOptionsOpts,
VerifiedAssertion, VerifiedAuthenticationResponse,
VerifiedAttestation, VerifiedRegistrationResponse,
VerifyAssertionResponseOpts, VerifyAuthenticationResponseOpts,
VerifyAttestationResponseOpts, VerifyRegistrationResponseOpts,
generateAssertionOptions, generateAuthenticationOptions,
generateAttestationOptions, generateRegistrationOptions,
verifyAssertionResponse, verifyAuthenticationResponse,
verifyAttestationResponse verifyRegistrationResponse
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { import {
@ -46,10 +46,10 @@ export class WebAuthService {
return this.configurationService.get('ROOT_URL'); return this.configurationService.get('ROOT_URL');
} }
public async generateAttestationOptions() { public async generateRegistrationOptions() {
const user = this.request.user; const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: user.id,
@ -63,7 +63,7 @@ export class WebAuthService {
} }
}; };
const options = generateAttestationOptions(opts); const options = generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -84,27 +84,27 @@ export class WebAuthService {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyAttestationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
credential, credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID
}; };
verification = await verifyAttestationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
const { verified, attestationInfo } = verification; const { registrationInfo, verified } = verification;
const devices = await this.deviceService.authDevices({ const devices = await this.deviceService.authDevices({
where: { userId: user.id } where: { userId: user.id }
}); });
if (verified && attestationInfo) { if (registrationInfo && verified) {
const { credentialPublicKey, credentialID, counter } = attestationInfo; const { counter, credentialID, credentialPublicKey } = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find(
(device) => device.credentialId === credentialID (device) => device.credentialId === credentialID
@ -115,9 +115,9 @@ export class WebAuthService {
* Add the returned device to the user's list of devices * Add the returned device to the user's list of devices
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey, credentialPublicKey,
credentialId: credentialID, credentialId: credentialID,
counter,
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
} }
@ -138,20 +138,20 @@ export class WebAuthService {
throw new Error('Device not found'); throw new Error('Device not found');
} }
const opts: GenerateAssertionOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
timeout: 60000,
allowCredentials: [ allowCredentials: [
{ {
id: device.credentialId, id: device.credentialId,
type: 'public-key', transports: ['internal'],
transports: ['internal'] type: 'public-key'
} }
], ],
userVerification: 'preferred', rpID: this.rpID,
rpID: this.rpID timeout: 60000,
userVerification: 'preferred'
}; };
const options = generateAssertionOptions(opts); const options = generateAuthenticationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -177,29 +177,29 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId }); const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAssertionResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
credential, credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: { authenticator: {
credentialID: device.credentialId, credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey, credentialPublicKey: device.credentialPublicKey,
counter: device.counter counter: device.counter
} },
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
}; };
verification = verifyAssertionResponse(opts); verification = verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }
const { verified, assertionInfo } = verification; const { verified, authenticationInfo } = verification;
if (verified) { if (verified) {
device.counter = assertionInfo.newCounter; device.counter = authenticationInfo.newCounter;
await this.deviceService.updateAuthDevice({ await this.deviceService.updateAuthDevice({
data: device, data: device,

View File

@ -1,6 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -1,7 +1,7 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config'; import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions'; import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

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

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -90,6 +90,27 @@ export class InfoService {
}); });
} }
private async countGitHubContributors(): Promise<number> {
try {
const get = bent(
`https://api.github.com/repos/ghostfolio/ghostfolio/contributors`,
'GET',
'json',
200,
{
'User-Agent': 'request'
}
);
const contributors = await get();
return contributors?.length;
} catch (error) {
console.error(error);
return undefined;
}
}
private async countGitHubStargazers(): Promise<number> { private async countGitHubStargazers(): Promise<number> {
try { try {
const get = bent( const get = bent(
@ -131,11 +152,13 @@ export class InfoService {
const activeUsers1d = await this.countActiveUsers(1); const activeUsers1d = await this.countActiveUsers(1);
const activeUsers30d = await this.countActiveUsers(30); const activeUsers30d = await this.countActiveUsers(30);
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers(); const gitHubStargazers = await this.countGitHubStargazers();
return { return {
activeUsers1d, activeUsers1d,
activeUsers30d, activeUsers30d,
gitHubContributors,
gitHubStargazers gitHubStargazers
}; };
} }

View File

@ -6,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -56,7 +56,9 @@ export class OrderService {
]); ]);
} }
this.dataGatheringService.gatherProfileData([data.symbol]); this.dataGatheringService.gatherProfileData([
{ dataSource: data.dataSource, symbol: data.symbol }
]);
await this.cacheService.flush(); await this.cacheService.flush();

View File

@ -1,6 +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 { Currency, MarketData } from '@prisma/client'; import { Currency, DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service'; import { MarketDataService } from './market-data.service';
@ -14,6 +14,7 @@ jest.mock('./market-data.service', () => {
date, date,
symbol, symbol,
createdAt: date, createdAt: date,
dataSource: DataSource.YAHOO,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584', id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
marketPrice: 1847.839966 marketPrice: 1847.839966
}); });
@ -30,6 +31,7 @@ jest.mock('./market-data.service', () => {
return Promise.resolve<MarketData[]>([ return Promise.resolve<MarketData[]>([
{ {
createdAt: dateRangeStart, createdAt: dateRangeStart,
dataSource: DataSource.YAHOO,
date: dateRangeStart, date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902, marketPrice: 1841.823902,
@ -37,6 +39,7 @@ jest.mock('./market-data.service', () => {
}, },
{ {
createdAt: dateRangeEnd, createdAt: dateRangeEnd,
dataSource: DataSource.YAHOO,
date: dateRangeEnd, date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966, marketPrice: 1847.839966,
@ -106,11 +109,11 @@ describe('CurrentRateService', () => {
expect( expect(
await currentRateService.getValues({ await currentRateService.getValues({
currencies: { AMZN: Currency.USD }, currencies: { AMZN: Currency.USD },
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
dateQuery: { dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)), lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)) gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
}, },
symbols: ['AMZN'],
userCurrency: Currency.CHF userCurrency: Currency.CHF
}) })
).toMatchObject([ ).toMatchObject([

View File

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-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 { DataSource } from '@prisma/client';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
@ -25,7 +26,9 @@ export class CurrentRateService {
userCurrency userCurrency
}: GetValueParams): Promise<GetValueObject> { }: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) { if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([symbol]); const dataProviderResult = await this.dataProviderService.get([
{ symbol, dataSource: DataSource.YAHOO }
]);
return { return {
date: resetHours(date), date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0, marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
@ -55,8 +58,8 @@ export class CurrentRateService {
public async getValues({ public async getValues({
currencies, currencies,
dataGatheringItems,
dateQuery, dateQuery,
symbols,
userCurrency userCurrency
}: GetValuesParams): Promise<GetValueObject[]> { }: GetValuesParams): Promise<GetValueObject[]> {
const includeToday = const includeToday =
@ -75,17 +78,20 @@ export class CurrentRateService {
if (includeToday) { if (includeToday) {
const today = resetHours(new Date()); const today = resetHours(new Date());
promises.push( promises.push(
this.dataProviderService.get(symbols).then((dataResultProvider) => { this.dataProviderService
.get(dataGatheringItems)
.then((dataResultProvider) => {
const result = []; const result = [];
for (const symbol of symbols) { for (const dataGatheringItem of dataGatheringItems) {
result.push({ result.push({
symbol,
date: today, date: today,
marketPrice: this.exchangeRateDataService.toCurrency( marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[symbol]?.marketPrice ?? 0, dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
dataResultProvider?.[symbol]?.currency, 0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency userCurrency
) ),
symbol: dataGatheringItem.symbol
}); });
} }
return result; return result;
@ -93,6 +99,10 @@ export class CurrentRateService {
); );
} }
const symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
promises.push( promises.push(
this.marketDataService this.marketDataService
.getRange({ .getRange({

View File

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

View File

@ -1,10 +1,11 @@
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { DateQuery } from './date-query.interface'; import { DateQuery } from './date-query.interface';
export interface GetValuesParams { export interface GetValuesParams {
currencies: { [symbol: string]: Currency }; currencies: { [symbol: string]: Currency };
dataGatheringItems: IDataGatheringItem[];
dateQuery: DateQuery; dateQuery: DateQuery;
symbols: string[];
userCurrency: Currency; userCurrency: Currency;
} }

View File

@ -1,10 +1,12 @@
import { OrderType } from '@ghostfolio/api/models/order-type'; import { OrderType } from '@ghostfolio/api/models/order-type';
import { Currency } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface PortfolioOrder { export interface PortfolioOrder {
currency: Currency; currency: Currency;
date: string; date: string;
dataSource: DataSource;
fee: Big;
name: string; name: string;
quantity: Big; quantity: Big;
symbol: string; symbol: string;

View File

@ -11,6 +11,9 @@ export interface PortfolioPositionDetail {
marketPrice: number; marketPrice: number;
maxPrice: number; maxPrice: number;
minPrice: number; minPrice: number;
name: string;
netPerformance: number;
netPerformancePercent: number;
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

View File

@ -4,5 +4,6 @@ export interface TimelinePeriod {
date: string; date: string;
grossPerformance: Big; grossPerformance: Big;
investment: Big; investment: Big;
netPerformance: Big;
value: Big; value: Big;
} }

View File

@ -1,8 +1,10 @@
import { Currency } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
export interface TransactionPointSymbol { export interface TransactionPointSymbol {
currency: Currency; currency: Currency;
dataSource: DataSource;
fee: Big;
firstBuyDate: string; firstBuyDate: string;
investment: Big; investment: Big;
quantity: Big; quantity: Big;

File diff suppressed because it is too large Load Diff

View File

@ -1,12 +1,14 @@
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 { 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 { Currency } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
addDays, addDays,
addMonths, addMonths,
addYears, addYears,
differenceInDays,
endOfDay, endOfDay,
format, format,
isAfter, isAfter,
@ -14,7 +16,7 @@ import {
max, max,
min min
} from 'date-fns'; } from 'date-fns';
import { flatten } from 'lodash'; import { flatten, isNumber } from 'lodash';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface'; import { CurrentPositions } from './interfaces/current-positions.interface';
@ -58,6 +60,8 @@ export class PortfolioCalculator {
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = { currentTransactionPointItem = {
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0) investment: newQuantity.eq(0)
? new Big(0) ? new Big(0)
@ -72,6 +76,8 @@ export class PortfolioCalculator {
} else { } else {
currentTransactionPointItem = { currentTransactionPointItem = {
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date, firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor), investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor), quantity: order.quantity.mul(factor),
@ -101,6 +107,23 @@ export class PortfolioCalculator {
} }
} }
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
public getTransactionPoints(): TransactionPoint[] { public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints; return this.transactionPoints;
} }
@ -112,11 +135,14 @@ export class PortfolioCalculator {
public async getCurrentPositions(start: Date): Promise<CurrentPositions> { public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) { if (!this.transactionPoints?.length) {
return { return {
currentValue: new Big(0),
hasErrors: false, hasErrors: false,
positions: [],
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
currentValue: new Big(0), netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0) totalInvestment: new Big(0)
}; };
} }
@ -130,12 +156,15 @@ export class PortfolioCalculator {
let firstTransactionPoint: TransactionPoint = null; let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length; let firstIndex = this.transactionPoints.length;
const dates = []; const dates = [];
const symbols = new Set<string>(); const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: Currency } = {}; const currencies: { [symbol: string]: Currency } = {};
dates.push(resetHours(start)); dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) { for (const item of this.transactionPoints[firstIndex - 1].items) {
symbols.add(item.symbol); dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency; currencies[item.symbol] = item.currency;
} }
for (let i = 0; i < this.transactionPoints.length; i++) { for (let i = 0; i < this.transactionPoints.length; i++) {
@ -155,10 +184,10 @@ export class PortfolioCalculator {
const marketSymbols = await this.currentRateService.getValues({ const marketSymbols = await this.currentRateService.getValues({
currencies, currencies,
dataGatheringItems,
dateQuery: { dateQuery: {
in: dates in: dates
}, },
symbols: Array.from(symbols),
userCurrency: this.currency userCurrency: this.currency
}); });
@ -181,7 +210,9 @@ export class PortfolioCalculator {
const startString = format(start, DATE_FORMAT); const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {}; const holdingPeriodReturns: { [symbol: string]: Big } = {};
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {}; const grossPerformance: { [symbol: string]: Big } = {};
const netPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT); const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) { if (firstIndex > 0) {
@ -190,6 +221,7 @@ export class PortfolioCalculator {
const invalidSymbols = []; const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {}; const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {}; const lastQuantities: { [symbol: string]: Big } = {};
const lastFees: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {}; const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) { for (let i = firstIndex; i < this.transactionPoints.length; i++) {
@ -202,10 +234,6 @@ export class PortfolioCalculator {
const items = this.transactionPoints[i].items; const items = this.transactionPoints[i].items;
for (const item of items) { for (const item of items) {
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
if (!oldHoldingPeriodReturn) {
oldHoldingPeriodReturn = new Big(1);
}
if (!marketSymbolMap[nextDate]?.[item.symbol]) { if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol); invalidSymbols.push(item.symbol);
hasErrors = true; hasErrors = true;
@ -224,6 +252,13 @@ export class PortfolioCalculator {
const itemValue = marketSymbolMap[currentDate]?.[item.symbol]; const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
let initialValue = itemValue?.mul(lastQuantity); let initialValue = itemValue?.mul(lastQuantity);
let investedValue = itemValue?.mul(item.quantity); let investedValue = itemValue?.mul(item.quantity);
const isFirstOrderAndIsStartBeforeCurrentDate =
i === firstIndex &&
isBefore(parseDate(this.transactionPoints[i].date), start);
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
const fee = isFirstOrderAndIsStartBeforeCurrentDate
? new Big(0)
: item.fee.minus(lastFee);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) { if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment; initialValue = item.investment;
investedValue = item.investment; investedValue = item.investment;
@ -247,18 +282,26 @@ export class PortfolioCalculator {
); );
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow)); const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] = holdingPeriodReturns[item.symbol] = (
oldHoldingPeriodReturn.mul(holdingPeriodReturn); holdingPeriodReturns[item.symbol] ?? new Big(1)
let oldGrossPerformance = grossPerformance[item.symbol]; ).mul(holdingPeriodReturn);
if (!oldGrossPerformance) { grossPerformance[item.symbol] = (
oldGrossPerformance = new Big(0); grossPerformance[item.symbol] ?? new Big(0)
} ).plus(endValue.minus(investedValue));
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] = const netHoldingPeriodReturn = endValue.div(
oldGrossPerformance.plus(currentPerformance); initialValue.plus(cashFlow).plus(fee)
);
netHoldingPeriodReturns[item.symbol] = (
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
).mul(netHoldingPeriodReturn);
netPerformance[item.symbol] = (
netPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue).minus(fee));
} }
lastInvestments[item.symbol] = item.investment; lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity; lastQuantities[item.symbol] = item.quantity;
lastFees[item.symbol] = item.fee;
} }
} }
@ -272,6 +315,7 @@ export class PortfolioCalculator {
? new Big(0) ? new Big(0)
: item.investment.div(item.quantity), : item.investment.div(item.quantity),
currency: item.currency, currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: isValid grossPerformance: isValid
? grossPerformance[item.symbol] ?? null ? grossPerformance[item.symbol] ?? null
@ -282,15 +326,17 @@ export class PortfolioCalculator {
: null, : null,
investment: item.investment, investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null, marketPrice: marketValue?.toNumber() ?? null,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
netPerformancePercentage:
isValid && netHoldingPeriodReturns[item.symbol]
? netHoldingPeriodReturns[item.symbol].minus(1)
: null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });
} }
const overall = this.calculateOverallGrossPerformance( const overall = this.calculateOverallPerformance(positions, initialValues);
positions,
initialValues
);
return { return {
...overall, ...overall,
@ -378,7 +424,7 @@ export class PortfolioCalculator {
return flatten(timelinePeriods); return flatten(timelinePeriods);
} }
private calculateOverallGrossPerformance( private calculateOverallPerformance(
positions: TimelinePosition[], positions: TimelinePosition[],
initialValues: { [p: string]: Big } initialValues: { [p: string]: Big }
) { ) {
@ -387,7 +433,14 @@ export class PortfolioCalculator {
let totalInvestment = new Big(0); let totalInvestment = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0); let grossPerformancePercentage = new Big(0);
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0); let completeInitialValue = new Big(0);
let netAnnualizedPerformance = new Big(0);
// use Date.now() to use the mock for today
const today = new Date(Date.now());
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.marketPrice) { if (currentPosition.marketPrice) {
currentValue = currentValue.add( currentValue = currentValue.add(
@ -401,6 +454,7 @@ export class PortfolioCalculator {
grossPerformance = grossPerformance.plus( grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance currentPosition.grossPerformance
); );
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
hasErrors = true; hasErrors = true;
} }
@ -414,6 +468,18 @@ export class PortfolioCalculator {
grossPerformancePercentage = grossPerformancePercentage.plus( grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue) currentPosition.grossPerformancePercentage.mul(currentInitialValue)
); );
netAnnualizedPerformance = netAnnualizedPerformance.plus(
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
today,
parseDate(currentPosition.firstBuyDate)
),
netPerformancePercent: currentPosition.netPerformancePercentage
}).mul(currentInitialValue)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
console.error( console.error(
`Initial value is missing for symbol ${currentPosition.symbol}` `Initial value is missing for symbol ${currentPosition.symbol}`
@ -425,6 +491,10 @@ export class PortfolioCalculator {
if (!completeInitialValue.eq(0)) { if (!completeInitialValue.eq(0)) {
grossPerformancePercentage = grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue); grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
netAnnualizedPerformance =
netAnnualizedPerformance.div(completeInitialValue);
} }
return { return {
@ -432,6 +502,9 @@ export class PortfolioCalculator {
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
hasErrors, hasErrors,
netAnnualizedPerformance,
netPerformance,
netPerformancePercentage,
totalInvestment totalInvestment
}; };
} }
@ -442,30 +515,35 @@ export class PortfolioCalculator {
endDate: Date endDate: Date
): Promise<TimelinePeriod[]> { ): Promise<TimelinePeriod[]> {
let investment: Big = new Big(0); let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
} = {}; } = {};
if (j >= 0) { if (j >= 0) {
const currencies: { [name: string]: Currency } = {}; const currencies: { [name: string]: Currency } = {};
const symbols: string[] = []; const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) { for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency; currencies[item.symbol] = item.currency;
symbols.push(item.symbol); dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.add(item.investment); investment = investment.add(item.investment);
fees = fees.add(item.fee);
} }
let marketSymbols: GetValueObject[] = []; let marketSymbols: GetValueObject[] = [];
if (symbols.length > 0) { if (dataGatheringItems.length > 0) {
try { try {
marketSymbols = await this.currentRateService.getValues({ marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: { dateQuery: {
gte: startDate, gte: startDate,
lt: endOfDay(endDate) lt: endOfDay(endDate)
}, },
symbols,
currencies,
userCurrency: this.currency userCurrency: this.currency
}); });
} catch (error) { } catch (error) {
@ -490,7 +568,7 @@ export class PortfolioCalculator {
} }
} }
const results = []; const results: TimelinePeriod[] = [];
for ( for (
let currentDate = startDate; let currentDate = startDate;
isBefore(currentDate, endDate); isBefore(currentDate, endDate);
@ -513,11 +591,13 @@ export class PortfolioCalculator {
} }
} }
if (!invalid) { if (!invalid) {
const grossPerformance = value.minus(investment);
const result = { const result = {
date: currentDateAsString, grossPerformance,
grossPerformance: value.minus(investment),
investment, investment,
value value,
date: currentDateAsString,
netPerformance: grossPerformance.minus(fees)
}; };
results.push(result); results.push(result);
} }

View File

@ -11,7 +11,7 @@ import {
PortfolioSummary PortfolioSummary
} 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 { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -223,6 +223,7 @@ export class PortfolioController {
return nullifyValuesInObject(position, [ return nullifyValuesInObject(position, [
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance',
'quantity' 'quantity'
]); ]);
}); });
@ -246,6 +247,7 @@ export class PortfolioController {
'cash', 'cash',
'committedFunds', 'committedFunds',
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance',
'currentValue', 'currentValue',
'fees', 'fees',
'netWorth', 'netWorth',
@ -276,6 +278,7 @@ export class PortfolioController {
position = nullifyValuesInObject(position, [ position = nullifyValuesInObject(position, [
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance',
'quantity' 'quantity'
]); ]);
} }

View File

@ -26,14 +26,13 @@ import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
Position, Position,
TimelinePosition TimelinePosition
} 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 { import type {
DateRange, DateRange,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
@ -48,6 +47,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays,
endOfToday, endOfToday,
format, format,
isAfter, isAfter,
@ -59,7 +59,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, isNumber } from 'lodash';
import { import {
HistoricalDataItem, HistoricalDataItem,
@ -148,7 +148,7 @@ export class PortfolioService {
.map((timelineItem) => ({ .map((timelineItem) => ({
date: timelineItem.date, date: timelineItem.date,
marketPrice: timelineItem.value, marketPrice: timelineItem.value,
value: timelineItem.grossPerformance.toNumber() value: timelineItem.netPerformance.toNumber()
})); }));
} }
@ -191,12 +191,18 @@ export class PortfolioService {
); );
const totalValue = currentPositions.currentValue.plus(cashDetails.balance); const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const symbols = currentPositions.positions.map( const symbols = currentPositions.positions.map(
(position) => position.symbol (position) => position.symbol
); );
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols), this.dataProviderService.get(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -211,6 +217,11 @@ export class PortfolioService {
} }
for (const item of currentPositions.positions) { for (const item of currentPositions.positions) {
if (item.quantity.lte(0)) {
// Ignore positions without any quantity
continue;
}
const value = item.quantity.mul(item.marketPrice); const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
@ -229,6 +240,8 @@ export class PortfolioService {
marketPrice: item.marketPrice, marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState, marketState: dataProviderResponse.marketState,
name: symbolProfile.name, name: symbolProfile.name,
netPerformance: item.netPerformance?.toNumber() ?? 0,
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
quantity: item.quantity.toNumber(), quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol, symbol: item.symbol,
@ -276,6 +289,9 @@ export class PortfolioService {
marketPrice: undefined, marketPrice: undefined,
maxPrice: undefined, maxPrice: undefined,
minPrice: undefined, minPrice: undefined,
name: undefined,
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: undefined, quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined
@ -283,10 +299,13 @@ export class PortfolioService {
} }
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.symbol,
@ -314,13 +333,14 @@ export class PortfolioService {
const { const {
averagePrice, averagePrice,
currency, currency,
dataSource,
firstBuyDate, firstBuyDate,
marketPrice, marketPrice,
quantity, quantity,
transactionCount transactionCount
} = position; } = position;
// Convert investment and gross performance to currency of user // Convert investment, gross and net performance to currency of user
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency( const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(), position.investment.toNumber(),
@ -332,9 +352,14 @@ export class PortfolioService {
currency, currency,
userCurrency userCurrency
); );
const netPerformance = this.exchangeRateDataService.toCurrency(
position.netPerformance.toNumber(),
currency,
userCurrency
);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [{ dataSource, symbol: aSymbol }],
'day', 'day',
parseISO(firstBuyDate), parseISO(firstBuyDate),
new Date() new Date()
@ -393,19 +418,24 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
netPerformance,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
netPerformancePercent: position.netPerformancePercentage.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
symbol: aSymbol symbol: aSymbol
}; };
} else { } else {
const currentData = await this.dataProviderService.get([aSymbol]); const currentData = await this.dataProviderService.get([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const marketPrice = currentData[aSymbol]?.marketPrice; const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical( let historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
'day', 'day',
portfolioStart, portfolioStart,
new Date() new Date()
@ -439,6 +469,7 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
averagePrice: 0, averagePrice: 0,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined, firstBuyDate: undefined,
@ -446,6 +477,8 @@ export class PortfolioService {
grossPerformancePercent: undefined, grossPerformancePercent: undefined,
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: 0, investment: 0,
netPerformance: undefined,
netPerformancePercent: undefined,
quantity: 0, quantity: 0,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined
@ -484,10 +517,16 @@ export class PortfolioService {
const positions = currentPositions.positions.filter( const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0) (item) => !item.quantity.eq(0)
); );
const dataGatheringItem = positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const symbols = positions.map((position) => position.symbol); const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([ const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols), this.dataProviderService.get(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols) this.symbolProfileService.getSymbolProfiles(symbols)
]); ]);
@ -509,6 +548,9 @@ export class PortfolioService {
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
marketState: dataProviderResponses[position.symbol].marketState, marketState: dataProviderResponses[position.symbol].marketState,
name: symbolProfileMap[position.symbol].name, name: symbolProfileMap[position.symbol].name,
netPerformance: position.netPerformance?.toNumber() ?? null,
netPerformancePercentage:
position.netPerformancePercentage?.toNumber() ?? null,
quantity: new Big(position.quantity).toNumber() quantity: new Big(position.quantity).toNumber()
}; };
}) })
@ -532,8 +574,11 @@ export class PortfolioService {
return { return {
hasErrors: false, hasErrors: false,
performance: { performance: {
annualizedPerformancePercent: 0,
currentGrossPerformance: 0, currentGrossPerformance: 0,
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0 currentValue: 0
} }
}; };
@ -548,16 +593,25 @@ export class PortfolioService {
); );
const hasErrors = currentPositions.hasErrors; const hasErrors = currentPositions.hasErrors;
const annualizedPerformancePercent =
currentPositions.netAnnualizedPerformance.toNumber();
const currentValue = currentPositions.currentValue.toNumber(); const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = const currentGrossPerformance =
currentPositions.grossPerformance.toNumber(); currentPositions.grossPerformance.toNumber();
const currentGrossPerformancePercent = const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage.toNumber(); currentPositions.grossPerformancePercentage.toNumber();
const currentNetPerformance = currentPositions.netPerformance.toNumber();
const currentNetPerformancePercent =
currentPositions.netPerformancePercentage.toNumber();
return { return {
hasErrors: currentPositions.hasErrors || hasErrors, hasErrors: currentPositions.hasErrors || hasErrors,
performance: { performance: {
annualizedPerformancePercent,
currentGrossPerformance, currentGrossPerformance,
currentGrossPerformancePercent, currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue: currentValue currentValue: currentValue
} }
}; };
@ -719,6 +773,7 @@ export class PortfolioService {
allocationCurrent: cashValue.div(value).toNumber(), allocationCurrent: cashValue.div(value).toNumber(),
allocationInvestment: cashValue.div(investment).toNumber(), allocationInvestment: cashValue.div(investment).toNumber(),
assetClass: AssetClass.CASH, assetClass: AssetClass.CASH,
assetSubClass: AssetClass.CASH,
countries: [], countries: [],
currency: Currency.CHF, currency: Currency.CHF,
grossPerformance: 0, grossPerformance: 0,
@ -727,6 +782,8 @@ export class PortfolioService {
marketPrice: 0, marketPrice: 0,
marketState: MarketState.open, marketState: MarketState.open,
name: 'Cash', name: 'Cash',
netPerformance: 0,
netPerformancePercent: 0,
quantity: 0, quantity: 0,
sectors: [], sectors: [],
symbol: ghostfolioCashSymbol, symbol: ghostfolioCashSymbol,
@ -772,7 +829,15 @@ export class PortfolioService {
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
userCurrency
)
),
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
quantity: new Big(order.quantity), quantity: new Big(order.quantity),
symbol: order.symbol, symbol: order.symbol,

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -10,7 +10,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isEmpty } from 'lodash';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -45,9 +47,28 @@ export class SymbolController {
/** /**
* Must be after /lookup * Must be after /lookup
*/ */
@Get(':symbol') @Get(':dataSource/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> { public async getSymbolData(
return this.symbolService.get(symbol); @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolItem> {
if (!DataSource[dataSource]) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
const result = await this.symbolService.get({ dataSource, symbol });
if (!result || isEmpty(result)) {
throw new HttpException(
getReasonPhrase(StatusCodes.NOT_FOUND),
StatusCodes.NOT_FOUND
);
}
return result;
} }
} }

View File

@ -1,4 +1,5 @@
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 { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, DataSource } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
@ -13,17 +14,21 @@ export class SymbolService {
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(aSymbol: string): Promise<SymbolItem> { public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
const response = await this.dataProviderService.get([aSymbol]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, dataSource, marketPrice } = response[aSymbol]; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) {
return { return {
dataSource,
marketPrice, marketPrice,
currency: <Currency>(<unknown>currency) currency: <Currency>(<unknown>currency),
dataSource: dataGatheringItem.dataSource
}; };
} }
return undefined;
}
public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> { public async lookup(aQuery: string): Promise<{ items: LookupItem[] }> {
const results: { items: LookupItem[] } = { items: [] }; const results: { items: LookupItem[] } = { items: [] };

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,15 +1,13 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, benchmarks,
getUtc, currencyPairs,
isGhostfolioScraperApiSymbol, ghostfolioFearAndGreedIndexSymbol
resetHours } from '@ghostfolio/common/config';
} from '@ghostfolio/common/helper'; import { DATE_FORMAT, getUtc, resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { import {
differenceInHours, differenceInHours,
endOfToday,
format, format,
getDate, getDate,
getMonth, getMonth,
@ -119,20 +117,17 @@ export class DataGatheringService {
} }
} }
public async gatherProfileData(aSymbols?: string[]) { public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
console.log('Profile data gathering has been started.'); console.log('Profile data gathering has been started.');
console.time('data-gathering-profile'); console.time('data-gathering-profile');
let symbols = aSymbols; let dataGatheringItems = aDataGatheringItems;
if (!symbols) { if (!dataGatheringItems) {
const dataGatheringItems = await this.getSymbolsProfileData(); dataGatheringItems = await this.getSymbolsProfileData();
symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
} }
const currentData = await this.dataProviderService.get(symbols); const currentData = await this.dataProviderService.get(dataGatheringItems);
for (const [ for (const [
symbol, symbol,
@ -211,6 +206,7 @@ export class DataGatheringService {
try { try {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
dataSource,
symbol, symbol,
date: currentDate, date: currentDate,
marketPrice: lastMarketPrice marketPrice: lastMarketPrice
@ -295,7 +291,7 @@ export class DataGatheringService {
benchmarksToGather.push({ benchmarksToGather.push({
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
date: startDate, date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX' symbol: ghostfolioFearAndGreedIndexSymbol
}); });
} }
@ -305,24 +301,17 @@ export class DataGatheringService {
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prismaService.order.findMany({ const symbolProfilesToGather = (
distinct: ['symbol'], await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }, select: {
where: { dataSource: true,
date: { symbol: true
lt: endOfToday() // no draft
} }
}
});
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
.filter((distinctOrder) => {
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
}) })
.map((distinctOrder) => { ).map((symbolProfile) => {
return { return {
...distinctOrder, ...symbolProfile,
date: startDate date: startDate
}; };
}); });
@ -344,7 +333,7 @@ export class DataGatheringService {
...this.getBenchmarksToGather(startDate), ...this.getBenchmarksToGather(startDate),
...customSymbolsToGather, ...customSymbolsToGather,
...currencyPairsToGather, ...currencyPairsToGather,
...distinctOrdersWithDate ...symbolProfilesToGather
]; ];
} }
@ -364,14 +353,12 @@ export class DataGatheringService {
} }
); );
const distinctOrders = await this.prismaService.order.findMany({ const symbolProfilesToGather =
distinct: ['symbol'], await this.prismaService.symbolProfile.findMany({
orderBy: [{ date: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, date: true, symbol: true }, select: {
where: { dataSource: true,
date: { symbol: true
lt: endOfToday() // no draft
}
} }
}); });
@ -379,7 +366,7 @@ export class DataGatheringService {
...this.getBenchmarksToGather(startDate), ...this.getBenchmarksToGather(startDate),
...customSymbolsToGather, ...customSymbolsToGather,
...currencyPairsToGather, ...currencyPairsToGather,
...distinctOrders ...symbolProfilesToGather
]; ];
} }

View File

@ -6,11 +6,7 @@ import {
IDataProviderResponse IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { import { DATE_FORMAT } from '@ghostfolio/common/helper';
DATE_FORMAT,
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
@ -19,7 +15,10 @@ import { format } from 'date-fns';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service'; import {
YahooFinanceService,
convertToYahooFinanceSymbol
} from './yahoo-finance/yahoo-finance.service';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -34,49 +33,32 @@ export class DataProviderService {
this.rakutenRapidApiService?.setPrisma(this.prismaService); this.rakutenRapidApiService?.setPrisma(this.prismaService);
} }
public async get( public async get(items: IDataGatheringItem[]): Promise<{
aSymbols: string[] [symbol: string]: IDataProviderResponse;
): Promise<{ [symbol: string]: IDataProviderResponse }> { }> {
if (aSymbols.length === 1) { const response: {
const symbol = aSymbols[0]; [symbol: string]: IDataProviderResponse;
} = {};
if (isGhostfolioScraperApiSymbol(symbol)) { for (const item of items) {
return this.ghostfolioScraperApiService.get(aSymbols); if (item.dataSource === DataSource.ALPHA_VANTAGE) {
} else if (isRakutenRapidApiSymbol(symbol)) { response[item.symbol] = (
return this.rakutenRapidApiService.get(aSymbols); await this.alphaVantageService.get([item.symbol])
} )[item.symbol];
} } else if (item.dataSource === DataSource.GHOSTFOLIO) {
response[item.symbol] = (
const yahooFinanceSymbols = aSymbols.filter((symbol) => { await this.ghostfolioScraperApiService.get([item.symbol])
return ( )[item.symbol];
!isGhostfolioScraperApiSymbol(symbol) && } else if (item.dataSource === DataSource.RAKUTEN) {
!isRakutenRapidApiSymbol(symbol) response[item.symbol] = (
); await this.rakutenRapidApiService.get([item.symbol])
}); )[item.symbol];
} else if (item.dataSource === DataSource.YAHOO) {
const response = await this.yahooFinanceService.get(yahooFinanceSymbols); response[item.symbol] = (
await this.yahooFinanceService.get([
const ghostfolioScraperApiSymbols = aSymbols.filter((symbol) => { convertToYahooFinanceSymbol(item.symbol)
return isGhostfolioScraperApiSymbol(symbol); ])
}); )[item.symbol];
for (const symbol of ghostfolioScraperApiSymbols) {
if (symbol) {
const ghostfolioScraperApiResult =
await this.ghostfolioScraperApiService.get([symbol]);
response[symbol] = ghostfolioScraperApiResult[symbol];
}
}
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => {
return isRakutenRapidApiSymbol(symbol);
});
for (const symbol of rakutenRapidApiSymbols) {
if (symbol) {
const rakutenRapidApiResult =
await this.ghostfolioScraperApiService.get([symbol]);
response[symbol] = rakutenRapidApiResult[symbol];
} }
} }
@ -84,7 +66,7 @@ export class DataProviderService {
} }
public async getHistorical( public async getHistorical(
aSymbols: string[], aItems: IDataGatheringItem[],
aGranularity: Granularity = 'month', aGranularity: Granularity = 'month',
from: Date, from: Date,
to: Date to: Date
@ -108,8 +90,17 @@ export class DataProviderService {
)}'` )}'`
: ''; : '';
const dataSources = aItems.map((item) => {
return item.dataSource;
});
const symbols = aItems.map((item) => {
return item.symbol;
});
try { try {
const queryRaw = `SELECT * FROM "MarketData" WHERE "symbol" IN ('${aSymbols.join( const queryRaw = `SELECT * FROM "MarketData" WHERE "dataSource" IN ('${dataSources.join(
`','`
)}') AND "symbol" IN ('${symbols.join(
`','` `','`
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`; )}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
@ -168,13 +159,24 @@ export class DataProviderService {
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
const { items } = await this.getDataProvider( const promises: Promise<{ items: LookupItem[] }>[] = [];
<DataSource>this.configurationService.get('DATA_SOURCES')[0] let lookupItems: LookupItem[] = [];
).search(aSymbol);
const filteredItems = items.filter((item) => { for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
);
}
const searchResults = await Promise.all(promises);
searchResults.forEach((searchResult) => {
lookupItems = lookupItems.concat(searchResult.items);
});
const filteredItems = lookupItems.filter((lookupItem) => {
// Only allow symbols with supported currency // Only allow symbols with supported currency
return item.currency ? true : false; return lookupItem.currency ? true : false;
}); });
return { return {

View File

@ -1,6 +1,7 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getToday, getToday,
@ -47,11 +48,11 @@ export class RakutenRapidApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
return { return {
'GF.FEAR_AND_GREED_INDEX': { [ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
@ -82,7 +83,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
try { try {
@ -93,6 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN,
date: subWeeks(getToday(), 1), date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value marketPrice: fgi.oneWeekAgo.value
} }
@ -101,6 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN,
date: subMonths(getToday(), 1), date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value marketPrice: fgi.oneMonthAgo.value
} }
@ -109,6 +112,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN,
date: subYears(getToday(), 1), date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value marketPrice: fgi.oneYearAgo.value
} }
@ -118,7 +122,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
} catch {} } catch {}
return { return {
'GF.FEAR_AND_GREED_INDEX': { [ghostfolioFearAndGreedIndexSymbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value marketPrice: fgi.previousClose.value
} }

View File

@ -43,16 +43,12 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public async get( public async get(
aSymbols: string[] aYahooFinanceSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) { if (aYahooFinanceSymbols.length <= 0) {
return {}; return {};
} }
const yahooSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol);
});
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
@ -60,12 +56,12 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceQuoteResponse; [symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({ } = await yahooFinance.quote({
modules: ['price', 'summaryProfile'], modules: ['price', 'summaryProfile'],
symbols: yahooSymbols symbols: aYahooFinanceSymbols
}); });
for (const [yahooSymbol, value] of Object.entries(data)) { for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price); const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
@ -136,15 +132,15 @@ export class YahooFinanceService implements DataProviderInterface {
return {}; return {};
} }
const yahooSymbols = aSymbols.map((symbol) => { const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol); return convertToYahooFinanceSymbol(symbol);
}); });
try { try {
const historicalData: { const historicalData: {
[symbol: string]: IYahooFinanceHistoricalResponse[]; [symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({ } = await yahooFinance.historical({
symbols: yahooSymbols, symbols: yahooFinanceSymbols,
from: format(from, DATE_FORMAT), from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
@ -153,9 +149,11 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
for (const [yahooSymbol, timeSeries] of Object.entries(historicalData)) { for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
historicalData
)) {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {}; response[symbol] = {};
timeSeries.forEach((timeSerie) => { timeSeries.forEach((timeSerie) => {
@ -175,7 +173,7 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
@ -192,19 +190,6 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols // filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType }) => {
return quoteType === 'EQUITY' || quoteType === 'ETF';
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
items = searchResult.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => { .filter(({ quoteType }) => {
return ( return (
quoteType === 'CRYPTOCURRENCY' || quoteType === 'CRYPTOCURRENCY' ||
@ -220,42 +205,25 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
}) })
.map(({ longname, shortname, symbol }) => { .map(({ symbol }) => {
return { return symbol;
currency: marketData[symbol]?.currency,
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
}); });
const marketData = await this.get(symbols);
for (const [symbol, value] of Object.entries(marketData)) {
items.push({
symbol,
currency: value.currency,
dataSource: DataSource.YAHOO,
name: value.name
});
}
} catch {} } catch {}
return { items }; return { items };
} }
/**
* Converts a symbol to a Yahoo symbol
*
* Currency: USDCHF=X
* Cryptocurrency: BTC-USD
*/
private convertToYahooSymbol(aSymbol: string) {
if (isCurrency(aSymbol)) {
if (isCrypto(aSymbol)) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
return `${aSymbol.substring(0, aSymbol.length - 3)}-${aSymbol.substring(
aSymbol.length - 3
)}`;
}
return `${aSymbol}=X`;
}
return aSymbol;
}
private parseAssetClass(aPrice: IYahooFinancePrice): { private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
@ -290,7 +258,30 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooFinanceSymbol = (aYahooFinanceSymbol: string) => {
const symbol = aSymbol.replace('-', ''); const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', ''); return symbol.replace('=X', '');
}; };
/**
* Converts a symbol to a Yahoo Finance symbol
*
* Currency: USDCHF=X
* Cryptocurrency: BTC-USD
*/
export const convertToYahooFinanceSymbol = (aSymbol: string) => {
if (isCurrency(aSymbol)) {
if (isCrypto(aSymbol)) {
// Add a dash before the last three characters
// BTCUSD -> BTC-USD
// DOGEUSD -> DOGE-USD
return `${aSymbol.substring(0, aSymbol.length - 3)}-${aSymbol.substring(
aSymbol.length - 3
)}`;
}
return `${aSymbol}=X`;
}
return aSymbol;
};

View File

@ -1,15 +1,16 @@
import { currencyPairs } from '@ghostfolio/common/config'; import { currencyPairs } 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 } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isEmpty, isNumber } from 'lodash'; import { isEmpty, isNumber } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private currencyPairs: string[] = []; private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {}; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor(private dataProviderService: DataProviderService) { public constructor(private dataProviderService: DataProviderService) {
@ -20,8 +21,8 @@ export class ExchangeRateDataService {
this.currencyPairs = []; this.currencyPairs = [];
this.exchangeRates = {}; this.exchangeRates = {};
for (const { currency1, currency2 } of currencyPairs) { for (const { currency1, currency2, dataSource } of currencyPairs) {
this.addCurrencyPairs(currency1, currency2); this.addCurrencyPairs({ currency1, currency2, dataSource });
} }
await this.loadCurrencies(); await this.loadCurrencies();
@ -39,8 +40,8 @@ export class ExchangeRateDataService {
// Load currencies directly from data provider as a fallback // Load currencies directly from data provider as a fallback
// if historical data is not yet available // if historical data is not yet available
const historicalData = await this.dataProviderService.get( const historicalData = await this.dataProviderService.get(
this.currencyPairs.map((currencyPair) => { this.currencyPairs.map(({ dataSource, symbol }) => {
return currencyPair; return { dataSource, symbol };
}) })
); );
@ -67,21 +68,21 @@ export class ExchangeRateDataService {
}; };
}); });
this.currencyPairs.forEach((pair) => { this.currencyPairs.forEach(({ symbol }) => {
const [currency1, currency2] = pair.match(/.{1,3}/g); const [currency1, currency2] = symbol.match(/.{1,3}/g);
const date = format(getYesterday(), DATE_FORMAT); const date = format(getYesterday(), DATE_FORMAT);
this.exchangeRates[pair] = resultExtended[pair]?.[date]?.marketPrice; this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
if (!this.exchangeRates[pair]) { if (!this.exchangeRates[symbol]) {
// Not found, calculate indirectly via USD // Not found, calculate indirectly via USD
this.exchangeRates[pair] = this.exchangeRates[symbol] =
resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice * resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice *
resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice; resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
this.exchangeRates[`${currency2}${currency1}`] = this.exchangeRates[`${currency2}${currency1}`] =
1 / this.exchangeRates[pair]; 1 / this.exchangeRates[symbol];
} }
}); });
} }
@ -123,8 +124,22 @@ export class ExchangeRateDataService {
return aValue; return aValue;
} }
private addCurrencyPairs(aCurrency1: Currency, aCurrency2: Currency) { private addCurrencyPairs({
this.currencyPairs.push(`${aCurrency1}${aCurrency2}`); currency1,
this.currencyPairs.push(`${aCurrency2}${aCurrency1}`); currency2,
dataSource
}: {
currency1: Currency;
currency2: Currency;
dataSource: DataSource;
}) {
this.currencyPairs.push({
dataSource,
symbol: `${currency1}${currency2}`
});
this.currencyPairs.push({
dataSource,
symbol: `${currency2}${currency1}`
});
} }
} }

View File

@ -5,10 +5,10 @@ import { MatInputModule } from '@angular/material/input';
import { MatMenuModule } from '@angular/material/menu'; import { MatMenuModule } from '@angular/material/menu';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { AccountsTableComponent } from './accounts-table.component'; import { AccountsTableComponent } from './accounts-table.component';
@NgModule({ @NgModule({

View File

@ -5,7 +5,7 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar'; import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module'; import { LoginWithAccessTokenDialogModule } from '@ghostfolio/client/components/login-with-access-token-dialog/login-with-access-token-dialog.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module'; import { GfLogoModule } from '@ghostfolio/ui/logo';
import { HeaderComponent } from './header.component'; import { HeaderComponent } from './header.component';

View File

@ -3,12 +3,12 @@ import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { GfFearAndGreedIndexModule } from '../fear-and-greed-index/fear-and-greed-index.module'; import { GfFearAndGreedIndexModule } from '../fear-and-greed-index/fear-and-greed-index.module';
import { GfValueModule } from '../value/value.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component'; import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({ @NgModule({

View File

@ -37,7 +37,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : performance?.currentGrossPerformance" [value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value> ></gf-value>
</div> </div>
<div class="col"> <div class="col">
@ -46,7 +46,7 @@
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]=" [value]="
isLoading ? undefined : performance?.currentGrossPerformancePercent isLoading ? undefined : performance?.currentNetPerformancePercent
" "
></gf-value> ></gf-value>
</div> </div>

View File

@ -52,7 +52,7 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
new CountUp( new CountUp(
'value', 'value',
this.performance?.currentGrossPerformancePercent * 100, this.performance?.currentNetPerformancePercent * 100,
{ {
decimalPlaces: 2, decimalPlaces: 2,
duration: 0.75, duration: 0.75,

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value/value.module';
import { PortfolioPerformanceComponent } from './portfolio-performance.component'; import { PortfolioPerformanceComponent } from './portfolio-performance.component';
@NgModule({ @NgModule({

View File

@ -16,6 +16,7 @@ import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js'; import { ArcElement } from 'chart.js';
import { DoughnutController } from 'chart.js'; import { DoughnutController } from 'chart.js';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import * as Color from 'color'; import * as Color from 'color';
@Component({ @Component({
@ -32,6 +33,7 @@ export class PortfolioProportionChartComponent
@Input() keys: string[]; @Input() keys: string[];
@Input() locale: string; @Input() locale: string;
@Input() maxItems?: number; @Input() maxItems?: number;
@Input() showLabels = false;
@Input() positions: { @Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number }; [symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
}; };
@ -48,7 +50,13 @@ export class PortfolioProportionChartComponent
}; };
public constructor() { public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); Chart.register(
ArcElement,
ChartDataLabels,
DoughnutController,
LinearScale,
Tooltip
);
} }
public ngOnInit() {} public ngOnInit() {}
@ -167,7 +175,8 @@ export class PortfolioProportionChartComponent
// Reuse color // Reuse color
item.color = this.colorMap[symbol]; item.color = this.colorMap[symbol];
} else { } else {
const color = this.getColorPalette()[index]; const color =
this.getColorPalette()[index % this.getColorPalette().length];
// Store color for reuse // Store color for reuse
this.colorMap[symbol] = color; this.colorMap[symbol] = color;
@ -234,7 +243,30 @@ export class PortfolioProportionChartComponent
data, data,
options: { options: {
cutout: '70%', cutout: '70%',
layout: {
padding: this.showLabels === true ? 100 : 0
},
plugins: { plugins: {
datalabels: {
color: (context) => {
return this.getColorPalette()[
context.dataIndex % this.getColorPalette().length
];
},
display: this.showLabels === true ? 'auto' : false,
labels: {
index: {
align: 'end',
anchor: 'end',
formatter: (value, context) => {
return value > 0
? context.chart.data.labels[context.dataIndex]
: '';
},
offset: 8
}
}
},
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
callbacks: { callbacks: {

View File

@ -9,23 +9,6 @@
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.fees"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div> <div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
@ -66,7 +49,7 @@
</div> </div>
</div> </div>
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Performance</div> <div class="d-flex flex-grow-1" i18n>Absolute Gross Performance</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -77,7 +60,7 @@
</div> </div>
</div> </div>
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Performance (TWR)</div> <div class="d-flex flex-grow-1 ml-3" i18n>Gross Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end"> <div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -91,6 +74,48 @@
></gf-value> ></gf-value>
</div> </div>
</div> </div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ summary?.ordersCount }} {summary?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<span *ngIf="summary?.fees || summary?.fees === 0" class="mr-1">-</span>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.fees"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Absolute Net Performance</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformance"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Net Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.currentNetPerformancePercent"
></gf-value>
</div>
</div>
<div class="row"> <div class="row">
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
@ -121,7 +146,7 @@
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Net Worth</div> <div class="d-flex flex-grow-1 font-weight-bold" i18n>Net Worth</div>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -131,4 +156,17 @@
></gf-value> ></gf-value>
</div> </div>
</div> </div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1 ml-3" i18n>Annualized Performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent"
></gf-value>
</div>
</div>
</div> </div>

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { GfValueModule } from '@ghostfolio/ui/value';
import { GfValueModule } from '../value/value.module';
import { PortfolioSummaryComponent } from './portfolio-summary.component'; import { PortfolioSummaryComponent } from './portfolio-summary.component';
@NgModule({ @NgModule({

View File

@ -3,5 +3,4 @@ export interface PositionDetailDialogParams {
deviceType: string; deviceType: string;
locale: string; locale: string;
symbol: string; symbol: string;
title: string;
} }

View File

@ -34,7 +34,11 @@ export class PositionDetailDialog implements OnDestroy {
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public name: string;
public netPerformance: number;
public netPerformancePercent: number;
public quantity: number; public quantity: number;
public symbol: string;
public transactionCount: number; public transactionCount: number;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -60,7 +64,11 @@ export class PositionDetailDialog implements OnDestroy {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
netPerformance,
netPerformancePercent,
quantity, quantity,
symbol,
transactionCount transactionCount
}) => { }) => {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
@ -86,7 +94,11 @@ export class PositionDetailDialog implements OnDestroy {
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.name = name;
this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent;
this.quantity = quantity; this.quantity = quantity;
this.symbol = symbol;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
if (isToday(parseISO(this.firstBuyDate))) { if (isToday(parseISO(this.firstBuyDate))) {

View File

@ -1,7 +1,7 @@
<gf-dialog-header <gf-dialog-header
mat-dialog-title mat-dialog-title
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="data.title ?? data.symbol" [title]="name ?? symbol"
(closeButtonClicked)="onClose()" (closeButtonClicked)="onClose()"
></gf-dialog-header> ></gf-dialog-header>
@ -25,7 +25,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[currency]="data.baseCurrency" [currency]="data.baseCurrency"
[locale]="data.locale" [locale]="data.locale"
[value]="grossPerformance" [value]="netPerformance"
></gf-value> ></gf-value>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
@ -35,7 +35,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="data.locale" [locale]="data.locale"
[value]="grossPerformancePercent" [value]="netPerformancePercent"
></gf-value> ></gf-value>
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
@ -80,7 +80,8 @@
<gf-value <gf-value
label="Quantity" label="Quantity"
size="medium" size="medium"
[isCurrency]="true" [locale]="data.locale"
[precision]="2"
[value]="quantity" [value]="quantity"
></gf-value> ></gf-value>
</div> </div>
@ -102,9 +103,8 @@
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Transactions"
size="medium" size="medium"
[isCurrency]="true" [label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
[locale]="data.locale" [locale]="data.locale"
[value]="transactionCount" [value]="transactionCount"
></gf-value> ></gf-value>

View File

@ -3,11 +3,11 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module'; import { GfDialogFooterModule } from '../../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module'; import { GfDialogHeaderModule } from '../../dialog-header/dialog-header.module';
import { GfValueModule } from '../../value/value.module';
import { PositionDetailDialog } from './position-detail-dialog.component'; import { PositionDetailDialog } from './position-detail-dialog.component';
@NgModule({ @NgModule({

View File

@ -11,7 +11,7 @@
[isLoading]="isLoading" [isLoading]="isLoading"
[marketState]="position?.marketState" [marketState]="position?.marketState"
[range]="range" [range]="range"
[value]="position?.grossPerformancePercentage" [value]="position?.netPerformancePercentage"
></gf-trend-indicator> ></gf-trend-indicator>
</div> </div>
<div *ngIf="isLoading" class="flex-grow-1"> <div *ngIf="isLoading" class="flex-grow-1">
@ -47,13 +47,13 @@
[colorizeSign]="true" [colorizeSign]="true"
[currency]="baseCurrency" [currency]="baseCurrency"
[locale]="locale" [locale]="locale"
[value]="position?.grossPerformance" [value]="position?.netPerformance"
></gf-value> ></gf-value>
<gf-value <gf-value
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="position?.grossPerformancePercentage" [value]="position?.netPerformancePercentage"
></gf-value> ></gf-value>
</div> </div>
</div> </div>

View File

@ -64,8 +64,7 @@ export class PositionComponent implements OnDestroy, OnInit {
baseCurrency: this.baseCurrency, baseCurrency: this.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.locale, locale: this.locale,
symbol: this.position?.symbol, symbol: this.position?.symbol
title: this.position?.name
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -3,10 +3,10 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatDialogModule } from '@angular/material/dialog'; import { MatDialogModule } from '@angular/material/dialog';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfTrendIndicatorModule } from '@ghostfolio/ui/trend-indicator';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfTrendIndicatorModule } from '../trend-indicator/trend-indicator.module';
import { GfValueModule } from '../value/value.module';
import { GfPositionDetailDialogModule } from './position-detail-dialog/position-detail-dialog.module'; import { GfPositionDetailDialogModule } from './position-detail-dialog/position-detail-dialog.module';
import { PositionComponent } from './position.component'; import { PositionComponent } from './position.component';

View File

@ -30,7 +30,7 @@
[colorizeSign]="true" [colorizeSign]="true"
[isPercent]="true" [isPercent]="true"
[locale]="locale" [locale]="locale"
[value]="isLoading ? undefined : element.grossPerformancePercent" [value]="isLoading ? undefined : element.netPerformancePercent"
></gf-value> ></gf-value>
</div> </div>
</td> </td>
@ -87,7 +87,7 @@
}" }"
(click)=" (click)="
!this.ignoreAssetClasses.includes(row.assetClass) && !this.ignoreAssetClasses.includes(row.assetClass) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name }) onOpenPositionDialog({ symbol: row.symbol })
" "
></tr> ></tr>
</table> </table>

View File

@ -57,14 +57,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if ( if (params['positionDetailDialog'] && params['symbol']) {
params['positionDetailDialog'] &&
params['symbol'] &&
params['title']
) {
this.openPositionDialog({ this.openPositionDialog({
symbol: params['symbol'], symbol: params['symbol']
title: params['title']
}); });
} }
}); });
@ -96,15 +91,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase(); this.dataSource.filter = filterValue.trim().toLowerCase();
}*/ }*/
public onOpenPositionDialog({ public onOpenPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
this.router.navigate([], { this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title } queryParams: { positionDetailDialog: true, symbol }
}); });
} }
@ -116,18 +105,11 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public openPositionDialog({ public openPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: {
symbol, symbol,
title,
baseCurrency: this.baseCurrency, baseCurrency: this.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.locale locale: this.locale

View File

@ -8,12 +8,12 @@ import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module'; import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { PositionsTableComponent } from './positions-table.component'; import { PositionsTableComponent } from './positions-table.component';
@NgModule({ @NgModule({

View File

@ -1,8 +1,8 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionModule } from '../position/position.module'; import { GfPositionModule } from '../position/position.module';
import { PositionsComponent } from './positions.component'; import { PositionsComponent } from './positions.component';

View File

@ -3,8 +3,8 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfRuleModule } from '@ghostfolio/client/components/rule/rule.module'; import { GfRuleModule } from '@ghostfolio/client/components/rule/rule.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfNoTransactionsInfoModule } from '../no-transactions-info/no-transactions-info.module';
import { GfPositionModule } from '../position/position.module'; import { GfPositionModule } from '../position/position.module';
import { RulesComponent } from './rules.component'; import { RulesComponent } from './rules.component';

View File

@ -1,4 +1,8 @@
<mat-radio-group [formControl]="option" (change)="onValueChange()"> <mat-radio-group
class="text-nowrap"
[formControl]="option"
(change)="onValueChange()"
>
<mat-radio-button <mat-radio-button
*ngFor="let option of options" *ngFor="let option of options"
[disabled]="isLoading" [disabled]="isLoading"

View File

@ -255,8 +255,7 @@
mat-row mat-row
(click)=" (click)="
onOpenPositionDialog({ onOpenPositionDialog({
symbol: row.symbol, symbol: row.symbol
title: row.SymbolProfile?.name
}) })
" "
></tr> ></tr>

View File

@ -86,8 +86,7 @@ export class TransactionsTableComponent
.subscribe((params) => { .subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) { if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({ this.openPositionDialog({
symbol: params['symbol'], symbol: params['symbol']
title: params['title']
}); });
} }
}); });
@ -196,15 +195,9 @@ export class TransactionsTableComponent
this.import.emit(); this.import.emit();
} }
public onOpenPositionDialog({ public onOpenPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
this.router.navigate([], { this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title } queryParams: { positionDetailDialog: true, symbol }
}); });
} }
@ -216,18 +209,11 @@ export class TransactionsTableComponent
this.transactionToClone.emit(aTransaction); this.transactionToClone.emit(aTransaction);
} }
public openPositionDialog({ public openPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: {
symbol, symbol,
title,
baseCurrency: this.baseCurrency, baseCurrency: this.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.locale locale: this.locale

View File

@ -10,11 +10,11 @@ import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table'; import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module'; import { GfPositionDetailDialogModule } from '../position/position-detail-dialog/position-detail-dialog.module';
import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module'; import { GfSymbolIconModule } from '../symbol-icon/symbol-icon.module';
import { GfValueModule } from '../value/value.module';
import { TransactionsTableComponent } from './transactions-table.component'; import { TransactionsTableComponent } from './transactions-table.component';
@NgModule({ @NgModule({

View File

@ -32,7 +32,12 @@
</p> </p>
<p> <p>
If you encounter a bug or would like to suggest an improvement or a If you encounter a bug or would like to suggest an improvement or a
new feature, please tweet to new feature, please join the Ghostfolio
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
title="Join the Ghostfolio Slack channel"
>Slack channel</a
>, tweet to
<a <a
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter" title="Tweet to Ghostfolio on Twitter"
@ -65,6 +70,14 @@
> >
<ion-icon name="mail" size="large"></ion-icon> <ion-icon name="mail" size="large"></ion-icon>
</a> </a>
<a
class="mx-2"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
mat-icon-button
title="Join the Ghostfolio Slack channel"
>
<ion-icon name="logo-slack" size="large"></ion-icon>
</a>
<a <a
class="mx-2" class="mx-2"
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"
@ -94,27 +107,37 @@
<mat-card> <mat-card>
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers1d"> <h3 class="mb-0" [hidden]="!statistics?.activeUsers1d">
{{ statistics?.activeUsers1d ?? '-' }} {{ statistics?.activeUsers1d ?? '-' }}
</h3> </h3>
<div class="h6 mb-0"> <div class="h6 mb-0">
Active Users <small class="text-muted">(Last 24 hours)</small> <span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 24 hours)</small
>
</div> </div>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.activeUsers30d"> <h3 class="mb-0" [hidden]="!statistics?.activeUsers30d">
{{ statistics?.activeUsers30d ?? '-' }} {{ statistics?.activeUsers30d ?? '-' }}
</h3> </h3>
<div class="h6 mb-0"> <div class="h6 mb-0">
Active Users <small class="text-muted">(Last 30 days)</small> <span i18n>Active Users</span>&nbsp;<small class="text-muted"
>(Last 30 days)</small
>
</div> </div>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.gitHubContributors">
{{ statistics?.gitHubContributors ?? '-' }}
</h3>
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
</div>
<div class="col-xs-12 col-md-3 my-2">
<h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers"> <h3 class="mb-0" [hidden]="!statistics?.gitHubStargazers">
{{ statistics?.gitHubStargazers ?? '-' }} {{ statistics?.gitHubStargazers ?? '-' }}
</h3> </h3>
<div class="h6 mb-0">Stars on GitHub</div> <div class="h6 mb-0" i18n>Stars on GitHub</div>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>

View File

@ -20,6 +20,7 @@ import {
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import {
PortfolioPerformance, PortfolioPerformance,
PortfolioSummary, PortfolioSummary,
@ -28,6 +29,7 @@ import {
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs'; import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -58,6 +60,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean; public hasPositions: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true; public isLoadingPerformance = true;
@ -110,7 +113,10 @@ export class HomePageComponent implements OnDestroy, OnInit {
if (this.hasPermissionToAccessFearAndGreedIndex) { if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService this.dataService
.fetchSymbolItem('GF.FEAR_AND_GREED_INDEX') .fetchSymbolItem({
dataSource: DataSource.RAKUTEN,
symbol: ghostfolioFearAndGreedIndexSymbol
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => { .subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
@ -119,6 +125,11 @@ export class HomePageComponent implements OnDestroy, OnInit {
}); });
} }
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -135,6 +146,8 @@ export class HomePageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => { .subscribe((aId) => {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
this.changeDetectorRef.markForCheck();
}); });
this.dateRange = this.dateRange =

View File

@ -91,8 +91,8 @@
(change)="onChangeDateRange($event.value)" (change)="onChangeDateRange($event.value)"
></gf-toggle> ></gf-toggle>
</div> </div>
<ng-container *ngIf="hasPositions === true">
<mat-card *ngIf="hasPositions === true" class="p-0"> <mat-card class="p-0">
<mat-card-content> <mat-card-content>
<gf-positions <gf-positions
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
@ -103,6 +103,16 @@
></gf-positions> ></gf-positions>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</ng-container>
<div <div
*ngIf="hasPositions === false" *ngIf="hasPositions === false"
class="d-flex justify-content-center" class="d-flex justify-content-center"

View File

@ -5,12 +5,12 @@ import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/client/components/no-transactions-info/no-transactions-info.module';
import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module'; import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module'; import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module'; import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module'; import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module'; import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { HomePageRoutingModule } from './home-page-routing.module'; import { HomePageRoutingModule } from './home-page-routing.module';
import { HomePageComponent } from './home-page.component'; import { HomePageComponent } from './home-page.component';

View File

@ -16,6 +16,9 @@
right: 0; right: 0;
top: 0; top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep { ::ng-deep {
.mat-tab-body-wrapper { .mat-tab-body-wrapper {
height: 100%; height: 100%;

View File

@ -3,7 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module'; import { GfLogoModule } from '@ghostfolio/ui/logo';
import { LandingPageRoutingModule } from './landing-page-routing.module'; import { LandingPageRoutingModule } from './landing-page-routing.module';
import { LandingPageComponent } from './landing-page.component'; import { LandingPageComponent } from './landing-page.component';

View File

@ -42,6 +42,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public sectors: { public sectors: {
[name: string]: { name: string; value: number }; [name: string]: { name: string; value: number };
}; };
public symbols: {
[name: string]: { name: string; value: number };
};
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -114,6 +118,12 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
value: 0 value: 0
} }
}; };
this.symbols = {
[UNKNOWN_KEY]: {
name: UNKNOWN_KEY,
value: 0
}
};
for (const [name, { current, original }] of Object.entries( for (const [name, { current, original }] of Object.entries(
this.portfolioDetails.accounts this.portfolioDetails.accounts
@ -208,6 +218,13 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
: this.portfolioDetails.holdings[symbol].value; : this.portfolioDetails.holdings[symbol].value;
} }
} }
if (position.assetClass === AssetClass.EQUITY) {
this.symbols[symbol] = {
name: symbol,
value: aPeriod === 'original' ? position.investment : position.value
};
}
} }
} }

View File

@ -5,10 +5,10 @@
</div> </div>
</div> </div>
<div class="proportion-charts row"> <div class="proportion-charts row">
<div class="col-md-6"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title i18n>By Account</mat-card-title> <mat-card-title class="text-truncate" i18n>By Account</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -27,10 +27,12 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title i18n>By Asset Class</mat-card-title> <mat-card-title class="text-truncate" i18n
>By Asset Class</mat-card-title
>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -49,10 +51,12 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title i18n>By Currency</mat-card-title> <mat-card-title class="text-truncate" i18n
>By Currency</mat-card-title
>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -71,10 +75,34 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-6"> <div class="col-md-12 allocations-by-symbol">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title i18n>By Sector</mat-card-title> <mat-card-title class="text-truncate" i18n>By Symbol</mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
class="mx-auto"
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false"
[keys]="['name']"
[locale]="user?.settings?.locale"
[positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Sector</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -94,10 +122,12 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title i18n>By Continent</mat-card-title> <mat-card-title class="text-truncate" i18n
>By Continent</mat-card-title
>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -116,10 +146,10 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-6"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title i18n>By Country</mat-card-title> <mat-card-title class="text-truncate" i18n>By Country</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -143,8 +173,8 @@
<div class="row world-map-chart"> <div class="row world-map-chart">
<div class="col-lg"> <div class="col-lg">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title i18n>Regions</mat-card-title> <mat-card-title class="text-truncate" i18n>Regions</mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"

View File

@ -1,9 +1,7 @@
:host { :host {
.proportion-charts { .allocations-by-symbol {
.mat-card { gf-portfolio-proportion-chart {
.mat-card-content { max-width: 80vh;
padding: 1rem 2rem;
}
} }
} }

View File

@ -3,7 +3,8 @@ import {
ChangeDetectorRef, ChangeDetectorRef,
Component, Component,
Inject, Inject,
OnDestroy OnDestroy,
ViewChild
} from '@angular/core'; } from '@angular/core';
import { FormControl, Validators } from '@angular/forms'; import { FormControl, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete'; import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
@ -11,8 +12,10 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Observable, Subject } from 'rxjs'; import { isString } from 'lodash';
import { EMPTY, Observable, Subject } from 'rxjs';
import { import {
catchError,
debounceTime, debounceTime,
distinctUntilChanged, distinctUntilChanged,
startWith, startWith,
@ -30,13 +33,19 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
templateUrl: 'create-or-update-transaction-dialog.html' templateUrl: 'create-or-update-transaction-dialog.html'
}) })
export class CreateOrUpdateTransactionDialog implements OnDestroy { export class CreateOrUpdateTransactionDialog implements OnDestroy {
@ViewChild('autocomplete') autocomplete;
public currencies: Currency[] = []; public currencies: Currency[] = [];
public currentMarketPrice = null; public currentMarketPrice = null;
public filteredLookupItems: Observable<LookupItem[]>; public filteredLookupItems: LookupItem[];
public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public searchSymbolCtrl = new FormControl( public searchSymbolCtrl = new FormControl(
this.data.transaction.symbol, {
dataSource: this.data.transaction.dataSource,
symbol: this.data.transaction.symbol
},
Validators.required Validators.required
); );
@ -49,19 +58,27 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams @Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
) {} ) {}
ngOnInit() { public ngOnInit() {
const { currencies, platforms } = this.dataService.fetchInfo(); const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies; this.currencies = currencies;
this.platforms = platforms; this.platforms = platforms;
this.filteredLookupItems = this.searchSymbolCtrl.valueChanges.pipe( this.filteredLookupItemsObservable =
this.searchSymbolCtrl.valueChanges.pipe(
startWith(''), startWith(''),
debounceTime(400), debounceTime(400),
distinctUntilChanged(), distinctUntilChanged(),
switchMap((aQuery: string) => { switchMap((query: string) => {
if (aQuery) { if (isString(query)) {
return this.dataService.fetchSymbols(aQuery); const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
return filteredLookupItemsObservable;
} }
return []; return [];
@ -70,7 +87,10 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
if (this.data.transaction.symbol) { if (this.data.transaction.symbol) {
this.dataService this.dataService
.fetchSymbolItem(this.data.transaction.symbol) .fetchSymbolItem({
dataSource: this.data.transaction.dataSource,
symbol: this.data.transaction.symbol
})
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => { .subscribe(({ marketPrice }) => {
this.currentMarketPrice = marketPrice; this.currentMarketPrice = marketPrice;
@ -84,17 +104,68 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.data.transaction.unitPrice = this.currentMarketPrice; this.data.transaction.unitPrice = this.currentMarketPrice;
} }
public displayFn(aLookupItem: LookupItem) {
return aLookupItem?.symbol ?? '';
}
public onBlurSymbol() {
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
return lookupItem.symbol === this.data.transaction.symbol;
});
if (currentLookupItem) {
this.updateSymbol(currentLookupItem.symbol);
} else {
this.searchSymbolCtrl.setErrors({ incorrect: true });
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.symbol = null;
}
this.changeDetectorRef.markForCheck();
}
public onCancel(): void { public onCancel(): void {
this.dialogRef.close(); this.dialogRef.close();
} }
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) { public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
this.data.transaction.dataSource = event.option.value.dataSource;
this.updateSymbol(event.option.value.symbol);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private updateSymbol(symbol: string) {
this.isLoading = true; this.isLoading = true;
this.data.transaction.symbol = event.option.value;
this.searchSymbolCtrl.setErrors(null);
this.data.transaction.symbol = symbol;
this.dataService this.dataService
.fetchSymbolItem(this.data.transaction.symbol) .fetchSymbolItem({
.pipe(takeUntil(this.unsubscribeSubject)) dataSource: this.data.transaction.dataSource,
symbol: this.data.transaction.symbol
})
.pipe(
catchError(() => {
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.unitPrice = null;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ currency, dataSource, marketPrice }) => { .subscribe(({ currency, dataSource, marketPrice }) => {
this.data.transaction.currency = currency; this.data.transaction.currency = currency;
this.data.transaction.dataSource = dataSource; this.data.transaction.dataSource = dataSource;
@ -105,17 +176,4 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
}); });
} }
public onUpdateSymbolByTyping(value: string) {
this.data.transaction.currency = null;
this.data.transaction.dataSource = null;
this.data.transaction.unitPrice = null;
this.data.transaction.symbol = value;
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
} }

View File

@ -28,18 +28,19 @@
matInput matInput
required required
[formControl]="searchSymbolCtrl" [formControl]="searchSymbolCtrl"
[matAutocomplete]="auto" [matAutocomplete]="autocomplete"
(change)="onUpdateSymbolByTyping($event.target.value)" (blur)="onBlurSymbol()"
/> />
<mat-autocomplete <mat-autocomplete
#auto="matAutocomplete" #autocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)" (optionSelected)="onUpdateSymbol($event)"
> >
<ng-container> <ng-container>
<mat-option <mat-option
*ngFor="let lookupItem of filteredLookupItems | async" *ngFor="let lookupItem of filteredLookupItemsObservable | async"
class="autocomplete" class="autocomplete"
[value]="lookupItem.symbol" [value]="lookupItem"
> >
<span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span <span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span
><span><b>{{ lookupItem.name }}</b></span> ><span><b>{{ lookupItem.name }}</b></span>

View File

@ -9,8 +9,8 @@ import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select'; import { MatSelectModule } from '@angular/material/select';
import { GfValueModule } from '@ghostfolio/client/components/value/value.module';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module'; import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component'; import { CreateOrUpdateTransactionDialog } from './create-or-update-transaction-dialog.component';

View File

@ -3,7 +3,7 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module'; import { GfLogoModule } from '@ghostfolio/ui/logo';
import { RegisterPageRoutingModule } from './register-page-routing.module'; import { RegisterPageRoutingModule } from './register-page-routing.module';
import { RegisterPageComponent } from './register-page.component'; import { RegisterPageComponent } from './register-page.component';

View File

@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component'; import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { WebauthnPageRoutingModule } from './webauthn-page-routing.module'; import { WebauthnPageRoutingModule } from './webauthn-page-routing.module';

View File

@ -19,6 +19,7 @@ import {
Position, Position,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -36,6 +37,7 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
public dateRange: DateRange = 'max'; public dateRange: DateRange = 'max';
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean; public hasPositions: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true; public isLoadingPerformance = true;
@ -63,6 +65,11 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -76,6 +83,8 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => { .subscribe((aId) => {
this.hasImpersonationId = !!aId; this.hasImpersonationId = !!aId;
this.changeDetectorRef.markForCheck();
}); });
this.update(); this.update();

View File

@ -64,7 +64,8 @@
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
<div class="row"> <div class="row">
<div class="align-items-center col"> <div class="align-items-center col">
<mat-card *ngIf="hasPositions === true" class="p-0"> <ng-container *ngIf="hasPositions === true">
<mat-card class="p-0">
<mat-card-content> <mat-card-content>
<gf-positions <gf-positions
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
@ -75,6 +76,16 @@
></gf-positions> ></gf-positions>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</ng-container>
<div <div
*ngIf="hasPositions === false" *ngIf="hasPositions === false"
class="d-flex justify-content-center" class="d-flex justify-content-center"

View File

@ -5,9 +5,9 @@ import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs'; import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module'; import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/client/components/no-transactions-info/no-transactions-info.module';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module'; import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module'; import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { ZenPageRoutingModule } from './zen-page-routing.module'; import { ZenPageRoutingModule } from './zen-page-routing.module';
import { ZenPageComponent } from './zen-page.component'; import { ZenPageComponent } from './zen-page.component';

View File

@ -12,6 +12,9 @@
right: 0; right: 0;
top: 0; top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep { ::ng-deep {
.mat-tab-body-wrapper { .mat-tab-body-wrapper {
height: 100%; height: 100%;

View File

@ -29,8 +29,11 @@ import {
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { Order as OrderModel } from '@prisma/client'; import {
import { Account as AccountModel } from '@prisma/client'; Account as AccountModel,
DataSource,
Order as OrderModel
} from '@prisma/client';
import { parseISO } from 'date-fns'; import { parseISO } from 'date-fns';
import { cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
@ -108,8 +111,14 @@ export class DataService {
return info; return info;
} }
public fetchSymbolItem(aSymbol: string) { public fetchSymbolItem({
return this.http.get<SymbolItem>(`/api/symbol/${aSymbol}`); dataSource,
symbol
}: {
dataSource: DataSource;
symbol: string;
}) {
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`);
} }
public fetchPositions({ public fetchPositions({

View File

@ -6,7 +6,10 @@ import {
PublicKeyCredentialRequestOptionsJSON PublicKeyCredentialRequestOptionsJSON
} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn'; } from '@ghostfolio/api/app/auth/interfaces/simplewebauthn';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { startAssertion, startAttestation } from '@simplewebauthn/browser'; import {
startAuthentication,
startRegistration
} from '@simplewebauthn/browser';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators'; import { catchError, switchMap, tap } from 'rxjs/operators';
@ -32,7 +35,7 @@ export class WebAuthnService {
public register() { public register() {
return this.http return this.http
.get<PublicKeyCredentialCreationOptionsJSON>( .get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-attestation-options`, `/api/auth/webauthn/generate-registration-options`,
{} {}
) )
.pipe( .pipe(
@ -41,7 +44,7 @@ export class WebAuthnService {
return of(null); return of(null);
}), }),
switchMap((attOps) => { switchMap((attOps) => {
return startAttestation(attOps); return startRegistration(attOps);
}), }),
switchMap((attResp) => { switchMap((attResp) => {
return this.http.post<AuthDeviceDto>( return this.http.post<AuthDeviceDto>(
@ -83,7 +86,7 @@ export class WebAuthnService {
{ deviceId } { deviceId }
) )
.pipe( .pipe(
switchMap(startAssertion), switchMap(startAuthentication),
switchMap((assertionResponse) => { switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>( return this.http.post<{ authToken: string }>(
`/api/auth/webauthn/verify-assertion`, `/api/auth/webauthn/verify-assertion`,

View File

@ -27,7 +27,10 @@
name="twitter:title" name="twitter:title"
content="Ghostfolio Open Source Wealth Management Software" content="Ghostfolio Open Source Wealth Management Software"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width"
/>
<meta property="og:description" content="" /> <meta property="og:description" content="" />
<meta <meta
property="og:title" property="og:title"

View File

@ -0,0 +1,17 @@
{
"extends": ["plugin:cypress/recommended", "../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["src/plugins/index.js"],
"rules": {
"@typescript-eslint/no-var-requires": "off",
"no-undef": "off"
}
}
]
}

13
apps/ui-e2e/cypress.json Normal file
View File

@ -0,0 +1,13 @@
{
"fileServerFolder": ".",
"fixturesFolder": "./src/fixtures",
"integrationFolder": "./src/integration",
"modifyObstructiveCode": false,
"supportFile": "./src/support/index.ts",
"pluginsFile": "./src/plugins/index",
"video": true,
"videosFolder": "../../dist/cypress/apps/ui-e2e/videos",
"screenshotsFolder": "../../dist/cypress/apps/ui-e2e/screenshots",
"chromeWebSecurity": false,
"baseUrl": "http://localhost:4400"
}

View File

@ -0,0 +1,4 @@
{
"name": "Using fixtures to represent data",
"email": "hello@cypress.io"
}

View File

@ -0,0 +1,6 @@
describe('ui', () => {
beforeEach(() => cy.visit('/iframe.html?id=valuecomponent--loading'));
it('should render the component', () => {
cy.get('gf-value').should('exist');
});
});

View File

@ -0,0 +1,22 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
const { preprocessTypescript } = require('@nrwl/cypress/plugins/preprocessor');
module.exports = (on, config) => {
// `on` is used to hook into various events Cypress emits
// `config` is the resolved Cypress config
// Preprocess Typescript file using Nx helper
on('file:preprocessor', preprocessTypescript(config));
};

View File

@ -0,0 +1,33 @@
// ***********************************************
// This example commands.js shows you how to
// create various custom commands and overwrite
// existing commands.
//
// For more comprehensive examples of custom
// commands please read more here:
// https://on.cypress.io/custom-commands
// ***********************************************
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace Cypress {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Chainable<Subject> {
login(email: string, password: string): void;
}
}
//
// -- This is a parent command --
Cypress.Commands.add('login', (email, password) => {
console.log('Custom command example: Login', email, password);
});
//
// -- This is a child command --
// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
//
//
// -- This is a dual command --
// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
//
//
// -- This will overwrite an existing command --
// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... })

View File

@ -0,0 +1,17 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';

10
apps/ui-e2e/tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"sourceMap": false,
"outDir": "../../dist/out-tsc",
"allowJs": true,
"types": ["cypress", "node"]
},
"include": ["src/**/*.ts", "src/**/*.js"]
}

View File

@ -1,7 +1,3 @@
module.exports = { const { getJestProjects } = require('@nrwl/jest');
projects: [
'<rootDir>/apps/api', module.exports = { projects: getJestProjects() };
'<rootDir>/apps/client',
'<rootDir>/libs/common'
]
};

View File

@ -13,7 +13,7 @@ export const currencyPairs: Partial<
currency1: Currency; currency1: Currency;
currency2: Currency; currency2: Currency;
} }
>[] = Object.keys(Currency) >[] = (Object.keys(Currency) as Array<keyof typeof Currency>)
.filter((currency) => { .filter((currency) => {
return currency !== Currency.USD; return currency !== Currency.USD;
}) })
@ -28,6 +28,7 @@ export const currencyPairs: Partial<
export const ghostfolioScraperApiSymbolPrefix = '_GF_'; export const ghostfolioScraperApiSymbolPrefix = '_GF_';
export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`; export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const locale = 'de-CH'; export const locale = 'de-CH';

View File

@ -1,5 +1,8 @@
export interface PortfolioPerformance { export interface PortfolioPerformance {
annualizedPerformancePercent: number;
currentGrossPerformance: number; currentGrossPerformance: number;
currentGrossPerformancePercent: number; currentGrossPerformancePercent: number;
currentNetPerformance: number;
currentNetPerformancePercent: number;
currentValue: number; currentValue: number;
} }

View File

@ -8,7 +8,7 @@ export interface PortfolioPosition {
allocationCurrent: number; allocationCurrent: number;
allocationInvestment: number; allocationInvestment: number;
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass; assetSubClass?: AssetSubClass | 'CASH';
countries: Country[]; countries: Country[];
currency: Currency; currency: Currency;
exchange?: string; exchange?: string;
@ -20,6 +20,8 @@ export interface PortfolioPosition {
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name: string; name: string;
netPerformance: number;
netPerformancePercent: number;
quantity: number; quantity: number;
sectors: Sector[]; sectors: Sector[];
transactionCount: number; transactionCount: number;

View File

@ -1,6 +1,7 @@
import { PortfolioPerformance } from './portfolio-performance.interface'; import { PortfolioPerformance } from './portfolio-performance.interface';
export interface PortfolioSummary extends PortfolioPerformance { export interface PortfolioSummary extends PortfolioPerformance {
annualizedPerformancePercent: number;
cash: number; cash: number;
committedFunds: number; committedFunds: number;
fees: number; fees: number;

View File

@ -13,6 +13,8 @@ export interface Position {
marketPrice?: number; marketPrice?: number;
marketState?: MarketState; marketState?: MarketState;
name?: string; name?: string;
netPerformance?: number;
netPerformancePercentage?: number;
quantity: number; quantity: number;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

View File

@ -1,5 +1,6 @@
export interface Statistics { export interface Statistics {
activeUsers1d: number; activeUsers1d: number;
activeUsers30d: number; activeUsers30d: number;
gitHubContributors: number;
gitHubStargazers: number; gitHubStargazers: number;
} }

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