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/),
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
### Added

View File

@ -79,6 +79,7 @@ The frontend is built with [Angular](https://angular.io) and uses [Angular Mater
### Prerequisites
- [Docker](https://www.docker.com/products/docker-desktop)
- A local copy of this Git repository (clone)
### 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. 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:
```bash
docker-compose -f docker/docker-compose-build-local.yml exec ghostfolio yarn database:migrate
```
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`
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`
## 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)
- [Node.js](https://nodejs.org/en/download) (version 14+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- A local copy of this Git repository (clone)
### Setup
@ -162,16 +162,92 @@ Run `yarn start:client`
Run `yarn start:storybook`
### Migrate Database
With the following command you can keep your database schema in sync:
```bash
yarn database:push
```
## Testing
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
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.
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
© 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 {
nullifyValuesInObject,
@ -35,7 +35,7 @@ export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -91,9 +91,10 @@ export class AccountController {
this.request.user.id
);
let accountsWithAggregations = await this.portfolioServiceStrategy
.get()
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
let accountsWithAggregations =
await this.portfolioService.getAccountsWithAggregations(
impersonationUserId || this.request.user.id
);
if (
impersonationUserId ||

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@ import { parseDate } from '@ghostfolio/common/helper';
import Big from 'big.js';
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', () => {
return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
describe('PortfolioCalculatorNew', () => {
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
@ -23,7 +23,7 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => {
it.only('with BALN.SW buy', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: [
@ -41,13 +41,13 @@ describe('PortfolioCalculatorNew', () => {
]
});
portfolioCalculatorNew.computeTransactionPoints();
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
const currentPositions = await portfolioCalculator.getCurrentPositions(
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 { 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', () => {
return {
@ -14,7 +14,7 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
};
});
describe('PortfolioCalculatorNew', () => {
describe('PortfolioCalculator', () => {
let currentRateService: CurrentRateService;
beforeEach(() => {
@ -23,19 +23,19 @@ describe('PortfolioCalculatorNew', () => {
describe('get current positions', () => {
it('with no orders', async () => {
const portfolioCalculatorNew = new PortfolioCalculatorNew({
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
currency: 'CHF',
orders: []
});
portfolioCalculatorNew.computeTransactionPoints();
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const currentPositions = await portfolioCalculatorNew.getCurrentPositions(
const currentPositions = await portfolioCalculator.getCurrentPositions(
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 { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
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 { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
addDays,
addMilliseconds,
addMonths,
addYears,
differenceInDays,
endOfDay,
format,
isAfter,
@ -17,11 +17,12 @@ import {
max,
min
} from 'date-fns';
import { flatten, isNumber } from 'lodash';
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 {
@ -32,22 +33,39 @@ import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.in
import { TransactionPoint } from './interfaces/transaction-point.interface';
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[];
public constructor(
private currentRateService: CurrentRateService,
private currency: string
) {}
public constructor({
currency,
currentRateService,
orders
}: {
currency: string;
currentRateService: CurrentRateService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.orders = orders;
public computeTransactionPoints(orders: PortfolioOrder[]) {
orders.sort((a, b) => a.date.localeCompare(b.date));
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 orders) {
for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
@ -59,17 +77,30 @@ export class PortfolioCalculator {
const newQuantity = order.quantity
.mul(factor)
.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 = {
investment,
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
@ -140,7 +171,6 @@ export class PortfolioCalculator {
hasErrors: false,
grossPerformance: new Big(0),
grossPerformancePercentage: new Big(0),
netAnnualizedPerformance: new Big(0),
netPerformance: new Big(0),
netPerformancePercentage: new Big(0),
positions: [],
@ -195,6 +225,7 @@ export class PortfolioCalculator {
const marketSymbolMap: {
[date: string]: { [symbol: string]: Big };
} = {};
for (const marketSymbol of marketSymbols) {
const date = format(marketSymbol.date, DATE_FORMAT);
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);
if (firstIndex > 0) {
firstIndex--;
}
const invalidSymbols = [];
const lastInvestments: { [symbol: string]: Big } = {};
const lastQuantities: { [symbol: string]: Big } = {};
const lastFees: { [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[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
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({
averagePrice: item.quantity.eq(0)
? new Big(0)
@ -320,31 +276,33 @@ export class PortfolioCalculator {
currency: item.currency,
dataSource: item.dataSource,
firstBuyDate: item.firstBuyDate,
grossPerformance: isValid
? grossPerformance[item.symbol] ?? null
: null,
grossPerformancePercentage:
isValid && holdingPeriodReturns[item.symbol]
? holdingPeriodReturns[item.symbol].minus(1)
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
grossPerformancePercentage: !hasErrors
? grossPerformancePercentage ?? null
: null,
investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null,
netPerformance: isValid ? netPerformance[item.symbol] ?? null : null,
netPerformancePercentage:
isValid && netHoldingPeriodReturns[item.symbol]
? netHoldingPeriodReturns[item.symbol].minus(1)
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: hasErrors || overall.hasErrors
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};
}
@ -462,20 +420,16 @@ export class PortfolioCalculator {
private calculateOverallPerformance(
positions: TimelinePosition[],
initialValues: { [p: string]: Big }
initialValues: { [symbol: string]: Big }
) {
let hasErrors = false;
let currentValue = new Big(0);
let totalInvestment = new Big(0);
let grossPerformance = new Big(0);
let grossPerformancePercentage = new Big(0);
let hasErrors = false;
let netPerformance = new Big(0);
let netPerformancePercentage = new Big(0);
let completeInitialValue = new Big(0);
let netAnnualizedPerformance = new Big(0);
// use Date.now() to use the mock for today
const today = new Date(Date.now());
let sumOfWeights = new Big(0);
let totalInvestment = new Big(0);
for (const currentPosition of positions) {
if (currentPosition.marketPrice) {
@ -485,36 +439,34 @@ export class PortfolioCalculator {
} else {
hasErrors = true;
}
totalInvestment = totalInvestment.plus(currentPosition.investment);
if (currentPosition.grossPerformance) {
grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance
);
netPerformance = netPerformance.plus(currentPosition.netPerformance);
} else if (!currentPosition.quantity.eq(0)) {
hasErrors = true;
}
if (
currentPosition.grossPerformancePercentage &&
initialValues[currentPosition.symbol]
) {
const currentInitialValue = initialValues[currentPosition.symbol];
completeInitialValue = completeInitialValue.plus(currentInitialValue);
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(currentInitialValue)
);
netAnnualizedPerformance = netAnnualizedPerformance.plus(
this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(
today,
parseDate(currentPosition.firstBuyDate)
),
netPerformancePercent: currentPosition.netPerformancePercentage
}).mul(currentInitialValue)
currentPosition.grossPerformancePercentage.mul(weight)
);
netPerformancePercentage = netPerformancePercentage.plus(
currentPosition.netPerformancePercentage.mul(currentInitialValue)
currentPosition.netPerformancePercentage.mul(weight)
);
} else if (!currentPosition.quantity.eq(0)) {
Logger.warn(
@ -525,13 +477,12 @@ export class PortfolioCalculator {
}
}
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
netPerformancePercentage =
netPerformancePercentage.div(completeInitialValue);
netAnnualizedPerformance =
netAnnualizedPerformance.div(completeInitialValue);
if (sumOfWeights.gt(0)) {
grossPerformancePercentage = grossPerformancePercentage.div(sumOfWeights);
netPerformancePercentage = netPerformancePercentage.div(sumOfWeights);
} else {
grossPerformancePercentage = new Big(0);
netPerformancePercentage = new Big(0);
}
return {
@ -539,7 +490,6 @@ export class PortfolioCalculator {
grossPerformance,
grossPerformancePercentage,
hasErrors,
netAnnualizedPerformance,
netPerformance,
netPerformancePercentage,
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(
timelineSpecification: TimelineSpecification[],
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 { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
@ -46,7 +46,7 @@ export class PortfolioController {
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -57,9 +57,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioServiceStrategy
.get()
.getChart(impersonationId, range);
const historicalDataContainer = await this.portfolioService.getChart(
impersonationId,
range
);
let chartData = historicalDataContainer.items;
@ -106,22 +107,14 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): 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;
const { accounts, holdings, hasErrors } =
await this.portfolioServiceStrategy
.get(true)
.getDetails(impersonationId, this.request.user.id, range);
await this.portfolioService.getDetails(
impersonationId,
this.request.user.id,
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
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')
@ -180,9 +177,9 @@ export class PortfolioController {
);
}
let investments = await this.portfolioServiceStrategy
.get()
.getInvestments(impersonationId);
let investments = await this.portfolioService.getInvestments(
impersonationId
);
if (
impersonationId ||
@ -209,9 +206,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioServiceStrategy
.get()
.getPerformance(impersonationId, range);
const performanceInformation = await this.portfolioService.getPerformance(
impersonationId,
range
);
if (
impersonationId ||
@ -234,9 +232,10 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<PortfolioPositions> {
const result = await this.portfolioServiceStrategy
.get()
.getPositions(impersonationId, range);
const result = await this.portfolioService.getPositions(
impersonationId,
range
);
if (
impersonationId ||
@ -276,9 +275,10 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium';
}
const { holdings } = await this.portfolioServiceStrategy
.get(true)
.getDetails(access.userId, access.userId);
const { holdings } = await this.portfolioService.getDetails(
access.userId,
access.userId
);
const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails,
@ -320,9 +320,17 @@ export class PortfolioController {
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
let summary = await this.portfolioServiceStrategy
.get()
.getSummary(impersonationId);
if (
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
this.request.user.subscription.type === 'Basic'
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
let summary = await this.portfolioService.getSummary(impersonationId);
if (
impersonationId ||
@ -356,9 +364,11 @@ export class PortfolioController {
@Param('dataSource') dataSource,
@Param('symbol') symbol
): Promise<PortfolioPositionDetail> {
let position = await this.portfolioServiceStrategy
.get()
.getPosition(dataSource, impersonationId, symbol);
let position = await this.portfolioService.getPosition(
dataSource,
impersonationId,
symbol
);
if (position) {
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 { CurrentRateService } from './current-rate.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
import { RulesService } from './rules.service';
@Module({
controllers: [PortfolioController],
exports: [PortfolioServiceStrategy],
exports: [PortfolioService],
imports: [
AccessModule,
ConfigurationModule,
@ -39,8 +37,6 @@ import { RulesService } from './rules.service';
AccountService,
CurrentRateService,
PortfolioService,
PortfolioServiceNew,
PortfolioServiceStrategy,
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 { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.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 { UserService } from '@ghostfolio/api/app/user/user.service';
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 {
AccountWithValue,
DateRange,
Market,
OrderWithAccount,
RequestWithUser
} from '@ghostfolio/common/types';
@ -49,6 +49,7 @@ import { REQUEST } from '@nestjs/core';
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import {
differenceInDays,
endOfToday,
format,
isAfter,
@ -68,8 +69,12 @@ import {
HistoricalDataItem,
PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface';
import { PortfolioCalculator } from './portfolio-calculator';
import { RulesService } from './rules.service';
const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable()
export class PortfolioService {
public constructor(
@ -159,15 +164,18 @@ export class PortfolioService {
): Promise<InvestmentItem[]> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { transactionPoints } = await this.getTransactionPoints({
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId,
includeDrafts: true
});
const portfolioCalculator = new PortfolioCalculator({
currency: this.request.user.Settings.currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) {
return [];
@ -208,12 +216,17 @@ export class PortfolioService {
): Promise<HistoricalDataContainer> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
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);
if (transactionPoints.length === 0) {
return {
@ -302,15 +315,18 @@ export class PortfolioService {
this.request.user?.Settings?.currency ??
user.Settings?.currency ??
baseCurrency;
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
userCurrency
);
const { orders, transactionPoints } = await this.getTransactionPoints({
const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId
});
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(
@ -368,7 +384,31 @@ export class PortfolioService {
const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[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] = {
markets,
allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass,
@ -474,11 +514,13 @@ export class PortfolioService {
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
positionCurrency
);
portfolioCalculator.computeTransactionPoints(portfolioOrders);
const portfolioCalculator = new PortfolioCalculator({
currency: positionCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.computeTransactionPoints();
const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date);
@ -657,12 +699,16 @@ export class PortfolioService {
): Promise<{ hasErrors: boolean; positions: Position[] }> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
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) {
return {
@ -730,18 +776,21 @@ export class PortfolioService {
): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
this.request.user.Settings.currency
);
const { portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
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) {
return {
hasErrors: false,
performance: {
annualizedPerformancePercent: 0,
currentGrossPerformance: 0,
currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
@ -760,26 +809,34 @@ export class PortfolioService {
);
const hasErrors = currentPositions.hasErrors;
const annualizedPerformancePercent =
currentPositions.netAnnualizedPerformance.toNumber();
const currentValue = currentPositions.currentValue.toNumber();
const currentGrossPerformance =
currentPositions.grossPerformance.toNumber();
const currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage.toNumber();
const currentNetPerformance = currentPositions.netPerformance.toNumber();
const currentNetPerformancePercent =
currentPositions.netPerformancePercentage.toNumber();
const currentGrossPerformance = currentPositions.grossPerformance;
let currentGrossPerformancePercent =
currentPositions.grossPerformancePercentage;
const currentNetPerformance = currentPositions.netPerformance;
let currentNetPerformancePercent =
currentPositions.netPerformancePercentage;
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 {
errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
annualizedPerformancePercent,
currentGrossPerformance,
currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue
currentValue,
currentGrossPerformance: currentGrossPerformance.toNumber(),
currentGrossPerformancePercent:
currentGrossPerformancePercent.toNumber(),
currentNetPerformance: currentNetPerformance.toNumber(),
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
}
};
}
@ -788,7 +845,8 @@ export class PortfolioService {
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(impersonationId, this.request.user.id);
const { orders, transactionPoints } = await this.getTransactionPoints({
const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
userId
});
@ -798,10 +856,12 @@ export class PortfolioService {
};
}
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
currency
);
const portfolioCalculator = new PortfolioCalculator({
currency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
@ -907,8 +967,24 @@ export class PortfolioService {
.plus(items)
.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 {
...performanceInformation.performance,
annualizedPerformancePercent,
cash,
dividend,
fees,
@ -917,8 +993,6 @@ export class PortfolioService {
netWorth,
totalBuy,
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
ordersCount: orders.filter((order) => {
@ -937,8 +1011,8 @@ export class PortfolioService {
cashDetails: CashDetails;
emergencyFund: Big;
investment: Big;
userCurrency: string;
value: Big;
userCurrency: string;
}) {
const cashPositions: PortfolioDetails['holdings'] = {};
@ -1111,6 +1185,7 @@ export class PortfolioService {
}): Promise<{
transactionPoints: TransactionPoint[];
orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[];
}> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
@ -1122,7 +1197,7 @@ export class PortfolioService {
});
if (orders.length <= 0) {
return { transactionPoints: [], orders: [] };
return { transactionPoints: [], orders: [], portfolioOrders: [] };
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
@ -1149,14 +1224,18 @@ export class PortfolioService {
)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
userCurrency
);
portfolioCalculator.computeTransactionPoints(portfolioOrders);
const portfolioCalculator = new PortfolioCalculator({
currency: userCurrency,
currentRateService: this.currentRateService,
orders: portfolioOrders
});
portfolioCalculator.computeTransactionPoints();
return {
transactionPoints: portfolioCalculator.getTransactionPoints(),
orders
orders,
portfolioOrders
};
}

View File

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

View File

@ -1,5 +1,5 @@
export interface UserSettings {
emergencyFund?: number;
isNewCalculationEngine?: boolean;
locale?: string;
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 {
@IsNumber()
@IsOptional()
emergencyFund?: number;
@IsBoolean()
@IsOptional()
isNewCalculationEngine?: boolean;
@IsBoolean()
@IsOptional()
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 { PROPERTY_IS_READ_ONLY_MODE } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import {
hasPermission,
hasRole,
permissions
} from '@ghostfolio/common/permissions';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Body,
Controller,
Delete,
Get,
Headers,
HttpException,
Inject,
Param,
@ -63,8 +60,13 @@ export class UserController {
@Get()
@UseGuards(AuthGuard('jwt'))
public async getUser(@Param('id') id: string): Promise<User> {
return this.userService.getUser(this.request.user);
public async getUser(
@Headers('accept-language') acceptLanguage: string
): Promise<User> {
return this.userService.getUser(
this.request.user,
acceptLanguage?.split(',')?.[0]
);
}
@Post()
@ -118,7 +120,7 @@ export class UserController {
};
for (const key in userSettings) {
if (userSettings[key] === false) {
if (userSettings[key] === false || userSettings[key] === null) {
delete userSettings[key];
}
}

View File

@ -33,14 +33,17 @@ export class UserService {
private readonly subscriptionService: SubscriptionService
) {}
public async getUser({
public async getUser(
{
Account,
alias,
id,
permissions,
Settings,
subscription
}: UserWithSettings): Promise<IUser> {
}: UserWithSettings,
aLocale = locale
): Promise<IUser> {
const access = await this.prismaService.access.findMany({
include: {
User: true
@ -63,8 +66,8 @@ export class UserService {
accounts: Account,
settings: {
...(<UserSettings>Settings.settings),
locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
locale: (<UserSettings>Settings.settings)?.locale ?? aLocale,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
}
};
@ -144,13 +147,6 @@ export class UserService {
user.subscription = this.subscriptionService.getSubscription(
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;

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,17 +16,17 @@ import {
DataSource,
SymbolProfile
} from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js';
import { countries } from 'countries-list';
import { addDays, format, isSameDay } from 'date-fns';
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()
export class YahooFinanceService implements DataProviderInterface {
private readonly yahooFinanceHostname = 'https://query1.finance.yahoo.com';
public constructor(
private readonly cryptocurrencyService: CryptocurrencyService
) {}
@ -92,8 +92,7 @@ export class YahooFinanceService implements DataProviderInterface {
response.assetSubClass = assetSubClass;
response.currency = assetProfile.price.currency;
response.dataSource = this.getName();
response.name =
assetProfile.price.longName || assetProfile.price.shortName || symbol;
response.name = this.formatName(assetProfile);
response.symbol = aSymbol;
if (
@ -244,16 +243,7 @@ export class YahooFinanceService implements DataProviderInterface {
const items: LookupItem[] = [];
try {
const get = bent(
`${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 searchResult = await yahooFinance.search(aQuery);
const quotes = searchResult.quotes
.filter((quote) => {
@ -279,20 +269,24 @@ export class YahooFinanceService implements DataProviderInterface {
return true;
});
const marketData = await this.getQuotes(
const marketData = await yahooFinance.quote(
quotes.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, value] of Object.entries(marketData)) {
const quote = quotes.find((currentQuote: any) => {
return currentQuote.symbol === symbol;
for (const marketDataItem of marketData) {
const quote = quotes.find((currentQuote) => {
return currentQuote.symbol === marketDataItem.symbol;
});
const symbol = this.convertFromYahooFinanceSymbol(
marketDataItem.symbol
);
items.push({
symbol,
currency: value.currency,
currency: marketDataItem.currency,
dataSource: this.getName(),
name: quote?.longname || quote?.shortname || symbol
});
@ -304,6 +298,25 @@ export class YahooFinanceService implements DataProviderInterface {
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): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;

View File

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

View File

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

View File

@ -78,10 +78,19 @@
</ng-container>
<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
</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
class="d-inline-block justify-content-end"
[isCurrency]="true"
@ -89,7 +98,11 @@
[value]="element.balance"
></gf-value>
</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"
@ -100,10 +113,19 @@
</ng-container>
<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
</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
class="d-inline-block justify-content-end"
[isCurrency]="true"
@ -111,7 +133,46 @@
[value]="element.value"
></gf-value>
</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
class="d-inline-block justify-content-end"
[isCurrency]="true"
@ -133,16 +194,17 @@
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #accountMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateAccount(element)">
Edit
<button mat-menu-item (click)="onUpdateAccount(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button
i18n
mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0"
(click)="onDeleteAccount(element.id)"
>
Delete
<ion-icon class="mr-2" name="trash-outline"></ion-icon>
<span i18n>Delete</span>
</button>
</mat-menu>
</td>

View File

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

View File

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

View File

@ -7,8 +7,9 @@ import {
} from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { DataSource, MarketData } from '@prisma/client';
import { Subject } from 'rxjs';
@ -23,9 +24,10 @@ import { takeUntil } from 'rxjs/operators';
export class AdminMarketDataComponent implements OnDestroy, OnInit {
public currentDataSource: DataSource;
public currentSymbol: string;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public defaultDateFormat: string;
public marketData: AdminMarketDataItem[] = [];
public marketDataDetails: MarketData[] = [];
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -35,8 +37,21 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public constructor(
private adminService: AdminService,
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

View File

@ -65,6 +65,7 @@
<gf-admin-market-data-detail
[dataSource]="item.dataSource"
[dateOfFirstActivity]="item.date"
[locale]="user?.settings?.locale"
[marketData]="marketDataDetails"
[symbol]="item.symbol"
(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 { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DEFAULT_DATE_FORMAT,
PROPERTY_COUPONS,
PROPERTY_CURRENCIES,
PROPERTY_IS_READ_ONLY_MODE,
@ -35,7 +34,6 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
public customCurrencies: string[];
public dataGatheringInProgress: boolean;
public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[];
public hasPermissionForSubscription: boolean;
public hasPermissionForSystemMessage: boolean;

View File

@ -180,6 +180,7 @@
[value]="couponDuration"
(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="30 days">30 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option>

View File

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

View File

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

View File

@ -10,7 +10,10 @@ import {
ViewChild
} from '@angular/core';
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 {
Chart,
@ -148,19 +151,10 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
display: false
},
ticks: {
display: true,
callback: (tickValue, index, ticks) => {
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 '';
callback: (value: number) => {
return transformTickToAbbreviation(value);
},
display: true,
mirror: true,
z: 1
}

View File

@ -8,7 +8,7 @@
<div>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<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
><span i18n>Sign in with Google</span></a
>

View File

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

View File

@ -119,7 +119,7 @@
<div class="col"><hr /></div>
</div>
<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">
<gf-value
class="justify-content-end"

View File

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

View File

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

View File

@ -123,17 +123,6 @@
}"
></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
*ngIf="dataSource.data.length > pageSize && !isLoading"
class="my-3 text-center"

View File

@ -27,7 +27,6 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string;
@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 { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { StatusCodes } from 'http-status-codes';
import { Observable, throwError } from 'rxjs';
import { catchError, tap } from 'rxjs/operators';
@Injectable()
export class HttpResponseInterceptor implements HttpInterceptor {
public hasPermissionForSubscription: boolean;
public info: InfoItem;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
@ -34,6 +36,11 @@ export class HttpResponseInterceptor implements HttpInterceptor {
private webAuthnService: WebAuthnService
) {
this.info = this.dataService.fetchInfo();
this.hasPermissionForSubscription = hasPermission(
this.info?.globalPermissions,
permissions.enableSubscription
);
}
public intercept(
@ -56,7 +63,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
} else {
this.snackBarRef = this.snackBar.open(
'This feature requires a subscription.',
'Upgrade Plan',
this.hasPermissionForSubscription ? 'Upgrade Plan' : undefined,
{ duration: 6000 }
);
}

View File

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

View File

@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { GfValueModule } from '@ghostfolio/ui/value';
import { AboutPageRoutingModule } from './about-page-routing.module';
import { AboutPageComponent } from './about-page.component';
@ -12,6 +13,7 @@ import { AboutPageComponent } from './about-page.component';
imports: [
AboutPageRoutingModule,
CommonModule,
GfValueModule,
MatButtonModule,
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 { UserService } from '@ghostfolio/client/services/user/user.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 { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { uniq } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { StripeService } from 'ngx-stripe';
import { EMPTY, Subject } from 'rxjs';
@ -45,13 +47,14 @@ export class AccountPageComponent implements OnDestroy, OnInit {
public coupon: number;
public couponId: string;
public currencies: string[] = [];
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public defaultDateFormat: string;
public deviceType: string;
public hasPermissionForSubscription: boolean;
public hasPermissionToCreateAccess: boolean;
public hasPermissionToDeleteAccess: boolean;
public hasPermissionToUpdateViewMode: boolean;
public hasPermissionToUpdateUserSettings: boolean;
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
public price: number;
public priceId: string;
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
@ -101,6 +104,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.defaultDateFormat = getDateFormatString(
this.user.settings.locale
);
this.hasPermissionToCreateAccess = hasPermission(
this.user.permissions,
permissions.createAccess
@ -121,6 +128,9 @@ export class AccountPageComponent implements OnDestroy, OnInit {
permissions.updateViewMode
);
this.locales.push(this.user.settings.locale);
this.locales = uniq(this.locales.sort());
this.changeDetectorRef.markForCheck();
}
});
@ -143,6 +153,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
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) {
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() {
let couponCode = prompt('Please enter your coupon code:');
couponCode = couponCode?.trim();

View File

@ -27,9 +27,8 @@
Valid until {{ user?.subscription?.expiresAt | date:
defaultDateFormat }}
</div>
<div
*ngIf="hasPermissionForSubscription && user?.subscription?.type === 'Basic'"
>
<div *ngIf="user?.subscription?.type === 'Basic'">
<ng-container *ngIf="hasPermissionForSubscription">
<button
color="primary"
i18n
@ -39,14 +38,17 @@
Upgrade
</button>
<div *ngIf="price" class="mt-1">
{{ baseCurrency }}
<ng-container *ngIf="coupon"
>{{ price - coupon | number : '1.2-2' }}
<del>{{ price }}</del>
</ng-container>
<ng-container *ngIf="!coupon">{{ price }}</ng-container>
<span i18n> per year</span>
><del class="text-muted"
>{{ baseCurrency }}&nbsp;{{ price }}</del
>&nbsp;{{ baseCurrency }}&nbsp;{{ price - coupon
}}</ng-container
>
<ng-container *ngIf="!coupon"
>{{ baseCurrency }}&nbsp;{{ price }}</ng-container
>&nbsp;<span i18n>per year</span>
</div>
</ng-container>
<a
*ngIf="!user?.subscription?.expiresAt"
class="mr-2 my-2"
@ -109,14 +111,34 @@
</mat-form-field>
</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="align-items-center d-flex pr-1 pt-1 w-50" i18n>
View Mode
<ion-icon
*ngIf="!hasPermissionToUpdateViewMode"
class="mx-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
<div class="pl-1 w-50">
<div class="align-items-center d-flex overflow-hidden">
@ -147,23 +169,6 @@
></mat-slide-toggle>
</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>
</div>

View File

@ -10,6 +10,7 @@ import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { RouterModule } from '@angular/router';
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 { AccountPageComponent } from './account-page.component';
@ -24,6 +25,7 @@ import { GfCreateOrUpdateAccessDialogModule } from './create-or-update-access-di
FormsModule,
GfCreateOrUpdateAccessDialogModule,
GfPortfolioAccessTableModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatDialogModule,

View File

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

View File

@ -30,33 +30,14 @@
<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 Asset Class</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-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
>
<mat-card-title class="align-items-center d-flex text-truncate"
><span i18n>By Currency</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"
@ -75,10 +56,46 @@
</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="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">
<mat-card class="mb-3">
<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
[defaultValue]="period"
[isLoading]="false"
@ -104,7 +121,14 @@
<div class="col-md-4">
<mat-card class="mb-3">
<mat-card-header class="overflow-hidden w-100">
<mat-card-title class="text-truncate" i18n>By Sector</mat-card-title>
<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
[defaultValue]="period"
[isLoading]="false"
@ -127,9 +151,14 @@
<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 Continent</mat-card-title
>
<mat-card-title class="align-items-center d-flex text-truncate"
><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
[defaultValue]="period"
[isLoading]="false"
@ -151,7 +180,14 @@
<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 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
[defaultValue]="period"
[isLoading]="false"
@ -176,7 +212,14 @@
<div class="col-lg">
<mat-card class="mb-3">
<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
[defaultValue]="period"
[isLoading]="false"
@ -225,7 +268,6 @@
<gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale"
[positions]="positionsArray"
></gf-positions-table>

View File

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

View File

@ -2,7 +2,7 @@
<div class="row">
<div class="col-lg">
<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>
<div *ngIf="isLoading">
<ngx-skeleton-loader
@ -27,7 +27,8 @@
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerYear"
[locale]="user?.settings?.locale"
[value]="withdrawalRatePerYear?.toNumber()"
></gf-value>
per year</span
>
@ -36,18 +37,31 @@
><gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[value]="withdrawalRatePerMonth"
[locale]="user?.settings?.locale"
[value]="withdrawalRatePerMonth?.toNumber()"
></gf-value>
per month</span
>, based on your net worth of
>, based on your total assets of
<gf-value
class="d-inline-block"
[currency]="user?.settings?.baseCurrency"
[value]="fireWealth"
[locale]="user?.settings?.locale"
[value]="fireWealth?.toNumber()"
></gf-value>
(excluding emergency fund) and a withdrawal rate of 4%.
and a withdrawal rate of 4%.
</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>

View File

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

View File

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

View File

@ -46,6 +46,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public filteredLookupItemsObservable: Observable<LookupItem[]>;
public isLoading = false;
public platforms: { id: string; name: string }[];
public total = 0;
public Validators = Validators;
private unsubscribeSubject = new Subject<void>();
@ -89,6 +90,25 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
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[
'searchSymbol'
].valueChanges.pipe(
@ -100,7 +120,9 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
filteredLookupItemsObservable
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((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') {
this.activityForm.controls['accountId'].removeValidators(
Validators.required

View File

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

View File

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

View File

@ -1,5 +1,5 @@
export interface ImportTransactionDialogParams {
activities: any[];
deviceType: 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 { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
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 { ImportTransactionsService } from '@ghostfolio/client/services/import-transactions.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -50,6 +51,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private icsService: IcsService,
private impersonationStorageService: ImpersonationStorageService,
private importTransactionsService: ImportTransactionsService,
private route: ActivatedRoute,
@ -152,14 +154,36 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.fetchExport(activityIds)
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((data) => {
downloadAsFile(
data,
`ghostfolio-export-${format(
for (const activity of data.activities) {
delete activity.id;
}
downloadAsFile({
content: data,
fileName: `ghostfolio-export-${format(
parseISO(data.meta.date),
'yyyyMMddHHmm'
)}.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')) {
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();
}
}
try {
await this.importTransactionsService.importJson({
content: content.orders
content: content.activities
});
this.handleImportSuccess();
} catch (error) {
console.error(error);
this.handleImportError({ error, orders: content.orders });
this.handleImportError({ error, activities: content.activities });
}
return;
@ -212,10 +248,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} catch (error) {
console.error(error);
this.handleImportError({
activities: error?.activities ?? [],
error: {
error: { message: error?.error?.message ?? [error?.message] }
},
orders: error?.orders ?? []
}
});
}
@ -226,8 +262,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} catch (error) {
console.error(error);
this.handleImportError({
error: { error: { message: ['Unexpected format'] } },
orders: []
activities: [],
error: { error: { message: ['Unexpected format'] } }
});
}
};
@ -281,12 +317,18 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private handleImportError({ error, orders }: { error: any; orders: any[] }) {
private handleImportError({
activities,
error
}: {
activities: any[];
error: any;
}) {
this.snackBar.dismiss();
this.dialog.open(ImportTransactionDialog, {
data: {
orders,
activities,
deviceType: this.deviceType,
messages: error?.error?.message
},

View File

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

View File

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

View File

@ -35,7 +35,7 @@
>
or
</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
><span i18n>Continue with Google</span></a
>

View File

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

View File

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

View File

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

View File

@ -8,6 +8,6 @@ export class CacheService {
public constructor(private http: HttpClient) {}
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,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -52,46 +50,46 @@ export class DataService {
couponId?: string;
priceId: string;
}) {
return this.http.post('/api/subscription/stripe/checkout-session', {
return this.http.post('/api/v1/subscription/stripe/checkout-session', {
couponId,
priceId
});
}
public fetchAccounts() {
return this.http.get<Accounts>('/api/account');
return this.http.get<Accounts>('/api/v1/account');
}
public fetchAdminData() {
return this.http.get<AdminData>('/api/admin');
return this.http.get<AdminData>('/api/v1/admin');
}
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) {
return this.http.delete<any>(`/api/access/${aId}`);
return this.http.delete<any>(`/api/v1/access/${aId}`);
}
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) {
return this.http.delete<any>(`/api/order/${aId}`);
return this.http.delete<any>(`/api/v1/order/${aId}`);
}
public deleteUser(aId: string) {
return this.http.delete<any>(`/api/user/${aId}`);
return this.http.delete<any>(`/api/v1/user/${aId}`);
}
public fetchAccesses() {
return this.http.get<Access[]>('/api/access');
return this.http.get<Access[]>('/api/v1/access');
}
public fetchChart({ range }: { range: DateRange }) {
return this.http.get<PortfolioChart>('/api/portfolio/chart', {
return this.http.get<PortfolioChart>('/api/v1/portfolio/chart', {
params: { range }
});
}
@ -103,7 +101,7 @@ export class DataService {
params = params.append('activityIds', activityIds.join(','));
}
return this.http.get<Export>('/api/export', {
return this.http.get<Export>('/api/v1/export', {
params
});
}
@ -121,7 +119,7 @@ export class DataService {
}
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) => {
if (response.firstOrderDate) {
response.firstOrderDate = parseISO(response.firstOrderDate);
@ -147,7 +145,7 @@ export class DataService {
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
});
}
@ -157,14 +155,14 @@ export class DataService {
}: {
range: DateRange;
}): Observable<PortfolioPositions> {
return this.http.get<PortfolioPositions>('/api/portfolio/positions', {
return this.http.get<PortfolioPositions>('/api/v1/portfolio/positions', {
params: { range }
});
}
public fetchSymbols(aQuery: string) {
return this.http
.get<{ items: LookupItem[] }>(`/api/symbol/lookup?query=${aQuery}`)
.get<{ items: LookupItem[] }>(`/api/v1/symbol/lookup?query=${aQuery}`)
.pipe(
map((respose) => {
return respose.items;
@ -173,7 +171,7 @@ export class DataService {
}
public fetchOrders(): Observable<Activities> {
return this.http.get<any>('/api/order').pipe(
return this.http.get<any>('/api/v1/order').pipe(
map(({ activities }) => {
for (const activity of activities) {
activity.createdAt = parseISO(activity.createdAt);
@ -185,14 +183,14 @@ export class DataService {
}
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
});
}
public fetchPortfolioPerformance(params: { [param: string]: any }) {
return this.http.get<PortfolioPerformanceResponse>(
'/api/portfolio/performance',
'/api/v1/portfolio/performance',
{
params
}
@ -201,16 +199,16 @@ export class DataService {
public fetchPortfolioPublic(aId: string) {
return this.http.get<PortfolioPublicDetails>(
`/api/portfolio/public/${aId}`
`/api/v1/portfolio/public/${aId}`
);
}
public fetchPortfolioReport() {
return this.http.get<PortfolioReport>('/api/portfolio/report');
return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
}
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) => {
if (summary.firstOrderDate) {
summary.firstOrderDate = parseISO(summary.firstOrderDate);
@ -229,7 +227,7 @@ export class DataService {
symbol: string;
}) {
return this.http
.get<any>(`/api/portfolio/position/${dataSource}/${symbol}`)
.get<any>(`/api/v1/portfolio/position/${dataSource}/${symbol}`)
.pipe(
map((data) => {
if (data.orders) {
@ -245,47 +243,47 @@ export class DataService {
}
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) {
return this.http.post<OrderModel>(`/api/access`, aAccess);
return this.http.post<OrderModel>(`/api/v1/access`, aAccess);
}
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) {
return this.http.post<OrderModel>(`/api/order`, aOrder);
return this.http.post<OrderModel>(`/api/v1/order`, aOrder);
}
public postUser() {
return this.http.post<UserItem>(`/api/user`, {});
return this.http.post<UserItem>(`/api/v1/user`, {});
}
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) {
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) {
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) {
return this.http.put<User>(`/api/user/setting`, aData);
return this.http.put<User>(`/api/v1/user/setting`, aData);
}
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) {
return this.http.post('/api/subscription/redeem-coupon', {
return this.http.post('/api/v1/subscription/redeem-coupon', {
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
}).data;
const orders: CreateOrderDto[] = [];
const activities: CreateOrderDto[] = [];
for (const [index, item] of content.entries()) {
orders.push({
activities.push({
accountId: this.parseAccount({ item, userAccounts }),
currency: this.parseCurrency({ content, index, 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> {
return new Promise((resolve, reject) => {
this.postImport({
orders: content
activities: content
})
.pipe(
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 }) {
@ -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({
@ -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({
@ -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({
@ -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({
@ -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({
@ -276,12 +294,12 @@ export class ImportTransactionsService {
}
throw {
message: `orders.${index}.unitPrice is not valid`,
orders: content
activities: content,
message: `activities.${index}.unitPrice is not valid`
};
}
private postImport(aImportData: { orders: CreateOrderDto[] }) {
return this.http.post<void>('/api/import', aImportData);
private postImport(aImportData: { activities: CreateOrderDto[] }) {
return this.http.post<void>('/api/v1/import', aImportData);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexDataSource = DataSource.RAKUTEN;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const locale = 'de-CH';
export const locale = 'en-US';
export const primaryColorHex = '#36cfcc';
export const primaryColorRgb = {
@ -44,7 +44,6 @@ export const warnColorRgb = {
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 PROPERTY_COUPONS = 'COUPONS';

View File

@ -2,7 +2,7 @@ import * as currencies from '@dinero.js/currencies';
import { DataSource } from '@prisma/client';
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { ghostfolioScraperApiSymbolPrefix } from './config';
import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
@ -12,17 +12,28 @@ export function decodeDataSource(encodedDataSource: string) {
return Buffer.from(encodedDataSource, 'hex').toString();
}
export function downloadAsFile(
aContent: unknown,
aFileName: string,
aContentType: string
) {
export function downloadAsFile({
content,
contentType = 'text/plain',
fileName,
format
}: {
content: unknown;
contentType?: string;
fileName: string;
format: 'json' | 'string';
}) {
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.download = aFileName;
a.download = fileName;
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() {
const cssVariable = getCssVariable(
window.matchMedia('(prefers-color-scheme: dark)').matches
@ -133,3 +187,7 @@ export function parseDate(date: string) {
export function prettifySymbol(aSymbol: string): string {
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;
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">
<th
*matHeaderCellDef
class="justify-content-end px-1"
class="d-none d-lg-table-cell justify-content-end px-1"
i18n
mat-header-cell
mat-sort-header
>
Value
</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">
<gf-value
[isCurrency]="true"
@ -264,9 +268,42 @@
></gf-value>
</div>
</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">
<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"
[isCurrency]="true"
[locale]="locale"
@ -321,11 +358,22 @@
*ngIf="hasPermissionToExportActivities"
class="align-items-center d-flex"
mat-menu-item
[disabled]="dataSource.data.length === 0"
(click)="onExport()"
>
<ion-icon class="mr-2" name="cloud-download-outline"></ion-icon>
<span i18n>Export</span>
</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>
</th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
@ -339,14 +387,25 @@
<ion-icon name="ellipsis-vertical"></ion-icon>
</button>
<mat-menu #activityMenu="matMenu" xPosition="before">
<button i18n mat-menu-item (click)="onUpdateActivity(element)">
Edit
<button mat-menu-item (click)="onUpdateActivity(element)">
<ion-icon class="mr-2" name="create-outline"></ion-icon>
<span i18n>Edit</span>
</button>
<button i18n mat-menu-item (click)="onCloneActivity(element)">
Clone
<button mat-menu-item (click)="onCloneActivity(element)">
<ion-icon class="mr-2" name="copy-outline"></ion-icon>
<span i18n>Clone</span>
</button>
<button i18n mat-menu-item (click)="onDeleteActivity(element.id)">
Delete
<button
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>
</mat-menu>
</td>

View File

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