Compare commits

..

142 Commits

Author SHA1 Message Date
56a5664c87 Release 1.31.0 (#239) 2021-08-01 10:05:32 +02:00
823501f43e Sort imports (#242) 2021-08-01 09:41:44 +02:00
331ac7ded2 remove unused method in portfolio.service.ts (#240)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-01 09:39:41 +02:00
92b44ba658 Update changelog 2021-08-01 00:55:10 +02:00
982ba7377a remove legacy portfolio.ts 2021-08-01 00:55:10 +02:00
4bbd17a37a fix tests 2021-08-01 00:55:10 +02:00
6a7def6c48 Clean up 2021-08-01 00:55:10 +02:00
ea219f0b88 Add guard 2021-08-01 00:55:10 +02:00
23b2e03923 refactor rule evaluation 2021-08-01 00:55:10 +02:00
04e6518226 move report generation to PortfolioService 2021-08-01 00:55:10 +02:00
72dbe00091 change rule service interface 2021-08-01 00:55:10 +02:00
9834c52739 refactor rule settings 2021-08-01 00:55:10 +02:00
c47578bd3e Clean up 2021-08-01 00:55:10 +02:00
9e4a49d811 Remove fallback 2021-08-01 00:55:10 +02:00
a3a98c68a5 Fix missing currency conversion 2021-08-01 00:55:10 +02:00
21570cca19 Fix impersonation in performance endpoint 2021-08-01 00:55:10 +02:00
71a3115fc6 refactor get position endpoint
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
de83dc7b84 create investment endpoint for analysis timeline
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
4a0695613e refactor PortfolioService#getOverview
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
328d814922 remove getPerformance from portfolio.ts
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
d23addb673 change getDetails to portfolio-calculator.ts
Co-authored-by: Thomas <dotsilver@gmail.com>
2021-08-01 00:55:10 +02:00
fb15cebb64 Add test (#237)
* Add test

* fix calculation for overall gross performance percentage

Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-01 00:55:10 +02:00
9c51a257ae fix performance of combination of investments 2021-08-01 00:55:10 +02:00
f9b9dc32cb Always show positions title 2021-08-01 00:55:10 +02:00
e7194ef3ce Use new positions service 2021-08-01 00:55:10 +02:00
ec5523b459 Handle empty portfolio 2021-08-01 00:55:10 +02:00
c8c21a016a Handle empty portfolio 2021-08-01 00:55:10 +02:00
9821b7f8f0 Add TWR test scenario 1 (#236)
* Add TWR test scenario 1

* fix second transaction item

* change time-weighted rate algorithm

Co-authored-by: Valentin Zickner <valentin.zickner@flowable.com>
2021-08-01 00:55:10 +02:00
ed731afc66 fix market price fetch in case symbols are missing 2021-08-01 00:55:10 +02:00
ff15d5cbc4 Rename performance labels 2021-08-01 00:55:10 +02:00
3c4949de35 Hide net performance 2021-08-01 00:55:10 +02:00
bd0e53525b Refactoring 2021-08-01 00:55:10 +02:00
cbdb68e2f8 Refactoring 2021-08-01 00:55:10 +02:00
8571709014 Refactoring 2021-08-01 00:55:10 +02:00
e7ef1d426e Refactoring 2021-08-01 00:55:10 +02:00
39cba0a8eb Refactoring 2021-08-01 00:55:10 +02:00
a90c314e30 Refactoring 2021-08-01 00:55:10 +02:00
47d71405e1 Refactoring 2021-08-01 00:55:10 +02:00
5e9cecc6c1 Refactoring 2021-08-01 00:55:10 +02:00
fb9e66318f Improve test 2021-08-01 00:55:10 +02:00
b8194eb64f Clean up code 2021-08-01 00:55:10 +02:00
cbb81916ee Sort imports 2021-08-01 00:55:10 +02:00
9b1e9397a8 add errors in case gross performance is not set 2021-08-01 00:55:10 +02:00
b779964adb add error handling to performance aggregation 2021-08-01 00:55:10 +02:00
409afac2a9 fix rate conversion for todays symbols 2021-08-01 00:55:10 +02:00
e0a4e16ea1 Improve error handling 2021-08-01 00:55:10 +02:00
dc84abdc0a change performance report to portfolio calculator 2021-08-01 00:55:10 +02:00
b031b028f1 add today to getValues 2021-08-01 00:55:10 +02:00
3b7e0a0106 remove unnecessary if condition 2021-08-01 00:55:10 +02:00
ea66081073 move interfaces to separate files 2021-08-01 00:55:10 +02:00
602a770a09 fix typo 2021-08-01 00:55:10 +02:00
e522722aa6 Update comment 2021-08-01 00:55:10 +02:00
03ca5d7663 add further tests for portfolio-calculator with one transaction 2021-08-01 00:55:10 +02:00
136563c949 fix single buy test 2021-08-01 00:55:10 +02:00
948c45c602 Update test 2021-08-01 00:55:10 +02:00
e0be792e46 Re-enable all tests 2021-08-01 00:55:10 +02:00
c3d010135f Add test for single buy 2021-08-01 00:55:10 +02:00
d6a16a6093 Improve error handling 2021-08-01 00:55:10 +02:00
34c13c80ec add error handling for current positions 2021-08-01 00:55:10 +02:00
f65a108436 change hardcoded timestamp in test to timezone specific 2021-08-01 00:55:10 +02:00
993f066e08 remove console.time statements during portfolio calls 2021-08-01 00:55:10 +02:00
852902d1ab add current position calculation with holding period return calculation 2021-08-01 00:55:10 +02:00
ee89822bfe Fix tests 2021-08-01 00:55:10 +02:00
e0435e5cad Add name to position 2021-08-01 00:55:10 +02:00
e2c23703dc Fix tests 2021-08-01 00:55:10 +02:00
1226c26a9d Refactor positions 2021-08-01 00:55:10 +02:00
fdc89f7182 optimize performance of positions endpoint 2021-08-01 00:55:10 +02:00
1e368d6e2d Extend type 2021-08-01 00:55:10 +02:00
04e03bd080 add multi-date fetch for current values 2021-08-01 00:55:10 +02:00
66e7ad3fd2 prepare for multi-day database fetch 2021-08-01 00:55:10 +02:00
b4dc21dd61 Implement new positions endpoint 2021-08-01 00:55:10 +02:00
8a482e63b9 fix gross performance number type 2021-08-01 00:55:10 +02:00
aabfb39e8f optimize portfolio calculator to fetch all symbols for one day 2021-08-01 00:55:10 +02:00
cdc8faff7f add current position gross performance (percentage) 2021-08-01 00:55:10 +02:00
7b696e39de add sorting and symbol as a result 2021-08-01 00:55:10 +02:00
c88ad2c225 change getValues to multiple symbols 2021-08-01 00:55:10 +02:00
fbc9269abf disable naming convention eslint 2021-08-01 00:55:10 +02:00
cbe079ae66 ignore missing values 2021-08-01 00:55:10 +02:00
8e4ee7feea optimize order loading 2021-08-01 00:55:10 +02:00
f1b3c61675 add additional time measurements 2021-08-01 00:55:10 +02:00
24dc312367 Add logs to benchmark 2021-08-01 00:55:10 +02:00
7ac7442f73 Declare functions explicitly as public 2021-08-01 00:55:10 +02:00
099571437e Extend current rate service with getRange() 2021-08-01 00:55:10 +02:00
7dac059a55 Sort imports 2021-08-01 00:55:10 +02:00
48fbeda72d Sort imports 2021-08-01 00:55:10 +02:00
19007cdc34 Optimize market data query 2021-08-01 00:55:10 +02:00
5037393866 Support today in current rate service 2021-08-01 00:55:10 +02:00
ddf24163b4 optimize database query execution for portfolio chart 2021-08-01 00:55:10 +02:00
b26521c4bd add workaround for database date search 2021-08-01 00:55:10 +02:00
cfee6c1ddd add draft integration of new portfolio calculator to chart 2021-08-01 00:55:10 +02:00
19bcd601d1 add gross performance 2021-08-01 00:55:10 +02:00
836df69e68 add calculation of current investment 2021-08-01 00:55:10 +02:00
dd86adcea1 add investment to timeline calculation 2021-08-01 00:55:10 +02:00
4f7628921d add timeline time point calculation 2021-08-01 00:55:10 +02:00
88f0cb095d make it pretty 2021-08-01 00:55:10 +02:00
7538133d09 implement getCurrentPositions of PortfolioCalculator 2021-08-01 00:55:10 +02:00
50b280c5a6 Fix version of big.js 2021-08-01 00:55:10 +02:00
67606e4026 Extend current rate service test 2021-08-01 00:55:10 +02:00
9de56c32ac create base structure for portfolio rewrite
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-01 00:55:10 +02:00
5c0f710563 Release 1.30.0 (#238) 2021-07-31 11:29:06 +02:00
1c65599a16 Feature/add blog (#218)
* Setup blog

* Add german blog post

* Add english blog post

* Update changelog
2021-07-31 11:21:32 +02:00
61e667213e Feature/add date range selector to positions tab (#234)
* Add date range selector

* Update changelog
2021-07-27 22:38:55 +02:00
ba47212057 Release 1.29.0 (#233) 2021-07-26 22:14:04 +02:00
f0c6517019 Improve tabs layout (#232) 2021-07-26 21:36:52 +02:00
80ba112bc0 Feature/change menu icon if menu open (#231)
* Change menu icon

* Update changelog
2021-07-26 21:06:10 +02:00
40696b425e Feature/add tabs on the home page (#230)
* Add tabs

* Update changelog
2021-07-26 20:23:32 +02:00
6dbdf23a68 Release 1.28.0 (#229) 2021-07-24 21:33:38 +02:00
cdcbe3ab71 Feature/various layout improvements (#224)
* Various layout improvements

* Update changelog
2021-07-24 21:17:06 +02:00
6996e5a140 Feature/add data gathering for symbol profile data (#228)
* Implement profile data gathering

* Update changelog
2021-07-24 21:13:48 +02:00
be8d60968d Feature/improve active menu button style (#227)
* Improve text decoration style

* Update changelog
2021-07-24 13:04:37 +02:00
d53e5c4da5 Refactor auth guard (#226) 2021-07-24 10:57:03 +02:00
a3a9957196 Release 1.27.0 (#223) 2021-07-18 20:05:46 +02:00
9072cbdba1 Feature/add no transactions info on zen page (#222)
* Add no transactions info to zen page

* Update changelog
2021-07-18 17:34:28 +02:00
120b691336 Bugfix/fix url to fear and greed index (#221)
* Fix url

* Update changelog
2021-07-18 09:33:23 +02:00
bd4ad76953 Feature/remove pause in onboarding (#220)
* Improve onboarding
  * Remove pause
  * Add icon

* Update changelog
2021-07-18 08:19:05 +02:00
94d56f553f Bugfix/fix chart on landing page (#219)
* Fix chart on landing page

* Update changelog
2021-07-17 20:48:08 +02:00
ecdd325228 Release 1.26.0 (#217) 2021-07-17 11:06:14 +02:00
51fbc538ca Feature/set public stripe key dynamically (#216)
* Set public Stripe key dynamically

* Update changelog
2021-07-17 11:04:43 +02:00
39a76f7f40 Feature/add robots.txt (#215)
* Add robots.txt

* Update changelog
2021-07-16 21:32:02 +02:00
e4d325daab Feature/various style improvements (#214)
* Improve styles

* Update changelog
2021-07-15 18:10:18 +02:00
b765df65d6 Improve wording (#213) 2021-07-14 20:54:23 +02:00
c7b7efae3b Feature/import transactions (#212)
* Implement import transactions functionality

* Update changelog
2021-07-14 20:54:05 +02:00
be5b58f49a Bugfix/fix warn color (#211)
* Fix warn color

* Update changelog
2021-07-13 20:29:22 +02:00
91c748c7ad Release 1.25.0 (#210) 2021-07-11 17:21:24 +02:00
ecfe694f0b Feature/export transactions (#209)
* Export functionality for transactions

* Update changelog
2021-07-11 17:05:58 +02:00
1491bf7f76 Update changelog (#208) 2021-07-11 10:37:02 +02:00
b3b9a051c3 Shorten slogan (#207) 2021-07-11 10:31:36 +02:00
bf1146bfd6 Feature/change slogan to wealth management (#206)
* Harmonize slogans to "Open Source Wealth Management Software"

* Update changelog
2021-07-10 19:20:02 +02:00
0774ca91a1 Improve settings selectors layout (#205) 2021-07-10 18:17:17 +02:00
f403807f2d Bugfix/fix average buy price calculation (#204)
* Fix average buy price calculation

* Update changelog
2021-07-10 18:16:46 +02:00
f22991b090 Feature/respect cash balance in analysis (#203)
* Respect cash balance in in analysis

* Update changelog
2021-07-10 14:57:03 +02:00
1135a5b335 Fix rendering of currency and platform in dialogs and clean up observables (#202) 2021-07-08 21:28:28 +02:00
d9ea255c17 Release 1.24.0 (#201) 2021-07-07 21:45:57 +02:00
2c19d8c8e7 Feature/add balance to account (#193)
* Add balance attribute and calculate total balance

* Update changelog
2021-07-07 21:23:36 +02:00
db090229ce Feature/add total value in the create or edit transaction dialog (#192)
* Display total value

* Update changelog
2021-07-07 21:14:01 +02:00
fbe590ddb9 Feature/upgrade angular material css vars to 2.0.0 (#200)
* Upgrade angular-material-css-vars

* Update changelog
2021-07-05 21:53:30 +02:00
0d65136a9e Revert "Remove unneeded dependencies (#197)" (#199)
This reverts commit a062a3cee4.
2021-07-05 20:35:10 +02:00
dea87cc3cf Improve README.md (#198) 2021-07-04 22:19:09 +02:00
a062a3cee4 Remove unneeded dependencies (#197) 2021-07-04 22:11:47 +02:00
5b1b207a6f Feature/upgrade angular dependencies to version 12.0.x (#196)
* Update angular dependencies to version 12.0.X

* Update changelog
2021-07-04 21:55:25 +02:00
63cc7b2871 Feature/upgrade nestjs dependencies (#195)
* Upgrade @nestjs dependencies

* Update changelog
2021-07-04 21:45:53 +02:00
3986e8f879 Upgrade Nx to version 12.5.4 (#194) 2021-07-04 21:31:15 +02:00
191 changed files with 7463 additions and 3775 deletions

View File

@ -44,7 +44,7 @@
}
],
"@typescript-eslint/member-ordering": "error",
"@typescript-eslint/naming-convention": "error",
"@typescript-eslint/naming-convention": "off",
"@typescript-eslint/no-empty-function": "off",
"@typescript-eslint/no-empty-interface": "error",
"@typescript-eslint/no-inferrable-types": [

View File

@ -5,6 +5,108 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.31.0 - 01.08.2021
### Added
- Added more data points to the chart
### Changed
- Refactored the core engine for the calculations
## 1.30.0 - 31.07.2021
### Added
- Added the date range component to the positions tab
- Added a blog
## 1.29.0 - 26.07.2021
### Changed
- Introduced tabs on the home page
- Changed the menu icon if the menu is open on mobile
## 1.28.0 - 24.07.2021
### Added
- Extended the data management by symbol profile data
- Added a currency attribute to the symbol profile model
- Added a positions button on the home page which scrolls into the view
### Changed
- Improved the style of the active page in the navigation on desktop
- Removed the footer for users
- Extended the _Zen Mode_ by positions
- Improved the _Create Account_ message in the _Live Demo_
## 1.27.0 - 18.07.2021
### Changed
- Improved the onboarding
- Flow of creating a new account
- Info message to add the first transaction
### Fixed
- Fixed the chart on the landing page
- Fixed the url to the _Fear & Greed Index_ on the resources page
## 1.26.0 - 17.07.2021
### Added
- Added the import functionality for transactions
- Added the `robots.txt` file
### Changed
- Improved the styling of the current pricing plan
- Improved the styling of the transaction type badge
- Set the public _Stripe_ key dynamically
- Upgraded `angular-material-css-vars` from version `2.0.0` to `2.1.0`
### Fixed
- Fixed the warn color (button) of the theme
## 1.25.0 - 11.07.2021
### Added
- Added the export functionality for transactions
### Changed
- Respected the cash balance on the analysis page
- Improved the settings selectors on the account page
- Harmonized the slogan to "Open Source Wealth Management Software"
### Fixed
- Fixed rendering of currency and platform in dialogs (account and transaction)
- Fixed an issue in the calculation of the average buy prices in the position detail chart
## 1.24.0 - 07.07.2021
### Added
- Added the total value in the create or edit transaction dialog
- Added a balance attribute to the account model
- Calculated the total balance (cash)
### Changed
- Upgraded `@angular/cdk` and `@angular/material` from version `11.0.4` to `12.0.6`
- Upgraded `@nestjs` dependencies
- Upgraded `angular-material-css-vars` from version `1.2.0` to `2.0.0`
- Upgraded `Nx` from version `12.3.6` to `12.5.4`
## 1.23.1 - 03.07.2021
### Fixed

View File

@ -1,13 +1,22 @@
<div align="center">
<a href="https://ghostfol.io">
<img
alt="Ghostfolio Logo"
src="https://avatars.githubusercontent.com/u/82473144?s=200"
width="100"
/>
</a>
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Portfolio Tracker</strong>
<strong>Open Source Wealth Management Software made for Humans</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a>
</p>
<p>
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/>
<a href="#contributing">
<img src="https://img.shields.io/badge/contributions-welcome-orange.svg"/></a>
<a href="https://travis-ci.com/github/ghostfolio/ghostfolio" rel="nofollow">
<img src="https://travis-ci.com/ghostfolio/ghostfolio.svg?branch=main" alt="Build Status"/></a>
<a href="https://www.gnu.org/licenses/agpl-3.0" rel="nofollow">
@ -15,7 +24,13 @@
</p>
</div>
**Ghostfolio** is an open source portfolio tracker based on web technology. The software empowers busy folks to have a sharp look of their financial assets and to make solid, data-driven investment decisions by evaluating automated static portfolio analysis rules.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
## Why Ghostfolio?
@ -79,8 +94,8 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
1. Run `yarn setup:database` to initialize the database schema and populate your database with (example) data
1. Start server and client (see [_Development_](#Development))
1. Login as _Admin_ with the following _Security Token_: `ae76872ae8f3419c6d6f64bf51888ecbcc703927a342d815fafe486acdb938da07d0cf44fca211a0be74a423238f535362d390a41e81e633a9ce668a6e31cdf9`
1. Go to the _Admin Control Panel_ and press _Gather All Data_ to fetch historical data
1. Press _Sign out_ and check out the _Live Demo_
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_
## Development

View File

@ -103,6 +103,11 @@
"input": "",
"output": "./"
},
{
"glob": "robots.txt",
"input": "apps/client/src/assets",
"output": "./"
},
{
"glob": "sitemap.xml",
"input": "apps/client/src/assets",

View File

@ -11,5 +11,6 @@ module.exports = {
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000
testTimeout: 10000,
testEnvironment: 'node'
};

View File

@ -4,6 +4,7 @@ import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alph
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@ -20,6 +21,7 @@ import { AccountService } from './account.service';
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,

View File

@ -1,12 +1,15 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Prisma } from '@prisma/client';
import { Account, Currency, Order, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService,
private prisma: PrismaService
) {}
@ -73,6 +76,27 @@ export class AccountService {
});
}
public async getCashDetails(
aUserId: string,
aCurrency: Currency
): Promise<CashDetails> {
let totalCashBalance = 0;
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
);
});
return { accounts, balance: totalCashBalance };
}
public async updateAccount(
params: {
where: Prisma.AccountWhereUniqueInput;

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class CreateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
name: string;

View File

@ -0,0 +1,6 @@
import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
}

View File

@ -1,10 +1,16 @@
import { AccountType } from '@prisma/client';
import { IsString, ValidateIf } from 'class-validator';
import { AccountType, Currency } from '@prisma/client';
import { IsNumber, IsString, ValidateIf } from 'class-validator';
export class UpdateAccountDto {
@IsString()
accountType: AccountType;
@IsNumber()
balance: number;
@IsString()
currency: Currency;
@IsString()
id: string;

View File

@ -61,8 +61,29 @@ export class AdminController {
);
}
await this.dataGatheringService.gatherProfileData();
this.dataGatheringService.gatherMax();
return;
}
@Post('gather/profile-data')
@UseGuards(AuthGuard('jwt'))
public async gatherProfileData(): Promise<void> {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
this.dataGatheringService.gatherProfileData();
return;
}
}

View File

@ -22,7 +22,10 @@ import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module';
import { CoreModule } from './core/core.module';
import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module';
@ -40,7 +43,10 @@ import { UserModule } from './user/user.module';
AuthModule,
CacheModule,
ConfigModule.forRoot(),
CoreModule,
ExperimentalModule,
ExportModule,
ImportModule,
InfoModule,
OrderModule,
PortfolioModule,

View File

@ -0,0 +1,30 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
@Module({
imports: [],
controllers: [],
providers: [
AlphaVantageService,
ConfigurationService,
CurrentRateService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
MarketDataService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class CoreModule {}

View File

@ -0,0 +1,129 @@
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Currency, MarketData } from '@prisma/client';
import { MarketDataService } from './market-data.service';
jest.mock('./market-data.service', () => {
return {
MarketDataService: jest.fn().mockImplementation(() => {
return {
get: (date: Date, symbol: string) => {
return Promise.resolve<MarketData>({
date,
symbol,
createdAt: date,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
marketPrice: 1847.839966
});
},
getRange: ({
dateRangeEnd,
dateRangeStart,
symbols
}: {
dateRangeEnd: Date;
dateRangeStart: Date;
symbols: string[];
}) => {
return Promise.resolve<MarketData[]>([
{
createdAt: dateRangeStart,
date: dateRangeStart,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
marketPrice: 1841.823902,
symbol: symbols[0]
},
{
createdAt: dateRangeEnd,
date: dateRangeEnd,
id: '082d6893-df27-4c91-8a5d-092e84315b56',
marketPrice: 1847.839966,
symbol: symbols[0]
}
]);
}
};
})
};
});
jest.mock('@ghostfolio/api/services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => {
return 1 * value;
}
};
})
};
});
describe('CurrentRateService', () => {
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let marketDataService: MarketDataService;
beforeAll(async () => {
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(null);
marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize();
currentRateService = new CurrentRateService(
dataProviderService,
exchangeRateDataService,
marketDataService
);
});
it('getValue', async () => {
expect(
await currentRateService.getValue({
currency: Currency.USD,
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
symbol: 'AMZN',
userCurrency: Currency.CHF
})
).toMatchObject({
marketPrice: 1847.839966
});
});
it('getValues', async () => {
expect(
await currentRateService.getValues({
currencies: { AMZN: Currency.USD },
dateQuery: {
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
},
symbols: ['AMZN'],
userCurrency: Currency.CHF
})
).toMatchObject([
{
date: undefined,
marketPrice: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]);
});
});

View File

@ -0,0 +1,128 @@
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
import { GetValueParams } from '@ghostfolio/api/app/core/interfaces/get-value-params.interface';
import { GetValuesParams } from '@ghostfolio/api/app/core/interfaces/get-values-params.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash';
import { MarketDataService } from './market-data.service';
@Injectable()
export class CurrentRateService {
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService
) {}
public async getValue({
currency,
date,
symbol,
userCurrency
}: GetValueParams): Promise<GetValueObject> {
if (isToday(date)) {
const dataProviderResult = await this.dataProviderService.get([symbol]);
return {
date: resetHours(date),
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
symbol: symbol
};
}
const marketData = await this.marketDataService.get({
date,
symbol
});
if (marketData) {
return {
date: marketData.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketData.marketPrice,
currency,
userCurrency
),
symbol: marketData.symbol
};
}
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
}
public async getValues({
currencies,
dateQuery,
symbols,
userCurrency
}: GetValuesParams): Promise<GetValueObject[]> {
const includeToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<
{
date: Date;
marketPrice: number;
symbol: string;
}[]
>[] = [];
if (includeToday) {
const today = resetHours(new Date());
promises.push(
this.dataProviderService.get(symbols).then((dataResultProvider) => {
const result = [];
for (const symbol of symbols) {
result.push({
symbol,
date: today,
marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[symbol]?.marketPrice ?? 0,
dataResultProvider?.[symbol]?.currency,
userCurrency
)
});
}
return result;
})
);
}
promises.push(
this.marketDataService
.getRange({
dateQuery,
symbols
})
.then((data) => {
return data.map((marketDataItem) => {
return {
date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
symbol: marketDataItem.symbol
};
});
})
);
return flatten(await Promise.all(promises));
}
private containsToday(dates: Date[]): boolean {
for (const date of dates) {
if (isToday(date)) {
return true;
}
}
return false;
}
}

View File

@ -0,0 +1,11 @@
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions {
hasErrors: boolean;
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;
currentValue: Big;
totalInvestment: Big;
}

View File

@ -0,0 +1,5 @@
export interface DateQuery {
gte?: Date;
in?: Date[];
lt?: Date;
}

View File

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

View File

@ -0,0 +1,8 @@
import { Currency } from '@prisma/client';
export interface GetValueParams {
currency: Currency;
date: Date;
symbol: string;
userCurrency: Currency;
}

View File

@ -0,0 +1,9 @@
import { DateQuery } from '@ghostfolio/api/app/core/interfaces/date-query.interface';
import { Currency } from '@prisma/client';
export interface GetValuesParams {
currencies: { [symbol: string]: Currency };
dateQuery: DateQuery;
symbols: string[];
userCurrency: Currency;
}

View File

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

View File

@ -0,0 +1,8 @@
import Big from 'big.js';
export interface TimelinePeriod {
date: string;
grossPerformance: Big;
investment: Big;
value: Big;
}

View File

@ -0,0 +1,6 @@
export type Accuracy = 'day' | 'month' | 'year';
export interface TimelineSpecification {
accuracy: Accuracy;
start: string;
}

View File

@ -0,0 +1,12 @@
import { Currency } from '@prisma/client';
import Big from 'big.js';
export interface TransactionPointSymbol {
currency: Currency;
firstBuyDate: string;
investment: Big;
name: string;
quantity: Big;
symbol: string;
transactionCount: number;
}

View File

@ -0,0 +1,6 @@
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
export interface TransactionPoint {
date: string;
items: TransactionPointSymbol[];
}

View File

@ -0,0 +1,51 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { MarketData } from '@prisma/client';
import { DateQuery } from './interfaces/date-query.interface';
@Injectable()
export class MarketDataService {
public constructor(private prisma: PrismaService) {}
public async get({
date,
symbol
}: {
date: Date;
symbol: string;
}): Promise<MarketData> {
return await this.prisma.marketData.findFirst({
where: {
symbol,
date: resetHours(date)
}
});
}
public async getRange({
dateQuery,
symbols
}: {
dateQuery: DateQuery;
symbols: string[];
}): Promise<MarketData[]> {
return await this.prisma.marketData.findMany({
orderBy: [
{
date: 'asc'
},
{
symbol: 'asc'
}
],
where: {
date: dateQuery,
symbol: {
in: symbols
}
}
});
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,563 @@
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelinePeriod } from '@ghostfolio/api/app/core/interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMonths,
addYears,
endOfDay,
format,
isAfter,
isBefore,
max,
min,
subDays
} from 'date-fns';
import { flatten } from 'lodash';
export class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
public constructor(
private currentRateService: CurrentRateService,
private currency: Currency
) {}
public computeTransactionPoints(orders: PortfolioOrder[]) {
orders.sort((a, b) => a.date.localeCompare(b.date));
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
currentTransactionPointItem = {
currency: order.currency,
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: unitPrice
.mul(order.quantity)
.mul(factor)
.add(oldAccumulatedSymbol.investment),
name: order.name,
quantity: order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity),
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency: order.currency,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
name: order.name,
quantity: order.quantity.mul(factor),
symbol: order.symbol,
transactionCount: 1
};
}
symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
if (!currentTransactionPointItem.quantity.eq(0)) {
newItems.push(currentTransactionPointItem);
} else {
delete symbols[order.symbol];
}
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.items = newItems;
}
lastDate = currentDate;
}
}
public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints;
}
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
return {
hasErrors: false,
positions: [],
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
currentValue: new Big(0),
totalInvestment: new Big(0)
};
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const symbols = new Set<string>();
const currencies: { [symbol: string]: Currency } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
symbols.add(item.symbol);
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
}
}
dates.push(resetHours(today));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dateQuery: {
in: dates
},
symbols: Array.from(symbols),
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
let hasErrors = false;
const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
const currentDate =
i === firstIndex ? startString : this.transactionPoints[i].date;
const nextDate =
i + 1 < this.transactionPoints.length
? this.transactionPoints[i + 1].date
: todayString;
const items = this.transactionPoints[i].items;
for (const item of items) {
let oldHoldingPeriodReturn = holdingPeriodReturns[item.symbol];
if (!oldHoldingPeriodReturn) {
oldHoldingPeriodReturn = new Big(1);
}
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
`Missing value for symbol ${item.symbol} at ${nextDate}`
);
continue;
}
let lastInvestment: Big = new Big(0);
let lastQuantity: Big = item.quantity;
if (lastInvestments[item.symbol] && lastQuantities[item.symbol]) {
lastInvestment = item.investment.minus(lastInvestments[item.symbol]);
lastQuantity = lastQuantities[item.symbol];
}
const itemValue = marketSymbolMap[currentDate]?.[item.symbol];
let initialValue = itemValue?.mul(lastQuantity);
let investedValue = itemValue?.mul(item.quantity);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment;
investedValue = item.investment;
}
if (i === firstIndex || !initialValues[item.symbol]) {
initialValues[item.symbol] = initialValue;
}
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] =
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] =
oldGrossPerformance.plus(currentPerformance);
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
}
}
const positions: TimelinePosition[] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
positions.push({
averagePrice: item.investment.div(item.quantity),
currency: item.currency,
firstBuyDate: item.firstBuyDate,
grossPerformance: isValid
? grossPerformance[item.symbol] ?? null
: null,
grossPerformancePercentage:
isValid && holdingPeriodReturns[item.symbol]
? holdingPeriodReturns[item.symbol].minus(1)
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
name: item.name,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
}
const overall = this.calculateOverallGrossPerformance(
positions,
initialValues
);
return {
...overall,
positions,
hasErrors: hasErrors || overall.hasErrors
};
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
}
return this.transactionPoints.map((transactionPoint) => {
return {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.add(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelinePeriod[]> {
if (timelineSpecification.length === 0) {
return [];
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelinePeriod[]>[] = [];
let i = 0;
let j = -1;
for (
let currentDate = start;
!isAfter(currentDate, end);
currentDate = this.addToDate(
currentDate,
timelineSpecification[i].accuracy
)
) {
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
i++;
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
let periodEndDate = currentDate;
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
max([currentDate, nextEndDate])
]);
}
const timePeriodForDates = this.getTimePeriodForDate(
j,
currentDate,
endOfDay(periodEndDate)
);
currentDate = periodEndDate;
if (timePeriodForDates != null) {
timelinePeriodPromises.push(timePeriodForDates);
}
}
const timelinePeriods: TimelinePeriod[][] = await Promise.all(
timelinePeriodPromises
);
return flatten(timelinePeriods);
}
private calculateOverallGrossPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
) {
let hasErrors = false;
let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.add(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.add(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
} else {
hasErrors = true;
}
if (
currentPosition.grossPerformancePercentage &&
initialValues[currentPosition.symbol]
) {
const currentInitialValue = initialValues[currentPosition.symbol];
completeInitialValue = completeInitialValue.plus(currentInitialValue);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
);
} else {
console.error(
`Initial value is missing for symbol ${currentPosition.symbol}`
);
hasErrors = true;
}
}
return {
currentValue,
grossPerformance,
hasErrors,
totalInvestment,
grossPerformancePercentage:
grossPerformancePercentage.div(completeInitialValue)
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelinePeriod[]> {
let investment: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: Currency } = {};
const symbols: string[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
symbols.push(item.symbol);
investment = investment.add(item.investment);
}
let marketSymbols: GetValueObject[] = [];
if (symbols.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
symbols,
currencies,
userCurrency: this.currency
});
} catch (error) {
console.error(
`Failed to fetch info for date ${startDate} with exception`,
error
);
return null;
}
}
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
}
const results = [];
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
currentDate = addDays(currentDate, 1)
) {
let value = new Big(0);
const currentDateAsString = format(currentDate, DATE_FORMAT);
let invalid = false;
if (j >= 0) {
for (const item of this.transactionPoints[j].items) {
if (
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
) {
invalid = true;
break;
}
value = value.add(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const result = {
date: currentDateAsString,
grossPerformance: value.minus(investment),
investment,
value
};
results.push(result);
}
}
return results;
}
private getFactor(type: OrderType) {
let factor: number;
switch (type) {
case OrderType.Buy:
factor = 1;
break;
case OrderType.Sell:
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}
private addToDate(date: Date, accuracy: Accuracy): Date {
switch (accuracy) {
case 'day':
return addDays(date, 1);
case 'month':
return addMonths(date, 1);
case 'year':
return addYears(date, 1);
}
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

View File

@ -1,4 +1,5 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
@ -65,26 +66,4 @@ export class ExperimentalController {
return marketData;
}
@Post('value/:dateString?')
public async getValue(
@Body() orders: CreateOrderDto[],
@Headers('Authorization') apiToken: string,
@Param('dateString') dateString: string
): Promise<Data> {
if (!isApiTokenAuthorized(apiToken)) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let date = new Date();
if (dateString) {
date = parse(dateString, 'yyyy-MM-dd', new Date());
}
return this.experimentalService.getValue(orders, date, baseCurrency);
}
}

View File

@ -1,3 +1,5 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -13,9 +15,10 @@ import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service';
@Module({
imports: [],
imports: [RedisCacheModule],
controllers: [ExperimentalController],
providers: [
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,

View File

@ -1,19 +1,14 @@
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Currency, Type } from '@prisma/client';
import { parseISO } from 'date-fns';
import { CreateOrderDto } from './create-order.dto';
import { Data } from './interfaces/data.interface';
@Injectable()
export class ExperimentalService {
public constructor(
private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService,
@ -27,40 +22,4 @@ export class ExperimentalService {
where: { symbol: aSymbol }
});
}
public async getValue(
aOrders: CreateOrderDto[],
aDate: Date,
aBaseCurrency: Currency
): Promise<Data> {
const ordersWithPlatform: OrderWithAccount[] = aOrders.map((order) => {
return {
...order,
accountId: undefined,
accountUserId: undefined,
createdAt: new Date(),
dataSource: undefined,
date: parseISO(order.date),
fee: 0,
id: undefined,
platformId: undefined,
symbolProfileId: undefined,
type: Type.BUY,
updatedAt: undefined,
userId: undefined
};
});
const portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
await portfolio.setOrders(ordersWithPlatform);
return {
currency: aBaseCurrency,
value: portfolio.getValue(aDate)
};
}
}

View File

@ -0,0 +1,23 @@
import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { ExportService } from './export.service';
@Controller('export')
export class ExportController {
public constructor(
private readonly exportService: ExportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
public async export(): Promise<Export> {
return await this.exportService.export({
userId: this.request.user.id
});
}
}

View File

@ -0,0 +1,32 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
import { ExportService } from './export.service';
@Module({
imports: [RedisCacheModule],
controllers: [ExportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExportService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ExportModule {}

View File

@ -0,0 +1,31 @@
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private prisma: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({
orderBy: { date: 'desc' },
select: {
currency: true,
dataSource: true,
date: true,
fee: true,
quantity: true,
symbol: true,
type: true,
unitPrice: true
},
where: { userId }
});
return {
meta: { date: new Date().toISOString(), version: environment.version },
orders
};
}
}

View File

@ -0,0 +1,7 @@
import { Order } from '@prisma/client';
import { IsArray } from 'class-validator';
export class ImportDataDto {
@IsArray()
orders: Partial<Order>[];
}

View File

@ -0,0 +1,50 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
HttpException,
Inject,
Post,
UseGuards
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { ImportDataDto } from './import-data.dto';
import { ImportService } from './import.service';
@Controller('import')
export class ImportController {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly importService: ImportService,
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Post()
@UseGuards(AuthGuard('jwt'))
public async import(@Body() importData: ImportDataDto): Promise<void> {
if (!this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
try {
return await this.importService.import({
orders: importData.orders,
userId: this.request.user.id
});
} catch (error) {
console.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
}
}

View File

@ -0,0 +1,34 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { ImportController } from './import.controller';
import { ImportService } from './import.service';
@Module({
imports: [RedisCacheModule],
controllers: [ImportController],
providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImportService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class ImportModule {}

View File

@ -0,0 +1,43 @@
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { parseISO } from 'date-fns';
@Injectable()
export class ImportService {
public constructor(private readonly orderService: OrderService) {}
public async import({
orders,
userId
}: {
orders: Partial<Order>[];
userId: string;
}): Promise<void> {
for (const {
currency,
dataSource,
date,
fee,
quantity,
symbol,
type,
unitPrice
} of orders) {
await this.orderService.createOrder(
{
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
date: parseISO(<string>(<unknown>date)),
User: { connect: { id: userId } }
},
userId
);
}
}
}

View File

@ -20,6 +20,7 @@ export class InfoService {
) {}
public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {};
const platforms = await this.prisma.platform.findMany({
orderBy: { name: 'asc' },
select: { id: true, name: true }
@ -27,6 +28,14 @@ export class InfoService {
const globalPermissions: string[] = [];
if (this.configurationService.get('ENABLE_FEATURE_BLOG')) {
globalPermissions.push(permissions.enableBlog);
}
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {
globalPermissions.push(permissions.enableImport);
}
if (this.configurationService.get('ENABLE_FEATURE_SOCIAL_LOGIN')) {
globalPermissions.push(permissions.enableSocialLogin);
}
@ -37,9 +46,12 @@ export class InfoService {
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
globalPermissions.push(permissions.enableSubscription);
info.stripePublicKey = this.configurationService.get('STRIPE_PUBLIC_KEY');
}
return {
...info,
globalPermissions,
platforms,
currencies: Object.values(Currency),

View File

@ -1,5 +1,5 @@
import { Currency, DataSource, Type } from '@prisma/client';
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
import { IsISO8601, IsNumber, IsString } from 'class-validator';
export class CreateOrderDto {
@IsString()

View File

@ -62,6 +62,8 @@ export class OrderService {
]);
}
this.dataGatheringService.gatherProfileData([data.symbol]);
await this.cacheService.flush(aUserId);
return this.prisma.order.create({

View File

@ -0,0 +1,5 @@
import { Position } from '@ghostfolio/common/interfaces';
export interface PortfolioPositions {
positions: Position[];
}

View File

@ -11,6 +11,7 @@ import {
PortfolioPosition,
PortfolioReport
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
getPermissions,
hasPermission,
@ -37,6 +38,7 @@ import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
@ -48,12 +50,14 @@ export class PortfolioController {
@Inject(REQUEST) private readonly request: RequestWithUser
) {}
@Get()
@Get('/investments')
@UseGuards(AuthGuard('jwt'))
public async findAll(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioItem[]> {
let portfolio = await this.portfolioService.findAll(impersonationId);
): Promise<InvestmentItem[]> {
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId &&
@ -62,25 +66,18 @@ export class PortfolioController {
permissions.readForeignPortfolio
)
) {
portfolio = portfolio.map((portfolioItem) => {
Object.keys(portfolioItem.positions).forEach((symbol) => {
portfolioItem.positions[symbol].investment =
portfolioItem.positions[symbol].investment > 0 ? 1 : 0;
portfolioItem.positions[symbol].investmentInOriginalCurrency =
portfolioItem.positions[symbol].investmentInOriginalCurrency > 0
? 1
: 0;
portfolioItem.positions[symbol].quantity =
portfolioItem.positions[symbol].quantity > 0 ? 1 : 0;
});
const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment),
1
);
portfolioItem.investment = null;
return portfolioItem;
});
investments = investments.map((item) => ({
date: item.date,
investment: item.investment / maxInvestment
}));
}
return portfolio;
return investments;
}
@Get('chart')
@ -142,17 +139,17 @@ export class PortfolioController {
): Promise<{ [symbol: string]: PortfolioPosition }> {
let details: { [symbol: string]: PortfolioPosition } = {};
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
try {
details = await portfolio.getDetails(range);
details = await this.portfolioService.getDetails(
impersonationUserId,
range
);
} catch (error) {
console.error(error);
@ -221,6 +218,7 @@ export class PortfolioController {
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
@ -238,21 +236,16 @@ export class PortfolioController {
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPerformance> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
this.request.user.id
range
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
let performance = await portfolio.getPerformance(range);
if (hasNotDefinedValuesInObject(performance)) {
if (performanceInformation?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
let performance = performanceInformation.performance;
if (
impersonationId &&
!hasPermission(
@ -270,6 +263,25 @@ export class PortfolioController {
return <any>res.json(performance);
}
@Get('positions')
@UseGuards(AuthGuard('jwt'))
public async getPositions(
@Headers('impersonation-id') impersonationId,
@Query('range') range,
@Res() res: Response
): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
if (result?.hasErrors) {
res.status(StatusCodes.ACCEPTED);
}
return <any>res.json(result);
}
@Get('position/:symbol')
@UseGuards(AuthGuard('jwt'))
public async getPosition(
@ -306,15 +318,6 @@ export class PortfolioController {
public async getReport(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioReport> {
const impersonationUserId = await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
const portfolio = await this.portfolioService.createPortfolio(
impersonationUserId || this.request.user.id
);
return await portfolio.getReport();
return await this.portfolioService.getReport(impersonationId);
}
}

View File

@ -1,3 +1,10 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { MarketDataService } from '@ghostfolio/api/app/core/market-data.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
@ -9,12 +16,9 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { OrderService } from '../order/order.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { UserService } from '../user/user.service';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
@ -22,19 +26,23 @@ import { PortfolioService } from './portfolio.service';
imports: [RedisCacheModule],
controllers: [PortfolioController],
providers: [
AccountService,
AlphaVantageService,
CacheService,
CurrentRateService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
MarketDataService,
OrderService,
PortfolioService,
PrismaService,
RakutenRapidApiService,
RulesService,
SymbolProfileService,
UserService,
YahooFinanceService
]

View File

@ -1,38 +1,60 @@
import { Portfolio } from '@ghostfolio/api/models/portfolio';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { OrderType } from '@ghostfolio/api/models/order-type';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
import { Type } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
PortfolioItem,
PortfolioOverview
PortfolioOverview,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
Position,
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { DateRange, RequestWithUser } from '@ghostfolio/common/types';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
DateRange,
OrderWithAccount,
RequestWithUser
} from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { DataSource } from '@prisma/client';
import { Currency, DataSource, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
add,
endOfToday,
format,
getDate,
getMonth,
getYear,
isAfter,
isSameDay,
isBefore,
max,
parse,
parseISO,
setDate,
setMonth,
sub
setDayOfYear,
subDays,
subYears
} from 'date-fns';
import { isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { OrderService } from '../order/order.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { UserService } from '../user/user.service';
import {
HistoricalDataItem,
PortfolioPositionDetail
@ -41,187 +63,242 @@ import {
@Injectable()
export class PortfolioService {
public constructor(
private readonly accountService: AccountService,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
private readonly redisCacheService: RedisCacheService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly rulesService: RulesService,
private readonly userService: UserService
private readonly symbolProfileService: SymbolProfileService
) {}
public async createPortfolio(aUserId: string): Promise<Portfolio> {
let portfolio: Portfolio;
const stringifiedPortfolio = await this.redisCacheService.get(
`${aUserId}.portfolio`
public async getInvestments(
aImpersonationId: string
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const user = await this.userService.user({ id: aUserId });
const { transactionPoints } = await this.getTransactionPoints(userId);
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
}
if (stringifiedPortfolio) {
// Get portfolio from redis
const {
orders,
portfolioItems
}: { orders: IOrder[]; portfolioItems: PortfolioItem[] } =
JSON.parse(stringifiedPortfolio);
portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
).createFromData({ orders, portfolioItems, user });
} else {
// Get portfolio from database
const orders = await this.orderService.orders({
include: {
Account: true,
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
portfolio = new Portfolio(
this.dataProviderService,
this.exchangeRateDataService,
this.rulesService
);
portfolio.setUser(user);
await portfolio.setOrders(orders);
// Cache data for the next time...
const portfolioData = {
orders: portfolio.getOrders(),
portfolioItems: portfolio.getPortfolioItems()
return portfolioCalculator.getInvestments().map((item) => {
return {
date: item.date,
investment: item.investment.toNumber()
};
await this.redisCacheService.set(
`${aUserId}.portfolio`,
JSON.stringify(portfolioData)
);
}
// Enrich portfolio with current data
await portfolio.addCurrentPortfolioItems();
// Enrich portfolio with future data
await portfolio.addFuturePortfolioItems();
return portfolio;
}
public async findAll(aImpersonationId: string): Promise<PortfolioItem[]> {
try {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
);
return portfolio.get();
} catch (error) {
console.error(error);
}
});
}
public async getChart(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<HistoricalDataItem[]> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const userId = await this.getUserId(aImpersonationId);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
if (portfolio.getOrders().length <= 0) {
const { transactionPoints } = await this.getTransactionPoints(userId);
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
}
let portfolioStart = parse(
transactionPoints[0].date,
DATE_FORMAT,
new Date()
);
portfolioStart = this.getStartDate(aDateRange, portfolioStart);
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
portfolio.getMinDate()
const timelineSpecification: TimelineSpecification[] = [
{
start: format(portfolioStart, DATE_FORMAT),
accuracy: 'day'
}
];
const timeline = await portfolioCalculator.calculateTimeline(
timelineSpecification,
format(new Date(), DATE_FORMAT)
);
return portfolio
.get()
.filter((portfolioItem) => {
if (isAfter(parseISO(portfolioItem.date), endOfToday())) {
// Filter out future dates
return false;
}
if (dateRangeDate === undefined) {
return true;
}
return (
isSameDay(parseISO(portfolioItem.date), dateRangeDate) ||
isAfter(parseISO(portfolioItem.date), dateRangeDate)
);
})
.map((portfolioItem) => {
return {
date: format(parseISO(portfolioItem.date), 'yyyy-MM-dd'),
grossPerformancePercent: portfolioItem.grossPerformancePercent,
marketPrice: portfolioItem.value ?? null,
value: portfolioItem.value - portfolioItem.investment ?? null
};
});
return timeline
.filter((timelineItem) => timelineItem !== null)
.map((timelineItem) => ({
date: timelineItem.date,
marketPrice: timelineItem.value,
value: timelineItem.grossPerformance.toNumber()
}));
}
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const userId = await this.getUserId(aImpersonationId);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
const currency = this.request.user.Settings.currency;
const { balance } = await this.accountService.getCashDetails(
userId,
currency
);
const orders = await this.getOrders(userId);
const fees = this.getFees(orders);
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
return {
committedFunds: totalBuy - totalSell,
fees,
cash: balance,
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
};
}
public async getDetails(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ [symbol: string]: PortfolioPosition }> {
const userId = await this.getUserId(aImpersonationId);
const userCurrency = this.request.user.Settings.currency;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
userCurrency
);
const committedFunds = portfolio.getCommittedFunds();
const fees = portfolio.getFees();
const { transactionPoints, orders } = await this.getTransactionPoints(
userId
);
return {
committedFunds,
fees,
ordersCount: portfolio.getOrders().length,
totalBuy: portfolio.getTotalBuy(),
totalSell: portfolio.getTotalSell()
};
if (transactionPoints?.length <= 0) {
return {};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
if (currentPositions.hasErrors) {
throw new Error('Missing information');
}
const result: { [symbol: string]: PortfolioPosition } = {};
const totalValue = currentPositions.currentValue;
const symbols = currentPositions.positions.map(
(position) => position.symbol
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
}
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
for (const item of currentPositions.positions) {
const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
result[item.symbol] = {
accounts,
allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment
.div(currentPositions.totalInvestment)
.toNumber(),
countries: symbolProfile.countries,
currency: item.currency,
exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance.toNumber(),
grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
investment: item.investment.toNumber(),
marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState,
name: item.name,
quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors,
symbol: item.symbol,
transactionCount: item.transactionCount,
type: dataProviderResponse.type,
value: value.toNumber()
};
}
return result;
}
public async getPosition(
aImpersonationId: string,
aSymbol: string
): Promise<PortfolioPositionDetail> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
const userId = await this.getUserId(aImpersonationId);
const portfolio = await this.createPortfolio(
impersonationUserId || this.request.user.id
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const positions = portfolio.getPositions(new Date())[aSymbol];
const { transactionPoints, orders } = await this.getTransactionPoints(
userId
);
if (positions) {
let {
if (transactionPoints?.length <= 0) {
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions(
portfolioStart
);
const position = currentPositions.positions.find(
(item) => item.symbol === aSymbol
);
if (position) {
const {
averagePrice,
currency,
firstBuyDate,
@ -229,9 +306,7 @@ export class PortfolioService {
marketPrice,
quantity,
transactionCount
} = portfolio.getPositions(new Date())[aSymbol];
const orders = portfolio.getOrders(aSymbol);
} = position;
const historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
@ -240,32 +315,29 @@ export class PortfolioService {
new Date()
);
if (marketPrice === 0) {
marketPrice = averagePrice;
}
const historicalDataArray: HistoricalDataItem[] = [];
let currentAveragePrice: number;
let maxPrice = marketPrice;
let minPrice = marketPrice;
if (historicalData[aSymbol]) {
let j = -1;
for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol]
)) {
const currentDate = parse(date, 'yyyy-MM-dd', new Date());
if (
isSameDay(currentDate, parseISO(orders[0]?.getDate())) ||
isAfter(currentDate, parseISO(orders[0]?.getDate()))
while (
j + 1 < transactionPoints.length &&
!isAfter(parseDate(transactionPoints[j + 1].date), parseDate(date))
) {
// Get snapshot of first day of month
const snapshot = portfolio.get(setDate(currentDate, 1))[0]
.positions[aSymbol];
orders.shift();
if (snapshot?.averagePrice) {
currentAveragePrice = snapshot?.averagePrice;
}
j++;
}
let currentAveragePrice = 0;
const currentSymbol = transactionPoints[j].items.find(
(item) => item.symbol === aSymbol
);
if (currentSymbol) {
currentAveragePrice = currentSymbol.investment
.div(currentSymbol.quantity)
.toNumber();
}
historicalDataArray.push({
@ -274,58 +346,40 @@ export class PortfolioService {
value: marketPrice
});
if (
marketPrice &&
(marketPrice > maxPrice || maxPrice === undefined)
) {
maxPrice = marketPrice;
}
if (
marketPrice &&
(marketPrice < minPrice || minPrice === undefined)
) {
minPrice = marketPrice;
}
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
}
}
return {
averagePrice,
averagePrice: averagePrice.toNumber(),
currency,
firstBuyDate,
investment,
investment: investment.toNumber(),
marketPrice,
maxPrice,
minPrice,
quantity,
quantity: quantity.toNumber(),
transactionCount,
grossPerformance: this.exchangeRateDataService.toCurrency(
marketPrice - averagePrice,
currency,
this.request.user.Settings.currency
),
grossPerformancePercent: roundTo(
(marketPrice - averagePrice) / averagePrice,
4
),
grossPerformance: position.grossPerformance.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray,
symbol: aSymbol
};
} else if (portfolio.getMinDate()) {
} else {
const currentData = await this.dataProviderService.get([aSymbol]);
let historicalData = await this.dataProviderService.getHistorical(
[aSymbol],
'day',
portfolio.getMinDate(),
portfolioStart,
new Date()
);
if (isEmpty(historicalData)) {
historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
portfolio.getMinDate(),
portfolioStart,
new Date()
);
}
@ -343,13 +397,13 @@ export class PortfolioService {
return {
averagePrice: undefined,
currency: currentData[aSymbol].currency,
currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: historicalDataArray,
investment: undefined,
marketPrice: currentData[aSymbol].marketPrice,
marketPrice: currentData[aSymbol]?.marketPrice,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
@ -357,68 +411,334 @@ export class PortfolioService {
transactionCount: undefined
};
}
}
public async getPositions(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints(userId);
if (transactionPoints?.length <= 0) {
return {
hasErrors: false,
positions: []
};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
return {
averagePrice: undefined,
currency: undefined,
firstBuyDate: undefined,
grossPerformance: undefined,
grossPerformancePercent: undefined,
historicalData: [],
investment: undefined,
marketPrice: undefined,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol,
transactionCount: undefined
hasErrors: currentPositions.hasErrors,
positions: currentPositions.positions.map((position) => {
return {
...position,
averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(),
name: position.name,
quantity: new Big(position.quantity).toNumber(),
type: Type.Unknown, // TODO
url: '' // TODO
};
})
};
}
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const userId = await this.getUserId(aImpersonationId);
const normalizedMinDate =
getDate(aMinDate) === 1
? aMinDate
: add(setDate(aMinDate, 1), { months: 1 });
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
const { transactionPoints } = await this.getTransactionPoints(userId);
currentDate = new Date(Date.UTC(year, month, day, 0));
if (transactionPoints?.length <= 0) {
return {
hasErrors: false,
performance: {
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
}
};
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
);
const hasErrors = currentPositions.hasErrors;
const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance =
currentPositions.grossPerformance.toNumber();
const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage.toNumber();
return {
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentGrossPerformance,
currentGrossPerformancePercent,
// TODO: the next two should include fees
currentNetPerformance: currentGrossPerformance,
currentNetPerformancePercent: currentGrossPerformancePercent,
currentValue: currentValue
}
};
}
public getFees(orders: OrderWithAccount[], date = new Date(0)) {
return orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(date, new Date(order.date));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
this.request.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public async getReport(impersonationId: string): Promise<PortfolioReport> {
const userId = await this.getUserId(impersonationId);
const baseCurrency = this.request.user.Settings.currency;
const { transactionPoints, orders } = await this.getTransactionPoints(
userId
);
if (isEmpty(orders)) {
return {
rules: {}
};
}
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions(
portfolioStart
);
const portfolioItemsNow: { [symbol: string]: TimelinePosition } = {};
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency);
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
[
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService,
accounts
),
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService,
accounts
),
new AccountClusterRiskSingleAccount(
this.exchangeRateDataService,
accounts
)
],
{ baseCurrency }
),
currencyClusterRisk: await this.rulesService.evaluate(
[
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
this.exchangeRateDataService,
currentPositions
),
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService,
currentPositions
),
new CurrencyClusterRiskInitialInvestment(
this.exchangeRateDataService,
currentPositions
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService,
currentPositions
)
],
{ baseCurrency }
),
fees: await this.rulesService.evaluate(
[
new FeeRatioInitialInvestment(
this.exchangeRateDataService,
currentPositions.totalInvestment.toNumber(),
this.getFees(orders)
)
],
{ baseCurrency }
)
}
};
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) {
case '1d':
return sub(currentDate, {
days: 1
});
portfolioStart = max([portfolioStart, subDays(new Date(), 1)]);
break;
case 'ytd':
currentDate = setDate(currentDate, 1);
currentDate = setMonth(currentDate, 0);
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
portfolioStart = max([portfolioStart, setDayOfYear(new Date(), 1)]);
break;
case '1y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 1
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
portfolioStart = max([portfolioStart, subYears(new Date(), 1)]);
break;
case '5y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 5
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
default:
// Gets handled as all data
return undefined;
portfolioStart = max([portfolioStart, subYears(new Date(), 5)]);
break;
}
return portfolioStart;
}
private async getTransactionPoints(userId: string): Promise<{
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
}> {
const orders = await this.getOrders(userId);
if (orders.length <= 0) {
return { transactionPoints: [], orders: [] };
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
date: format(order.date, DATE_FORMAT),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: <OrderType>order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
portfolioCalculator.computeTransactionPoints(portfolioOrders);
return {
transactionPoints: portfolioCalculator.getTransactionPoints(),
orders
};
}
private getAccounts(
orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency
) {
const accounts: PortfolioPosition['accounts'] = {};
for (const order of orders) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
order.currency,
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
}
return accounts;
}
private getOrders(aUserId: string) {
return this.orderService.orders({
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
}
private async getUserId(aImpersonationId: string) {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
aImpersonationId,
this.request.user.id
);
return impersonationUserId || this.request.user.id;
}
private getTotalByType(
orders: OrderWithAccount[],
currency: Currency,
type: TypeOfOrder
) {
return orders
.filter(
(order) => !isAfter(order.date, endOfToday()) && order.type === type
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
currency
);
})
.reduce((previous, current) => previous + current, 0);
}
}

View File

@ -1,3 +1,4 @@
export const environment = {
production: true
production: true,
version: `v${require('../../../../package.json').version}`
};

View File

@ -1,3 +1,4 @@
export const environment = {
production: false
production: false,
version: 'dev'
};

View File

@ -5,13 +5,9 @@ import { Order } from '../order';
export interface PortfolioInterface {
get(aDate?: Date): PortfolioItem[];
getCommittedFunds(): number;
getFees(): number;
getPositions(
aDate: Date
): {
getPositions(aDate: Date): {
[symbol: string]: Position;
};

View File

@ -0,0 +1,3 @@
export interface RuleSettings {
isActive: boolean;
}

View File

@ -1,15 +1,10 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { EvaluationResult } from './evaluation-result.interface';
export interface RuleInterface {
evaluate(
aPortfolioPositionMap: {
[symbol: string]: PortfolioPosition;
},
aFees: number,
aRuleSettingsMap: {
[key: string]: any;
}
): EvaluationResult;
export interface RuleInterface<T extends RuleSettings> {
evaluate(aRuleSettings: T): EvaluationResult;
getSettings(aUserSettings: UserSettings): T;
}

View File

@ -0,0 +1,5 @@
import { Currency } from '@prisma/client';
export interface UserSettings {
baseCurrency: Currency;
}

View File

@ -1,648 +0,0 @@
import { UNKNOWN_KEY, baseCurrency } from '@ghostfolio/common/config';
import { getUtc, getYesterday } from '@ghostfolio/common/helper';
import {
AccountType,
Currency,
DataSource,
Role,
Type,
ViewMode
} from '@prisma/client';
import { format } from 'date-fns';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { MarketState } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { Portfolio } from './portfolio';
jest.mock('../services/data-provider.service', () => {
return {
DataProviderService: jest.fn().mockImplementation(() => {
const today = format(new Date(), 'yyyy-MM-dd');
const yesterday = format(getYesterday(), 'yyyy-MM-dd');
return {
get: () => {
return Promise.resolve({
BTCUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
type: 'Cryptocurrency'
},
ETHUSD: {
currency: Currency.USD,
dataSource: DataSource.YAHOO,
exchange: UNKNOWN_KEY,
marketPrice: 3915.337,
marketState: MarketState.open,
name: 'Ethereum USD',
type: 'Cryptocurrency'
}
});
},
getHistorical: () => {
return Promise.resolve({
BTCUSD: {
[yesterday]: 56710.122,
[today]: 57973.008
},
ETHUSD: {
[yesterday]: 3641.984,
[today]: 3915.337
}
});
}
};
})
};
});
jest.mock('../services/exchange-rate-data.service', () => {
return {
ExchangeRateDataService: jest.fn().mockImplementation(() => {
return {
initialize: () => Promise.resolve(),
toCurrency: (value: number) => value
};
})
};
});
jest.mock('../services/data-provider.service');
jest.mock('../services/exchange-rate-data.service');
jest.mock('../services/rules.service');
const DEFAULT_ACCOUNT_ID = '693a834b-eb89-42c9-ae47-35196c25d269';
const USER_ID = 'ca6ce867-5d31-495a-bce9-5942bbca9237';
describe('Portfolio', () => {
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolio: Portfolio;
let rulesService: RulesService;
beforeAll(async () => {
dataProviderService = new DataProviderService(
null,
null,
null,
null,
null,
null
);
exchangeRateDataService = new ExchangeRateDataService(null);
rulesService = new RulesService();
await exchangeRateDataService.initialize();
portfolio = new Portfolio(
dataProviderService,
exchangeRateDataService,
rulesService
);
portfolio.setUser({
accessToken: null,
Account: [
{
accountType: AccountType.SECURITIES,
createdAt: new Date(),
id: DEFAULT_ACCOUNT_ID,
isDefault: true,
name: 'Default Account',
platformId: null,
updatedAt: new Date(),
userId: USER_ID
}
],
alias: 'Test',
authChallenge: null,
createdAt: new Date(),
id: USER_ID,
provider: null,
role: Role.USER,
Settings: {
currency: Currency.CHF,
updatedAt: new Date(),
userId: USER_ID,
viewMode: ViewMode.DEFAULT
},
thirdPartyId: null,
updatedAt: new Date()
});
});
describe('works with no orders', () => {
it('should return []', () => {
expect(portfolio.get(new Date())).toEqual([]);
expect(portfolio.getFees()).toEqual(0);
expect(portfolio.getPositions(new Date())).toEqual({});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('1d');
expect(details).toEqual({});
});
it('should return empty details', async () => {
const details = await portfolio.getDetails('max');
expect(details).toEqual({});
});
it('should return zero performance for 1d', async () => {
const performance = await portfolio.getPerformance('1d');
expect(performance).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
});
});
it('should return zero performance for max', async () => {
const performance = await portfolio.getPerformance('max');
expect(performance).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
});
});
});
describe(`works with today's orders`, () => {
it('should return ["BTC"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(),
id: '8d999347-dee2-46ee-88e1-26b344e71fcc',
quantity: 1,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 49631.24,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
);
const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
BTCUSD: {
accounts: {
[UNKNOWN_KEY]: {
/*current: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),*/
original: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
}
},
allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
),
marketPrice: 57973.008,
marketState: MarketState.open,
name: 'Bitcoin USD',
quantity: 1,
symbol: 'BTCUSD',
transactionCount: 1,
type: 'Cryptocurrency'
}
});
expect(portfolio.getFees()).toEqual(0);
/*const performance1d = await portfolio.getPerformance('1d');
expect(performance1d).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: exchangeRateDataService.toBaseCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
});*/
/*const performanceMax = await portfolio.getPerformance('max');
expect(performanceMax).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: exchangeRateDataService.toBaseCurrency(
1 * 49631.24,
Currency.USD,
baseCurrency
)
});*/
expect(portfolio.getPositions(getYesterday())).toMatchObject({});
expect(portfolio.getSymbols(getYesterday())).toEqual([]);
expect(portfolio.getSymbols(new Date())).toEqual(['BTCUSD']);
});
});
describe('works with orders', () => {
it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
);
/*const details = await portfolio.getDetails('1d');
expect(details).toMatchObject({
ETHUSD: {
accounts: {
[UNKNOWN_KEY]: {
current: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
original: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
}
},
// allocationCurrent: 1,
allocationInvestment: 1,
countries: [],
currency: Currency.USD,
exchange: UNKNOWN_KEY,
// grossPerformance: 0,
// grossPerformancePercent: 0,
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
marketPrice: 3915.337,
name: 'Ethereum USD',
quantity: 0.2,
transactionCount: 1,
symbol: 'ETHUSD',
type: 'Cryptocurrency'
}
});*/
expect(portfolio.getFees()).toEqual(0);
/*const performance = await portfolio.getPerformance('max');
expect(performance).toEqual({
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0
});*/
expect(portfolio.getPositions(getYesterday())).toMatchObject({
ETHUSD: {
averagePrice: 991.49,
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 3915.337,
quantity: 0.2
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
it('should return ["ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.3,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.3 * 1050,
Currency.USD,
baseCurrency
)
);
expect(portfolio.getFees()).toEqual(0);
expect(portfolio.getPositions(getYesterday())).toMatchObject({
ETHUSD: {
averagePrice: (0.2 * 991.49 + 0.3 * 1050) / (0.2 + 0.3),
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment:
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.3 * 1050,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49 + 0.3 * 1050,
// marketPrice: 3641.984,
quantity: 0.5
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
it('should return ["BTCUSD", "ETHUSD"]', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.EUR,
dataSource: DataSource.YAHOO,
date: new Date(getUtc('2017-08-16')),
fee: 2.99,
id: 'd96795b2-6ae6-420e-aa21-fabe5e45d475',
quantity: 0.05614682,
symbol: 'BTCUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 3562.089535970158,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 2.99,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.05614682 * 3562.089535970158,
Currency.EUR,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
)
);
expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(2.99, Currency.EUR, baseCurrency) +
exchangeRateDataService.toCurrency(2.99, Currency.USD, baseCurrency)
);
expect(portfolio.getPositions(getYesterday())).toMatchObject({
BTCUSD: {
averagePrice: 3562.089535970158,
currency: Currency.EUR,
firstBuyDate: '2017-08-16T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.05614682 * 3562.089535970158,
Currency.EUR,
baseCurrency
),
investmentInOriginalCurrency: 0.05614682 * 3562.089535970158,
// marketPrice: 0,
quantity: 0.05614682
},
ETHUSD: {
averagePrice: 991.49,
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49,
// marketPrice: 0,
quantity: 0.2
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual([
'BTCUSD',
'ETHUSD'
]);
});
it('should work with buy and sell', async () => {
await portfolio.setOrders([
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-05')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fb',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 991.49,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-28')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.1,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.SELL,
unitPrice: 1050,
updatedAt: null,
userId: USER_ID
},
{
accountId: DEFAULT_ACCOUNT_ID,
accountUserId: USER_ID,
createdAt: null,
currency: Currency.USD,
dataSource: DataSource.YAHOO,
fee: 1.0,
date: new Date(getUtc('2018-01-31')),
id: '4a5a5c6e-659d-45cc-9fd4-fd6c873b50fc',
quantity: 0.2,
symbol: 'ETHUSD',
symbolProfileId: null,
type: Type.BUY,
unitPrice: 1050,
updatedAt: null,
userId: USER_ID
}
]);
expect(portfolio.getCommittedFunds()).toEqual(
exchangeRateDataService.toCurrency(
0.2 * 991.49,
Currency.USD,
baseCurrency
) -
exchangeRateDataService.toCurrency(
0.1 * 1050,
Currency.USD,
baseCurrency
) +
exchangeRateDataService.toCurrency(
0.2 * 1050,
Currency.USD,
baseCurrency
)
);
expect(portfolio.getFees()).toEqual(
exchangeRateDataService.toCurrency(3, Currency.USD, baseCurrency)
);
expect(portfolio.getPositions(getYesterday())).toMatchObject({
ETHUSD: {
averagePrice:
(0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050) / (0.2 - 0.1 + 0.2),
currency: Currency.USD,
firstBuyDate: '2018-01-05T00:00:00.000Z',
investment: exchangeRateDataService.toCurrency(
0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
Currency.USD,
baseCurrency
),
investmentInOriginalCurrency: 0.2 * 991.49 - 0.1 * 1050 + 0.2 * 1050,
// marketPrice: 0,
quantity: 0.2 - 0.1 + 0.2
}
});
expect(portfolio.getSymbols(getYesterday())).toEqual(['ETHUSD']);
});
});
});

View File

@ -1,908 +0,0 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getToday, getYesterday, resetHours } from '@ghostfolio/common/helper';
import {
PortfolioItem,
PortfolioPerformance,
PortfolioPosition,
PortfolioReport,
Position,
UserWithSettings
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { DateRange, OrderWithAccount } from '@ghostfolio/common/types';
import { Prisma } from '@prisma/client';
import { continents, countries } from 'countries-list';
import {
add,
format,
getDate,
getMonth,
getYear,
isAfter,
isBefore,
isSameDay,
isToday,
isYesterday,
parseISO,
setDate,
setMonth,
sub
} from 'date-fns';
import { cloneDeep, isEmpty } from 'lodash';
import * as roundTo from 'round-to';
import { DataProviderService } from '../services/data-provider.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { IOrder } from '../services/interfaces/interfaces';
import { RulesService } from '../services/rules.service';
import { PortfolioInterface } from './interfaces/portfolio.interface';
import { Order } from './order';
import { OrderType } from './order-type';
import { AccountClusterRiskCurrentInvestment } from './rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from './rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from './rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from './rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from './rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from './rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from './rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from './rules/fees/fee-ratio-initial-investment';
export class Portfolio implements PortfolioInterface {
private orders: Order[] = [];
private portfolioItems: PortfolioItem[] = [];
private user: UserWithSettings;
public constructor(
private dataProviderService: DataProviderService,
private exchangeRateDataService: ExchangeRateDataService,
private rulesService: RulesService
) {}
public async addCurrentPortfolioItems() {
const currentData = await this.dataProviderService.get(this.getSymbols());
const currentDate = new Date();
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
const today = new Date(Date.UTC(year, month, day));
const yesterday = getYesterday();
const [portfolioItemsYesterday] = this.get(yesterday);
const positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
averagePrice: portfolioItemsYesterday?.positions[symbol]?.averagePrice,
currency: portfolioItemsYesterday?.positions[symbol]?.currency,
firstBuyDate: portfolioItemsYesterday?.positions[symbol]?.firstBuyDate,
investment: portfolioItemsYesterday?.positions[symbol]?.investment,
investmentInOriginalCurrency:
portfolioItemsYesterday?.positions[symbol]
?.investmentInOriginalCurrency,
marketPrice:
currentData[symbol]?.marketPrice ??
portfolioItemsYesterday.positions[symbol]?.marketPrice,
quantity: portfolioItemsYesterday?.positions[symbol]?.quantity,
transactionCount:
portfolioItemsYesterday?.positions[symbol]?.transactionCount
};
});
if (portfolioItemsYesterday?.investment) {
const portfolioItemsLength = this.portfolioItems.push(
cloneDeep({
date: today.toISOString(),
grossPerformancePercent: 0,
investment: portfolioItemsYesterday?.investment,
positions: positions,
value: 0
})
);
// Set value after pushing today's portfolio items
this.portfolioItems[portfolioItemsLength - 1].value =
this.getValue(today);
}
return this;
}
public async addFuturePortfolioItems() {
let investment = this.getInvestment(new Date());
this.getOrders()
.filter((order) => order.getIsDraft() === true)
.forEach((order) => {
investment += this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
const portfolioItem = this.portfolioItems.find((item) => {
return item.date === order.getDate();
});
if (portfolioItem) {
portfolioItem.investment = investment;
} else {
this.portfolioItems.push({
investment,
date: order.getDate(),
grossPerformancePercent: 0,
positions: {},
value: 0
});
}
});
return this;
}
public createFromData({
orders,
portfolioItems,
user
}: {
orders: IOrder[];
portfolioItems: PortfolioItem[];
user: UserWithSettings;
}): Portfolio {
orders.forEach(
({
account,
currency,
fee,
date,
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
}) => {
this.orders.push(
new Order({
account,
currency,
fee,
date,
id,
quantity,
symbol,
symbolProfile,
type,
unitPrice
})
);
}
);
portfolioItems.forEach(
({ date, grossPerformancePercent, investment, positions, value }) => {
this.portfolioItems.push({
date,
grossPerformancePercent,
investment,
positions,
value
});
}
);
this.setUser(user);
return this;
}
public get(aDate?: Date): PortfolioItem[] {
if (aDate) {
const filteredPortfolio = this.portfolioItems.find((item) => {
return isSameDay(aDate, new Date(item.date));
});
if (filteredPortfolio) {
return [cloneDeep(filteredPortfolio)];
}
return [];
}
return cloneDeep(this.portfolioItems);
}
public getCommittedFunds() {
return this.getTotalBuy() - this.getTotalSell();
}
public async getDetails(
aDateRange: DateRange = 'max'
): Promise<{ [symbol: string]: PortfolioPosition }> {
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
this.getMinDate()
);
const [portfolioItemsBefore] = this.get(dateRangeDate);
const [portfolioItemsNow] = await this.get(new Date());
const investment = this.getInvestment(new Date());
const portfolioItems = this.get(new Date());
const symbols = this.getSymbols(new Date());
const value = this.getValue();
const details: { [symbol: string]: PortfolioPosition } = {};
const data = await this.dataProviderService.get(symbols);
symbols.forEach((symbol) => {
const accounts: PortfolioPosition['accounts'] = {};
let countriesOfSymbol: Country[];
let sectorsOfSymbol: Sector[];
const [portfolioItem] = portfolioItems;
const ordersBySymbol = this.getOrders().filter((order) => {
return order.getSymbol() === symbol;
});
ordersBySymbol.forEach((orderOfSymbol) => {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
orderOfSymbol.getQuantity() *
portfolioItemsNow.positions[symbol].marketPrice,
orderOfSymbol.getCurrency(),
this.user.Settings.currency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
orderOfSymbol.getQuantity() * orderOfSymbol.getUnitPrice(),
orderOfSymbol.getCurrency(),
this.user.Settings.currency
);
if (orderOfSymbol.getType() === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
}
if (
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY]?.current
) {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[orderOfSymbol.getAccount()?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
countriesOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.countries as Prisma.JsonArray) ??
[]
).map((country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
});
sectorsOfSymbol = (
(orderOfSymbol.getSymbolProfile()?.sectors as Prisma.JsonArray) ?? []
).map((sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
});
});
let now = portfolioItemsNow.positions[symbol].marketPrice;
// 1d
let before = portfolioItemsBefore?.positions[symbol].marketPrice;
if (aDateRange === 'ytd') {
before =
portfolioItemsBefore.positions[symbol].marketPrice ||
portfolioItemsNow.positions[symbol].averagePrice;
} else if (
aDateRange === '1y' ||
aDateRange === '5y' ||
aDateRange === 'max'
) {
before = portfolioItemsNow.positions[symbol].averagePrice;
}
if (
!isBefore(
parseISO(portfolioItemsNow.positions[symbol].firstBuyDate),
parseISO(portfolioItemsBefore?.date)
)
) {
// Trade was not before the date of portfolioItemsBefore, then override it with average price
// (e.g. on same day)
before = portfolioItemsNow.positions[symbol].averagePrice;
}
if (isToday(parseISO(portfolioItemsNow.positions[symbol].firstBuyDate))) {
now = portfolioItemsNow.positions[symbol].averagePrice;
}
details[symbol] = {
...data[symbol],
accounts,
symbol,
allocationCurrent:
this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
) / value,
allocationInvestment:
portfolioItem.positions[symbol].investment / investment,
countries: countriesOfSymbol,
grossPerformance: roundTo(
portfolioItemsNow.positions[symbol].quantity * (now - before),
2
),
grossPerformancePercent: roundTo((now - before) / before, 4),
investment: portfolioItem.positions[symbol].investment,
quantity: portfolioItem.positions[symbol].quantity,
sectors: sectorsOfSymbol,
transactionCount: portfolioItem.positions[symbol].transactionCount,
value: this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol].quantity * now,
data[symbol]?.currency,
this.user.Settings.currency
)
};
});
return details;
}
public getFees(aDate = new Date(0)) {
return this.orders
.filter((order) => {
// Filter out all orders before given date
return isBefore(aDate, new Date(order.getDate()));
})
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getFee(),
order.getCurrency(),
this.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public getInvestment(aDate: Date): number {
return this.get(aDate)[0]?.investment || 0;
}
public getMinDate() {
const orders = this.getOrders().filter(
(order) => order.getIsDraft() === false
);
if (orders.length > 0) {
return new Date(this.orders[0].getDate());
}
return null;
}
public async getPerformance(
aDateRange: DateRange = 'max'
): Promise<PortfolioPerformance> {
const dateRangeDate = this.convertDateRangeToDate(
aDateRange,
this.getMinDate()
);
const currentInvestment = this.getInvestment(new Date());
const currentValue = await this.getValue();
let originalInvestment = currentInvestment;
let originalValue = this.getCommittedFunds();
if (dateRangeDate) {
originalInvestment = this.getInvestment(dateRangeDate);
originalValue = (await this.getValue(dateRangeDate)) || originalValue;
}
const fees = this.getFees(dateRangeDate);
const currentGrossPerformance =
currentValue - currentInvestment - (originalValue - originalInvestment);
// https://www.skillsyouneed.com/num/percent-change.html
const currentGrossPerformancePercent =
currentGrossPerformance / originalInvestment || 0;
const currentNetPerformance = currentGrossPerformance - fees;
// https://www.skillsyouneed.com/num/percent-change.html
const currentNetPerformancePercent =
currentNetPerformance / originalInvestment || 0;
return {
currentGrossPerformance,
currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue
};
}
public getPositions(aDate: Date) {
const [portfolioItem] = this.get(aDate);
if (portfolioItem) {
return portfolioItem.positions;
}
return {};
}
public getPortfolioItems() {
return this.portfolioItems;
}
public async getReport(): Promise<PortfolioReport> {
const details = await this.getDetails();
if (isEmpty(details)) {
return {
rules: {}
};
}
return {
rules: {
accountClusterRisk: await this.rulesService.evaluate(
this,
[
new AccountClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskCurrentInvestment(
this.exchangeRateDataService
),
new AccountClusterRiskSingleAccount(this.exchangeRateDataService)
],
{ baseCurrency: this.user.Settings.currency }
),
currencyClusterRisk: await this.rulesService.evaluate(
this,
[
new CurrencyClusterRiskBaseCurrencyInitialInvestment(
this.exchangeRateDataService
),
new CurrencyClusterRiskBaseCurrencyCurrentInvestment(
this.exchangeRateDataService
),
new CurrencyClusterRiskInitialInvestment(
this.exchangeRateDataService
),
new CurrencyClusterRiskCurrentInvestment(
this.exchangeRateDataService
)
],
{ baseCurrency: this.user.Settings.currency }
),
fees: await this.rulesService.evaluate(
this,
[new FeeRatioInitialInvestment(this.exchangeRateDataService)],
{ baseCurrency: this.user.Settings.currency }
)
}
};
}
public getSymbols(aDate?: Date) {
let symbols: string[] = [];
if (aDate) {
const positions = this.getPositions(aDate);
for (const symbol in positions) {
if (positions[symbol].quantity > 0) {
symbols.push(symbol);
}
}
} else {
symbols = this.orders
.filter((order) => order.getIsDraft() === false)
.map((order) => {
return order.getSymbol();
});
}
// unique values
return Array.from(new Set(symbols));
}
public getTotalBuy() {
return this.orders
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'BUY'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public getTotalSell() {
return this.orders
.filter(
(order) => order.getIsDraft() === false && order.getType() === 'SELL'
)
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
})
.reduce((previous, current) => previous + current, 0);
}
public getOrders(aSymbol?: string) {
if (aSymbol) {
return this.orders.filter((order) => {
return order.getSymbol() === aSymbol;
});
}
return this.orders;
}
public getValue(aDate = getToday()) {
const positions = this.getPositions(aDate);
let value = 0;
const [portfolioItem] = this.get(aDate);
for (const symbol in positions) {
if (portfolioItem.positions[symbol]?.quantity > 0) {
if (
isBefore(
aDate,
parseISO(portfolioItem.positions[symbol]?.firstBuyDate)
) ||
portfolioItem.positions[symbol]?.marketPrice === 0
) {
value += this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol]?.quantity *
portfolioItem.positions[symbol]?.averagePrice,
portfolioItem.positions[symbol]?.currency,
this.user.Settings.currency
);
} else {
value += this.exchangeRateDataService.toCurrency(
portfolioItem.positions[symbol]?.quantity *
portfolioItem.positions[symbol]?.marketPrice,
portfolioItem.positions[symbol]?.currency,
this.user.Settings.currency
);
}
}
}
return isFinite(value) ? value : null;
}
public async setOrders(aOrders: OrderWithAccount[]) {
this.orders = [];
// Map data
aOrders.forEach((order) => {
this.orders.push(
new Order({
account: order.Account,
currency: order.currency,
date: order.date.toISOString(),
fee: order.fee,
quantity: order.quantity,
symbol: order.symbol,
symbolProfile: order.SymbolProfile,
type: <OrderType>order.type,
unitPrice: order.unitPrice
})
);
});
await this.update();
return this;
}
public setUser(aUser: UserWithSettings) {
this.user = aUser;
return this;
}
/**
* TODO: Refactor
*/
private async update() {
this.portfolioItems = [];
let currentDate = this.getMinDate();
if (!currentDate) {
return;
}
// Set current date to first of month
currentDate = setDate(currentDate, 1);
const historicalData = await this.dataProviderService.getHistorical(
this.getSymbols(),
'month',
currentDate,
new Date()
);
while (isBefore(currentDate, Date.now())) {
const positions: { [symbol: string]: Position } = {};
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
averagePrice: 0,
currency: undefined,
firstBuyDate: null,
investment: 0,
investmentInOriginalCurrency: 0,
marketPrice:
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0,
transactionCount: 0
};
});
if (!isYesterday(currentDate) && !isToday(currentDate)) {
// Add to portfolio (ignore yesterday and today because they are added later)
this.portfolioItems.push(
cloneDeep({
date: currentDate.toISOString(),
grossPerformancePercent: 0,
investment: 0,
positions: positions,
value: 0
})
);
}
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
// Count month one up for iteration
currentDate = new Date(Date.UTC(year, month + 1, day, 0));
}
const yesterday = getYesterday();
let positions: { [symbol: string]: Position } = {};
if (isAfter(yesterday, this.getMinDate())) {
// Add yesterday
this.getSymbols().forEach((symbol) => {
positions[symbol] = {
averagePrice: 0,
currency: undefined,
firstBuyDate: null,
investment: 0,
investmentInOriginalCurrency: 0,
marketPrice:
historicalData[symbol]?.[format(yesterday, 'yyyy-MM-dd')]
?.marketPrice || 0,
quantity: 0,
transactionCount: 0
};
});
this.portfolioItems.push(
cloneDeep({
positions,
date: yesterday.toISOString(),
grossPerformancePercent: 0,
investment: 0,
value: 0
})
);
}
this.updatePortfolioItems();
}
private convertDateRangeToDate(aDateRange: DateRange, aMinDate: Date) {
let currentDate = new Date();
const normalizedMinDate =
getDate(aMinDate) === 1
? aMinDate
: add(setDate(aMinDate, 1), { months: 1 });
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
switch (aDateRange) {
case '1d':
return sub(currentDate, {
days: 1
});
case 'ytd':
currentDate = setDate(currentDate, 1);
currentDate = setMonth(currentDate, 0);
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '1y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 1
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
case '5y':
currentDate = setDate(currentDate, 1);
currentDate = sub(currentDate, {
years: 5
});
return isAfter(currentDate, normalizedMinDate)
? currentDate
: undefined;
default:
// Gets handled as all data
return undefined;
}
}
private updatePortfolioItems() {
let currentDate = new Date();
const year = getYear(currentDate);
const month = getMonth(currentDate);
const day = getDate(currentDate);
currentDate = new Date(Date.UTC(year, month, day, 0));
if (this.portfolioItems?.length === 1) {
// At least one portfolio items is needed, keep it but change the date to today.
// This happens if there are only orders from today
this.portfolioItems[0].date = currentDate.toISOString();
} else {
// Only keep entries which are not before first buy date
this.portfolioItems = this.portfolioItems.filter((portfolioItem) => {
return (
isSameDay(parseISO(portfolioItem.date), this.getMinDate()) ||
isAfter(parseISO(portfolioItem.date), this.getMinDate())
);
});
}
this.orders.forEach((order) => {
if (order.getIsDraft() === false) {
let index = this.portfolioItems.findIndex((item) => {
const dateOfOrder = setDate(parseISO(order.getDate()), 1);
return isSameDay(parseISO(item.date), dateOfOrder);
});
if (index === -1) {
// if not found, we only have one order, which means we do not loop below
index = 0;
}
for (let i = index; i < this.portfolioItems.length; i++) {
// Set currency
this.portfolioItems[i].positions[order.getSymbol()].currency =
order.getCurrency();
this.portfolioItems[i].positions[
order.getSymbol()
].transactionCount += 1;
if (order.getType() === 'BUY') {
if (
!this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate
) {
this.portfolioItems[i].positions[order.getSymbol()].firstBuyDate =
resetHours(parseISO(order.getDate())).toISOString();
}
this.portfolioItems[i].positions[order.getSymbol()].quantity +=
order.getQuantity();
this.portfolioItems[i].positions[order.getSymbol()].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency += order.getTotal();
this.portfolioItems[i].investment +=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
} else if (order.getType() === 'SELL') {
this.portfolioItems[i].positions[order.getSymbol()].quantity -=
order.getQuantity();
if (
this.portfolioItems[i].positions[order.getSymbol()].quantity === 0
) {
this.portfolioItems[i].positions[
order.getSymbol()
].investment = 0;
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency = 0;
} else {
this.portfolioItems[i].positions[order.getSymbol()].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
this.portfolioItems[i].positions[
order.getSymbol()
].investmentInOriginalCurrency -= order.getTotal();
}
this.portfolioItems[i].investment -=
this.exchangeRateDataService.toCurrency(
order.getTotal(),
order.getCurrency(),
this.user.Settings.currency
);
}
this.portfolioItems[i].positions[order.getSymbol()].averagePrice =
this.portfolioItems[i].positions[order.getSymbol()]
.investmentInOriginalCurrency /
this.portfolioItems[i].positions[order.getSymbol()].quantity;
const currentValue = this.getValue(
parseISO(this.portfolioItems[i].date)
);
this.portfolioItems[i].grossPerformancePercent =
currentValue / this.portfolioItems[i].investment - 1 || 0;
this.portfolioItems[i].value = currentValue;
}
}
});
}
}

View File

@ -1,16 +1,21 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { groupBy } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import {
PortfolioPosition,
TimelinePosition
} from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface';
export abstract class Rule implements RuleInterface {
export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
private name: string;
public constructor(
public exchangeRateDataService: ExchangeRateDataService,
protected exchangeRateDataService: ExchangeRateDataService,
{
name
}: {
@ -20,44 +25,38 @@ export abstract class Rule implements RuleInterface {
this.name = name;
}
public abstract evaluate(
aPortfolioPositionMap: {
[symbol: string]: PortfolioPosition;
},
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
): EvaluationResult;
public abstract evaluate(aRuleSettings: T): EvaluationResult;
public abstract getSettings(aUserSettings: UserSettings): T;
public getName() {
return this.name;
}
public groupPositionsByAttribute(
aPositions: { [symbol: string]: PortfolioPosition },
aAttribute: keyof PortfolioPosition,
aBaseCurrency: Currency
public groupCurrentPositionsByAttribute(
positions: TimelinePosition[],
attribute: keyof TimelinePosition,
baseCurrency: Currency
) {
return Array.from(
groupBy(aAttribute, Object.values(aPositions)).entries()
).map(([attributeValue, objs]) => ({
groupKey: attributeValue,
investment: objs.reduce(
(previousValue, currentValue) =>
previousValue + currentValue.investment,
0
),
value: objs.reduce(
(previousValue, currentValue) =>
previousValue +
this.exchangeRateDataService.toCurrency(
currentValue.quantity * currentValue.marketPrice,
currentValue.currency,
aBaseCurrency
),
0
)
}));
return Array.from(groupBy(attribute, positions).entries()).map(
([attributeValue, objs]) => ({
groupKey: attributeValue,
investment: objs.reduce(
(previousValue, currentValue) =>
previousValue + currentValue.investment.toNumber(),
0
),
value: objs.reduce(
(previousValue, currentValue) =>
previousValue +
this.exchangeRateDataService.toCurrency(
currentValue.quantity.mul(currentValue.marketPrice).toNumber(),
currentValue.currency,
baseCurrency
),
0
)
})
);
}
}

View File

@ -1,43 +1,35 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule';
export class AccountClusterRiskCurrentInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
) {
super(exchangeRateDataService, {
name: 'Current Investment'
});
}
public evaluate(
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[AccountClusterRiskCurrentInvestment.name];
public evaluate(ruleSettings: Settings) {
const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
Object.values(aPositions).forEach((position) => {
for (const [account, { current }] of Object.entries(position.accounts)) {
if (accounts[account]?.investment) {
accounts[account].investment += current;
} else {
accounts[account] = {
investment: current,
name: account
};
}
}
});
for (const account of Object.keys(this.accounts)) {
accounts[account] = {
name: account,
investment: this.accounts[account].current
};
}
let maxItem;
let totalInvestment = 0;
@ -78,4 +70,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
threshold: number;
}

View File

@ -1,43 +1,35 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class AccountClusterRiskInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
) {
super(exchangeRateDataService, {
name: 'Initial Investment'
});
}
public evaluate(
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[AccountClusterRiskInitialInvestment.name];
public evaluate(ruleSettings?: Settings) {
const platforms: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number;
};
} = {};
Object.values(aPositions).forEach((position) => {
for (const [account, { original }] of Object.entries(position.accounts)) {
if (platforms[account]?.investment) {
platforms[account].investment += original;
} else {
platforms[account] = {
investment: original,
name: account
};
}
}
});
for (const account of Object.keys(this.accounts)) {
platforms[account] = {
name: account,
investment: this.accounts[account].original
};
}
let maxItem;
let totalInvestment = 0;
@ -78,4 +70,18 @@ export class AccountClusterRiskInitialInvestment extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: string;
isActive: boolean;
threshold: number;
}

View File

@ -1,25 +1,23 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class AccountClusterRiskSingleAccount extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private accounts: {
[account: string]: { current: number; original: number };
}
) {
super(exchangeRateDataService, {
name: 'Single Account'
});
}
public evaluate(positions: { [symbol: string]: PortfolioPosition }) {
const accounts: string[] = [];
Object.values(positions).forEach((position) => {
for (const [account] of Object.entries(position.accounts)) {
if (!accounts.includes(account)) {
accounts.push(account);
}
}
});
public evaluate() {
const accounts: string[] = Object.keys(this.accounts);
if (accounts.length === 1) {
return {
@ -33,4 +31,10 @@ export class AccountClusterRiskSingleAccount extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): RuleSettings {
return {
isActive: true
};
}
}

View File

@ -1,27 +1,25 @@
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, {
name: 'Current Investment: Base Currency'
});
}
public evaluate(
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
'currency',
ruleSettings.baseCurrency
);
@ -61,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
}

View File

@ -1,27 +1,24 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, {
name: 'Initial Investment: Base Currency'
});
}
public evaluate(
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskBaseCurrencyInitialInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
'currency',
ruleSettings.baseCurrency
);
@ -62,4 +59,15 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
}

View File

@ -1,27 +1,24 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class CurrencyClusterRiskCurrentInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor(
public exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, {
name: 'Current Investment'
});
}
public evaluate(
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskCurrentInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
'currency',
ruleSettings.baseCurrency
);
@ -61,4 +58,17 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
threshold: number;
}

View File

@ -1,27 +1,24 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class CurrencyClusterRiskInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private currentPositions: CurrentPositions
) {
super(exchangeRateDataService, {
name: 'Initial Investment'
});
}
public evaluate(
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings =
aRuleSettingsMap[CurrencyClusterRiskInitialInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
public evaluate(ruleSettings: Settings) {
const positionsGroupedByCurrency = this.groupCurrentPositionsByAttribute(
this.currentPositions.positions,
'currency',
ruleSettings.baseCurrency
);
@ -61,4 +58,17 @@ export class CurrencyClusterRiskInitialInvestment extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
threshold: number;
}

View File

@ -1,38 +1,23 @@
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule';
export class FeeRatioInitialInvestment extends Rule {
public constructor(public exchangeRateDataService: ExchangeRateDataService) {
export class FeeRatioInitialInvestment extends Rule<Settings> {
public constructor(
protected exchangeRateDataService: ExchangeRateDataService,
private totalInvestment: number,
private fees: number
) {
super(exchangeRateDataService, {
name: 'Initial Investment'
});
}
public evaluate(
aPositions: { [symbol: string]: PortfolioPosition },
aFees: number,
aRuleSettingsMap?: {
[key: string]: any;
}
) {
const ruleSettings = aRuleSettingsMap[FeeRatioInitialInvestment.name];
const positionsGroupedByCurrency = this.groupPositionsByAttribute(
aPositions,
'currency',
ruleSettings.baseCurrency
);
let totalInvestment = 0;
positionsGroupedByCurrency.forEach((groupItem) => {
// Calculate total investment
totalInvestment += groupItem.investment;
});
const feeRatio = aFees / totalInvestment;
public evaluate(ruleSettings: Settings) {
const feeRatio = this.fees / this.totalInvestment;
if (feeRatio > ruleSettings.threshold) {
return {
@ -50,4 +35,17 @@ export class FeeRatioInitialInvestment extends Rule {
value: true
};
}
public getSettings(aUserSettings: UserSettings): Settings {
return {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.01
};
}
}
interface Settings extends RuleSettings {
baseCurrency: Currency;
threshold: number;
}

View File

@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface';
@Injectable()
@ -14,8 +15,10 @@ export class ConfigurationService {
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
@ -28,6 +31,7 @@ export class ConfigurationService {
REDIS_HOST: str({ default: 'localhost' }),
REDIS_PORT: port({ default: 6379 }),
ROOT_URL: str({ default: 'http://localhost:4200' }),
STRIPE_PUBLIC_KEY: str({ default: '' }),
STRIPE_SECRET_KEY: str({ default: '' }),
WEB_AUTH_RP_ID: host({ default: 'localhost' })
});

View File

@ -18,6 +18,7 @@ export class CronService {
@Cron(CronExpression.EVERY_12_HOURS)
public async runEveryTwelveHours() {
await this.dataGatheringService.gatherProfileData();
await this.exchangeRateDataService.loadCurrencies();
}
}

View File

@ -1,7 +1,9 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getUtc,
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol,
resetHours
} from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
@ -37,7 +39,7 @@ export class DataGatheringService {
if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.');
console.time('data-gathering');
console.time('7d-data-gathering');
await this.prisma.property.create({
data: {
@ -70,7 +72,7 @@ export class DataGatheringService {
});
console.log('7d data gathering has been completed.');
console.timeEnd('data-gathering');
console.timeEnd('7d-data-gathering');
}
}
@ -81,7 +83,7 @@ export class DataGatheringService {
if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.');
console.time('data-gathering');
console.time('max-data-gathering');
await this.prisma.property.create({
data: {
@ -114,10 +116,56 @@ export class DataGatheringService {
});
console.log('Max data gathering has been completed.');
console.timeEnd('data-gathering');
console.timeEnd('max-data-gathering');
}
}
public async gatherProfileData(aSymbols?: string[]) {
console.log('Profile data gathering has been started.');
console.time('profile-data-gathering');
let symbols = aSymbols;
if (!symbols) {
const dataGatheringItems = await this.getSymbolsProfileData();
symbols = dataGatheringItems.map((dataGatheringItem) => {
return dataGatheringItem.symbol;
});
}
const currentData = await this.dataProviderService.get(symbols);
for (const [symbol, { currency, dataSource, name }] of Object.entries(
currentData
)) {
try {
await this.prisma.symbolProfile.upsert({
create: {
currency,
dataSource,
name,
symbol
},
update: {
currency,
name
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
});
} catch (error) {
console.error(`${symbol}: ${error?.meta?.cause}`);
}
}
console.log('Profile data gathering has been completed.');
console.timeEnd('profile-data-gathering');
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false;
@ -146,11 +194,11 @@ export class DataGatheringService {
)
) {
if (
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice
) {
lastMarketPrice =
historicalData[symbol]?.[format(currentDate, 'yyyy-MM-dd')]
historicalData[symbol]?.[format(currentDate, DATE_FORMAT)]
?.marketPrice;
}
@ -303,6 +351,25 @@ export class DataGatheringService {
];
}
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
});
return [...this.getBenchmarksToGather(startDate), ...distinctOrders].filter(
(distinctOrder) => {
return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.RAKUTEN
);
}
);
}
private async isDataGatheringNeeded() {
const lastDataGathering = await this.prisma.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }

View File

@ -1,4 +1,5 @@
import {
DATE_FORMAT,
isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
@ -29,7 +30,7 @@ export class DataProviderService {
private readonly rakutenRapidApiService: RakutenRapidApiService,
private readonly yahooFinanceService: YahooFinanceService
) {
this.rakutenRapidApiService.setPrisma(this.prisma);
this.rakutenRapidApiService?.setPrisma(this.prisma);
}
public async get(
@ -46,7 +47,10 @@ export class DataProviderService {
}
const yahooFinanceSymbols = aSymbols.filter((symbol) => {
return !isGhostfolioScraperApiSymbol(symbol);
return (
!isGhostfolioScraperApiSymbol(symbol) &&
!isRakutenRapidApiSymbol(symbol)
);
});
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
@ -57,13 +61,24 @@ export class DataProviderService {
for (const symbol of ghostfolioScraperApiSymbols) {
if (symbol) {
const ghostfolioScraperApiResult = await this.ghostfolioScraperApiService.get(
[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];
}
}
return response;
}
@ -86,9 +101,9 @@ export class DataProviderService {
const rangeQuery =
from && to
? `AND date >= '${format(from, 'yyyy-MM-dd')}' AND date <= '${format(
? `AND date >= '${format(from, DATE_FORMAT)}' AND date <= '${format(
to,
'yyyy-MM-dd'
DATE_FORMAT
)}'`
: '';
@ -106,7 +121,7 @@ export class DataProviderService {
r[symbol] = {
...(r[symbol] || {}),
[format(new Date(date), 'yyyy-MM-dd')]: { marketPrice }
[format(new Date(date), DATE_FORMAT)]: { marketPrice }
};
return r;

View File

@ -1,4 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -66,8 +67,8 @@ export class AlphaVantageService implements DataProviderInterface {
historicalData['Time Series (Digital Currency Daily)']
).sort()) {
if (
isAfter(from, parse(key, 'yyyy-MM-dd', new Date())) &&
isBefore(to, parse(key, 'yyyy-MM-dd', new Date()))
isAfter(from, parse(key, DATE_FORMAT, new Date())) &&
isBefore(to, parse(key, DATE_FORMAT, new Date()))
) {
response[symbol][key] = {
marketPrice: parseFloat(timeSeries['4a. close (USD)'])

View File

@ -1,4 +1,5 @@
import {
DATE_FORMAT,
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
@ -95,7 +96,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return {
[symbol]: {
[format(getYesterday(), 'yyyy-MM-dd')]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: value
}
}
@ -109,14 +110,13 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try {
const {
value: scraperConfigString
} = await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
const { value: scraperConfigString } =
await this.prisma.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString);
} catch {}

View File

@ -1,4 +1,5 @@
import {
DATE_FORMAT,
getToday,
getYesterday,
isRakutenRapidApiSymbol
@ -117,7 +118,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {
'GF.FEAR_AND_GREED_INDEX': {
[format(getYesterday(), 'yyyy-MM-dd')]: {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value
}
}

View File

@ -1,6 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { isCrypto, isCurrency, parseCurrency } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
isCrypto,
isCurrency,
parseCurrency
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client';
@ -103,8 +108,8 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({
symbols: yahooSymbols,
from: format(from, 'yyyy-MM-dd'),
to: format(to, 'yyyy-MM-dd')
from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT)
});
const response: {
@ -117,7 +122,7 @@ export class YahooFinanceService implements DataProviderInterface {
response[symbol] = {};
timeSeries.forEach((timeSerie) => {
response[symbol][format(timeSerie.date, 'yyyy-MM-dd')] = {
response[symbol][format(timeSerie.date, DATE_FORMAT)] = {
marketPrice: timeSerie.close,
performance: timeSerie.open - timeSerie.close
};

View File

@ -1,4 +1,4 @@
import { getYesterday } from '@ghostfolio/common/helper';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { format } from 'date-fns';
@ -51,7 +51,7 @@ export class ExchangeRateDataService {
this.pairs.forEach((pair) => {
const [currency1, currency2] = pair.match(/.{1,3}/g);
const date = format(getYesterday(), 'yyyy-MM-dd');
const date = format(getYesterday(), DATE_FORMAT);
this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice;

View File

@ -5,8 +5,10 @@ export interface Environment extends CleanedEnvAccessors {
ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: boolean;
ENABLE_FEATURE_IMPORT: boolean;
ENABLE_FEATURE_SOCIAL_LOGIN: boolean;
ENABLE_FEATURE_STATISTICS: boolean;
ENABLE_FEATURE_SUBSCRIPTION: boolean;
@ -19,6 +21,7 @@ export interface Environment extends CleanedEnvAccessors {
REDIS_HOST: string;
REDIS_PORT: number;
ROOT_URL: string;
STRIPE_PUBLIC_KEY: string;
STRIPE_SECRET_KEY: string;
WEB_AUTH_RP_ID: string;
}

View File

@ -10,6 +10,7 @@ export const MarketState = {
};
export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Stock: 'Stock',

View File

@ -0,0 +1,15 @@
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Currency, DataSource } from '@prisma/client';
export interface EnhancedSymbolProfile {
createdAt: Date;
currency: Currency | null;
dataSource: DataSource;
id: string;
name: string | null;
updatedAt: Date;
symbol: string;
countries: Country[];
sectors: Sector[];
}

View File

@ -1,78 +1,24 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client';
import { Portfolio } from '../models/portfolio';
import { Rule } from '../models/rule';
import { AccountClusterRiskCurrentInvestment } from '../models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '../models/rules/account-cluster-risk/initial-investment';
import { AccountClusterRiskSingleAccount } from '../models/rules/account-cluster-risk/single-account';
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '../models/rules/currency-cluster-risk/base-currency-current-investment';
import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '../models/rules/currency-cluster-risk/base-currency-initial-investment';
import { CurrencyClusterRiskCurrentInvestment } from '../models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '../models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '../models/rules/fees/fee-ratio-initial-investment';
@Injectable()
export class RulesService {
public constructor() {}
public async evaluate(
aPortfolio: Portfolio,
aRules: Rule[],
aUserSettings: { baseCurrency: string }
public async evaluate<T extends RuleSettings>(
aRules: Rule<T>[],
aUserSettings: { baseCurrency: Currency }
) {
const defaultSettings = this.getDefaultRuleSettings(aUserSettings);
const details = await aPortfolio.getDetails();
return aRules
.filter((rule) => {
return defaultSettings[rule.constructor.name]?.isActive;
return rule.getSettings(aUserSettings)?.isActive;
})
.map((rule) => {
const evaluationResult = rule.evaluate(
details,
aPortfolio.getFees(),
defaultSettings
);
const evaluationResult = rule.evaluate(rule.getSettings(aUserSettings));
return { ...evaluationResult, name: rule.getName() };
});
}
private getDefaultRuleSettings(aUserSettings: { baseCurrency: string }) {
return {
[AccountClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[AccountClusterRiskSingleAccount.name]: { isActive: true },
[CurrencyClusterRiskBaseCurrencyInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
},
[CurrencyClusterRiskBaseCurrencyCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true
},
[CurrencyClusterRiskCurrentInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[CurrencyClusterRiskInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.5
},
[FeeRatioInitialInvestment.name]: {
baseCurrency: aUserSettings.baseCurrency,
isActive: true,
threshold: 0.01
}
};
}
}

View File

@ -0,0 +1,64 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile } from '@prisma/client';
import { continents, countries } from 'countries-list';
@Injectable()
export class SymbolProfileService {
constructor(private prisma: PrismaService) {}
public async getSymbolProfiles(
symbols: string[]
): Promise<EnhancedSymbolProfile[]> {
return this.prisma.symbolProfile
.findMany({
where: {
symbol: {
in: symbols
}
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => ({
...symbolProfile,
countries: this.getCountries(symbolProfile),
sectors: this.getSectors(symbolProfile)
}));
}
private getCountries(symbolProfile: SymbolProfile): Country[] {
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
(country) => {
const { code, weight } = country as Prisma.JsonObject;
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
}
);
}
private getSectors(symbolProfile: SymbolProfile): Sector[] {
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
(sector) => {
const { name, weight } = sector as Prisma.JsonObject;
return {
name: (name as string) ?? UNKNOWN_KEY,
weight: weight as number
};
}
);
}
}

View File

@ -5,13 +5,7 @@ module.exports = {
globals: {
'ts-jest': {
tsconfig: '<rootDir>/tsconfig.spec.json',
stringifyContentPathRegex: '\\.(html|svg)$',
astTransformers: {
before: [
'jest-preset-angular/build/InlineFilesTransformer',
'jest-preset-angular/build/StripStylesTransformer'
]
}
stringifyContentPathRegex: '\\.(html|svg)$'
}
},
coverageDirectory: '../../coverage/apps/client',
@ -19,5 +13,6 @@ module.exports = {
'jest-preset-angular/build/serializers/no-ng-attributes',
'jest-preset-angular/build/serializers/ng-snapshot',
'jest-preset-angular/build/serializers/html-comment'
]
],
transform: { '^.+\\.(ts|js|html)$': 'jest-preset-angular' }
};

View File

@ -33,6 +33,20 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/auth/auth-page.module').then((m) => m.AuthPageModule)
},
{
path: 'de/blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'en/blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'home',
loadChildren: () =>
@ -119,6 +133,7 @@ const routes: Routes = [
routes,
// Preload all lazy loaded modules with the attribute preload === true
{
anchorScrolling: 'enabled',
preloadingStrategy: ModulePreloadService,
// enableTracing: true // <-- debugging purposes only
relativeLinkResolution: 'legacy'

View File

@ -10,16 +10,16 @@
<main role="main">
<div *ngIf="canCreateAccount" class="container create-account-container">
<div class="row mb-5">
<div class="col-md-6 offset-md-3">
<a [routerLink]="['/']">
<mat-card
class="create-account-box p-2 text-center"
<div class="row">
<div class="col-md-8 offset-md-2 text-center">
<a class="text-center" [routerLink]="['/']">
<div
class="create-account-box d-inline-block px-3 py-2"
(click)="onCreateAccount()"
>
<div class="mt-1" i18n>You are using the Live Demo.</div>
<button mat-button color="primary" i18n>Create Account</button>
</mat-card></a
<span i18n>You are using the Live Demo.</span>
<a class="ml-2" href="#" i18n>Create Account</a>
</div></a
>
</div>
</div>
@ -28,10 +28,7 @@
<router-outlet></router-outlet>
</main>
<footer
*ngIf="currentRoute === 'start' || deviceType !== 'mobile'"
class="footer d-flex justify-content-center position-absolute w-100"
>
<footer *ngIf="!user" class="footer d-flex justify-content-center w-100">
<div class="container text-center">
<div>
© {{ currentYear }} <a href="https://ghostfol.io">Ghostfolio</a>

View File

@ -1,21 +1,32 @@
@import '~apps/client/src/styles/ghostfolio-style';
:host {
display: block;
min-height: 100vh;
main {
padding: 5rem 0;
min-height: 100vh;
padding-top: 5rem;
.create-account-box {
cursor: pointer;
font-size: 90%;
.create-account-container {
height: 3.5rem;
margin-top: -0.5rem;
.link {
color: rgba(var(--palette-primary-500), 1);
.create-account-box {
background-color: rgba(0, 0, 0, $alpha-hover);
border-radius: 2rem;
cursor: pointer;
font-size: 80%;
a {
color: rgba(var(--palette-primary-500), 1);
font-weight: 500;
}
}
}
}
.footer {
bottom: 0;
height: 5rem;
line-height: 1;
}

View File

@ -6,7 +6,11 @@ import {
OnInit
} from '@angular/core';
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
import { primaryColorHex, secondaryColorHex } from '@ghostfolio/common/config';
import {
primaryColorHex,
secondaryColorHex,
warnColorHex
} from '@ghostfolio/common/config';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { MaterialCssVarsService } from 'angular-material-css-vars';
@ -52,10 +56,6 @@ export class AppComponent implements OnDestroy, OnInit {
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.dataService.fetchInfo().subscribe((info) => {
this.info = info;
});
this.router.events
.pipe(filter((event) => event instanceof NavigationEnd))
.subscribe(() => {
@ -63,6 +63,8 @@ export class AppComponent implements OnDestroy, OnInit {
const urlSegmentGroup = urlTree.root.children[PRIMARY_OUTLET];
const urlSegments = urlSegmentGroup.segments;
this.currentRoute = urlSegments[0].path;
this.info = this.dataService.fetchInfo();
});
this.userService.stateChanged
@ -106,5 +108,6 @@ export class AppComponent implements OnDestroy, OnInit {
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
this.materialCssVarsService.setAccentColor(secondaryColorHex);
this.materialCssVarsService.setWarnColor(warnColorHex);
}
}

View File

@ -1,8 +1,6 @@
import { Platform } from '@angular/cdk/platform';
import { HttpClientModule } from '@angular/common/http';
import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import {
DateAdapter,
MAT_DATE_FORMATS,
@ -15,7 +13,7 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { MaterialCssVarsModule } from 'angular-material-css-vars';
import { MarkdownModule } from 'ngx-markdown';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { NgxStripeModule } from 'ngx-stripe';
import { NgxStripeModule, STRIPE_PUBLISHABLE_KEY } from 'ngx-stripe';
import { environment } from '../environments/environment';
import { CustomDateAdapter } from './adapter/custom-date-adapter';
@ -27,6 +25,10 @@ import { authInterceptorProviders } from './core/auth.interceptor';
import { httpResponseInterceptorProviders } from './core/http-response.interceptor';
import { LanguageService } from './core/language.service';
export function NgxStripeFactory(): string {
return environment.stripePublicKey;
}
@NgModule({
declarations: [AppComponent],
imports: [
@ -36,8 +38,6 @@ import { LanguageService } from './core/language.service';
GfHeaderModule,
HttpClientModule,
MarkdownModule.forRoot(),
MatButtonModule,
MatCardModule,
MaterialCssVarsModule.forRoot({
darkThemeClass: 'is-dark-theme',
isAutoContrast: true,
@ -57,7 +57,11 @@ import { LanguageService } from './core/language.service';
useClass: CustomDateAdapter,
deps: [LanguageService, MAT_DATE_LOCALE, Platform]
},
{ provide: MAT_DATE_FORMATS, useValue: DateFormats }
{ provide: MAT_DATE_FORMATS, useValue: DateFormats },
{
provide: STRIPE_PUBLISHABLE_KEY,
useFactory: NgxStripeFactory
}
],
bootstrap: [AppComponent]
})

View File

@ -26,6 +26,27 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<ng-container matColumnDef="balance">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
<td *matCellDef="let element" class="text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[currency]="element.currency"
[locale]="locale"
[value]="element.balance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -53,15 +74,6 @@
</td>
</ng-container>
<ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
Transactions
</th>
<td *matCellDef="let element" class="text-right" mat-cell>
{{ element.Order?.length }}
</td>
</ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr>
</table>

View File

@ -28,7 +28,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Output() accountDeleted = new EventEmitter<string>();
@Output() accountToUpdate = new EventEmitter<AccountModel>();
public dataSource: MatTableDataSource<AccountModel> = new MatTableDataSource();
public dataSource: MatTableDataSource<AccountModel> =
new MatTableDataSource();
public displayedColumns = [];
public isLoading = true;
public routeQueryParams: Subscription;
@ -40,7 +41,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions'];
this.displayedColumns = ['account', 'platform', 'transactions', 'balance'];
if (this.showActions) {
this.displayedColumns.push('actions');

View File

@ -106,6 +106,8 @@
class="no-min-width px-1"
mat-flat-button
[matMenuTriggerFor]="accountMenu"
(menuClosed)="onMenuClosed()"
(menuOpened)="onMenuOpened()"
>
<ion-icon
class="d-none d-sm-block"
@ -114,8 +116,8 @@
></ion-icon>
<ion-icon
class="d-block d-sm-none"
name="menu-outline"
size="large"
[name]="isMenuOpen ? 'close-outline' : 'menu-outline'"
></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">

View File

@ -5,10 +5,7 @@
z-index: 999;
.mat-toolbar {
background-color: rgba(
var(--light-primary-text),
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(var(--light-disabled-text));
.spacer {
flex: 1 1 auto;
@ -17,6 +14,8 @@
.mat-flat-button {
&:not(.mat-primary) {
background-color: transparent;
text-decoration-color: rgba(var(--palette-primary-500), 1) !important;
text-underline-offset: 0.25rem;
}
ion-icon {
@ -28,11 +27,6 @@
:host-context(.is-dark-theme) {
.mat-toolbar {
background-color: rgba(
39,
39,
39,
var(--palette-foreground-disabled-alpha)
);
background-color: rgba(39, 39, 39, $alpha-disabled-text);
}
}

View File

@ -38,6 +38,7 @@ export class HeaderComponent implements OnChanges {
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessAdminControl: boolean;
public impersonationId: string;
public isMenuOpen: boolean;
private unsubscribeSubject = new Subject<void>();
@ -51,6 +52,7 @@ export class HeaderComponent implements OnChanges {
) {
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((id) => {
this.impersonationId = id;
});
@ -83,6 +85,14 @@ export class HeaderComponent implements OnChanges {
window.location.reload();
}
public onMenuClosed() {
this.isMenuOpen = false;
}
public onMenuOpened() {
this.isMenuOpen = true;
}
public onSignOut() {
this.signOut.next();
}
@ -98,23 +108,26 @@ export class HeaderComponent implements OnChanges {
width: '30rem'
});
dialogRef.afterClosed().subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
if (data?.accessToken) {
this.dataService
.loginAnonymous(data?.accessToken)
.pipe(
catchError(() => {
alert('Oops! Incorrect Security Token.');
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
return EMPTY;
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ authToken }) => {
this.setToken(authToken);
});
}
});
}
public setToken(aToken: string) {
@ -125,4 +138,9 @@ export class HeaderComponent implements OnChanges {
this.router.navigate(['/']);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -5,8 +5,8 @@ import { MatMenuModule } from '@angular/material/menu';
import { MatToolbarModule } from '@angular/material/toolbar';
import { RouterModule } from '@angular/router';
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 '../logo/logo.module';
import { HeaderComponent } from './header.component';
@NgModule({

View File

@ -10,15 +10,15 @@ import {
ViewChild
} from '@angular/core';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { PortfolioItem } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
Chart,
LineController,
LineElement,
LinearScale,
PointElement,
TimeScale
} from 'chart.js';
import { Chart } from 'chart.js';
import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
@Component({
@ -28,7 +28,7 @@ import { addMonths, isAfter, parseISO, subMonths } from 'date-fns';
styleUrls: ['./investment-chart.component.scss']
})
export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
@Input() portfolioItems: PortfolioItem[];
@Input() investments: InvestmentItem[];
@ViewChild('chartCanvas') chartCanvas;
@ -48,7 +48,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
if (this.portfolioItems) {
if (this.investments) {
this.initialize();
}
}
@ -60,32 +60,32 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
private initialize() {
this.isLoading = true;
if (this.portfolioItems?.length > 0) {
if (this.investments?.length > 0) {
// Extend chart by three months (before)
const firstItem = this.portfolioItems[0];
this.portfolioItems.unshift({
const firstItem = this.investments[0];
this.investments.unshift({
...firstItem,
date: subMonths(parseISO(firstItem.date), 3).toISOString(),
investment: 0
});
// Extend chart by three months (after)
const lastItem = this.portfolioItems[this.portfolioItems.length - 1];
this.portfolioItems.push({
const lastItem = this.investments[this.investments.length - 1];
this.investments.push({
...lastItem,
date: addMonths(parseISO(lastItem.date), 3).toISOString()
});
}
const data = {
labels: this.portfolioItems.map((position) => {
labels: this.investments.map((position) => {
return position.date;
}),
datasets: [
{
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
borderWidth: 2,
data: this.portfolioItems.map((position) => {
data: this.investments.map((position) => {
return position.investment;
}),
segment: {

View File

@ -2,6 +2,7 @@ import 'chartjs-adapter-date-fns';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Input,
OnChanges,
@ -44,7 +45,7 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
public chart: Chart;
public isLoading = true;
public constructor() {
public constructor(private changeDetectorRef: ChangeDetectorRef) {
Chart.register(
Filler,
LineController,
@ -59,7 +60,12 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
public ngOnChanges() {
if (this.historicalDataItems) {
this.initialize();
setTimeout(() => {
// Wait for the chartCanvas
this.initialize();
this.changeDetectorRef.markForCheck();
});
}
}
@ -79,8 +85,6 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
marketPrices.push(historicalDataItem.value);
});
const canvas = document.getElementById('chartCanvas');
const gradient = this.chartCanvas?.nativeElement
?.getContext('2d')
.createLinearGradient(
@ -89,11 +93,14 @@ export class LineChartComponent implements OnChanges, OnDestroy, OnInit {
0,
(this.chartCanvas.nativeElement.parentNode.offsetHeight * 4) / 5
);
gradient.addColorStop(
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(1, getBackgroundColor());
if (gradient) {
gradient.addColorStop(
0,
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.01)`
);
gradient.addColorStop(1, getBackgroundColor());
}
const data = {
labels,

View File

@ -1,4 +1,4 @@
<span class="align-items-center d-flex"
><span class="d-inline-block logo mr-1"></span>
<span class="name">Ghostfolio</span></span
<span *ngIf="!hideName" class="name">Ghostfolio</span></span
>

View File

@ -14,10 +14,12 @@ import {
})
export class LogoComponent implements OnInit {
@HostBinding('class') @Input() size: 'large' | 'medium';
@Input() hideName: boolean;
public constructor() {}
public ngOnInit() {
this.size = this.size || 'medium';
this.hideName = this.hideName ?? false;
this.size = this.size ?? 'medium';
}
}

View File

@ -1,9 +1,13 @@
<a
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/transactions']"
mat-button
>
<ion-icon class="mr-1" name="time-outline" size="large"></ion-icon>
<span i18n>Time to add your first transaction.</span>
</a>
<div class="p-3">
<div class="d-flex justify-content-center mb-1">
<gf-logo size="large" [hideName]="true"></gf-logo>
</div>
<a
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/transactions']"
mat-button
>
<span i18n>Time to add your first transaction.</span>
</a>
</div>

View File

@ -1,3 +1,13 @@
:host {
border: 1px solid rgba(var(--dark-dividers));
border-radius: 0.25rem;
display: block;
gf-logo {
opacity: 0.25;
}
}
:host-context(.is-dark-theme) {
border-color: rgba(var(--light-dividers));
}

View File

@ -2,13 +2,14 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfLogoModule } from '@ghostfolio/client/components/logo/logo.module';
import { NoTransactionsInfoComponent } from './no-transactions-info.component';
@NgModule({
declarations: [NoTransactionsInfoComponent],
exports: [NoTransactionsInfoComponent],
imports: [CommonModule, MatButtonModule, RouterModule],
imports: [CommonModule, GfLogoModule, MatButtonModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -6,7 +6,10 @@ import {
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isToday, parse } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -27,6 +30,8 @@ export class PerformanceChartDialog {
public historicalDataItems: LineChartItem[];
public title: string;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -35,6 +40,7 @@ export class PerformanceChartDialog {
) {
this.dataService
.fetchPositionDetail(this.benchmarkSymbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ currency, firstBuyDate, historicalData, marketPrice }) => {
this.benchmarkDataItems = [];
this.currency = currency;
@ -61,7 +67,7 @@ export class PerformanceChartDialog {
value: benchmarkItem.value * coefficient
});
} else if (
isToday(parse(historicalDataItem.date, 'yyyy-MM-dd', new Date()))
isToday(parse(historicalDataItem.date, DATE_FORMAT, new Date()))
) {
this.benchmarkDataItems.push({
date: historicalDataItem.date,
@ -84,4 +90,9 @@ export class PerformanceChartDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

View File

@ -1,4 +1,18 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.cash"
></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>Buy</div>
<div class="d-flex justify-content-end">

View File

@ -12,16 +12,21 @@
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Gross performance</div>
<div class="d-flex flex-grow-1" i18n>Absolute Performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end mb-2"
class="justify-content-end"
position="end"
[colorizeSign]="true"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentGrossPerformance"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Performance (TWR)</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
@ -34,27 +39,29 @@
></gf-value>
</div>
</div>
<div class="row px-3 py-2">
<div class="d-flex flex-grow-1" i18n>Net performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end mb-2"
position="end"
[colorizeSign]="true"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
<!--
<div class="row px-3 py-2">
<div class="d-flex flex-grow-1" i18n>Net performance</div>
<div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end mb-2"
position="end"
[colorizeSign]="true"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
<gf-value
class="justify-content-end"
position="end"
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>
</div>
</div>
-->
</div>

View File

@ -8,7 +8,7 @@ import {
ViewChild
} from '@angular/core';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { getCssVariable, getTextColor } from '@ghostfolio/common/helper';
import { getTextColor } from '@ghostfolio/common/helper';
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { Tooltip } from 'chart.js';
@ -43,9 +43,7 @@ export class PortfolioProportionChartComponent
private colorMap: {
[symbol: string]: string;
} = {
[UNKNOWN_KEY]: `rgba(${getTextColor()}, ${getCssVariable(
'--palette-foreground-divider-alpha'
)})`
[UNKNOWN_KEY]: `rgba(${getTextColor()}, 0.12)`
};
public constructor() {

View File

@ -2,11 +2,15 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject
Inject,
OnDestroy
} from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
import { PositionDetailDialogParams } from './interfaces/interfaces';
@ -18,7 +22,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
templateUrl: 'position-detail-dialog.html',
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog {
export class PositionDetailDialog implements OnDestroy {
public averagePrice: number;
public benchmarkDataItems: LineChartItem[];
public currency: string;
@ -33,6 +37,8 @@ export class PositionDetailDialog {
public quantity: number;
public transactionCount: number;
private unsubscribeSubject = new Subject<void>();
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -41,6 +47,7 @@ export class PositionDetailDialog {
) {
this.dataService
.fetchPositionDetail(data.symbol)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
averagePrice,
@ -60,7 +67,7 @@ export class PositionDetailDialog {
this.benchmarkDataItems = [];
this.currency = currency;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = quantity * grossPerformance;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;
this.historicalDataItems = historicalData.map(
(historicalDataItem) => {
@ -109,13 +116,13 @@ export class PositionDetailDialog {
} else {
// Add market price
this.historicalDataItems.push({
date: format(new Date(), 'yyyy-MM-dd'),
date: format(new Date(), DATE_FORMAT),
value: this.marketPrice
});
// Add benchmark
this.benchmarkDataItems.push({
date: format(new Date(), 'yyyy-MM-dd'),
date: format(new Date(), DATE_FORMAT),
value: averagePrice
});
}
@ -135,4 +142,9 @@ export class PositionDetailDialog {
public onClose(): void {
this.dialogRef.close();
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
}

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