Compare commits

...

60 Commits

Author SHA1 Message Date
bbe9183fb0 Release 1.140.0 (#852) 2022-04-22 21:29:08 +02:00
1b03ddc586 Feature/add symbol profile overrides model (#851)
* Add symbol profile overrides model

* Update changelog
2022-04-22 21:27:55 +02:00
beb12637ce Bugfix/fix total calculation for sell and dividend (#850)
* Fix calculation for sell and dividend activities

* Update changelog
2022-04-22 19:29:18 +02:00
20358d9105 Feature/persist savings rate (#849)
* Persist savings rate

* Update changelog
2022-04-21 23:07:19 +02:00
0e4c39d145 Feature/reuse value component in ghostfolio in numbers section (#846)
* Reuse value component

* Update changelog
2022-04-19 17:06:12 +02:00
83ebacbb06 Add Buy me a coffee link (#848) 2022-04-18 21:32:54 +02:00
7c58c5fb7f Setup funding.yml (#847) 2022-04-18 21:20:30 +02:00
f3271ab1ff Feature/upgrade yahoo finance2 to version 2.3.1 (#844)
* Upgrade yahoo-finance2 to version 2.3.1

* Update changelog
2022-04-18 17:10:00 +02:00
9f597cbff1 Release 1.139.0 (#843) 2022-04-18 11:59:16 +02:00
90efc2ac51 Feature/beautify etf names in asset profile (#842)
* Beautify ETF names

* Update changelog
2022-04-18 11:57:57 +02:00
056b318d86 Bugfix/fix end date in ics files (#841)
* Fix end date

* Update changelog
2022-04-18 11:31:16 +02:00
82ede2fe32 Bugfix/fix fear and greed data source (#840)
* Fix data source of Fear & Greed Index

* Update changelog
2022-04-18 10:49:02 +02:00
8ae041faa0 Bugfix/fix issue in fire calculator after changing investment horizon (#839)
* Properly update chart datasets and improve tooltip

* Update changelog
2022-04-18 10:31:16 +02:00
bd4608e521 Release 1.138.0 (#838) 2022-04-16 21:03:28 +02:00
0d8362ca8f Feature/separate deposit and savings in fire calculator (#837)
* Separate deposit and savings

* Update changelog
2022-04-16 21:01:55 +02:00
638ae3f7fa Feature/add boringly getting rich guide to resources page (#836)
* Add "Boringly Getting Rich" guide

* Update changelog
2022-04-16 15:35:31 +02:00
6e7cf0380b Feature/export single draft (#835)
* Export single draft

* Update changelog
2022-04-16 11:33:01 +02:00
ec2ecab751 Clean up name (#834) 2022-04-16 10:21:32 +02:00
598fe41b8c Release 1.137.0 (#831) 2022-04-15 18:58:33 +02:00
ba7c98d325 Add test case for BUY and SELL (partially) (#826)
* Add test case for BUY and SELL (partially)

* Fix investment calculation for sell activities

* Do not show total value if sell activity

* Update changelog
2022-04-15 18:56:23 +02:00
65e062ad26 Simplify search (#828)
* Simplify search

* Update changelog
2022-04-15 12:39:33 +02:00
8526b5a027 Feature/export draft activities as ics (#830)
* Export draft activities as ICS

* Update changelog
2022-04-15 10:53:40 +02:00
f1feb04f29 Release 1.136.0 (#829) 2022-04-13 07:46:35 +02:00
500e09d95a Bugfix/fix loading state in fire calculator (#824)
* Fix loading state

* Update changelog
2022-04-12 19:57:23 +02:00
aef91d3e30 Feature/improve label in summary (#827)
* Improve label

* Update changelog
2022-04-12 18:04:48 +02:00
70723f8d5f Bugfix/fix projected total amount in fire calculator (#825)
* Fix calculation of projected total amount

* Update changelog
2022-04-11 22:10:45 +02:00
6cfd052781 Release 1.135.0 (#823) 2022-04-10 20:03:39 +02:00
23f2ac472e Feature/add fire calculator (#822)
* Add fire calculator

* Update changelog
2022-04-10 20:02:31 +02:00
d5ba624403 Feature/add support for terra (#820)
* Add Terra (LUNA1-USD)

* Update changelog
2022-04-10 19:38:27 +02:00
9b49ed77f7 Feature/add support for thor chain (#819)
* Add THORChain (RUNE-USD)

* Update changelog
2022-04-09 20:16:36 +02:00
08405d14d5 Release 1.134.0 (#818) 2022-04-09 14:50:13 +02:00
56b169e1c4 Feature/make header background solid (#817)
* Remove alpha

* Update changelog
2022-04-09 10:28:07 +02:00
67f2b326f3 Switch to new calculation engine (#814)
* Switch to new calculation engine

* Clean up old portfolio calculation engine (#815)

* Rename new portfolio calculation engine (#816)

* Update changelog
2022-04-09 10:17:31 +02:00
3d3a6c1204 Feature/improve fire section (#813)
* Improve FIRE section

* Update changelog
2022-04-09 09:03:39 +02:00
bfc8f87d88 Release 1.133.0 (#812) 2022-04-07 22:15:09 +02:00
957200854c Feature/improve empty state of proportion chart (#811)
* Improve empty state

* Update changelog
2022-04-07 22:13:41 +02:00
6575440877 Bugfix/fix dates in value component (#810)
* Fix dates

* Update changelog
2022-04-07 17:20:12 +02:00
255af6a6e9 Release 1.132.1 (#809) 2022-04-06 22:02:40 +02:00
795a6a6799 Release 1.132.0 (#808) 2022-04-06 21:23:20 +02:00
2a854e2574 Various improvements (#807) 2022-04-06 21:21:53 +02:00
52d113e71f Feature/improve label of average price (#805)
* Improve label

* Update changelog
2022-04-06 18:02:21 +02:00
204c7360c3 Feature/prepare for localized date format (#803)
* Support localized date and number format

* Update changelog
2022-04-05 21:02:07 +02:00
fa41e25c8f Release/1.131.1 (#804)
* Add API version

* Update changelog
2022-04-04 07:30:17 +02:00
ba765b9de6 Release 1.131.0 (#797) 2022-04-02 19:32:47 +02:00
fa79196278 Feature/display values in base currency on mobile (#796)
* Display values in base currency on mobile

* Update changelog
2022-04-02 19:28:32 +02:00
d1230ca3ad Support coupon codes on Google Play (#795) 2022-04-02 18:47:53 +02:00
69a1316cfe Feature/upgrade prisma to 3.11.1 (#794)
* Upgrade prisma dependencies to version 3.11.1

* Update changelog
2022-04-02 17:38:47 +02:00
a256b783bc Feature/upgrade yahoo finance to 2.3.0 (#792)
* Upgrade yahoo-finance2

* Update changelog
2022-04-02 17:36:49 +02:00
ebbdd47fa2 Exclude google login callback endpoint from versioning (#793) 2022-04-02 17:36:15 +02:00
3d21e2eac6 Improve upgrade guide (#780) 2022-04-02 12:06:06 +02:00
bc117fe601 Fix port (#788) 2022-04-02 10:47:26 +02:00
65f6bcb166 Feature/harmonize algebraic sign of gross and net performance percent (#776)
* Harmonize algebraic sign

* Update changelog
2022-04-02 10:44:19 +02:00
b8c43ecf89 Add documentation for import API (#787) 2022-04-02 10:29:41 +02:00
1214127ec0 Feature/rename orders to activities in import and export (#786)
* Rename orders to activities

* Update changelog
2022-04-02 10:26:17 +02:00
e986310302 Improve price (#785) 2022-04-02 09:52:59 +02:00
6762572658 Feature/setup api versioning (#783)
* Setup API versioning

* Update changelog
2022-04-02 08:46:24 +02:00
eb77652d6a Clean up import (#784) 2022-04-02 08:03:17 +02:00
a7b59f4ec6 Extend prerequisites (#781) 2022-04-02 06:47:58 +02:00
dd71f2be45 Feature/improve pricing page (#778)
* Improve pricing page

* Update changelog
2022-04-01 19:58:33 +02:00
d530cb38fa Feature/add 7 days in coupon system (#777)
* Add 7 days

* Update changelog
2022-04-01 19:49:05 +02:00
114 changed files with 2789 additions and 6367 deletions

1
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1 @@
custom: ['https://www.buymeacoffee.com/ghostfolio']

View File

@ -5,6 +5,144 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.140.0 - 22.04.2022
### Added
- Added support for sub-labels in the value component
- Added a symbol profile overrides model for manual adjustments
### Changed
- Reused the value component in the _Ghostfolio in Numbers_ section of the about page
- Persisted the savings rate in the _FIRE_ calculator
- Upgraded `yahoo-finance2` from version `2.3.0` to `2.3.1`
### Fixed
- Fixed the calculation of the total value for sell and dividend activities in the create or edit transaction dialog
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.139.0 - 18.04.2022
### Added
- Added the total amount to the tooltip in the chart of the _FIRE_ calculator
### Changed
- Beautified the ETF names in the symbol profile
### Fixed
- Fixed an issue with changing the investment horizon in the chart of the _FIRE_ calculator
- Fixed an issue with the end dates in the `.ics` file of the future activities (drafts) export
- Fixed the data source of the _Fear & Greed Index_ (market mood)
## 1.138.0 - 16.04.2022
### Added
- Added support to export a single future activity (draft) as an `.ics` file
- Added the _Boringly Getting Rich_ guide to the resources section
### Changed
- Separated the deposit and savings in the chart of the _FIRE_ calculator
## 1.137.0 - 15.04.2022
### Added
- Added support to export future activities (drafts) as an `.ics` file
### Changed
- Migrated the search functionality to `yahoo-finance2`
### Fixed
- Fixed an issue in the average price / investment calculation for sell activities
## 1.136.0 - 13.04.2022
### Changed
- Changed the _Total_ label to _Total Assets_ in the portfolio summary tab on the home page
### Fixed
- Fixed an issue with the calculation of the projected total amount in the _FIRE_ calculator
- Fixed an issue with the loading state of the _FIRE_ calculator
## 1.135.0 - 10.04.2022
### Added
- Added a calculator to the _FIRE_ section
- Added support for the cryptocurrency _Terra_ (`LUNA1-USD`)
- Added support for the cryptocurrency _THORChain_ (`RUNE-USD`)
## 1.134.0 - 09.04.2022
### Changed
- Switched to the new calculation engine
- Improved the 4% rule in the _FIRE_ section
- Changed the background of the header to a solid color
## 1.133.0 - 07.04.2022
### Changed
- Improved the empty state of the portfolio proportion chart component
### Fixed
- Fixed an issue with dates in the value component
## 1.132.1 - 06.04.2022
### Fixed
- Fixed an issue with percentages in the value component
## 1.132.0 - 06.04.2022
### Added
- Added support for localization (date and number format) in user settings
### Changed
- Improved the label of the average price from _Ø Buy Price_ to _Average Unit Price_
## 1.131.1 - 04.04.2022
### Fixed
- Fixed the missing API version in the _Stripe_ success callback url
## 1.131.0 - 02.04.2022
### Added
- Added API versioning
- Added more durations in the coupon system
### Changed
- Display the value in base currency in the accounts table on mobile
- Display the value in base currency in the activities table on mobile
- Renamed `orders` to `activities` in import and export functionality
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
- Improved the pricing page
- Upgraded `prisma` from version `3.10.0` to `3.11.1`
- Upgraded `yahoo-finance2` from version `2.2.0` to `2.3.0`
## 1.130.0 - 30.03.2022 ## 1.130.0 - 30.03.2022
### Added ### Added

View File

@ -79,6 +79,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
### Prerequisites ### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- A local copy of this Git repository (clone)
### a. Run environment ### a. Run environment
@ -121,13 +122,11 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data 1. Go to the _Admin Control Panel_ and click _Gather All Data_ to fetch historical data
1. Click _Sign out_ and check out the _Live Demo_ 1. Click _Sign out_ and check out the _Live Demo_
### Migrate Database ### Upgrade Version
With the following command you can keep your database schema in sync after a Ghostfolio version update: 1. Increase the version of the `ghostfolio/ghostfolio` Docker image in `docker/docker-compose.yml`
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
```bash 1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
```
## Development ## Development
@ -136,6 +135,7 @@ docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn dat
- [Docker](https://www.docker.com/products/docker-desktop) - [Docker](https://www.docker.com/products/docker-desktop)
- [Node.js](https://nodejs.org/en/download) (version 14+) - [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install) - [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
### Setup ### Setup
@ -162,16 +162,92 @@ Run `yarn start:client`
Run `yarn start:storybook` Run `yarn start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
```
## Testing ## Testing
Run `yarn test` Run `yarn test`
## Public API (experimental)
### Import Activities
#### Request
`POST http://localhost:3333/api/v1/import`
#### Authorization: Bearer Token
Set the header as follows:
```
"Authorization": "Bearer eyJh..."
```
#### Body
```
{
"activities": [
{
"currency": "USD",
"dataSource": "YAHOO",
"date": "2021-09-15T00:00:00.000Z",
"fee": 19,
"quantity": 5,
"symbol": "MSFT"
"type": "BUY",
"unitPrice": 298.58
}
]
}
```
| Field | Type | Description |
| ---------- | ------------------- | -------------------------------------------------- |
| accountId | string (`optional`) | Id of the account |
| currency | string | `CHF` \| `EUR` \| `USD` etc. |
| dataSource | string | `MANUAL` (for type `ITEM`) \| `YAHOO` |
| date | string | Date in the format `ISO-8601` |
| fee | number | Fee of the activity |
| quantity | number | Quantity of the activity |
| symbol | string | Symbol of the activity (suitable for `dataSource`) |
| type | string | `BUY` \| `DIVIDEND` \| `ITEM` \| `SELL` |
| unitPrice | number | Price per unit of the activity |
#### Response
##### Success
`201 Created`
##### Error
`400 Bad Request`
```
{
"error": "Bad Request",
"message": [
"activities.1 is a duplicate activity"
]
}
```
## Contributing ## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you. Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you. Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
If you like to support this project, get **[Ghostfolio Premium](https://ghostfol.io/pricing)** or **[Buy me a coffee](https://www.buymeacoffee.com/ghostfolio)**.
## License ## License
© 2022 [Ghostfolio](https://ghostfol.io) © 2022 [Ghostfolio](https://ghostfol.io)

View File

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

View File

@ -9,7 +9,9 @@ import {
Post, Post,
Req, Req,
Res, Res,
UseGuards UseGuards,
VERSION_NEUTRAL,
Version
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -51,6 +53,7 @@ export class AuthController {
@Get('google/callback') @Get('google/callback')
@UseGuards(AuthGuard('google')) @UseGuards(AuthGuard('google'))
@Version(VERSION_NEUTRAL)
public googleLoginCallback(@Req() req, @Res() res) { public googleLoginCallback(@Req() req, @Res() res) {
// Handles the Google OAuth2 callback // Handles the Google OAuth2 callback
const jwt: string = req.user.jwt; const jwt: string = req.user.jwt;

View File

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

View File

@ -14,7 +14,7 @@ export class ExportService {
activityIds?: string[]; activityIds?: string[];
userId: string; userId: string;
}): Promise<Export> { }): Promise<Export> {
let orders = await this.prismaService.order.findMany({ let activities = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
accountId: true, accountId: true,
@ -30,18 +30,19 @@ export class ExportService {
}); });
if (activityIds) { if (activityIds) {
orders = orders.filter((order) => { activities = activities.filter((activity) => {
return activityIds.includes(order.id); return activityIds.includes(activity.id);
}); });
} }
return { return {
meta: { date: new Date().toISOString(), version: environment.version }, meta: { date: new Date().toISOString(), version: environment.version },
orders: orders.map( activities: activities.map(
({ ({
accountId, accountId,
date, date,
fee, fee,
id,
quantity, quantity,
SymbolProfile, SymbolProfile,
type, type,
@ -49,13 +50,14 @@ export class ExportService {
}) => { }) => {
return { return {
accountId, accountId,
date,
fee, fee,
id,
quantity, quantity,
type, type,
unitPrice, unitPrice,
currency: SymbolProfile.currency, currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource, dataSource: SymbolProfile.dataSource,
date: date.toISOString(),
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
}; };
} }

View File

@ -6,5 +6,5 @@ export class ImportDataDto {
@IsArray() @IsArray()
@Type(() => CreateOrderDto) @Type(() => CreateOrderDto)
@ValidateNested({ each: true }) @ValidateNested({ each: true })
orders: CreateOrderDto[]; activities: CreateOrderDto[];
} }

View File

@ -36,7 +36,7 @@ export class ImportController {
try { try {
return await this.importService.import({ return await this.importService.import({
orders: importData.orders, activities: importData.activities,
userId: this.request.user.id userId: this.request.user.id
}); });
} catch (error) { } catch (error) {

View File

@ -16,23 +16,23 @@ export class ImportService {
) {} ) {}
public async import({ public async import({
orders, activities,
userId userId
}: { }: {
orders: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
userId: string; userId: string;
}): Promise<void> { }): Promise<void> {
for (const order of orders) { for (const activity of activities) {
if (!order.dataSource) { if (!activity.dataSource) {
if (order.type === 'ITEM') { if (activity.type === 'ITEM') {
order.dataSource = 'MANUAL'; activity.dataSource = 'MANUAL';
} else { } else {
order.dataSource = this.dataProviderService.getPrimaryDataSource(); activity.dataSource = this.dataProviderService.getPrimaryDataSource();
} }
} }
} }
await this.validateOrders({ orders, userId }); await this.validateActivities({ activities, userId });
const accountIds = (await this.accountService.getAccounts(userId)).map( const accountIds = (await this.accountService.getAccounts(userId)).map(
(account) => { (account) => {
@ -50,7 +50,7 @@ export class ImportService {
symbol, symbol,
type, type,
unitPrice unitPrice
} of orders) { } of activities) {
await this.orderService.createOrder({ await this.orderService.createOrder({
fee, fee,
quantity, quantity,
@ -79,24 +79,24 @@ export class ImportService {
} }
} }
private async validateOrders({ private async validateActivities({
orders, activities,
userId userId
}: { }: {
orders: Partial<CreateOrderDto>[]; activities: Partial<CreateOrderDto>[];
userId: string; userId: string;
}) { }) {
if ( if (
orders?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT') activities?.length > this.configurationService.get('MAX_ORDERS_TO_IMPORT')
) { ) {
throw new Error( throw new Error(
`Too many transactions (${this.configurationService.get( `Too many activities (${this.configurationService.get(
'MAX_ORDERS_TO_IMPORT' 'MAX_ORDERS_TO_IMPORT'
)} at most)` )} at most)`
); );
} }
const existingOrders = await this.orderService.orders({ const existingActivities = await this.orderService.orders({
include: { SymbolProfile: true }, include: { SymbolProfile: true },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
where: { userId } where: { userId }
@ -105,22 +105,22 @@ export class ImportService {
for (const [ for (const [
index, index,
{ currency, dataSource, date, fee, quantity, symbol, type, unitPrice } { currency, dataSource, date, fee, quantity, symbol, type, unitPrice }
] of orders.entries()) { ] of activities.entries()) {
const duplicateOrder = existingOrders.find((order) => { const duplicateActivity = existingActivities.find((activity) => {
return ( return (
order.SymbolProfile.currency === currency && activity.SymbolProfile.currency === currency &&
order.SymbolProfile.dataSource === dataSource && activity.SymbolProfile.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) && isSameDay(activity.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee && activity.fee === fee &&
order.quantity === quantity && activity.quantity === quantity &&
order.SymbolProfile.symbol === symbol && activity.SymbolProfile.symbol === symbol &&
order.type === type && activity.type === type &&
order.unitPrice === unitPrice activity.unitPrice === unitPrice
); );
}); });
if (duplicateOrder) { if (duplicateActivity) {
throw new Error(`orders.${index} is a duplicate transaction`); throw new Error(`activities.${index} is a duplicate activity`);
} }
if (dataSource !== 'MANUAL') { if (dataSource !== 'MANUAL') {
@ -130,13 +130,13 @@ export class ImportService {
if (quotes[symbol] === undefined) { if (quotes[symbol] === undefined) {
throw new Error( throw new Error(
`orders.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")` `activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
); );
} }
if (quotes[symbol].currency !== currency) { if (quotes[symbol].currency !== currency) {
throw new Error( throw new Error(
`orders.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"` `activities.${index}.currency ("${currency}") does not match with "${quotes[symbol].currency}"`
); );
} }
} }

View File

@ -52,9 +52,15 @@ export class InfoService {
} }
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) { if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') === true
) {
info.fearAndGreedDataSource = encodeDataSource( info.fearAndGreedDataSource = encodeDataSource(
ghostfolioFearAndGreedIndexDataSource ghostfolioFearAndGreedIndexDataSource
); );
} else {
info.fearAndGreedDataSource = ghostfolioFearAndGreedIndexDataSource;
}
} }
if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) { if (this.configurationService.get('ENABLE_FEATURE_IMPORT')) {

View File

@ -20,6 +20,13 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'NOVN.SW':
if (isSameDay(parseDate('2022-04-11'), date)) {
return { marketPrice: 87.8 };
}
return { marketPrice: 0 };
default: default:
return { marketPrice: 0 }; return { marketPrice: 0 };
} }

View File

@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock'; import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new'; import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
describe('PortfolioCalculatorNew', () => { describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
beforeEach(() => { beforeEach(() => {
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => { it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', currency: 'CHF',
orders: [ orders: [
@ -52,13 +52,13 @@ describe('PortfolioCalculatorNew', () => {
] ]
}); });
portfolioCalculatorNew.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
const spy = jest const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-22') parseDate('2021-11-22')
); );

View File

@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock'; import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new'; import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
describe('PortfolioCalculatorNew', () => { describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
beforeEach(() => { beforeEach(() => {
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => { describe('get current positions', () => {
it.only('with BALN.SW buy', async () => { it.only('with BALN.SW buy', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', currency: 'CHF',
orders: [ orders: [
@ -41,13 +41,13 @@ describe('PortfolioCalculatorNew', () => {
] ]
}); });
portfolioCalculatorNew.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
const spy = jest const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2021-11-30') parseDate('2021-11-30')
); );

View File

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

View File

@ -1,997 +0,0 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import {
ResponseError,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMilliseconds,
addMonths,
addYears,
endOfDay,
format,
isAfter,
isBefore,
max,
min
} from 'date-fns';
import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from './interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculatorNew {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = false;
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[];
public constructor({
currency,
currentRateService,
orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
this.orders.sort((a, b) => a.date.localeCompare(b.date));
}
public computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity,
symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
transactionCount: 1
};
}
symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = {
date: currentDate,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.items = newItems;
}
lastDate = currentDate;
}
}
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: Big;
}): Big {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return new Big(
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
).minus(1);
}
return new Big(0);
}
public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints;
}
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
this.transactionPoints = transactionPoints;
}
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
if (!this.transactionPoints?.length) {
return {
currentValue: new Big(0),
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
totalInvestment: new Big(0)
};
}
const lastTransactionPoint =
this.transactionPoints[this.transactionPoints.length - 1];
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let firstTransactionPoint: TransactionPoint = null;
let firstIndex = this.transactionPoints.length;
const dates = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const currencies: { [symbol: string]: string } = {};
dates.push(resetHours(start));
for (const item of this.transactionPoints[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
currencies[item.symbol] = item.currency;
}
for (let i = 0; i < this.transactionPoints.length; i++) {
if (
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
firstTransactionPoint === null
) {
firstTransactionPoint = this.transactionPoints[i];
firstIndex = i;
}
if (firstTransactionPoint !== null) {
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
}
}
dates.push(resetHours(today));
const marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
in: dates
},
userCurrency: this.currency
});
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
}
const initialValues: { [symbol: string]: Big } = {};
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
marketSymbolMap,
start,
symbol: item.symbol
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: !hasErrors
? netPerformancePercentage ?? null
: null,
quantity: item.quantity,
symbol: item.symbol,
transactionCount: item.transactionCount
});
if (hasErrors) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
errors,
positions,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
}
public getInvestments(): { date: string; investment: Big }[] {
if (this.transactionPoints.length === 0) {
return [];
}
return this.transactionPoints.map((transactionPoint) => {
return {
date: transactionPoint.date,
investment: transactionPoint.items.reduce(
(investment, transactionPointSymbol) =>
investment.plus(transactionPointSymbol.investment),
new Big(0)
)
};
});
}
public async calculateTimeline(
timelineSpecification: TimelineSpecification[],
endDate: string
): Promise<TimelineInfoInterface> {
if (timelineSpecification.length === 0) {
return {
maxNetPerformance: new Big(0),
minNetPerformance: new Big(0),
timelinePeriods: []
};
}
const startDate = timelineSpecification[0].start;
const start = parseDate(startDate);
const end = parseDate(endDate);
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
let i = 0;
let j = -1;
for (
let currentDate = start;
!isAfter(currentDate, end);
currentDate = this.addToDate(
currentDate,
timelineSpecification[i].accuracy
)
) {
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
i++;
}
while (
j + 1 < this.transactionPoints.length &&
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
) {
j++;
}
let periodEndDate = currentDate;
if (timelineSpecification[i].accuracy === 'day') {
let nextEndDate = end;
if (j + 1 < this.transactionPoints.length) {
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
}
periodEndDate = min([
addMonths(currentDate, 3),
max([currentDate, nextEndDate])
]);
}
const timePeriodForDates = this.getTimePeriodForDate(
j,
currentDate,
endOfDay(periodEndDate)
);
currentDate = periodEndDate;
if (timePeriodForDates != null) {
timelinePeriodPromises.push(timePeriodForDates);
}
}
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
timelinePeriodPromises
);
const minNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.minNetPerformance)
.filter((performance) => performance !== null)
.reduce((minPerformance, current) => {
if (minPerformance.lt(current)) {
return minPerformance;
} else {
return current;
}
});
const maxNetPerformance = timelineInfoInterfaces
.map((timelineInfo) => timelineInfo.maxNetPerformance)
.filter((performance) => performance !== null)
.reduce((maxPerformance, current) => {
if (maxPerformance.gt(current)) {
return maxPerformance;
} else {
return current;
}
});
const timelinePeriods = timelineInfoInterfaces.map(
(timelineInfo) => timelineInfo.timelinePeriods
);
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: flatten(timelinePeriods)
};
}
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [symbol: string]: Big }
) {
let currentValue = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
currentValue = currentValue.plus(
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
);
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (currentPosition.grossPerformancePercentage) {
// Use the average from the initial value and the current investment as
// a weight
const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
.plus(currentPosition.investment)
.div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(weight)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`,
'PortfolioCalculatorNew'
);
hasErrors = true;
}
}
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return {
currentValue,
grossPerformance,
grossPerformancePercentage,
hasErrors,
netPerformance,
netPerformancePercentage,
totalInvestment
};
}
private async getTimePeriodForDate(
j: number,
startDate: Date,
endDate: Date
): Promise<TimelineInfoInterface> {
let investment: Big = new Big(0);
let fees: Big = new Big(0);
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
if (j >= 0) {
const currencies: { [name: string]: string } = {};
const dataGatheringItems: IDataGatheringItem[] = [];
for (const item of this.transactionPoints[j].items) {
currencies[item.symbol] = item.currency;
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
});
investment = investment.plus(item.investment);
fees = fees.plus(item.fee);
}
let marketSymbols: GetValueObject[] = [];
if (dataGatheringItems.length > 0) {
try {
marketSymbols = await this.currentRateService.getValues({
currencies,
dataGatheringItems,
dateQuery: {
gte: startDate,
lt: endOfDay(endDate)
},
userCurrency: this.currency
});
} catch (error) {
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error,
'PortfolioCalculatorNew'
);
return null;
}
}
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
);
}
}
}
const results: TimelinePeriod[] = [];
let maxNetPerformance: Big = null;
let minNetPerformance: Big = null;
for (
let currentDate = startDate;
isBefore(currentDate, endDate);
currentDate = addDays(currentDate, 1)
) {
let value = new Big(0);
const currentDateAsString = format(currentDate, DATE_FORMAT);
let invalid = false;
if (j >= 0) {
for (const item of this.transactionPoints[j].items) {
if (
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
) {
invalid = true;
break;
}
value = value.plus(
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
);
}
}
if (!invalid) {
const grossPerformance = value.minus(investment);
const netPerformance = grossPerformance.minus(fees);
if (
minNetPerformance === null ||
minNetPerformance.gt(netPerformance)
) {
minNetPerformance = netPerformance;
}
if (
maxNetPerformance === null ||
maxNetPerformance.lt(netPerformance)
) {
maxNetPerformance = netPerformance;
}
const result = {
grossPerformance,
investment,
netPerformance,
value,
date: currentDateAsString
};
results.push(result);
}
}
return {
maxNetPerformance,
minNetPerformance,
timelinePeriods: results
};
}
private getFactor(type: TypeOfOrder) {
let factor: number;
switch (type) {
case 'BUY':
factor = 1;
break;
case 'SELL':
factor = -1;
break;
default:
factor = 0;
break;
}
return factor;
}
private addToDate(date: Date, accuracy: Accuracy): Date {
switch (accuracy) {
case 'day':
return addDays(date, 1);
case 'month':
return addMonths(date, 1);
case 'year':
return addYears(date, 1);
}
}
private getSymbolMetrics({
marketSymbolMap,
start,
symbol
}: {
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
return order.symbol === symbol;
});
if (orders.length <= 0) {
return {
hasErrors: false,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
hasErrors: true,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0);
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
let valueAtStartDate: Big;
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtStartDate
});
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtEndDate
});
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
let sortIndex = new Date(order.date);
if (order.itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex((order) => {
return order.itemType === 'start';
});
const indexOfEndOrder = orders.findIndex((order) => {
return order.itemType === 'end';
});
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
// Calculate the average start price as soon as any units are held
if (
averagePriceAtStartDate.eq(0) &&
i >= indexOfStartOrder &&
totalUnits.gt(0)
) {
averagePriceAtStartDate = totalInvestment.div(totalUnits);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPrice
);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
}
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
maxTotalInvestment = totalInvestment;
}
if (i === indexOfEndOrder && totalUnits.gt(0)) {
averagePriceAtEndDate = totalInvestment.div(totalUnits);
}
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
}
}
fees = fees.plus(order.fee);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
const valueOfInvestment = totalUnits.mul(order.unitPrice);
const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
totalInvestmentWithGrossPerformanceFromSell =
totalInvestmentWithGrossPerformanceFromSell
.plus(transactionInvestment)
.plus(grossPerformanceFromSell);
lastAveragePrice = totalUnits.eq(0)
? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestmentWithGrossPerformanceFromSell)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
}
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate)
);
const grossPerformancePercentage =
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const netPerformancePercentage =
PortfolioCalculatorNew.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
if (PortfolioCalculatorNew.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Average price: ${averagePriceAtStartDate.toFixed(
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
);
}
return {
initialValue,
grossPerformancePercentage,
netPerformancePercentage,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance
};
}
private isNextItemActive(
timelineSpecification: TimelineSpecification[],
currentDate: Date,
i: number
) {
return (
i + 1 < timelineSpecification.length &&
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
);
}
}

View File

@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js'; import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock'; import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculatorNew } from './portfolio-calculator-new'; import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return { return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
}; };
}); });
describe('PortfolioCalculatorNew', () => { describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService; let currentRateService: CurrentRateService;
beforeEach(() => { beforeEach(() => {
@ -23,19 +23,19 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => { describe('get current positions', () => {
it('with no orders', async () => { it('with no orders', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({ const portfolioCalculator = new PortfolioCalculator({
currentRateService, currentRateService,
currency: 'CHF', currency: 'CHF',
orders: [] orders: []
}); });
portfolioCalculatorNew.computeTransactionPoints(); portfolioCalculator.computeTransactionPoints();
const spy = jest const spy = jest
.spyOn(Date, 'now') .spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime()); .mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
new Date() new Date()
); );

View File

@ -0,0 +1,96 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
return {
// eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => {
return CurrentRateServiceMock;
})
};
});
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
});
describe('get current positions', () => {
it.only('with BALN.SW buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
type: 'BUY',
unitPrice: new Big(75.8)
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG',
quantity: new Big(1),
symbol: 'NOVN.SW',
type: 'SELL',
unitPrice: new Big(85.73)
}
]
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2022-03-07')
);
spy.mockRestore();
expect(currentPositions).toEqual({
currentValue: new Big('87.8'),
errors: [],
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
hasErrors: false,
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
positions: [
{
averagePrice: new Big('75.80'),
currency: 'CHF',
dataSource: 'YAHOO',
firstBuyDate: '2022-03-07',
grossPerformance: new Big('21.93'),
grossPerformancePercentage: new Big('0.14465699208443271768'),
investment: new Big('75.80'),
netPerformance: new Big('17.68'),
netPerformancePercentage: new Big('0.11662269129287598945'),
marketPrice: 87.8,
quantity: new Big('1'),
symbol: 'NOVN.SW',
transactionCount: 2
}
],
totalInvestment: new Big('75.80')
});
});
});
});

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,15 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface'; import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client'; import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
addDays, addDays,
addMilliseconds,
addMonths, addMonths,
addYears, addYears,
differenceInDays,
endOfDay, endOfDay,
format, format,
isAfter, isAfter,
@ -17,11 +17,12 @@ import {
max, max,
min min
} from 'date-fns'; } from 'date-fns';
import { flatten, isNumber } from 'lodash'; import { first, flatten, isNumber, sortBy } from 'lodash';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface'; import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface'; import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface'; import { TimelinePeriod } from './interfaces/timeline-period.interface';
import { import {
@ -32,22 +33,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface'; import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator { export class PortfolioCalculator {
private static readonly CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT =
true;
private static readonly ENABLE_LOGGING = false;
private currency: string;
private currentRateService: CurrentRateService;
private orders: PortfolioOrder[];
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
public constructor( public constructor({
private currentRateService: CurrentRateService, currency,
private currency: string currentRateService,
) {} orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
public computeTransactionPoints(orders: PortfolioOrder[]) { this.orders.sort((a, b) => a.date.localeCompare(b.date));
orders.sort((a, b) => a.date.localeCompare(b.date)); }
public computeTransactionPoints() {
this.transactionPoints = []; this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {}; const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null; let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null; let lastTransactionPoint: TransactionPoint = null;
for (const order of orders) { for (const order of this.orders) {
const currentDate = order.date; const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol; let currentTransactionPointItem: TransactionPointSymbol;
@ -59,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity const newQuantity = order.quantity
.mul(factor) .mul(factor)
.plus(oldAccumulatedSymbol.quantity); .plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
} else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice)
);
}
}
currentTransactionPointItem = { currentTransactionPointItem = {
investment,
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee), fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: newQuantity.eq(0)
? new Big(0)
: unitPrice
.mul(order.quantity)
.mul(factor)
.plus(oldAccumulatedSymbol.investment),
quantity: newQuantity, quantity: newQuantity,
symbol: order.symbol, symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1
@ -140,7 +171,6 @@ export class PortfolioCalculator {
hasErrors: false, hasErrors: false,
grossPerformance: new Big(0), grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0), grossPerformancePercentage: new Big(0),
netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0), netPerformance: new Big(0),
netPerformancePercentage: new Big(0), netPerformancePercentage: new Big(0),
positions: [], positions: [],
@ -195,6 +225,7 @@ export class PortfolioCalculator {
const marketSymbolMap: { const marketSymbolMap: {
[date: string]: { [symbol: string]: Big }; [date: string]: { [symbol: string]: Big };
} = {}; } = {};
for (const marketSymbol of marketSymbols) { for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT); const date = format(marketSymbol.date, DATE_FORMAT);
if (!marketSymbolMap[date]) { if (!marketSymbolMap[date]) {
@ -207,112 +238,37 @@ export class PortfolioCalculator {
} }
} }
let hasErrors = false;
const startString = format(start, DATE_FORMAT);
const holdingPeriodReturns: { [symbol: string]: Big } = {};
const netHoldingPeriodReturns: { [symbol: string]: Big } = {};
const grossPerformance: { [symbol: string]: Big } = {};
const netPerformance: { [symbol: string]: Big } = {};
const todayString = format(today, DATE_FORMAT); const todayString = format(today, DATE_FORMAT);
if (firstIndex > 0) { if (firstIndex > 0) {
firstIndex--; firstIndex--;
} }
const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {};
const lastFees: { [symbol: string]: Big } = {};
const initialValues: { [symbol: string]: Big } = {}; const initialValues: { [symbol: string]: Big } = {};
for (let i = firstIndex; i < this.transactionPoints.length; i++) {
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) {
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(
`Missing value for symbol ${item.symbol} at ${nextDate}`,
'PortfolioCalculator'
);
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);
const isFirstOrderAndIsStartBeforeCurrentDate =
i === firstIndex &&
isBefore(parseDate(this.transactionPoints[i].date), start);
const lastFee: Big = lastFees[item.symbol] ?? new Big(0);
const fee = isFirstOrderAndIsStartBeforeCurrentDate
? new Big(0)
: item.fee.minus(lastFee);
if (!isAfter(parseDate(currentDate), parseDate(item.firstBuyDate))) {
initialValue = item.investment;
investedValue = item.investment;
}
if (i === firstIndex || !initialValues[item.symbol]) {
initialValues[item.symbol] = initialValue;
}
if (!item.quantity.eq(0)) {
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
Logger.warn(
`Missing value for symbol ${item.symbol} at ${currentDate}`,
'PortfolioCalculator'
);
continue;
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] = (
holdingPeriodReturns[item.symbol] ?? new Big(1)
).mul(holdingPeriodReturn);
grossPerformance[item.symbol] = (
grossPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue));
const netHoldingPeriodReturn = endValue.div(
initialValue.plus(cashFlow).plus(fee)
);
netHoldingPeriodReturns[item.symbol] = (
netHoldingPeriodReturns[item.symbol] ?? new Big(1)
).mul(netHoldingPeriodReturn);
netPerformance[item.symbol] = (
netPerformance[item.symbol] ?? new Big(0)
).plus(endValue.minus(investedValue).minus(fee));
}
lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity;
lastFees[item.symbol] = item.fee;
}
}
const positions: TimelinePosition[] = []; const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol]; const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
const {
grossPerformance,
grossPerformancePercentage,
hasErrors,
initialValue,
netPerformance,
netPerformancePercentage
} = this.getSymbolMetrics({
marketSymbolMap,
start,
symbol: item.symbol
});
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
initialValues[item.symbol] = initialValue;
positions.push({ positions.push({
averagePrice: item.quantity.eq(0) averagePrice: item.quantity.eq(0)
? new Big(0) ? new Big(0)
@ -320,31 +276,33 @@ export class PortfolioCalculator {
currency: item.currency, currency: item.currency,
dataSource: item.dataSource, dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: isValid grossPerformance: !hasErrors ? grossPerformance ?? null : null,
? grossPerformance[item.symbol] ?? null grossPerformancePercentage: !hasErrors
: null, ? grossPerformancePercentage ?? null
grossPerformancePercentage:
isValid && holdingPeriodReturns[item.symbol]
? holdingPeriodReturns[item.symbol].minus(1)
: null, : null,
investment: item.investment, investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null, marketPrice: marketValue?.toNumber() ?? null,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null, netPerformance: !hasErrors ? netPerformance ?? null : null,
netPerformancePercentage: netPerformancePercentage: !hasErrors
isValid && netHoldingPeriodReturns[item.symbol] ? netPerformancePercentage ?? null
? netHoldingPeriodReturns[item.symbol].minus(1)
: null, : null,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
transactionCount: item.transactionCount transactionCount: item.transactionCount
}); });
if (hasErrors) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
} }
}
const overall = this.calculateOverallPerformance(positions, initialValues); const overall = this.calculateOverallPerformance(positions, initialValues);
return { return {
...overall, ...overall,
errors,
positions, positions,
hasErrors: hasErrors || overall.hasErrors hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
}; };
} }
@ -462,20 +420,16 @@ export class PortfolioCalculator {
private calculateOverallPerformance( private calculateOverallPerformance(
positions: TimelinePosition[], positions: TimelinePosition[],
initialValues: { [p: string]: Big } initialValues: { [symbol: string]: Big }
) { ) {
let hasErrors = false;
let currentValue = new Big(0); let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0); let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0); let grossPerformancePercentage = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0); let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0); let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0); let sumOfWeights = new Big(0);
let netAnnualizedPerformance = new Big(0); let totalInvestment = new Big(0);
// use Date.now() to use the mock for today
const today = new Date(Date.now());
for (const currentPosition of positions) { for (const currentPosition of positions) {
if (currentPosition.marketPrice) { if (currentPosition.marketPrice) {
@ -485,36 +439,34 @@ export class PortfolioCalculator {
} else { } else {
hasErrors = true; hasErrors = true;
} }
totalInvestment = totalInvestment.plus(currentPosition.investment); totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) { if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus( grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance currentPosition.grossPerformance
); );
netPerformance = netPerformance.plus(currentPosition.netPerformance); netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
hasErrors = true; hasErrors = true;
} }
if ( if (currentPosition.grossPerformancePercentage) {
currentPosition.grossPerformancePercentage && // Use the average from the initial value and the current investment as
initialValues[currentPosition.symbol] // a weight
) { const weight = (initialValues[currentPosition.symbol] ?? new Big(0))
const currentInitialValue = initialValues[currentPosition.symbol]; .plus(currentPosition.investment)
completeInitialValue = completeInitialValue.plus(currentInitialValue); .div(2);
sumOfWeights = sumOfWeights.plus(weight);
grossPerformancePercentage = grossPerformancePercentage.plus( grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue) currentPosition.grossPerformancePercentage.mul(weight)
);
netAnnualizedPerformance = netAnnualizedPerformance.plus(
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
today,
parseDate(currentPosition.firstBuyDate)
),
netPerformancePercent: currentPosition.netPerformancePercentage
}).mul(currentInitialValue)
); );
netPerformancePercentage = netPerformancePercentage.plus( netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue) currentPosition.netPerformancePercentage.mul(weight)
); );
} else if (!currentPosition.quantity.eq(0)) { } else if (!currentPosition.quantity.eq(0)) {
Logger.warn( Logger.warn(
@ -525,13 +477,12 @@ export class PortfolioCalculator {
} }
} }
if (!completeInitialValue.eq(0)) { if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
grossPerformancePercentage.div(completeInitialValue); netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = } else {
netPerformancePercentage.div(completeInitialValue); grossPerformancePercentage = new Big(0);
netAnnualizedPerformance = netPerformancePercentage = new Big(0);
netAnnualizedPerformance.div(completeInitialValue);
} }
return { return {
@ -539,7 +490,6 @@ export class PortfolioCalculator {
grossPerformance, grossPerformance,
grossPerformancePercentage, grossPerformancePercentage,
hasErrors, hasErrors,
netAnnualizedPerformance,
netPerformance, netPerformance,
netPerformancePercentage, netPerformancePercentage,
totalInvestment totalInvestment
@ -693,6 +643,356 @@ export class PortfolioCalculator {
} }
} }
private getSymbolMetrics({
marketSymbolMap,
start,
symbol
}: {
marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
};
start: Date;
symbol: string;
}) {
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
return order.symbol === symbol;
});
if (orders.length <= 0) {
return {
hasErrors: false,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
const dateOfFirstTransaction = new Date(first(orders).date);
const endDate = new Date(Date.now());
const unitPriceAtStartDate =
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
const unitPriceAtEndDate =
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
if (
!unitPriceAtEndDate ||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
) {
return {
hasErrors: true,
initialValue: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0)
};
}
let averagePriceAtEndDate = new Big(0);
let averagePriceAtStartDate = new Big(0);
let feesAtStartDate = new Big(0);
let fees = new Big(0);
let grossPerformance = new Big(0);
let grossPerformanceAtStartDate = new Big(0);
let grossPerformanceFromSells = new Big(0);
let initialValue: Big;
let investmentAtStartDate: Big;
let lastAveragePrice = new Big(0);
let lastTransactionInvestment = new Big(0);
let lastValueOfInvestmentBeforeTransaction = new Big(0);
let maxTotalInvestment = new Big(0);
let timeWeightedGrossPerformancePercentage = new Big(1);
let timeWeightedNetPerformancePercentage = new Big(1);
let totalInvestment = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalUnits = new Big(0);
let valueAtStartDate: Big;
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtStartDate
});
orders.push({
symbol,
currency: null,
date: format(endDate, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
itemType: 'end',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
unitPrice: unitPriceAtEndDate
});
// Sort orders so that the start and end placeholder order are at the right
// position
orders = sortBy(orders, (order) => {
let sortIndex = new Date(order.date);
if (order.itemType === 'start') {
sortIndex = addMilliseconds(sortIndex, -1);
}
if (order.itemType === 'end') {
sortIndex = addMilliseconds(sortIndex, 1);
}
return sortIndex.getTime();
});
const indexOfStartOrder = orders.findIndex((order) => {
return order.itemType === 'start';
});
const indexOfEndOrder = orders.findIndex((order) => {
return order.itemType === 'end';
});
for (let i = 0; i < orders.length; i += 1) {
const order = orders[i];
if (order.itemType === 'start') {
// Take the unit price of the order as the market price if there are no
// orders of this symbol before the start date
order.unitPrice =
indexOfStartOrder === 0
? orders[i + 1]?.unitPrice
: unitPriceAtStartDate;
}
// Calculate the average start price as soon as any units are held
if (
averagePriceAtStartDate.eq(0) &&
i >= indexOfStartOrder &&
totalUnits.gt(0)
) {
averagePriceAtStartDate = totalInvestment.div(totalUnits);
}
const valueOfInvestmentBeforeTransaction = totalUnits.mul(
order.unitPrice
);
if (!investmentAtStartDate && i >= indexOfStartOrder) {
investmentAtStartDate = totalInvestment ?? new Big(0);
valueAtStartDate = valueOfInvestmentBeforeTransaction;
}
const transactionInvestment = order.quantity
.mul(order.unitPrice)
.mul(this.getFactor(order.type));
totalInvestment = totalInvestment.plus(transactionInvestment);
if (i >= indexOfStartOrder && totalInvestment.gt(maxTotalInvestment)) {
maxTotalInvestment = totalInvestment;
}
if (i === indexOfEndOrder && totalUnits.gt(0)) {
averagePriceAtEndDate = totalInvestment.div(totalUnits);
}
if (i >= indexOfStartOrder && !initialValue) {
if (
i === indexOfStartOrder &&
!valueOfInvestmentBeforeTransaction.eq(0)
) {
initialValue = valueOfInvestmentBeforeTransaction;
} else if (transactionInvestment.gt(0)) {
initialValue = transactionInvestment;
}
}
fees = fees.plus(order.fee);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
const valueOfInvestment = totalUnits.mul(order.unitPrice);
const grossPerformanceFromSell =
order.type === TypeOfOrder.SELL
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
: new Big(0);
grossPerformanceFromSells = grossPerformanceFromSells.plus(
grossPerformanceFromSell
);
totalInvestmentWithGrossPerformanceFromSell =
totalInvestmentWithGrossPerformanceFromSell
.plus(transactionInvestment)
.plus(grossPerformanceFromSell);
lastAveragePrice = totalUnits.eq(0)
? new Big(0)
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
const newGrossPerformance = valueOfInvestment
.minus(totalInvestmentWithGrossPerformanceFromSell)
.plus(grossPerformanceFromSells);
if (
i > indexOfStartOrder &&
!lastValueOfInvestmentBeforeTransaction
.plus(lastTransactionInvestment)
.eq(0)
) {
const grossHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.mul(
new Big(1).plus(grossHoldingPeriodReturn)
);
const netHoldingPeriodReturn = valueOfInvestmentBeforeTransaction
.minus(fees.minus(feesAtStartDate))
.minus(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
)
.div(
lastValueOfInvestmentBeforeTransaction.plus(
lastTransactionInvestment
)
);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.mul(
new Big(1).plus(netHoldingPeriodReturn)
);
}
grossPerformance = newGrossPerformance;
lastTransactionInvestment = transactionInvestment;
lastValueOfInvestmentBeforeTransaction =
valueOfInvestmentBeforeTransaction;
if (order.itemType === 'start') {
feesAtStartDate = fees;
grossPerformanceAtStartDate = grossPerformance;
}
}
timeWeightedGrossPerformancePercentage =
timeWeightedGrossPerformancePercentage.minus(1);
timeWeightedNetPerformancePercentage =
timeWeightedNetPerformancePercentage.minus(1);
const totalGrossPerformance = grossPerformance.minus(
grossPerformanceAtStartDate
);
const totalNetPerformance = grossPerformance
.minus(grossPerformanceAtStartDate)
.minus(fees.minus(feesAtStartDate));
const maxInvestmentBetweenStartAndEndDate = valueAtStartDate.plus(
maxTotalInvestment.minus(investmentAtStartDate)
);
const grossPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalGrossPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
const feesPerUnit = totalUnits.gt(0)
? fees.minus(feesAtStartDate).div(totalUnits)
: new Big(0);
const netPerformancePercentage =
PortfolioCalculator.CALCULATE_PERCENTAGE_PERFORMANCE_WITH_MAX_INVESTMENT ||
averagePriceAtStartDate.eq(0) ||
averagePriceAtEndDate.eq(0) ||
orders[indexOfStartOrder].unitPrice.eq(0)
? maxInvestmentBetweenStartAndEndDate.gt(0)
? totalNetPerformance.div(maxInvestmentBetweenStartAndEndDate)
: new Big(0)
: // This formula has the issue that buying more units with a price
// lower than the average buying price results in a positive
// performance even if the market price stays constant
unitPriceAtEndDate
.minus(feesPerUnit)
.div(averagePriceAtEndDate)
.div(
orders[indexOfStartOrder].unitPrice.div(averagePriceAtStartDate)
)
.minus(1);
if (PortfolioCalculator.ENABLE_LOGGING) {
console.log(
`
${symbol}
Unit price: ${orders[indexOfStartOrder].unitPrice.toFixed(
2
)} -> ${unitPriceAtEndDate.toFixed(2)}
Average price: ${averagePriceAtStartDate.toFixed(
2
)} -> ${averagePriceAtEndDate.toFixed(2)}
Max. total investment: ${maxTotalInvestment.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
Fees per unit: ${feesPerUnit.toFixed(2)}
Net performance: ${totalNetPerformance.toFixed(
2
)} / ${netPerformancePercentage.mul(100).toFixed(2)}%`
);
}
return {
initialValue,
grossPerformancePercentage,
netPerformancePercentage,
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
netPerformance: totalNetPerformance,
grossPerformance: totalGrossPerformance
};
}
private isNextItemActive( private isNextItemActive(
timelineSpecification: TimelineSpecification[], timelineSpecification: TimelineSpecification[],
currentDate: Date, currentDate: Date,

View File

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

View File

@ -38,7 +38,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioServiceStrategy } from './portfolio-service.strategy'; import { PortfolioService } from './portfolio.service';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
@ -46,7 +46,7 @@ export class PortfolioController {
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy, private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -57,9 +57,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<PortfolioChart> { ): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioServiceStrategy const historicalDataContainer = await this.portfolioService.getChart(
.get() impersonationId,
.getChart(impersonationId, range); range
);
let chartData = historicalDataContainer.items; let chartData = historicalDataContainer.items;
@ -106,22 +107,14 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<PortfolioDetails & { hasError: boolean }> { ): Promise<PortfolioDetails & { hasError: boolean }> {
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let hasError = false; let hasError = false;
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioServiceStrategy await this.portfolioService.getDetails(
.get(true) impersonationId,
.getDetails(impersonationId, this.request.user.id, range); this.request.user.id,
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true; hasError = true;
@ -162,7 +155,11 @@ export class PortfolioController {
} }
} }
return { accounts, hasError, holdings }; const isBasicUser =
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic';
return { accounts, hasError, holdings: isBasicUser ? {} : holdings };
} }
@Get('investments') @Get('investments')
@ -180,9 +177,9 @@ export class PortfolioController {
); );
} }
let investments = await this.portfolioServiceStrategy let investments = await this.portfolioService.getInvestments(
.get() impersonationId
.getInvestments(impersonationId); );
if ( if (
impersonationId || impersonationId ||
@ -209,9 +206,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioServiceStrategy const performanceInformation = await this.portfolioService.getPerformance(
.get() impersonationId,
.getPerformance(impersonationId, range); range
);
if ( if (
impersonationId || impersonationId ||
@ -234,9 +232,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Query('range') range @Query('range') range
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
const result = await this.portfolioServiceStrategy const result = await this.portfolioService.getPositions(
.get() impersonationId,
.getPositions(impersonationId, range); range
);
if ( if (
impersonationId || impersonationId ||
@ -276,9 +275,10 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium'; hasDetails = user.subscription.type === 'Premium';
} }
const { holdings } = await this.portfolioServiceStrategy const { holdings } = await this.portfolioService.getDetails(
.get(true) access.userId,
.getDetails(access.userId, access.userId); access.userId
);
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails, hasDetails,
@ -320,9 +320,17 @@ export class PortfolioController {
public async getSummary( public async getSummary(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> { ): Promise<PortfolioSummary> {
let summary = await this.portfolioServiceStrategy if (
.get() this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
.getSummary(impersonationId); this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let summary = await this.portfolioService.getSummary(impersonationId);
if ( if (
impersonationId || impersonationId ||
@ -356,9 +364,11 @@ export class PortfolioController {
@Param('dataSource') dataSource, @Param('dataSource') dataSource,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
let position = await this.portfolioServiceStrategy let position = await this.portfolioService.getPosition(
.get() dataSource,
.getPosition(dataSource, impersonationId, symbol); impersonationId,
symbol
);
if (position) { if (position) {
if ( if (
@ -399,6 +409,6 @@ export class PortfolioController {
); );
} }
return await this.portfolioServiceStrategy.get().getReport(impersonationId); return await this.portfolioService.getReport(impersonationId);
} }
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,6 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface'; import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface'; import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface'; import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/app/user/interfaces/user-settings.interface';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
@ -41,6 +40,7 @@ import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.in
import type { import type {
AccountWithValue, AccountWithValue,
DateRange, DateRange,
Market,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
@ -49,6 +49,7 @@ import { REQUEST } from '@nestjs/core';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client'; import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays,
endOfToday, endOfToday,
format, format,
isAfter, isAfter,
@ -68,8 +69,12 @@ import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { PortfolioCalculator } from './portfolio-calculator';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
public constructor( public constructor(
@ -159,15 +164,18 @@ export class PortfolioService {
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints({
userId, userId,
includeDrafts: true includeDrafts: true
}); });
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return [];
@ -208,12 +216,17 @@ export class PortfolioService {
): Promise<HistoricalDataContainer> { ): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency userId
); });
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return { return {
@ -302,15 +315,18 @@ export class PortfolioService {
this.request.user?.Settings?.currency ?? this.request.user?.Settings?.currency ??
user.Settings?.currency ?? user.Settings?.currency ??
baseCurrency; baseCurrency;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
userCurrency
);
const { orders, transactionPoints } = await this.getTransactionPoints({ const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId userId
}); });
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate( const portfolioStart = parseDate(
@ -368,7 +384,31 @@ export class PortfolioService {
const value = item.quantity.mul(item.marketPrice); const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
const markets: { [key in Market]: number } = {
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
holdings[item.symbol] = { holdings[item.symbol] = {
markets,
allocationCurrent: value.div(totalValue).toNumber(), allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(), allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
@ -474,11 +514,13 @@ export class PortfolioService {
unitPrice: new Big(order.unitPrice) unitPrice: new Big(order.unitPrice)
})); }));
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator({
this.currentRateService, currency: positionCurrency,
positionCurrency currentRateService: this.currentRateService,
); orders: portfolioOrders
portfolioCalculator.computeTransactionPoints(portfolioOrders); });
portfolioCalculator.computeTransactionPoints();
const transactionPoints = portfolioCalculator.getTransactionPoints(); const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
@ -657,12 +699,16 @@ export class PortfolioService {
): Promise<{ hasErrors: boolean; positions: Position[] }> { ): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency userId
); });
const { transactionPoints } = await this.getTransactionPoints({ userId }); const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
@ -730,18 +776,21 @@ export class PortfolioService {
): Promise<PortfolioPerformanceResponse> { ): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator( const { portfolioOrders, transactionPoints } =
this.currentRateService, await this.getTransactionPoints({
this.request.user.Settings.currency userId
); });
const { transactionPoints } = await this.getTransactionPoints({ userId }); const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
hasErrors: false, hasErrors: false,
performance: { performance: {
annualizedPerformancePercent: 0,
currentGrossPerformance: 0, currentGrossPerformance: 0,
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0, currentNetPerformance: 0,
@ -760,26 +809,34 @@ export class PortfolioService {
); );
const hasErrors = currentPositions.hasErrors; const hasErrors = currentPositions.hasErrors;
const annualizedPerformancePercent =
currentPositions.netAnnualizedPerformance.toNumber();
const currentValue = currentPositions.currentValue.toNumber(); const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance = const currentGrossPerformance = currentPositions.grossPerformance;
currentPositions.grossPerformance.toNumber(); let currentGrossPerformancePercent =
const currentGrossPerformancePercent = currentPositions.grossPerformancePercentage;
currentPositions.grossPerformancePercentage.toNumber(); const currentNetPerformance = currentPositions.netPerformance;
const currentNetPerformance = currentPositions.netPerformance.toNumber(); let currentNetPerformancePercent =
const currentNetPerformancePercent = currentPositions.netPerformancePercentage;
currentPositions.netPerformancePercentage.toNumber();
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
}
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
// If algebraic sign is different, harmonize it
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
}
return { return {
errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors, hasErrors: currentPositions.hasErrors || hasErrors,
performance: { performance: {
annualizedPerformancePercent, currentValue,
currentGrossPerformance, currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent, currentGrossPerformancePercent:
currentNetPerformance, currentGrossPerformancePercent.toNumber(),
currentNetPerformancePercent, currentNetPerformance: currentNetPerformance.toNumber(),
currentValue currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
} }
}; };
} }
@ -788,7 +845,8 @@ export class PortfolioService {
const currency = this.request.user.Settings.currency; const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id); const userId = await this.getUserId(impersonationId, this.request.user.id);
const { orders, transactionPoints } = await this.getTransactionPoints({ const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId userId
}); });
@ -798,10 +856,12 @@ export class PortfolioService {
}; };
} }
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator({
this.currentRateService, currency,
currency currentRateService: this.currentRateService,
); orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
@ -907,8 +967,24 @@ export class PortfolioService {
.plus(items) .plus(items)
.toNumber(); .toNumber();
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
const annualizedPerformancePercent = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: []
})
.getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent: new Big(
performanceInformation.performance.currentNetPerformancePercent
)
})
?.toNumber();
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
annualizedPerformancePercent,
cash, cash,
dividend, dividend,
fees, fees,
@ -917,8 +993,6 @@ export class PortfolioService {
netWorth, netWorth,
totalBuy, totalBuy,
totalSell, totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
committedFunds: committedFunds.toNumber(), committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(), emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => { ordersCount: orders.filter((order) => {
@ -937,8 +1011,8 @@ export class PortfolioService {
cashDetails: CashDetails; cashDetails: CashDetails;
emergencyFund: Big; emergencyFund: Big;
investment: Big; investment: Big;
userCurrency: string;
value: Big; value: Big;
userCurrency: string;
}) { }) {
const cashPositions: PortfolioDetails['holdings'] = {}; const cashPositions: PortfolioDetails['holdings'] = {};
@ -1111,6 +1185,7 @@ export class PortfolioService {
}): Promise<{ }): Promise<{
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[];
}> { }> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency; const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
@ -1122,7 +1197,7 @@ export class PortfolioService {
}); });
if (orders.length <= 0) { if (orders.length <= 0) {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [], portfolioOrders: [] };
} }
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
@ -1149,14 +1224,18 @@ export class PortfolioService {
) )
})); }));
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator({
this.currentRateService, currency: userCurrency,
userCurrency currentRateService: this.currentRateService,
); orders: portfolioOrders
portfolioCalculator.computeTransactionPoints(portfolioOrders); });
portfolioCalculator.computeTransactionPoints();
return { return {
transactionPoints: portfolioCalculator.getTransactionPoints(), transactionPoints: portfolioCalculator.getTransactionPoints(),
orders orders,
portfolioOrders
}; };
} }

View File

@ -45,7 +45,7 @@ export class SubscriptionService {
payment_method_types: ['card'], payment_method_types: ['card'],
success_url: `${this.configurationService.get( success_url: `${this.configurationService.get(
'ROOT_URL' 'ROOT_URL'
)}/api/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}` )}/api/v1/subscription/stripe/callback?checkoutSessionId={CHECKOUT_SESSION_ID}`
}; };
if (couponId) { if (couponId) {

View File

@ -1,5 +1,5 @@
export interface UserSettings { export interface UserSettings {
emergencyFund?: number; emergencyFund?: number;
isNewCalculationEngine?: boolean; locale?: string;
isRestrictedView?: boolean; isRestrictedView?: boolean;
} }

View File

@ -1,15 +1,19 @@
import { IsBoolean, IsNumber, IsOptional } from 'class-validator'; import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsNumber() @IsNumber()
@IsOptional() @IsOptional()
emergencyFund?: number; emergencyFund?: number;
@IsBoolean()
@IsOptional()
isNewCalculationEngine?: boolean;
@IsBoolean() @IsBoolean()
@IsOptional() @IsOptional()
isRestrictedView?: boolean; isRestrictedView?: boolean;
@IsString()
@IsOptional()
locale?: string;
@IsNumber()
@IsOptional()
savingsRate?: number;
} }

View File

@ -2,17 +2,14 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PropertyService } from '@ghostfolio/api/services/property/property.service'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config'; import { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { import { hasPermission, permissions } from '@ghostfolio/common/permissions';
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Headers,
HttpException, HttpException,
Inject, Inject,
Param, Param,
@ -63,8 +60,13 @@ export class UserController {
@Get() @Get()
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> { public async getUser(
return this.userService.getUser(this.request.user); @Headers('accept-language') acceptLanguage: string
): Promise<User> {
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]
);
} }
@Post() @Post()
@ -118,7 +120,7 @@ export class UserController {
}; };
for (const key in userSettings) { for (const key in userSettings) {
if (userSettings[key] === false) { if (userSettings[key] === false || userSettings[key] === null) {
delete userSettings[key]; delete userSettings[key];
} }
} }

View File

@ -33,14 +33,17 @@ export class UserService {
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
public async getUser({ public async getUser(
{
Account, Account,
alias, alias,
id, id,
permissions, permissions,
Settings, Settings,
subscription subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({ const access = await this.prismaService.access.findMany({
include: { include: {
User: true User: true
@ -63,8 +66,8 @@ export class UserService {
accounts: Account, accounts: Account,
settings: { settings: {
...(<UserSettings>Settings.settings), ...(<UserSettings>Settings.settings),
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
} }
}; };
@ -144,13 +147,6 @@ export class UserService {
user.subscription = this.subscriptionService.getSubscription( user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription userFromDatabase?.Subscription
); );
if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => {
return permission !== permissions.updateViewMode;
});
user.Settings.viewMode = ViewMode.ZEN;
}
} }
return user; return user;

View File

@ -1,4 +1,4 @@
import { Logger, ValidationPipe } from '@nestjs/common'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module'; import { AppModule } from './app/app.module';
@ -7,8 +7,11 @@ import { environment } from './environments/environment';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
app.enableCors(); app.enableCors();
const globalPrefix = 'api'; app.enableVersioning({
app.setGlobalPrefix(globalPrefix); defaultVersion: '1',
type: VersioningType.URI
});
app.setGlobalPrefix('api');
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidNonWhitelisted: true, forbidNonWhitelisted: true,

View File

@ -1,10 +1,10 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition PortfolioPosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioDetails } from '@ghostfolio/common/interfaces'; import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,7 +1,7 @@
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -1,6 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';

View File

@ -4,8 +4,10 @@
"ATOM": "Cosmos", "ATOM": "Cosmos",
"AVAX": "Avalanche", "AVAX": "Avalanche",
"DOT": "Polkadot", "DOT": "Polkadot",
"LUNA1": "Terra",
"MATIC": "Polygon", "MATIC": "Polygon",
"MINA": "Mina Protocol", "MINA": "Mina Protocol",
"RUNE": "THORChain",
"SHIB": "Shiba Inu", "SHIB": "Shiba Inu",
"SOL": "Solana", "SOL": "Solana",
"UNI3": "Uniswap" "UNI3": "Uniswap"

View File

@ -17,8 +17,6 @@ import { format, subMonths, subWeeks, subYears } from 'date-fns';
@Injectable() @Injectable()
export class RakutenRapidApiService implements DataProviderInterface { export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService

View File

@ -16,17 +16,17 @@ import {
DataSource, DataSource,
SymbolProfile SymbolProfile
} from '@prisma/client'; } from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list'; import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns'; import { addDays, format, isSameDay } from 'date-fns';
import yahooFinance from 'yahoo-finance2'; import yahooFinance from 'yahoo-finance2';
import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface'; import type {
Price,
QuoteSummaryResult
} from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-iface';
@Injectable() @Injectable()
export class YahooFinanceService implements DataProviderInterface { export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor( public constructor(
private readonly cryptocurrencyService: CryptocurrencyService private readonly cryptocurrencyService: CryptocurrencyService
) {} ) {}
@ -92,8 +92,7 @@ export class YahooFinanceService implements DataProviderInterface {
response.assetSubClass = assetSubClass; response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency; response.currency = assetProfile.price.currency;
response.dataSource = this.getName(); response.dataSource = this.getName();
response.name = response.name = this.formatName(assetProfile);
assetProfile.price.longName || assetProfile.price.shortName || symbol;
response.symbol = aSymbol; response.symbol = aSymbol;
if ( if (
@ -244,16 +243,7 @@ export class YahooFinanceService implements DataProviderInterface {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const searchResult = await yahooFinance.search(aQuery);
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
aQuery
)}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET',
'json',
200
);
const searchResult = await get();
const quotes = searchResult.quotes const quotes = searchResult.quotes
.filter((quote) => { .filter((quote) => {
@ -279,20 +269,24 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
}); });
const marketData = await this.getQuotes( const marketData = await yahooFinance.quote(
quotes.map(({ symbol }) => { quotes.map(({ symbol }) => {
return symbol; return symbol;
}) })
); );
for (const [symbol, value] of Object.entries(marketData)) { for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote: any) => { const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === symbol; return currentQuote.symbol === marketDataItem.symbol;
}); });
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: marketDataItem.currency,
dataSource: this.getName(), dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol name: quote?.longname || quote?.shortname || symbol
}); });
@ -304,6 +298,25 @@ export class YahooFinanceService implements DataProviderInterface {
return { items }; return { items };
} }
private formatName(aAssetProfile: QuoteSummaryResult) {
let name = aAssetProfile.price.longName;
if (name) {
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');
name = name.replace('iShares VI Public Limited Company - ', '');
name = name.replace('iShares VII PLC - ', '');
name = name.replace('Multi Units Luxembourg - ', '');
name = name.replace('VanEck ETFs N.V. - ', '');
name = name.replace('Vaneck Vectors Ucits Etfs Plc - ', '');
name = name.replace('Vanguard Funds Public Limited Company - ', '');
name = name.replace('Vanguard Index Funds - ', '');
name = name.replace('Xtrackers (IE) Plc - ', '');
}
return name || aAssetProfile.price.shortName || aAssetProfile.price.symbol;
}
private parseAssetClass(aPrice: Price): { private parseAssetClass(aPrice: Price): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;

View File

@ -4,7 +4,12 @@ import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client'; import {
DataSource,
Prisma,
SymbolProfile,
SymbolProfileOverrides
} from '@prisma/client';
import { continents, countries } from 'countries-list'; import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface'; import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@ -36,6 +41,7 @@ export class SymbolProfileService {
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
include: { SymbolProfileOverrides: true },
where: { where: {
symbol: { symbol: {
in: symbols in: symbols
@ -45,14 +51,38 @@ export class SymbolProfileService {
.then((symbolProfiles) => this.getSymbols(symbolProfiles)); .then((symbolProfiles) => this.getSymbols(symbolProfiles));
} }
private getSymbols(symbolProfiles: SymbolProfile[]): EnhancedSymbolProfile[] { private getSymbols(
return symbolProfiles.map((symbolProfile) => ({ symbolProfiles: (SymbolProfile & {
SymbolProfileOverrides: SymbolProfileOverrides;
})[]
): EnhancedSymbolProfile[] {
return symbolProfiles.map((symbolProfile) => {
const item = {
...symbolProfile, ...symbolProfile,
countries: this.getCountries(symbolProfile), countries: this.getCountries(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile), scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile), sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile) symbolMapping: this.getSymbolMapping(symbolProfile)
})); };
if (item.SymbolProfileOverrides) {
item.assetClass =
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
item.countries =
(item.SymbolProfileOverrides.sectors as unknown as Country[]) ??
item.countries;
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
item.sectors;
delete item.SymbolProfileOverrides;
}
return item;
});
} }
private getCountries(symbolProfile: SymbolProfile): Country[] { private getCountries(symbolProfile: SymbolProfile): Country[] {

View File

@ -1,16 +1,14 @@
import { import { DEFAULT_DATE_FORMAT_MONTH_YEAR } from '@ghostfolio/common/config';
DEFAULT_DATE_FORMAT, import { getDateFormatString } from '@ghostfolio/common/helper';
DEFAULT_DATE_FORMAT_MONTH_YEAR
} from '@ghostfolio/common/config';
export const DateFormats = { export const DateFormats = {
display: { display: {
dateInput: DEFAULT_DATE_FORMAT, dateInput: getDateFormatString(),
monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR, monthYearLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR,
dateA11yLabel: DEFAULT_DATE_FORMAT, dateA11yLabel: getDateFormatString(),
monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR monthYearA11yLabel: DEFAULT_DATE_FORMAT_MONTH_YEAR
}, },
parse: { parse: {
dateInput: DEFAULT_DATE_FORMAT dateInput: getDateFormatString()
} }
}; };

View File

@ -78,10 +78,19 @@
</ng-container> </ng-container>
<ng-container matColumnDef="balance"> <ng-container matColumnDef="balance">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Cash Balance Cash Balance
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -89,7 +98,11 @@
[value]="element.balance" [value]="element.balance"
></gf-value> ></gf-value>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td
*matFooterCellDef
class="d-none d-lg-table-cell px-1 text-right"
mat-footer-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -100,10 +113,19 @@
</ng-container> </ng-container>
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell> <th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 text-right"
i18n
mat-header-cell
>
Value Value
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1 text-right"
mat-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -111,7 +133,46 @@
[value]="element.value" [value]="element.value"
></gf-value> ></gf-value>
</td> </td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell> <td
*matFooterCellDef
class="d-none d-lg-table-cell px-1 text-right"
mat-footer-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValueInBaseCurrency"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="d-lg-none d-xl-none px-1 text-right"
i18n
mat-header-cell
>
Value
</th>
<td
*matCellDef="let element"
class="d-lg-none d-xl-none px-1 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.valueInBaseCurrency"
></gf-value>
</td>
<td
*matFooterCellDef
class="d-lg-none d-xl-none px-1 text-right"
mat-footer-cell
>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[isCurrency]="true" [isCurrency]="true"
@ -133,16 +194,17 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateAccount(element)"> <button mat-menu-item (click)="onUpdateAccount(element)">
Edit <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button> </button>
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0" [disabled]="element.isDefault || element.Order?.length > 0"
(click)="onDeleteAccount(element.id)" (click)="onDeleteAccount(element.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -50,7 +50,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
'transactions', 'transactions',
'balance', 'balance',
'value', 'value',
'currency' 'currency',
'valueInBaseCurrency'
]; ];
if (this.showActions) { if (this.showActions) {

View File

@ -8,8 +8,11 @@ import {
Output Output
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import {
import { DATE_FORMAT } from '@ghostfolio/common/helper'; DATE_FORMAT,
getDateFormatString,
getLocale
} from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { import {
@ -35,13 +38,14 @@ import { MarketDataDetailDialog } from './market-data-detail-dialog/market-data-
export class AdminMarketDataDetailComponent implements OnChanges, OnInit { export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
@Input() dataSource: DataSource; @Input() dataSource: DataSource;
@Input() dateOfFirstActivity: string; @Input() dateOfFirstActivity: string;
@Input() locale = getLocale();
@Input() marketData: MarketData[]; @Input() marketData: MarketData[];
@Input() symbol: string; @Input() symbol: string;
@Output() marketDataChanged = new EventEmitter<boolean>(); @Output() marketDataChanged = new EventEmitter<boolean>();
public days = Array(31); public days = Array(31);
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public marketDataByMonth: { public marketDataByMonth: {
@ -62,6 +66,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
public ngOnInit() {} public ngOnInit() {}
public ngOnChanges() { public ngOnChanges() {
this.defaultDateFormat = getDateFormatString(this.locale);
this.historicalDataItems = this.marketData.map((marketDataItem) => { this.historicalDataItems = this.marketData.map((marketDataItem) => {
return { return {
date: format(marketDataItem.date, DATE_FORMAT), date: format(marketDataItem.date, DATE_FORMAT),

View File

@ -7,8 +7,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service'; import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface'; import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
@ -23,9 +24,10 @@ import { takeUntil } from 'rxjs/operators';
export class AdminMarketDataComponent implements OnDestroy, OnInit { export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentDataSource: DataSource; public currentDataSource: DataSource;
public currentSymbol: string; public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = []; public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = []; public marketDataDetails: MarketData[] = [];
public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -35,8 +37,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private adminService: AdminService, private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService private dataService: DataService,
) {} private userService: UserService
) {
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
}
});
}
/** /**
* Initializes the controller * Initializes the controller

View File

@ -65,6 +65,7 @@
<gf-admin-market-data-detail <gf-admin-market-data-detail
[dataSource]="item.dataSource" [dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date" [dateOfFirstActivity]="item.date"
[locale]="user?.settings?.locale"
[marketData]="marketDataDetails" [marketData]="marketDataDetails"
[symbol]="item.symbol" [symbol]="item.symbol"
(marketDataChanged)="onMarketDataChanged($event)" (marketDataChanged)="onMarketDataChanged($event)"

View File

@ -5,7 +5,6 @@ import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { import {
DEFAULT_DATE_FORMAT,
PROPERTY_COUPONS, PROPERTY_COUPONS,
PROPERTY_CURRENCIES, PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE, PROPERTY_IS_READ_ONLY_MODE,
@ -35,7 +34,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public customCurrencies: string[]; public customCurrencies: string[];
public dataGatheringInProgress: boolean; public dataGatheringInProgress: boolean;
public dataGatheringProgress: number; public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[]; public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean; public hasPermissionForSystemMessage: boolean;

View File

@ -180,6 +180,7 @@
[value]="couponDuration" [value]="couponDuration"
(selectionChange)="onChangeCouponDuration($event.value)" (selectionChange)="onChangeCouponDuration($event.value)"
> >
<mat-option value="7 days">7 Days</mat-option>
<mat-option value="14 days">14 Days</mat-option> <mat-option value="14 days">14 Days</mat-option>
<mat-option value="30 days">30 Days</mat-option> <mat-option value="30 days">30 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option> <mat-option value="1 year">1 Year</mat-option>

View File

@ -68,12 +68,12 @@
</button> </button>
<mat-menu #accountMenu="matMenu" xPosition="before"> <mat-menu #accountMenu="matMenu" xPosition="before">
<button <button
i18n
mat-menu-item mat-menu-item
[disabled]="userItem.id === user?.id" [disabled]="userItem.id === user?.id"
(click)="onDeleteUser(userItem.id)" (click)="onDeleteUser(userItem.id)"
> >
Delete <ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -5,7 +5,7 @@
z-index: 999; z-index: 999;
.mat-toolbar { .mat-toolbar {
background-color: rgba(var(--light-disabled-text)); background-color: var(--light-background);
.spacer { .spacer {
flex: 1 1 auto; flex: 1 1 auto;
@ -27,6 +27,6 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
.mat-toolbar { .mat-toolbar {
background-color: rgba(39, 39, 39, $alpha-disabled-text); background-color: var(--dark-background);
} }
} }

View File

@ -10,7 +10,10 @@ import {
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import { primaryColorRgb } from '@ghostfolio/common/config'; import { primaryColorRgb } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper'; import {
parseDate,
transformTickToAbbreviation
} from '@ghostfolio/common/helper';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { import {
Chart, Chart,
@ -148,19 +151,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
display: false display: false
}, },
ticks: { ticks: {
display: true, callback: (value: number) => {
callback: (tickValue, index, ticks) => { return transformTickToAbbreviation(value);
if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry
if (typeof tickValue === 'number') {
return tickValue.toFixed(2);
}
return tickValue;
}
return '';
}, },
display: true,
mirror: true, mirror: true,
z: 1 z: 1
} }

View File

@ -8,7 +8,7 @@
<div> <div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin"> <ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="text-center"> <div class="text-center">
<a color="accent" href="/api/auth/google" mat-flat-button <a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon ><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Sign in with Google</span></a ><span i18n>Sign in with Google</span></a
> >

View File

@ -7,6 +7,10 @@ import {
OnInit, OnInit,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
import {
getNumberFormatDecimal,
getNumberFormatGroup
} from '@ghostfolio/common/helper';
import { import {
PortfolioPerformance, PortfolioPerformance,
ResponseError ResponseError
@ -50,13 +54,14 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
this.unit = this.baseCurrency; this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, { new CountUp('value', this.performance?.currentValue, {
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: decimalPlaces:
this.deviceType === 'mobile' && this.deviceType === 'mobile' &&
this.performance?.currentValue >= 100000 this.performance?.currentValue >= 100000
? 0 ? 0
: 2, : 2,
duration: 1, duration: 1,
separator: `'` separator: getNumberFormatGroup(this.locale)
}).start(); }).start();
} else if (this.performance?.currentValue === null) { } else if (this.performance?.currentValue === null) {
this.unit = '%'; this.unit = '%';
@ -65,9 +70,10 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
'value', 'value',
this.performance?.currentNetPerformancePercent * 100, this.performance?.currentNetPerformancePercent * 100,
{ {
decimal: getNumberFormatDecimal(this.locale),
decimalPlaces: 2, decimalPlaces: 2,
duration: 0.75, duration: 1,
separator: `'` separator: getNumberFormatGroup(this.locale)
} }
).start(); ).start();
} }

View File

@ -119,7 +119,7 @@
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="row px-3 py-1"> <div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Total</div> <div class="d-flex flex-grow-1" i18n>Total Assets</div>
<div class="d-flex flex-column flex-wrap justify-content-end"> <div class="d-flex flex-column flex-wrap justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"

View File

@ -211,14 +211,14 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
) )
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile( downloadAsFile({
data, content: data,
`ghostfolio-export-${this.SymbolProfile?.symbol}-${format( fileName: `ghostfolio-export-${this.SymbolProfile?.symbol}-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
)}.json`, )}.json`,
'text/plain' format: 'json'
); });
}); });
} }

View File

@ -21,7 +21,7 @@
<gf-line-chart <gf-line-chart
class="mb-4" class="mb-4"
benchmarkLabel="Buy Price" benchmarkLabel="Average Unit Price"
[benchmarkDataItems]="benchmarkDataItems" [benchmarkDataItems]="benchmarkDataItems"
[historicalDataItems]="historicalDataItems" [historicalDataItems]="historicalDataItems"
[showGradient]="true" [showGradient]="true"
@ -53,7 +53,7 @@
</div> </div>
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
label="Ø Buy Price" label="Average Unit Price"
size="medium" size="medium"
[currency]="SymbolProfile?.currency" [currency]="SymbolProfile?.currency"
[locale]="data.locale" [locale]="data.locale"
@ -111,6 +111,8 @@
<gf-value <gf-value
label="First Buy Date" label="First Buy Date"
size="medium" size="medium"
[isDate]="true"
[locale]="data.locale"
[value]="firstBuyDate" [value]="firstBuyDate"
></gf-value> ></gf-value>
</div> </div>

View File

@ -123,17 +123,6 @@
}" }"
></ngx-skeleton-loader> ></ngx-skeleton-loader>
<div
*ngIf="
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
"
class="p-3 text-center"
>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>
<div <div
*ngIf="dataSource.data.length > pageSize && !isLoading" *ngIf="dataSource.data.length > pageSize && !isLoading"
class="my-3 text-center" class="my-3 text-center"

View File

@ -27,7 +27,6 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit { export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale: string;
@Input() positions: PortfolioPosition[]; @Input() positions: PortfolioPosition[];

View File

@ -17,12 +17,14 @@ import { DataService } from '@ghostfolio/client/services/data.service';
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service'; import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { StatusCodes } from 'http-status-codes'; import { StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs'; import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators'; import { catchError, tap } from 'rxjs/operators';
@Injectable() @Injectable()
export class HttpResponseInterceptor implements HttpInterceptor { export class HttpResponseInterceptor implements HttpInterceptor {
public hasPermissionForSubscription: boolean;
public info: InfoItem; public info: InfoItem;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
@ -34,6 +36,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
private webAuthnService: WebAuthnService private webAuthnService: WebAuthnService
) { ) {
this.info = this.dataService.fetchInfo(); this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
} }
public intercept( public intercept(
@ -56,7 +63,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else { } else {
this.snackBarRef = this.snackBar.open( this.snackBarRef = this.snackBar.open(
'This feature requires a subscription.', 'This feature requires a subscription.',
'Upgrade Plan', this.hasPermissionForSubscription ? 'Upgrade Plan' : undefined,
{ duration: 6000 } { duration: 6000 }
); );
} }

View File

@ -109,38 +109,39 @@
<mat-card-content> <mat-card-content>
<div class="row"> <div class="row">
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers1d || '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 24 hours)</small subLabel="(Last 24 hours)"
> [value]="statistics?.activeUsers1d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.newUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="New Users"
<span i18n>New Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.newUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.activeUsers30d ?? '-' }}</h3> <gf-value
<div class="h6 mb-0"> label="Active Users"
<span i18n>Active Users</span>&nbsp;<small class="text-muted" size="large"
>(Last 30 days)</small subLabel="(Last 30 days)"
> [value]="statistics?.activeUsers30d ?? '-'"
</div> ></gf-value>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<a <a
class="d-block" class="d-block"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg" href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.slackCommunityUsers ?? '-' }} label="Users in Slack community"
</h3> size="large"
<div class="h6 mb-0" i18n>Users in Slack community</div> [value]="statistics?.slackCommunityUsers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -148,10 +149,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors" href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
> >
<h3 class="mb-0"> <gf-value
{{ statistics?.gitHubContributors ?? '-' }} label="Contributors on GitHub"
</h3> size="large"
<div class="h6 mb-0" i18n>Contributors on GitHub</div> [value]="statistics?.gitHubContributors ?? '-'"
></gf-value>
</a> </a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
@ -159,8 +161,11 @@
class="d-block" class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers" href="https://github.com/ghostfolio/ghostfolio/stargazers"
> >
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3> <gf-value
<div class="h6 mb-0" i18n>Stars on GitHub</div> label="Stars on GitHub"
size="large"
[value]="statistics?.gitHubStargazers ?? '-'"
></gf-value>
</a> </a>
</div> </div>
</div> </div>

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module'; import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component'; import { AboutPageComponent } from './about-page.component';
@ -12,6 +13,7 @@ import { AboutPageComponent } from './about-page.component';
imports: [ imports: [
AboutPageRoutingModule, AboutPageRoutingModule,
CommonModule, CommonModule,
GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule MatCardModule
], ],

View File

@ -20,9 +20,11 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service'; import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config'; import { baseCurrency } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces'; import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { uniq } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { StripeService } from 'ngx-stripe'; import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs'; import { EMPTY, Subject } from 'rxjs';
@ -45,13 +47,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public coupon: number; public coupon: number;
public couponId: string; public couponId: string;
public currencies: string[] = []; public currencies: string[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public deviceType: string; public deviceType: string;
public hasPermissionForSubscription: boolean; public hasPermissionForSubscription: boolean;
public hasPermissionToCreateAccess: boolean; public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean; public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean; public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean; public hasPermissionToUpdateUserSettings: boolean;
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
public price: number; public price: number;
public priceId: string; public priceId: string;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>; public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
@ -101,6 +104,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
this.hasPermissionToCreateAccess = hasPermission( this.hasPermissionToCreateAccess = hasPermission(
this.user.permissions, this.user.permissions,
permissions.createAccess permissions.createAccess
@ -121,6 +128,9 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.updateViewMode permissions.updateViewMode
); );
this.locales.push(this.user.settings.locale);
this.locales = uniq(this.locales.sort());
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });
@ -143,6 +153,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
this.update(); this.update();
} }
public onChangeUserSetting(aKey: string, aValue: string) {
this.dataService
.putUserSetting({ [aKey]: aValue })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onChangeUserSettings(aKey: string, aValue: string) { public onChangeUserSettings(aKey: string, aValue: string) {
const settings = { ...this.user.settings, [aKey]: aValue }; const settings = { ...this.user.settings, [aKey]: aValue };
@ -194,24 +222,6 @@ export class AccountPageComponent implements OnDestroy, OnInit {
}); });
} }
public onNewCalculationChange(aEvent: MatSlideToggleChange) {
this.dataService
.putUserSetting({ isNewCalculationEngine: aEvent.checked })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.userService.remove();
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
this.changeDetectorRef.markForCheck();
});
});
}
public onRedeemCoupon() { public onRedeemCoupon() {
let couponCode = prompt('Please enter your coupon code:'); let couponCode = prompt('Please enter your coupon code:');
couponCode = couponCode?.trim(); couponCode = couponCode?.trim();

View File

@ -27,9 +27,8 @@
Valid until {{ user?.subscription?.expiresAt | date: Valid until {{ user?.subscription?.expiresAt | date:
defaultDateFormat }} defaultDateFormat }}
</div> </div>
<div <div *ngIf="user?.subscription?.type === 'Basic'">
*ngIf="hasPermissionForSubscription && user?.subscription?.type === 'Basic'" <ng-container *ngIf="hasPermissionForSubscription">
>
<button <button
color="primary" color="primary"
i18n i18n
@ -39,14 +38,17 @@
Upgrade Upgrade
</button> </button>
<div *ngIf="price" class="mt-1"> <div *ngIf="price" class="mt-1">
{{ baseCurrency }}
<ng-container *ngIf="coupon" <ng-container *ngIf="coupon"
>{{ price - coupon | number : '1.2-2' }} ><del class="text-muted"
<del>{{ price }}</del> >{{ baseCurrency }}&nbsp;{{ price }}</del
</ng-container> >&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon
<ng-container *ngIf="!coupon">{{ price }}</ng-container> }}</ng-container
<span i18n> per year</span> >
<ng-container *ngIf="!coupon"
>{{ baseCurrency }}&nbsp;{{ price }}</ng-container
>&nbsp;<span i18n>per year</span>
</div> </div>
</ng-container>
<a <a
*ngIf="!user?.subscription?.expiresAt" *ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2" class="mr-2 my-2"
@ -109,14 +111,34 @@
</mat-form-field> </mat-form-field>
</div> </div>
</div> </div>
<div class="align-items-center d-flex mb-2">
<div class="pr-1 w-50">
<div i18n>Locale</div>
<div class="hint-text text-muted" i18n>
Date and number format
</div>
</div>
<div class="pl-1 w-50">
<mat-form-field appearance="outline" class="w-100">
<mat-select
name="locale"
[disabled]="!hasPermissionToUpdateUserSettings"
[value]="user.settings.locale"
(selectionChange)="onChangeUserSetting('locale', $event.value)"
>
<mat-option [value]="null"></mat-option>
<mat-option
*ngFor="let locale of locales"
[value]="locale"
>{{ locale }}</mat-option
>
</mat-select>
</mat-form-field>
</div>
</div>
<div class="d-flex"> <div class="d-flex">
<div class="align-items-center d-flex pr-1 pt-1 w-50" i18n> <div class="align-items-center d-flex pr-1 pt-1 w-50" i18n>
View Mode View Mode
<ion-icon
*ngIf="!hasPermissionToUpdateViewMode"
class="mx-1 text-muted"
name="diamond-outline"
></ion-icon>
</div> </div>
<div class="pl-1 w-50"> <div class="pl-1 w-50">
<div class="align-items-center d-flex overflow-hidden"> <div class="align-items-center d-flex overflow-hidden">
@ -147,23 +169,6 @@
></mat-slide-toggle> ></mat-slide-toggle>
</div> </div>
</div> </div>
<div
*ngIf="user?.subscription"
class="align-items-center d-flex mt-4 py-1"
>
<div class="pr-1 w-50">
<div i18n>New Calculation Engine</div>
<div class="hint-text text-muted" i18n>Experimental</div>
</div>
<div class="pl-1 w-50">
<mat-slide-toggle
color="primary"
[checked]="user.settings.isNewCalculationEngine"
[disabled]="!hasPermissionToUpdateUserSettings"
(change)="onNewCalculationChange($event)"
></mat-slide-toggle>
</div>
</div>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -10,6 +10,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router'; import { RouterModule } from '@angular/router';
import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module'; import { GfPortfolioAccessTableModule } from '@ghostfolio/client/components/access-table/access-table.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AccountPageRoutingModule } from './account-page-routing.module'; import { AccountPageRoutingModule } from './account-page-routing.module';
import { AccountPageComponent } from './account-page.component'; import { AccountPageComponent } from './account-page.component';
@ -24,6 +25,7 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
FormsModule, FormsModule,
GfCreateOrUpdateAccessDialogModule, GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule, GfPortfolioAccessTableModule,
GfValueModule,
MatButtonModule, MatButtonModule,
MatCardModule, MatCardModule,
MatDialogModule, MatDialogModule,

View File

@ -13,7 +13,6 @@ import {
UniqueAsset, UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types'; import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client'; import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -41,7 +40,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}; };
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public markets: { public markets: {
[key in Market]: { name: string; value: number }; [key in Market]: { name: string; value: number };
}; };
@ -139,11 +137,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });

View File

@ -30,33 +30,14 @@
<div class="col-md-4"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n <mat-card-title class="align-items-center d-flex text-truncate"
>By Asset Class</mat-card-title ><span i18n>By Currency</span
> ><ion-icon
<gf-toggle *ngIf="user?.subscription?.type === 'Basic'"
[defaultValue]="period" class="ml-1 text-muted"
[isLoading]="false" name="diamond-outline"
[options]="periodOptions" ></ion-icon
(change)="onChangePeriod($event.value)" ></mat-card-title>
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['assetClass', 'assetSubClass']"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n
>By Currency</mat-card-title
>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -75,10 +56,46 @@
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>
</div> </div>
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Asset Class</span
><ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
></mat-card-title>
<gf-toggle
[defaultValue]="period"
[isLoading]="false"
[options]="periodOptions"
(change)="onChangePeriod($event.value)"
></gf-toggle>
</mat-card-header>
<mat-card-content>
<gf-portfolio-proportion-chart
[baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
[keys]="['assetClass', 'assetSubClass']"
[locale]="user?.settings?.locale"
[positions]="positions"
></gf-portfolio-proportion-chart>
</mat-card-content>
</mat-card>
</div>
<div class="col-md-12 allocations-by-symbol"> <div class="col-md-12 allocations-by-symbol">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Symbol</mat-card-title> <mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Symbol</span
><ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
></mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -104,7 +121,14 @@
<div class="col-md-4"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Sector</mat-card-title> <mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Sector</span
><ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
></mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -127,9 +151,14 @@
<div class="col-md-4"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n <mat-card-title class="align-items-center d-flex text-truncate"
>By Continent</mat-card-title ><span i18n>By Continent</span
> ><ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
></mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -151,7 +180,14 @@
<div class="col-md-4"> <div class="col-md-4">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Country</mat-card-title> <mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Country</span
><ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
></mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -176,7 +212,14 @@
<div class="col-lg"> <div class="col-lg">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100"> <mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>Regions</mat-card-title> <mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>Regions</span
><ion-icon
*ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon
></mat-card-title>
<gf-toggle <gf-toggle
[defaultValue]="period" [defaultValue]="period"
[isLoading]="false" [isLoading]="false"
@ -225,7 +268,6 @@
<gf-positions-table <gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positionsArray" [positions]="positionsArray"
></gf-positions-table> ></gf-positions-table>

View File

@ -1,9 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import Big from 'big.js'; import Big from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -14,12 +14,12 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './fire-page.html' templateUrl: './fire-page.html'
}) })
export class FirePageComponent implements OnDestroy, OnInit { export class FirePageComponent implements OnDestroy, OnInit {
public fireWealth: number; public deviceType: string;
public hasImpersonationId: boolean; public fireWealth: Big;
public isLoading = false; public isLoading = false;
public user: User; public user: User;
public withdrawalRatePerMonth: number; public withdrawalRatePerMonth: Big;
public withdrawalRatePerYear: number; public withdrawalRatePerYear: Big;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -29,7 +29,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService, private deviceService: DeviceDetectorService,
private userService: UserService private userService: UserService
) {} ) {}
@ -38,13 +38,7 @@ export class FirePageComponent implements OnDestroy, OnInit {
*/ */
public ngOnInit() { public ngOnInit() {
this.isLoading = true; this.isLoading = true;
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.impersonationStorageService
.onChangeHasImpersonation()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((aId) => {
this.hasImpersonationId = !!aId;
});
this.dataService this.dataService
.fetchPortfolioSummary() .fetchPortfolioSummary()
@ -54,14 +48,9 @@ export class FirePageComponent implements OnDestroy, OnInit {
return; return;
} }
this.fireWealth = new Big(currentValue).plus(cash).toNumber(); this.fireWealth = new Big(currentValue);
this.withdrawalRatePerYear = new Big(this.fireWealth) this.withdrawalRatePerYear = this.fireWealth.mul(4).div(100);
.mul(4) this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
.div(100)
.toNumber();
this.withdrawalRatePerMonth = new Big(this.withdrawalRatePerYear)
.div(12)
.toNumber();
this.isLoading = false; this.isLoading = false;
@ -79,6 +68,13 @@ export class FirePageComponent implements OnDestroy, OnInit {
}); });
} }
public onSavingsRateChange(savingsRate: number) {
this.dataService
.putUserSetting({ savingsRate })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {});
}
public ngOnDestroy() { public ngOnDestroy() {
this.unsubscribeSubject.next(); this.unsubscribeSubject.next();
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();

View File

@ -2,7 +2,7 @@
<div class="row"> <div class="row">
<div class="col-lg"> <div class="col-lg">
<h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3> <h3 class="d-flex justify-content-center mb-3" i18n>FIRE</h3>
<div class="mb-4"> <div class="mb-5">
<h4 i18n>4% Rule</h4> <h4 i18n>4% Rule</h4>
<div *ngIf="isLoading"> <div *ngIf="isLoading">
<ngx-skeleton-loader <ngx-skeleton-loader
@ -27,7 +27,8 @@
><gf-value ><gf-value
class="d-inline-block" class="d-inline-block"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYear" [locale]="user?.settings?.locale"
[value]="withdrawalRatePerYear?.toNumber()"
></gf-value> ></gf-value>
per year</span per year</span
> >
@ -36,18 +37,31 @@
><gf-value ><gf-value
class="d-inline-block" class="d-inline-block"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonth" [locale]="user?.settings?.locale"
[value]="withdrawalRatePerMonth?.toNumber()"
></gf-value> ></gf-value>
per month</span per month</span
>, based on your net worth of >, based on your total assets of
<gf-value <gf-value
class="d-inline-block" class="d-inline-block"
[currency]="user?.settings?.baseCurrency" [currency]="user?.settings?.baseCurrency"
[value]="fireWealth" [locale]="user?.settings?.locale"
[value]="fireWealth?.toNumber()"
></gf-value> ></gf-value>
(excluding emergency fund) and a withdrawal rate of 4%. and a withdrawal rate of 4%.
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div>
<h4 class="mb-3" i18n>Calculator</h4>
<gf-fire-calculator
[currency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[fireWealth]="fireWealth?.toNumber()"
[locale]="user?.settings?.locale"
[savingsRate]="user?.settings?.savingsRate"
(savingsRateChanged)="onSavingsRateChange($event)"
></gf-fire-calculator>
</div>
</div> </div>

View File

@ -1,5 +1,6 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFireCalculatorModule } from '@ghostfolio/ui/fire-calculator';
import { GfValueModule } from '@ghostfolio/ui/value'; import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -11,6 +12,7 @@ import { FirePageComponent } from './fire-page.component';
imports: [ imports: [
CommonModule, CommonModule,
FirePageRoutingModule, FirePageRoutingModule,
GfFireCalculatorModule,
GfValueModule, GfValueModule,
NgxSkeletonLoaderModule NgxSkeletonLoaderModule
], ],

View File

@ -25,7 +25,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Allocations</span> <span i18n>Allocations</span>
<ion-icon <ion-icon
*ngIf="hasPermissionForSubscription" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"
name="diamond-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
@ -38,7 +38,6 @@
<a <a
color="primary" color="primary"
mat-button mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'allocations']" [routerLink]="['/portfolio', 'allocations']"
> >
<span i18n>Open Allocations</span> <span i18n>Open Allocations</span>
@ -52,7 +51,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>Analysis</span> <span i18n>Analysis</span>
<ion-icon <ion-icon
*ngIf="hasPermissionForSubscription" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"
name="diamond-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
@ -65,7 +64,6 @@
<a <a
color="primary" color="primary"
mat-button mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'analysis']" [routerLink]="['/portfolio', 'analysis']"
> >
<span i18n>Open Analysis</span> <span i18n>Open Analysis</span>
@ -79,7 +77,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>X-ray</span> <span i18n>X-ray</span>
<ion-icon <ion-icon
*ngIf="hasPermissionForSubscription" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"
name="diamond-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
@ -89,12 +87,7 @@
risks in your portfolio. risks in your portfolio.
</div> </div>
<div class="mt-2 text-right"> <div class="mt-2 text-right">
<a <a color="primary" mat-button [routerLink]="['/portfolio', 'report']">
color="primary"
mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'report']"
>
<span i18n>Open X-ray</span> <span i18n>Open X-ray</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon> <ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a> </a>
@ -106,7 +99,7 @@
<h4 class="align-items-center d-flex"> <h4 class="align-items-center d-flex">
<span i18n>FIRE</span> <span i18n>FIRE</span>
<ion-icon <ion-icon
*ngIf="hasPermissionForSubscription" *ngIf="user?.subscription?.type === 'Basic'"
class="ml-1 text-muted" class="ml-1 text-muted"
name="diamond-outline" name="diamond-outline"
></ion-icon> ></ion-icon>
@ -116,12 +109,7 @@
<i>Financial Independence, Retire Early</i> lifestyle. <i>Financial Independence, Retire Early</i> lifestyle.
</div> </div>
<div class="mt-2 text-right"> <div class="mt-2 text-right">
<a <a color="primary" mat-button [routerLink]="['/portfolio', 'fire']">
color="primary"
mat-button
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
[routerLink]="['/portfolio', 'fire']"
>
<span i18n>Open FIRE</span> <span i18n>Open FIRE</span>
<ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon> <ion-icon class="ml-1" name="arrow-forward-outline"></ion-icon>
</a> </a>

View File

@ -46,6 +46,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public filteredLookupItemsObservable: Observable<LookupItem[]>; public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false; public isLoading = false;
public platforms: { id: string; name: string }[]; public platforms: { id: string; name: string }[];
public total = 0;
public Validators = Validators; public Validators = Validators;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -89,6 +90,25 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
unitPrice: [this.data.activity?.unitPrice, Validators.required] unitPrice: [this.data.activity?.unitPrice, Validators.required]
}); });
this.activityForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
if (
this.activityForm.controls['type'].value === 'BUY' ||
this.activityForm.controls['type'].value === 'ITEM'
) {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value +
this.activityForm.controls['fee'].value ?? 0;
} else {
this.total =
this.activityForm.controls['quantity'].value *
this.activityForm.controls['unitPrice'].value -
this.activityForm.controls['fee'].value ?? 0;
}
});
this.filteredLookupItemsObservable = this.activityForm.controls[ this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol' 'searchSymbol'
].valueChanges.pipe( ].valueChanges.pipe(
@ -100,7 +120,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
const filteredLookupItemsObservable = const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query); this.dataService.fetchSymbols(query);
filteredLookupItemsObservable.subscribe((filteredLookupItems) => { filteredLookupItemsObservable
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems; this.filteredLookupItems = filteredLookupItems;
}); });
@ -111,7 +133,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
}) })
); );
this.activityForm.controls['type'].valueChanges.subscribe((type: Type) => { this.activityForm.controls['type'].valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((type: Type) => {
if (type === 'ITEM') { if (type === 'ITEM') {
this.activityForm.controls['accountId'].removeValidators( this.activityForm.controls['accountId'].removeValidators(
Validators.required Validators.required

View File

@ -138,9 +138,9 @@
<div class="d-flex" mat-dialog-actions> <div class="d-flex" mat-dialog-actions>
<gf-value <gf-value
class="flex-grow-1" class="flex-grow-1"
[currency]="activityForm.controls['currency'].value" [currency]="activityForm.controls['currency']?.value ?? data.user?.settings?.baseCurrency"
[locale]="data.user?.settings?.locale" [locale]="data.user?.settings?.locale"
[value]="activityForm.controls['fee'].value + (activityForm.controls['quantity'].value * activityForm.controls['unitPrice'].value) ?? 0" [value]="total"
></gf-value> ></gf-value>
<div> <div>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> <button i18n mat-button type="button" (click)="onCancel()">Cancel</button>

View File

@ -27,12 +27,12 @@ export class ImportTransactionDialog implements OnDestroy {
public ngOnInit() { public ngOnInit() {
for (const message of this.data.messages) { for (const message of this.data.messages) {
if (message.includes('orders.')) { if (message.includes('activities.')) {
let [index] = message.split(' '); let [index] = message.split(' ');
index = index.replace('orders.', ''); index = index.replace('activities.', '');
[index] = index.split('.'); [index] = index.split('.');
this.details.push(this.data.orders[index]); this.details.push(this.data.activities[index]);
} else { } else {
this.details.push(''); this.details.push('');
} }

View File

@ -1,5 +1,5 @@
export interface ImportTransactionDialogParams { export interface ImportTransactionDialogParams {
activities: any[];
deviceType: string; deviceType: string;
messages: string[]; messages: string[];
orders: any[];
} }

View File

@ -7,6 +7,7 @@ import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interf
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto'; import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service'; import { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -50,6 +51,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dataService: DataService, private dataService: DataService,
private deviceService: DeviceDetectorService, private deviceService: DeviceDetectorService,
private dialog: MatDialog, private dialog: MatDialog,
private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService, private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService, private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute, private route: ActivatedRoute,
@ -152,14 +154,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.fetchExport(activityIds) .fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => { .subscribe((data) => {
downloadAsFile( for (const activity of data.activities) {
data, delete activity.id;
`ghostfolio-export-${format( }
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date), parseISO(data.meta.date),
'yyyyMMddHHmm' 'yyyyMMddHHmm'
)}.json`, )}.json`,
'text/plain' format: 'json'
); });
});
}
public onExportDrafts(activityIds?: string[]) {
this.dataService
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile({
content: this.icsService.transformActivitiesToIcsContent(
data.activities
),
contentType: 'text/calendar',
fileName: `ghostfolio-draft${
data.activities.length > 1 ? 's' : ''
}-${format(parseISO(data.meta.date), 'yyyyMMddHHmmss')}.ics`,
format: 'string'
});
}); });
} }
@ -185,19 +209,31 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
if (file.name.endsWith('.json')) { if (file.name.endsWith('.json')) {
const content = JSON.parse(fileContent); const content = JSON.parse(fileContent);
if (!isArray(content.orders)) { if (!isArray(content.activities)) {
if (isArray(content.orders)) {
this.handleImportError({
activities: [],
error: {
error: {
message: [`orders needs to be renamed to activities`]
}
}
});
return;
} else {
throw new Error(); throw new Error();
} }
}
try { try {
await this.importTransactionsService.importJson({ await this.importTransactionsService.importJson({
content: content.orders content: content.activities
}); });
this.handleImportSuccess(); this.handleImportSuccess();
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ error, orders: content.orders }); this.handleImportError({ error, activities: content.activities });
} }
return; return;
@ -212,10 +248,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ this.handleImportError({
activities: error?.activities ?? [],
error: { error: {
error: { message: error?.error?.message ?? [error?.message] } error: { message: error?.error?.message ?? [error?.message] }
}, }
orders: error?.orders ?? []
}); });
} }
@ -226,8 +262,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} catch (error) { } catch (error) {
console.error(error); console.error(error);
this.handleImportError({ this.handleImportError({
error: { error: { message: ['Unexpected format'] } }, activities: [],
orders: [] error: { error: { message: ['Unexpected format'] } }
}); });
} }
}; };
@ -281,12 +317,18 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete(); this.unsubscribeSubject.complete();
} }
private handleImportError({ error, orders }: { error: any; orders: any[] }) { private handleImportError({
activities,
error
}: {
activities: any[];
error: any;
}) {
this.snackBar.dismiss(); this.snackBar.dismiss();
this.dialog.open(ImportTransactionDialog, { this.dialog.open(ImportTransactionDialog, {
data: { data: {
orders, activities,
deviceType: this.deviceType, deviceType: this.deviceType,
messages: error?.error?.message messages: error?.error?.message
}, },

View File

@ -15,6 +15,7 @@
(activityToClone)="onCloneTransaction($event)" (activityToClone)="onCloneTransaction($event)"
(activityToUpdate)="onUpdateTransaction($event)" (activityToUpdate)="onUpdateTransaction($event)"
(export)="onExport($event)" (export)="onExport($event)"
(exportDrafts)="onExportDrafts($event)"
(import)="onImport()" (import)="onImport()"
></gf-activities-table> ></gf-activities-table>
</div> </div>

View File

@ -178,16 +178,19 @@
</div> </div>
<p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p> <p>Fully managed <strong>Ghostfolio</strong> cloud offering.</p>
<p class="h5 text-right" [hidden]="!price"> <p class="h5 text-right" [hidden]="!price">
<span class="font-weight-normal" <span class="font-weight-normal">
>{{ baseCurrency }}
<ng-container *ngIf="coupon" <ng-container *ngIf="coupon"
><strong>{{ price - coupon | number : '1.2-2' }} </strong> ><del class="text-muted"
<del>{{ price }}</del> >{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;<strong
>{{ price - coupon }}</strong
>
</ng-container> </ng-container>
<ng-container *ngIf="!coupon" <ng-container *ngIf="!coupon"
><strong>{{ price }}</strong></ng-container >{{ baseCurrency }}&nbsp;<strong
> >{{ price }}</strong
<span i18n> per year</span></span ></ng-container
>&nbsp;<span i18n>per year</span></span
> >
</p> </p>
</mat-card> </mat-card>

View File

@ -35,7 +35,7 @@
> >
or or
</div> </div>
<a color="accent" href="/api/auth/google" mat-flat-button <a color="accent" href="/api/v1/auth/google" mat-flat-button
><ion-icon class="mr-1" name="logo-google"></ion-icon ><ion-icon class="mr-1" name="logo-google"></ion-icon
><span i18n>Continue with Google</span></a ><span i18n>Continue with Google</span></a
> >

View File

@ -1,18 +1,35 @@
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Resources</h3> <h1 class="d-flex h3 justify-content-center mb-3" i18n>Resources</h1>
<mat-card class="mb-3"> <h2 class="h4 mb-3">Guides</h2>
<mat-card-content>
<h4 class="mb-3">Market</h4>
<div class="mb-5"> <div class="mb-5">
<div class="mb-4 media"> <div class="mb-4 media">
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Fear & Greed Index</h5> <h3 class="h5 mt-0">Boringly Getting Rich</h3>
<div class="mb-1">
The <i>Boringly Getting Rich</i> guide supports you to get started
with investing. It introduces a strategy utilizing a broadly
diversified, low-cost portfolio excluding the risks of individual
stocks.
</div>
<div>
<a href="https://herget.me/investing-guide" target="_blank"
>Boringly Getting Rich →</a
>
</div>
</div>
</div>
</div>
<h2 class="h4 mb-3">Market</h2>
<div class="mb-5">
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Fear & Greed Index</h3>
<div class="mb-1"> <div class="mb-1">
The fear and greed index was developed by <i>CNNMoney</i> to The fear and greed index was developed by <i>CNNMoney</i> to
measure the primary emotions (fear and greed) that influence measure the primary emotions (fear and greed) that influence how
how much investors are willing to pay for stocks. much investors are willing to pay for stocks.
</div> </div>
<div> <div>
<a <a
@ -25,12 +42,12 @@
</div> </div>
<div class="media"> <div class="media">
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Inflation Chart</h5> <h3 class="h5 mt-0">Inflation Chart</h3>
<div class="mb-1"> <div class="mb-1">
Inflation Chart helps you find the intrinsic value of stock Inflation Chart helps you find the intrinsic value of stock
markets, stock prices, goods and services by adjusting them to markets, stock prices, goods and services by adjusting them to the
the amount of the money supply (M0, M1, M2) or price of other amount of the money supply (M0, M1, M2) or price of other goods
goods (food or oil). (food or oil).
</div> </div>
<div> <div>
<a href="https://inflationchart.com" target="_blank" <a href="https://inflationchart.com" target="_blank"
@ -40,16 +57,15 @@
</div> </div>
</div> </div>
</div> </div>
<h4 class="mb-3">Glossary</h4> <h2 class="h4 mb-3">Glossary</h2>
<div> <div>
<div class="mb-4 media"> <div class="mb-4 media">
<!--<img src="" class="mr-3" />-->
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Buy and Hold</h5> <h3 class="h5 mt-0">Buy and Hold</h3>
<div class="mb-1"> <div class="mb-1">
Buy and hold is a passive investment strategy where you buy Buy and hold is a passive investment strategy where you buy assets
assets and hold them for a long period regardless of and hold them for a long period regardless of fluctuations in the
fluctuations in the market. market.
</div> </div>
<div> <div>
<a <a
@ -61,14 +77,13 @@
</div> </div>
</div> </div>
<div class="mb-4 media"> <div class="mb-4 media">
<!--<img src="" class="mr-3" />-->
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Dollar-Cost Averaging (DCA)</h5> <h3 class="h5 mt-0">Dollar-Cost Averaging (DCA)</h3>
<div class="mb-1"> <div class="mb-1">
Dollar-cost averaging is an investment strategy where you Dollar-cost averaging is an investment strategy where you split
split the total amount to be invested across periodic the total amount to be invested across periodic purchases of a
purchases of a target asset to reduce the impact of volatility target asset to reduce the impact of volatility on the overall
on the overall purchase. purchase.
</div> </div>
<div> <div>
<a <a
@ -80,13 +95,12 @@
</div> </div>
</div> </div>
<div class="media"> <div class="media">
<!--<img src="" class="mr-3" />-->
<div class="media-body"> <div class="media-body">
<h5 class="mt-0">Financial Independence</h5> <h3 class="h5 mt-0">Financial Independence</h3>
<div class="mb-1"> <div class="mb-1">
Financial independence is the status of having enough income, Financial independence is the status of having enough income, for
for example with a passive income like dividends, to cover example with a passive income like dividends, to cover your living
your living expenses for the rest of your life. expenses for the rest of your life.
</div> </div>
<div> <div>
<a <a
@ -98,8 +112,6 @@
</div> </div>
</div> </div>
</div> </div>
</mat-card-content>
</mat-card>
</div> </div>
</div> </div>
</div> </div>

View File

@ -2,7 +2,6 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
.mat-card {
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
font-weight: 500; font-weight: 500;
@ -11,7 +10,6 @@
color: rgba(var(--palette-primary-300), 1); color: rgba(var(--palette-primary-300), 1);
} }
} }
}
} }
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {

View File

@ -19,7 +19,7 @@ export class AdminService {
public deleteProfileData({ dataSource, symbol }: UniqueAsset) { public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
return this.http.delete<void>( return this.http.delete<void>(
`/api/admin/profile-data/${dataSource}/${symbol}` `/api/v1/admin/profile-data/${dataSource}/${symbol}`
); );
} }
@ -31,7 +31,7 @@ export class AdminService {
symbol: string; symbol: string;
}): Observable<AdminMarketDataDetails> { }): Observable<AdminMarketDataDetails> {
return this.http return this.http
.get<any>(`/api/admin/market-data/${dataSource}/${symbol}`) .get<any>(`/api/v1/admin/market-data/${dataSource}/${symbol}`)
.pipe( .pipe(
map((data) => { map((data) => {
for (const item of data.marketData) { for (const item of data.marketData) {
@ -43,16 +43,16 @@ export class AdminService {
} }
public gatherMax() { public gatherMax() {
return this.http.post<void>(`/api/admin/gather/max`, {}); return this.http.post<void>(`/api/v1/admin/gather/max`, {});
} }
public gatherProfileData() { public gatherProfileData() {
return this.http.post<void>(`/api/admin/gather/profile-data`, {}); return this.http.post<void>(`/api/v1/admin/gather/profile-data`, {});
} }
public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) { public gatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
return this.http.post<void>( return this.http.post<void>(
`/api/admin/gather/profile-data/${dataSource}/${symbol}`, `/api/v1/admin/gather/profile-data/${dataSource}/${symbol}`,
{} {}
); );
} }
@ -64,7 +64,7 @@ export class AdminService {
}: UniqueAsset & { }: UniqueAsset & {
date?: Date; date?: Date;
}) { }) {
let url = `/api/admin/gather/${dataSource}/${symbol}`; let url = `/api/v1/admin/gather/${dataSource}/${symbol}`;
if (date) { if (date) {
url = `${url}/${format(date, DATE_FORMAT)}`; url = `${url}/${format(date, DATE_FORMAT)}`;
@ -82,7 +82,7 @@ export class AdminService {
date: Date; date: Date;
symbol: string; symbol: string;
}) { }) {
const url = `/api/symbol/${dataSource}/${symbol}/${format( const url = `/api/v1/symbol/${dataSource}/${symbol}/${format(
date, date,
DATE_FORMAT DATE_FORMAT
)}`; )}`;
@ -101,7 +101,7 @@ export class AdminService {
marketData: UpdateMarketDataDto; marketData: UpdateMarketDataDto;
symbol: string; symbol: string;
}) { }) {
const url = `/api/admin/market-data/${dataSource}/${symbol}/${format( const url = `/api/v1/admin/market-data/${dataSource}/${symbol}/${format(
date, date,
DATE_FORMAT DATE_FORMAT
)}`; )}`;

View File

@ -8,6 +8,6 @@ export class CacheService {
public constructor(private http: HttpClient) {} public constructor(private http: HttpClient) {}
public flush() { public flush() {
return this.http.post<any>(`/api/cache/flush`, {}); return this.http.post<any>(`/api/v1/cache/flush`, {});
} }
} }

View File

@ -23,12 +23,10 @@ import {
PortfolioChart, PortfolioChart,
PortfolioDetails, PortfolioDetails,
PortfolioInvestments, PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse, PortfolioPerformanceResponse,
PortfolioPublicDetails, PortfolioPublicDetails,
PortfolioReport, PortfolioReport,
PortfolioSummary, PortfolioSummary,
UniqueAsset,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -52,46 +50,46 @@ export class DataService {
couponId?: string; couponId?: string;
priceId: string; priceId: string;
}) { }) {
return this.http.post('/api/subscription/stripe/checkout-session', { return this.http.post('/api/v1/subscription/stripe/checkout-session', {
couponId, couponId,
priceId priceId
}); });
} }
public fetchAccounts() { public fetchAccounts() {
return this.http.get<Accounts>('/api/account'); return this.http.get<Accounts>('/api/v1/account');
} }
public fetchAdminData() { public fetchAdminData() {
return this.http.get<AdminData>('/api/admin'); return this.http.get<AdminData>('/api/v1/admin');
} }
public fetchAdminMarketData() { public fetchAdminMarketData() {
return this.http.get<AdminMarketData>('/api/admin/market-data'); return this.http.get<AdminMarketData>('/api/v1/admin/market-data');
} }
public deleteAccess(aId: string) { public deleteAccess(aId: string) {
return this.http.delete<any>(`/api/access/${aId}`); return this.http.delete<any>(`/api/v1/access/${aId}`);
} }
public deleteAccount(aId: string) { public deleteAccount(aId: string) {
return this.http.delete<any>(`/api/account/${aId}`); return this.http.delete<any>(`/api/v1/account/${aId}`);
} }
public deleteOrder(aId: string) { public deleteOrder(aId: string) {
return this.http.delete<any>(`/api/order/${aId}`); return this.http.delete<any>(`/api/v1/order/${aId}`);
} }
public deleteUser(aId: string) { public deleteUser(aId: string) {
return this.http.delete<any>(`/api/user/${aId}`); return this.http.delete<any>(`/api/v1/user/${aId}`);
} }
public fetchAccesses() { public fetchAccesses() {
return this.http.get<Access[]>('/api/access'); return this.http.get<Access[]>('/api/v1/access');
} }
public fetchChart({ range }: { range: DateRange }) { public fetchChart({ range }: { range: DateRange }) {
return this.http.get<PortfolioChart>('/api/portfolio/chart', { return this.http.get<PortfolioChart>('/api/v1/portfolio/chart', {
params: { range } params: { range }
}); });
} }
@ -103,7 +101,7 @@ export class DataService {
params = params.append('activityIds', activityIds.join(',')); params = params.append('activityIds', activityIds.join(','));
} }
return this.http.get<Export>('/api/export', { return this.http.get<Export>('/api/v1/export', {
params params
}); });
} }
@ -121,7 +119,7 @@ export class DataService {
} }
public fetchInvestments(): Observable<PortfolioInvestments> { public fetchInvestments(): Observable<PortfolioInvestments> {
return this.http.get<any>('/api/portfolio/investments').pipe( return this.http.get<any>('/api/v1/portfolio/investments').pipe(
map((response) => { map((response) => {
if (response.firstOrderDate) { if (response.firstOrderDate) {
response.firstOrderDate = parseISO(response.firstOrderDate); response.firstOrderDate = parseISO(response.firstOrderDate);
@ -147,7 +145,7 @@ export class DataService {
params = params.append('includeHistoricalData', includeHistoricalData); params = params.append('includeHistoricalData', includeHistoricalData);
} }
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`, { return this.http.get<SymbolItem>(`/api/v1/symbol/${dataSource}/${symbol}`, {
params params
}); });
} }
@ -157,14 +155,14 @@ export class DataService {
}: { }: {
range: DateRange; range: DateRange;
}): Observable<PortfolioPositions> { }): Observable<PortfolioPositions> {
return this.http.get<PortfolioPositions>('/api/portfolio/positions', { return this.http.get<PortfolioPositions>('/api/v1/portfolio/positions', {
params: { range } params: { range }
}); });
} }
public fetchSymbols(aQuery: string) { public fetchSymbols(aQuery: string) {
return this.http return this.http
.get<{ items: LookupItem[] }>(`/api/symbol/lookup?query=${aQuery}`) .get<{ items: LookupItem[] }>(`/api/v1/symbol/lookup?query=${aQuery}`)
.pipe( .pipe(
map((respose) => { map((respose) => {
return respose.items; return respose.items;
@ -173,7 +171,7 @@ export class DataService {
} }
public fetchOrders(): Observable<Activities> { public fetchOrders(): Observable<Activities> {
return this.http.get<any>('/api/order').pipe( return this.http.get<any>('/api/v1/order').pipe(
map(({ activities }) => { map(({ activities }) => {
for (const activity of activities) { for (const activity of activities) {
activity.createdAt = parseISO(activity.createdAt); activity.createdAt = parseISO(activity.createdAt);
@ -185,14 +183,14 @@ export class DataService {
} }
public fetchPortfolioDetails(aParams: { [param: string]: any }) { public fetchPortfolioDetails(aParams: { [param: string]: any }) {
return this.http.get<PortfolioDetails>('/api/portfolio/details', { return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
params: aParams params: aParams
}); });
} }
public fetchPortfolioPerformance(params: { [param: string]: any }) { public fetchPortfolioPerformance(params: { [param: string]: any }) {
return this.http.get<PortfolioPerformanceResponse>( return this.http.get<PortfolioPerformanceResponse>(
'/api/portfolio/performance', '/api/v1/portfolio/performance',
{ {
params params
} }
@ -201,16 +199,16 @@ export class DataService {
public fetchPortfolioPublic(aId: string) { public fetchPortfolioPublic(aId: string) {
return this.http.get<PortfolioPublicDetails>( return this.http.get<PortfolioPublicDetails>(
`/api/portfolio/public/${aId}` `/api/v1/portfolio/public/${aId}`
); );
} }
public fetchPortfolioReport() { public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/portfolio/report'); return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
} }
public fetchPortfolioSummary(): Observable<PortfolioSummary> { public fetchPortfolioSummary(): Observable<PortfolioSummary> {
return this.http.get<any>('/api/portfolio/summary').pipe( return this.http.get<any>('/api/v1/portfolio/summary').pipe(
map((summary) => { map((summary) => {
if (summary.firstOrderDate) { if (summary.firstOrderDate) {
summary.firstOrderDate = parseISO(summary.firstOrderDate); summary.firstOrderDate = parseISO(summary.firstOrderDate);
@ -229,7 +227,7 @@ export class DataService {
symbol: string; symbol: string;
}) { }) {
return this.http return this.http
.get<any>(`/api/portfolio/position/${dataSource}/${symbol}`) .get<any>(`/api/v1/portfolio/position/${dataSource}/${symbol}`)
.pipe( .pipe(
map((data) => { map((data) => {
if (data.orders) { if (data.orders) {
@ -245,47 +243,47 @@ export class DataService {
} }
public loginAnonymous(accessToken: string) { public loginAnonymous(accessToken: string) {
return this.http.get<any>(`/api/auth/anonymous/${accessToken}`); return this.http.get<any>(`/api/v1/auth/anonymous/${accessToken}`);
} }
public postAccess(aAccess: CreateAccessDto) { public postAccess(aAccess: CreateAccessDto) {
return this.http.post<OrderModel>(`/api/access`, aAccess); return this.http.post<OrderModel>(`/api/v1/access`, aAccess);
} }
public postAccount(aAccount: CreateAccountDto) { public postAccount(aAccount: CreateAccountDto) {
return this.http.post<OrderModel>(`/api/account`, aAccount); return this.http.post<OrderModel>(`/api/v1/account`, aAccount);
} }
public postOrder(aOrder: CreateOrderDto) { public postOrder(aOrder: CreateOrderDto) {
return this.http.post<OrderModel>(`/api/order`, aOrder); return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
} }
public postUser() { public postUser() {
return this.http.post<UserItem>(`/api/user`, {}); return this.http.post<UserItem>(`/api/v1/user`, {});
} }
public putAccount(aAccount: UpdateAccountDto) { public putAccount(aAccount: UpdateAccountDto) {
return this.http.put<UserItem>(`/api/account/${aAccount.id}`, aAccount); return this.http.put<UserItem>(`/api/v1/account/${aAccount.id}`, aAccount);
} }
public putAdminSetting(key: string, aData: PropertyDto) { public putAdminSetting(key: string, aData: PropertyDto) {
return this.http.put<void>(`/api/admin/settings/${key}`, aData); return this.http.put<void>(`/api/v1/admin/settings/${key}`, aData);
} }
public putOrder(aOrder: UpdateOrderDto) { public putOrder(aOrder: UpdateOrderDto) {
return this.http.put<UserItem>(`/api/order/${aOrder.id}`, aOrder); return this.http.put<UserItem>(`/api/v1/order/${aOrder.id}`, aOrder);
} }
public putUserSetting(aData: UpdateUserSettingDto) { public putUserSetting(aData: UpdateUserSettingDto) {
return this.http.put<User>(`/api/user/setting`, aData); return this.http.put<User>(`/api/v1/user/setting`, aData);
} }
public putUserSettings(aData: UpdateUserSettingsDto) { public putUserSettings(aData: UpdateUserSettingsDto) {
return this.http.put<User>(`/api/user/settings`, aData); return this.http.put<User>(`/api/v1/user/settings`, aData);
} }
public redeemCoupon(couponCode: string) { public redeemCoupon(couponCode: string) {
return this.http.post('/api/subscription/redeem-coupon', { return this.http.post('/api/v1/subscription/redeem-coupon', {
couponCode couponCode
}); });
} }

View File

@ -0,0 +1,59 @@
import { Injectable } from '@angular/core';
import { capitalize } from '@ghostfolio/common/helper';
import { Export } from '@ghostfolio/common/interfaces';
import { Type } from '@prisma/client';
import { format, parseISO } from 'date-fns';
@Injectable({
providedIn: 'root'
})
export class IcsService {
private readonly ICS_DATE_FORMAT = 'yyyyMMdd';
private readonly ICS_LINE_BREAK = '\r\n';
public constructor() {}
public transformActivitiesToIcsContent(
aActivities: Export['activities']
): string {
const header = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Ghostfolio//NONSGML v1.0//EN'
];
const events = aActivities.map((activity) => {
return this.getEvent({
date: parseISO(activity.date),
id: activity.id,
symbol: activity.symbol,
type: activity.type
});
});
const footer = ['END:VCALENDAR'];
return [...header, ...events, ...footer].join(this.ICS_LINE_BREAK);
}
private getEvent({
date,
id,
symbol,
type
}: {
date: Date;
id: string;
symbol: string;
type: Type;
}) {
const today = format(new Date(), this.ICS_DATE_FORMAT);
return [
'BEGIN:VEVENT',
`UID:${id}`,
`DTSTAMP:${today}T000000`,
`DTSTART;VALUE=DATE:${format(date, this.ICS_DATE_FORMAT)}`,
`SUMMARY:${capitalize(type)} ${symbol}`,
'END:VEVENT'
].join(this.ICS_LINE_BREAK);
}
}

View File

@ -37,9 +37,9 @@ export class ImportTransactionsService {
skipEmptyLines: true skipEmptyLines: true
}).data; }).data;
const orders: CreateOrderDto[] = []; const activities: CreateOrderDto[] = [];
for (const [index, item] of content.entries()) { for (const [index, item] of content.entries()) {
orders.push({ activities.push({
accountId: this.parseAccount({ item, userAccounts }), accountId: this.parseAccount({ item, userAccounts }),
currency: this.parseCurrency({ content, index, item }), currency: this.parseCurrency({ content, index, item }),
dataSource: this.parseDataSource({ item }), dataSource: this.parseDataSource({ item }),
@ -52,13 +52,13 @@ export class ImportTransactionsService {
}); });
} }
await this.importJson({ content: orders }); await this.importJson({ content: activities });
} }
public importJson({ content }: { content: CreateOrderDto[] }): Promise<void> { public importJson({ content }: { content: CreateOrderDto[] }): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.postImport({ this.postImport({
orders: content activities: content
}) })
.pipe( .pipe(
catchError((error) => { catchError((error) => {
@ -121,7 +121,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.currency is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.currency is not valid`
};
} }
private parseDataSource({ item }: { item: any }) { private parseDataSource({ item }: { item: any }) {
@ -164,7 +167,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.date is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.date is not valid`
};
} }
private parseFee({ private parseFee({
@ -184,7 +190,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.fee is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.fee is not valid`
};
} }
private parseQuantity({ private parseQuantity({
@ -204,7 +213,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.quantity is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.quantity is not valid`
};
} }
private parseSymbol({ private parseSymbol({
@ -224,7 +236,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.symbol is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.symbol is not valid`
};
} }
private parseType({ private parseType({
@ -255,7 +270,10 @@ export class ImportTransactionsService {
} }
} }
throw { message: `orders.${index}.type is not valid`, orders: content }; throw {
activities: content,
message: `activities.${index}.type is not valid`
};
} }
private parseUnitPrice({ private parseUnitPrice({
@ -276,12 +294,12 @@ export class ImportTransactionsService {
} }
throw { throw {
message: `orders.${index}.unitPrice is not valid`, activities: content,
orders: content message: `activities.${index}.unitPrice is not valid`
}; };
} }
private postImport(aImportData: { orders: CreateOrderDto[] }) { private postImport(aImportData: { activities: CreateOrderDto[] }) {
return this.http.post<void>('/api/import', aImportData); return this.http.post<void>('/api/v1/import', aImportData);
} }
} }

View File

@ -36,7 +36,7 @@ export class UserService extends ObservableStore<UserStoreState> {
} }
private fetchUser() { private fetchUser() {
return this.http.get<User>('/api/user').pipe( return this.http.get<User>('/api/v1/user').pipe(
map((user) => { map((user) => {
this.setState({ user }, UserStoreActions.GetUser); this.setState({ user }, UserStoreActions.GetUser);
return user; return user;

View File

@ -35,7 +35,7 @@ export class WebAuthnService {
public register() { public register() {
return this.http return this.http
.get<PublicKeyCredentialCreationOptionsJSON>( .get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-registration-options`, `/api/v1/auth/webauthn/generate-registration-options`,
{} {}
) )
.pipe( .pipe(
@ -48,7 +48,7 @@ export class WebAuthnService {
}), }),
switchMap((attResp) => { switchMap((attResp) => {
return this.http.post<AuthDeviceDto>( return this.http.post<AuthDeviceDto>(
`/api/auth/webauthn/verify-attestation`, `/api/v1/auth/webauthn/verify-attestation`,
{ {
credential: attResp credential: attResp
} }
@ -65,7 +65,9 @@ export class WebAuthnService {
public deregister() { public deregister() {
const deviceId = this.getDeviceId(); const deviceId = this.getDeviceId();
return this.http.delete<AuthDeviceDto>(`/api/auth-device/${deviceId}`).pipe( return this.http
.delete<AuthDeviceDto>(`/api/v1/auth-device/${deviceId}`)
.pipe(
catchError((error) => { catchError((error) => {
console.warn(`Could not deregister device ${deviceId}`, error); console.warn(`Could not deregister device ${deviceId}`, error);
return of(null); return of(null);
@ -82,14 +84,14 @@ export class WebAuthnService {
const deviceId = this.getDeviceId(); const deviceId = this.getDeviceId();
return this.http return this.http
.post<PublicKeyCredentialRequestOptionsJSON>( .post<PublicKeyCredentialRequestOptionsJSON>(
`/api/auth/webauthn/generate-assertion-options`, `/api/v1/auth/webauthn/generate-assertion-options`,
{ deviceId } { deviceId }
) )
.pipe( .pipe(
switchMap(startAuthentication), switchMap(startAuthentication),
switchMap((assertionResponse) => { switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>( return this.http.post<{ authToken: string }>(
`/api/auth/webauthn/verify-assertion`, `/api/v1/auth/webauthn/verify-assertion`,
{ {
credential: assertionResponse, credential: assertionResponse,
deviceId deviceId

View File

@ -1,6 +1,7 @@
import { enableProdMode } from '@angular/core'; import { enableProdMode } from '@angular/core';
import { LOCALE_ID } from '@angular/core'; import { LOCALE_ID } from '@angular/core';
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { locale } from '@ghostfolio/common/config';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions'; import { permissions } from '@ghostfolio/common/permissions';
@ -8,7 +9,7 @@ import { AppModule } from './app/app.module';
import { environment } from './environments/environment'; import { environment } from './environments/environment';
(async () => { (async () => {
const response = await fetch('/api/info'); const response = await fetch('/api/v1/info');
const info: InfoItem = await response.json(); const info: InfoItem = await response.json();
if (window.localStorage.getItem('utm_source') === 'trusted-web-activity') { if (window.localStorage.getItem('utm_source') === 'trusted-web-activity') {
@ -27,7 +28,7 @@ import { environment } from './environments/environment';
platformBrowserDynamic() platformBrowserDynamic()
.bootstrapModule(AppModule, { .bootstrapModule(AppModule, {
providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }] providers: [{ provide: LOCALE_ID, useValue: locale }]
}) })
.catch((error) => console.error(error)); .catch((error) => console.error(error));
})(); })();

View File

@ -1,7 +1,7 @@
version: '3.7' version: '3.7'
services: services:
ghostfolio: ghostfolio:
image: ghostfolio/ghostfolio image: ghostfolio/ghostfolio:latest
env_file: env_file:
- ../.env - ../.env
environment: environment:

View File

@ -19,7 +19,7 @@ export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexDataSource = DataSource.RAKUTEN; export const ghostfolioFearAndGreedIndexDataSource = DataSource.RAKUTEN;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`; export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const locale = 'de-CH'; export const locale = 'en-US';
export const primaryColorHex = '#36cfcc'; export const primaryColorHex = '#36cfcc';
export const primaryColorRgb = { export const primaryColorRgb = {
@ -44,7 +44,6 @@ export const warnColorRgb = {
export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND'; export const ASSET_SUB_CLASS_EMERGENCY_FUND = 'EMERGENCY_FUND';
export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const PROPERTY_COUPONS = 'COUPONS'; export const PROPERTY_COUPONS = 'COUPONS';

View File

@ -2,7 +2,7 @@ import * as currencies from '@dinero.js/currencies';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns'; import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { ghostfolioScraperApiSymbolPrefix } from './config'; import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
export function capitalize(aString: string) { export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase(); return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
@ -12,17 +12,28 @@ export function decodeDataSource(encodedDataSource: string) {
return Buffer.from(encodedDataSource, 'hex').toString(); return Buffer.from(encodedDataSource, 'hex').toString();
} }
export function downloadAsFile( export function downloadAsFile({
aContent: unknown, content,
aFileName: string, contentType = 'text/plain',
aContentType: string fileName,
) { format
}: {
content: unknown;
contentType?: string;
fileName: string;
format: 'json' | 'string';
}) {
const a = document.createElement('a'); const a = document.createElement('a');
const file = new Blob([JSON.stringify(aContent, undefined, ' ')], {
type: aContentType if (format === 'json') {
content = JSON.stringify(content, undefined, ' ');
}
const file = new Blob([<string>content], {
type: contentType
}); });
a.href = URL.createObjectURL(file); a.href = URL.createObjectURL(file);
a.download = aFileName; a.download = fileName;
a.click(); a.click();
} }
@ -44,6 +55,49 @@ export function getCssVariable(aCssVariable: string) {
); );
} }
export function getDateFormatString(aLocale?: string) {
const formatObject = new Intl.DateTimeFormat(aLocale).formatToParts(
new Date()
);
return formatObject
.map((object) => {
switch (object.type) {
case 'day':
return 'dd';
case 'month':
return 'MM';
case 'year':
return 'yyyy';
default:
return object.value;
}
})
.join('');
}
export function getLocale() {
return navigator.languages?.length
? navigator.languages[0]
: navigator.language ?? locale;
}
export function getNumberFormatDecimal(aLocale?: string) {
const formatObject = new Intl.NumberFormat(aLocale).formatToParts(9999.99);
return formatObject.find((object) => {
return object.type === 'decimal';
}).value;
}
export function getNumberFormatGroup(aLocale?: string) {
const formatObject = new Intl.NumberFormat(aLocale).formatToParts(9999.99);
return formatObject.find((object) => {
return object.type === 'group';
}).value;
}
export function getTextColor() { export function getTextColor() {
const cssVariable = getCssVariable( const cssVariable = getCssVariable(
window.matchMedia('(prefers-color-scheme: dark)').matches window.matchMedia('(prefers-color-scheme: dark)').matches
@ -133,3 +187,7 @@ export function parseDate(date: string) {
export function prettifySymbol(aSymbol: string): string { export function prettifySymbol(aSymbol: string): string {
return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, ''); return aSymbol?.replace(ghostfolioScraperApiSymbolPrefix, '');
} }
export function transformTickToAbbreviation(value: number) {
return value < 1000000 ? `${value / 1000}K` : `${value / 1000000}M`;
}

View File

@ -5,5 +5,14 @@ export interface Export {
date: string; date: string;
version: string; version: string;
}; };
orders: Partial<Order>[]; activities: (Omit<
Order,
| 'accountUserId'
| 'createdAt'
| 'date'
| 'isDraft'
| 'symbolProfileId'
| 'updatedAt'
| 'userId'
> & { date: string; symbol: string })[];
} }

View File

@ -248,14 +248,18 @@
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="d-none d-lg-table-cell justify-content-end px-1"
i18n i18n
mat-header-cell mat-header-cell
mat-sort-header mat-sort-header
> >
Value Value
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
@ -264,9 +268,42 @@
></gf-value> ></gf-value>
</div> </div>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell> <td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
<div class="d-flex justify-content-end"> <div class="d-flex justify-content-end">
<gf-value <gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : totalValue"
></gf-value>
</div>
</td>
</ng-container>
<ng-container matColumnDef="valueInBaseCurrency">
<th
*matHeaderCellDef
class="d-lg-none d-xl-none justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Value
</th>
<td *matCellDef="let element" class="d-lg-none d-xl-none px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : element.valueInBaseCurrency"
></gf-value>
</div>
</td>
<td *matFooterCellDef class="d-lg-none d-xl-none px-1" mat-footer-cell>
<div class="d-flex justify-content-end">
<gf-value
*ngIf="totalValue !== null"
[isAbsolute]="true" [isAbsolute]="true"
[isCurrency]="true" [isCurrency]="true"
[locale]="locale" [locale]="locale"
@ -321,11 +358,22 @@
*ngIf="hasPermissionToExportActivities" *ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex" class="align-items-center d-flex"
mat-menu-item mat-menu-item
[disabled]="dataSource.data.length === 0"
(click)="onExport()" (click)="onExport()"
> >
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon> <ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span> <span i18n>Export</span>
</button> </button>
<button
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="!hasDrafts"
(click)="onExportDrafts()"
>
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Drafts as ICS</span>
</button>
</mat-menu> </mat-menu>
</th> </th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -339,14 +387,25 @@
<ion-icon name="ellipsis-vertical"></ion-icon> <ion-icon name="ellipsis-vertical"></ion-icon>
</button> </button>
<mat-menu #activityMenu="matMenu" xPosition="before"> <mat-menu #activityMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateActivity(element)"> <button mat-menu-item (click)="onUpdateActivity(element)">
Edit <ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button> </button>
<button i18n mat-menu-item (click)="onCloneActivity(element)"> <button mat-menu-item (click)="onCloneActivity(element)">
Clone <ion-icon class="mr-2" name="copy-outline"></ion-icon>
<span i18n>Clone</span>
</button> </button>
<button i18n mat-menu-item (click)="onDeleteActivity(element.id)"> <button
Delete mat-menu-item
[disabled]="!element.isDraft"
(click)="onExportDraft(element.id)"
>
<ion-icon class="mr-2" name="calendar-clear-outline"></ion-icon>
<span i18n>Export Draft as ICS</span>
</button>
<button mat-menu-item (click)="onDeleteActivity(element.id)">
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button> </button>
</mat-menu> </mat-menu>
</td> </td>

View File

@ -20,10 +20,9 @@ import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces'; import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { isUUID } from 'class-validator'; import { isUUID } from 'class-validator';
import { endOfToday, format, isAfter } from 'date-fns'; import { endOfToday, format, isAfter } from 'date-fns';
@ -57,6 +56,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Output() activityToClone = new EventEmitter<OrderWithAccount>(); @Output() activityToClone = new EventEmitter<OrderWithAccount>();
@Output() activityToUpdate = new EventEmitter<OrderWithAccount>(); @Output() activityToUpdate = new EventEmitter<OrderWithAccount>();
@Output() export = new EventEmitter<string[]>(); @Output() export = new EventEmitter<string[]>();
@Output() exportDrafts = new EventEmitter<string[]>();
@Output() import = new EventEmitter<void>(); @Output() import = new EventEmitter<void>();
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete; @ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ -64,11 +64,12 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource(); public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat: string;
public displayedColumns = []; public displayedColumns = [];
public endOfToday = endOfToday(); public endOfToday = endOfToday();
public filters$: Subject<string[]> = new BehaviorSubject([]); public filters$: Subject<string[]> = new BehaviorSubject([]);
public filters: Observable<string[]> = this.filters$.asObservable(); public filters: Observable<string[]> = this.filters$.asObservable();
public hasDrafts = false;
public isAfter = isAfter; public isAfter = isAfter;
public isLoading = true; public isLoading = true;
public isUUID = isUUID; public isUUID = isUUID;
@ -141,6 +142,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
'fee', 'fee',
'value', 'value',
'currency', 'currency',
'valueInBaseCurrency',
'account', 'account',
'actions' 'actions'
]; ];
@ -153,6 +155,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.isLoading = true; this.isLoading = true;
this.defaultDateFormat = getDateFormatString(this.locale);
if (this.activities) { if (this.activities) {
this.dataSource = new MatTableDataSource(this.activities); this.dataSource = new MatTableDataSource(this.activities);
this.dataSource.filterPredicate = (data, filter) => { this.dataSource.filterPredicate = (data, filter) => {
@ -196,6 +200,22 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
} }
} }
public onExportDraft(aActivityId: string) {
this.exportDrafts.emit([aActivityId]);
}
public onExportDrafts() {
this.exportDrafts.emit(
this.dataSource.filteredData
.filter((activity) => {
return activity.isDraft;
})
.map((activity) => {
return activity.id;
})
);
}
public onImport() { public onImport() {
this.import.emit(); this.import.emit();
} }
@ -232,6 +252,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.filters$.next(this.allFilters); this.filters$.next(this.allFilters);
this.hasDrafts = this.dataSource.data.some((activity) => {
return activity.isDraft === true;
});
this.totalFees = this.getTotalFees(); this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue(); this.totalValue = this.getTotalValue();
} }
@ -306,7 +329,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
if (activity.type === 'BUY' || activity.type === 'ITEM') { if (activity.type === 'BUY' || activity.type === 'ITEM') {
totalValue = totalValue.plus(activity.valueInBaseCurrency); totalValue = totalValue.plus(activity.valueInBaseCurrency);
} else if (activity.type === 'SELL') { } else if (activity.type === 'SELL') {
totalValue = totalValue.minus(activity.valueInBaseCurrency); return null;
} }
} else { } else {
return null; return null;

View File

@ -0,0 +1,65 @@
<div class="container p-0">
<div class="row">
<div class="col-md-3">
<form class="" [formGroup]="calculatorForm">
<!--<mat-form-field appearance="outline">
<input formControlName="principalInvestmentAmount" matInput />
</mat-form-field>-->
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Savings Rate</mat-label>
<input
formControlName="paymentPerPeriod"
matInput
step="100"
type="number"
/>
<span class="ml-2" i18n matSuffix>{{ currency }} per month</span>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Investment Horizon</mat-label>
<input formControlName="time" matInput type="number" />
<span class="ml-2" i18n matSuffix>years</span>
</mat-form-field>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Annual Interest Rate</mat-label>
<input
formControlName="annualInterestRate"
matInput
step="0.25"
type="number"
/>
<span class="ml-2" i18n matSuffix>%</span>
</mat-form-field>
<gf-value
label="Projected Total Amount"
size="large"
[currency]="currency"
[isCurrency]="true"
[locale]="locale"
[value]="projectedTotalAmount"
></gf-value>
</form>
</div>
<div class="col-md-9 text-center">
<div class="chart-container mb-4">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '100%',
width: '100%'
}"
></ngx-skeleton-loader>
<canvas
#chartCanvas
class="h-100"
[ngStyle]="{ display: isLoading ? 'none' : 'block' }"
></canvas>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,11 @@
:host {
display: block;
.chart-container {
aspect-ratio: 16 / 9;
ngx-skeleton-loader {
height: 100%;
}
}
}

View File

@ -0,0 +1,48 @@
import { CommonModule } from '@angular/common';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { baseCurrency, locale } from '@ghostfolio/common/config';
import { Meta, Story, moduleMetadata } from '@storybook/angular';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value';
import { FireCalculatorComponent } from './fire-calculator.component';
import { FireCalculatorService } from './fire-calculator.service';
export default {
title: 'FIRE Calculator',
component: FireCalculatorComponent,
decorators: [
moduleMetadata({
declarations: [FireCalculatorComponent],
imports: [
CommonModule,
FormsModule,
GfValueModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
NgxSkeletonLoaderModule,
NoopAnimationsModule,
ReactiveFormsModule
],
providers: [FireCalculatorService]
})
]
} as Meta<FireCalculatorComponent>;
const Template: Story<FireCalculatorComponent> = (
args: FireCalculatorComponent
) => ({
props: args
});
export const Simple = Template.bind({});
Simple.args = {
currency: baseCurrency,
fireWealth: 0,
locale: locale
};

View File

@ -0,0 +1,307 @@
import 'chartjs-adapter-date-fns';
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnChanges,
OnDestroy,
Output,
ViewChild
} from '@angular/core';
import { FormBuilder, FormControl } from '@angular/forms';
import { primaryColorRgb } from '@ghostfolio/common/config';
import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
import {
BarController,
BarElement,
CategoryScale,
Chart,
LinearScale,
Tooltip
} from 'chart.js';
import * as Color from 'color';
import { isNumber } from 'lodash';
import { Subject, takeUntil } from 'rxjs';
import { FireCalculatorService } from './fire-calculator.service';
@Component({
selector: 'gf-fire-calculator',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './fire-calculator.component.html',
styleUrls: ['./fire-calculator.component.scss']
})
export class FireCalculatorComponent
implements AfterViewInit, OnChanges, OnDestroy
{
@Input() currency: string;
@Input() deviceType: string;
@Input() fireWealth: number;
@Input() locale: string;
@Input() savingsRate = 0;
@Output() savingsRateChanged = new EventEmitter<number>();
@ViewChild('chartCanvas') chartCanvas;
public calculatorForm = this.formBuilder.group({
annualInterestRate: new FormControl(),
paymentPerPeriod: new FormControl(),
principalInvestmentAmount: new FormControl(),
time: new FormControl()
});
public chart: Chart;
public isLoading = true;
public projectedTotalAmount: number;
private unsubscribeSubject = new Subject<void>();
/**
* @constructor
*/
public constructor(
private changeDetectorRef: ChangeDetectorRef,
private fireCalculatorService: FireCalculatorService,
private formBuilder: FormBuilder
) {
Chart.register(
BarController,
BarElement,
CategoryScale,
LinearScale,
Tooltip
);
this.calculatorForm.setValue({
annualInterestRate: 5,
paymentPerPeriod: this.savingsRate,
principalInvestmentAmount: 0,
time: 10
});
this.calculatorForm.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.initialize();
});
this.calculatorForm
.get('paymentPerPeriod')
.valueChanges.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((savingsRate) => {
this.savingsRateChanged.emit(savingsRate);
});
}
public ngAfterViewInit() {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
setTimeout(() => {
// Wait for the chartCanvas
this.calculatorForm.patchValue(
{
principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0
},
{
emitEvent: false
}
);
this.calculatorForm.get('principalInvestmentAmount').disable();
this.changeDetectorRef.markForCheck();
});
}
}
public ngOnChanges() {
if (isNumber(this.fireWealth) && this.fireWealth >= 0) {
setTimeout(() => {
// Wait for the chartCanvas
this.calculatorForm.patchValue(
{
principalInvestmentAmount: this.fireWealth,
paymentPerPeriod: this.savingsRate ?? 0
},
{
emitEvent: false
}
);
this.calculatorForm.get('principalInvestmentAmount').disable();
this.changeDetectorRef.markForCheck();
});
}
}
public ngOnDestroy() {
this.chart?.destroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private initialize() {
this.isLoading = true;
const chartData = this.getChartData();
if (this.chartCanvas) {
if (this.chart) {
this.chart.data.labels = chartData.labels;
for (let index = 0; index < this.chart.data.datasets.length; index++) {
this.chart.data.datasets[index].data = chartData.datasets[index].data;
}
this.chart.update();
} else {
this.chart = new Chart(this.chartCanvas.nativeElement, {
data: chartData,
options: {
plugins: {
tooltip: {
itemSort: (a, b) => {
// Reverse order
return b.datasetIndex - a.datasetIndex;
},
mode: 'index',
callbacks: {
footer: (items) => {
const totalAmount = items.reduce(
(a, b) => a + b.parsed.y,
0
);
return `Total: ${new Intl.NumberFormat(this.locale, {
currency: this.currency,
currencyDisplay: 'code',
style: 'currency'
}).format(totalAmount)}`;
},
label: (context) => {
let label = context.dataset.label || '';
if (label) {
label += ': ';
}
if (context.parsed.y !== null) {
label += new Intl.NumberFormat(this.locale, {
currency: this.currency,
currencyDisplay: 'code',
style: 'currency'
}).format(context.parsed.y);
}
return label;
}
}
}
},
responsive: true,
scales: {
x: {
grid: {
display: false
},
stacked: true
},
y: {
display: this.deviceType !== 'mobile',
grid: {
display: false
},
stacked: true,
ticks: {
callback: (value: number) => {
return transformTickToAbbreviation(value);
}
}
}
}
},
type: 'bar'
});
}
}
this.isLoading = false;
}
private getChartData() {
const currentYear = new Date().getFullYear();
const labels = [];
// Principal investment amount
const P: number =
this.calculatorForm.get('principalInvestmentAmount').value || 0;
// Payment per period
const PMT: number = parseFloat(
this.calculatorForm.get('paymentPerPeriod').value
);
// Annual interest rate
const r: number = this.calculatorForm.get('annualInterestRate').value / 100;
// Time
const t: number = parseFloat(this.calculatorForm.get('time').value);
for (let year = currentYear; year < currentYear + t; year++) {
labels.push(year);
}
const datasetDeposit = {
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
data: [],
label: 'Deposit'
};
const datasetInterest = {
backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
)
.lighten(0.5)
.hex(),
data: [],
label: 'Interest'
};
const datasetSavings = {
backgroundColor: Color(
`rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`
)
.lighten(0.25)
.hex(),
data: [],
label: 'Savings'
};
for (let period = 1; period <= t; period++) {
const { interest, principal, totalAmount } =
this.fireCalculatorService.calculateCompoundInterest({
P,
period,
PMT,
r
});
datasetDeposit.data.push(this.fireWealth);
datasetInterest.data.push(interest.toNumber());
datasetSavings.data.push(principal.minus(this.fireWealth).toNumber());
if (period === t) {
this.projectedTotalAmount = totalAmount.toNumber();
}
}
return {
labels,
datasets: [datasetDeposit, datasetSavings, datasetInterest]
};
}
}

View File

@ -0,0 +1,28 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value';
import { FireCalculatorComponent } from './fire-calculator.component';
import { FireCalculatorService } from './fire-calculator.service';
@NgModule({
declarations: [FireCalculatorComponent],
exports: [FireCalculatorComponent],
imports: [
CommonModule,
FormsModule,
GfValueModule,
MatButtonModule,
MatFormFieldModule,
MatInputModule,
NgxSkeletonLoaderModule,
ReactiveFormsModule
],
providers: [FireCalculatorService]
})
export class GfFireCalculatorModule {}

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