Compare commits
110 Commits
Author | SHA1 | Date | |
---|---|---|---|
fdcf5fd396 | |||
8a9ae9bb33 | |||
3fb7e746df | |||
137e8e090a | |||
6d941500cd | |||
9c4e22978d | |||
af65a99398 | |||
1a0cb561cd | |||
9f875adf0c | |||
6b0dadb895 | |||
98a9523eee | |||
dfb3365efb | |||
0e08d8830e | |||
69d85eadfd | |||
c009f8c12f | |||
60ef46accf | |||
b12ac1fe84 | |||
4355c96ab6 | |||
fb326fe0cc | |||
02cfebd98c | |||
dd2936d703 | |||
918d0b85d4 | |||
a061595101 | |||
dcd496ac50 | |||
6b9ec549da | |||
b5bd4df483 | |||
766a2d7c2f | |||
6dabf7516a | |||
5d49ff7a4a | |||
8998c18836 | |||
741a0e36d2 | |||
e31b4c64cb | |||
812ff5cbdc | |||
035b90a689 | |||
5ea3a187f4 | |||
5d9c38663d | |||
5616bc4956 | |||
9ad1c2177c | |||
15bf9f2f9c | |||
ebc5008569 | |||
37759ba03f | |||
8319b216bb | |||
782d131b0d | |||
72e75208df | |||
4b1c27c245 | |||
61f0da35bc | |||
80464c7846 | |||
74f4323903 | |||
127dbf9dcd | |||
66bdb374e8 | |||
4ad4fa2b30 | |||
1fd836194f | |||
2090db1199 | |||
053c7e591e | |||
9b5e350e3b | |||
378e57c3bc | |||
6765191a8c | |||
8438a45bcf | |||
30a64e7fc1 | |||
f2cb671c7f | |||
3f41e5c5de | |||
c1ad483f33 | |||
f3d961bc16 | |||
42b70ef568 | |||
77beaaba08 | |||
d9c07456cd | |||
0a53df4293 | |||
4416ba0c88 | |||
486de968a2 | |||
a5833566a8 | |||
261f5844dd | |||
2173c418a7 | |||
4efd5cefd8 | |||
d735e4db75 | |||
e10707fde4 | |||
ac953df809 | |||
bb4ee50738 | |||
4f41bac328 | |||
cd07802400 | |||
b692b7432c | |||
a4efbc0131 | |||
55b0fe232c | |||
46432edce9 | |||
990028316e | |||
37871fbabc | |||
0fdeef7953 | |||
cdbe6eedeb | |||
a6dde8ad43 | |||
39bd4a349b | |||
ab59eb5c92 | |||
1132dc9bdd | |||
2d70b18593 | |||
b6ea7d23fa | |||
95382581f1 | |||
551b83a6e3 | |||
895c4fe299 | |||
ccb1bf881e | |||
7788b5a987 | |||
47fd029e0c | |||
458ee159e1 | |||
dfacbed66d | |||
22d63c6102 | |||
212aa6a63b | |||
bed9ae916c | |||
b6ad362850 | |||
ab86dd5318 | |||
dba73d80a3 | |||
92fb05320a | |||
73d62bb51f | |||
127b7d4f25 |
190
CHANGELOG.md
190
CHANGELOG.md
@ -5,6 +5,196 @@ 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).
|
||||
|
||||
## 2.86.0 - 2024-06-07
|
||||
|
||||
### Added
|
||||
|
||||
- Introduced the allocations by ETF holding on the allocations page (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `prettier` from version `3.2.5` to `3.3.1`
|
||||
|
||||
## 2.85.0 - 2024-06-06
|
||||
|
||||
### Added
|
||||
|
||||
- Added the ability to close a user account
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `ng-extract-i18n-merge` from version `2.10.0` to `2.12.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the default locale in the value component
|
||||
|
||||
## 2.84.0 - 2024-06-01
|
||||
|
||||
### Added
|
||||
|
||||
- Added the data provider information to the asset profile details dialog of the admin control
|
||||
- Added the cascading on delete for various relations in the database schema
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the initial annual interest rate in the _FIRE_ calculator
|
||||
- Fixed the state handling in the currency selector
|
||||
- Fixed the deletion of an asset profile with symbol profile overrides in the asset profile details dialog of the admin control
|
||||
|
||||
## 2.83.0 - 2024-05-30
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `@nestjs/passport` from version `10.0.0` to `10.0.3`
|
||||
- Upgraded `angular` from version `17.3.5` to `17.3.10`
|
||||
- Upgraded `class-validator` from version `0.14.0` to `0.14.1`
|
||||
- Upgraded `countup.js` from version `2.3.2` to `2.8.0`
|
||||
- Upgraded `Nx` from version `19.0.2` to `19.0.5`
|
||||
- Upgraded `passport` from version `0.6.0` to `0.7.0`
|
||||
- Upgraded `passport-jwt` from version `4.0.0` to `4.0.1`
|
||||
- Upgraded `prisma` from version `5.13.0` to `5.14.0`
|
||||
- Upgraded `yahoo-finance2` from version `2.11.2` to `2.11.3`
|
||||
|
||||
## 2.82.0 - 2024-05-22
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the create or update activity dialog by preselecting the (only) account
|
||||
- Improved the usability of the date range selector in the assistant
|
||||
- Refactored the holding detail dialog to a standalone component
|
||||
- Refreshed the cryptocurrencies list
|
||||
- Refactored various pages to standalone components
|
||||
- Upgraded `@internationalized/number` from version `3.5.0` to `3.5.2`
|
||||
- Upgraded `body-parser` from version `1.20.1` to `1.20.2`
|
||||
- Upgraded `zone.js` from version `0.14.4` to `0.14.5`
|
||||
|
||||
## 2.81.0 - 2024-05-12
|
||||
|
||||
### Added
|
||||
|
||||
- Added an indicator for active filters (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the delete all activities functionality on the portfolio activities page to work with the filters of the assistant
|
||||
- Improved the language localization for German (`de`)
|
||||
- Improved the language localization for Türkçe (`tr`)
|
||||
- Upgraded `Nx` from version `18.3.3` to `19.0.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the position detail dialog close functionality
|
||||
|
||||
## 2.80.0 - 2024-05-08
|
||||
|
||||
### Added
|
||||
|
||||
- Added the absolute change column to the holdings table on the home page
|
||||
|
||||
### Changed
|
||||
|
||||
- Increased the spacing around the floating action buttons (FAB)
|
||||
- Set the icon column of the activities table to stick at the beginning
|
||||
- Set the icon column of the holdings table to stick at the beginning
|
||||
- Increased the number of attempts of queue jobs from `10` to `12` (fail later)
|
||||
- Upgraded `ionicons` from version `7.3.0` to `7.4.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the position detail dialog open functionality when searching for a holding in the assistant
|
||||
|
||||
## 2.79.0 - 2024-05-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the holdings table to the holdings tab of the home page
|
||||
- Improved the performance labels (with and without currency effects) in the position detail dialog
|
||||
- Optimized the calculations of the portfolio details endpoint
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the benchmarks in the markets overview
|
||||
- Fixed an issue with the _Fear & Greed Index_ (market mood) in the markets overview
|
||||
|
||||
## 2.78.0 - 2024-05-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added a form validation against the DTO in the create or update access dialog
|
||||
- Added a form validation against the DTO in the asset profile details dialog of the admin control
|
||||
- Added a form validation against the DTO in the platform management of the admin control panel
|
||||
- Added a form validation against the DTO in the tag management of the admin control panel
|
||||
|
||||
### Changed
|
||||
|
||||
- Set the performance column of the holdings table to stick at the end
|
||||
- Skipped the caching in the portfolio calculator if there are active filters (experimental)
|
||||
- Improved the `INACTIVE` user role
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
|
||||
- Fixed a division by zero error in the dividend yield calculation (experimental)
|
||||
|
||||
## 2.77.1 - 2024-04-27
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the content of the _Self-Hosting_ section by the custom asset instructions on the Frequently Asked Questions (FAQ) page
|
||||
- Added the caching to the portfolio calculator (experimental)
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrated the `@ghostfolio/ui` components to control flow
|
||||
- Updated the browserslist database
|
||||
- Upgraded `prisma` from version `5.12.1` to `5.13.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the form submit in the asset profile details dialog of the admin control due to the `url` validation
|
||||
- Fixed the historical market data gathering for asset profiles with `MANUAL` data source
|
||||
|
||||
## 2.76.0 - 2024-04-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed `CASH` to `LIQUIDITY` in the asset class enum
|
||||
|
||||
## 2.75.1 - 2024-04-21
|
||||
|
||||
### Added
|
||||
|
||||
- Added `accountId` and `date` as a unique constraint to the `AccountBalance` database schema
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the chart in the account detail dialog
|
||||
- Improved the account balance management
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with `totalValueInBaseCurrency` in the value redaction interceptor for the impersonation mode
|
||||
|
||||
## 2.74.0 - 2024-04-20
|
||||
|
||||
### Added
|
||||
|
||||
- Added the date range support to the portfolio holdings page
|
||||
- Added support to create an account balance
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the date range support in the activities table on the portfolio activities page (experimental)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `angular` from version `17.3.3` to `17.3.5`
|
||||
- Upgraded `Nx` from version `18.2.3` to `18.3.3`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed gaps in the portfolio performance charts by considering `BUY` and `SELL` activities
|
||||
|
||||
## 2.73.0 - 2024-04-17
|
||||
|
||||
### Added
|
||||
|
40
README.md
40
README.md
@ -13,8 +13,6 @@
|
||||
[](#contributing)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
New: [Ghostfolio 2.0](https://ghostfol.io/en/blog/2023/09/ghostfolio-2)
|
||||
|
||||
</div>
|
||||
|
||||
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. The software is designed for personal use in continuous operation.
|
||||
@ -87,23 +85,23 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
|
||||
### Supported Environment Variables
|
||||
|
||||
| Name | Default Value | Description |
|
||||
| ------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
| Name | Type | Default Value | Description |
|
||||
| ------------------------ | ------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ACCESS_TOKEN_SALT` | string | | A random string used as salt for access tokens |
|
||||
| `API_KEY_COINGECKO_DEMO` | string (`optional`) | | The _CoinGecko_ Demo API key |
|
||||
| `API_KEY_COINGECKO_PRO` | string (`optional`) | | The _CoinGecko_ Pro API |
|
||||
| `DATABASE_URL` | string | | The database connection URL, e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer` |
|
||||
| `HOST` | string (`optional`) | `0.0.0.0` | The host where the Ghostfolio application will run on |
|
||||
| `JWT_SECRET_KEY` | string | | A random string used for _JSON Web Tokens_ (JWT) |
|
||||
| `PORT` | number (`optional`) | `3333` | The port where the Ghostfolio application will run on |
|
||||
| `POSTGRES_DB` | string | | The name of the _PostgreSQL_ database |
|
||||
| `POSTGRES_PASSWORD` | string | | The password of the _PostgreSQL_ database |
|
||||
| `POSTGRES_USER` | string | | The user of the _PostgreSQL_ database |
|
||||
| `REDIS_DB` | number (`optional`) | `0` | The database index of _Redis_ |
|
||||
| `REDIS_HOST` | string | | The host where _Redis_ is running |
|
||||
| `REDIS_PASSWORD` | string | | The password of _Redis_ |
|
||||
| `REDIS_PORT` | number | | The port where _Redis_ is running |
|
||||
| `REQUEST_TIMEOUT` | number (`optional`) | `2000` | The timeout of network requests to data providers in milliseconds |
|
||||
|
||||
### Run with Docker Compose
|
||||
|
||||
@ -144,7 +142,7 @@ docker compose --env-file ./.env -f docker/docker-compose.build.yml up -d
|
||||
|
||||
### Home Server Systems (Community)
|
||||
|
||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||
Ghostfolio is available for various home server systems, including [CasaOS](https://github.com/bigbeartechworld/big-bear-casaos), [Home Assistant](https://github.com/lildude/ha-addon-ghostfolio), [Runtipi](https://www.runtipi.io/docs/apps-available), [TrueCharts](https://truecharts.org/charts/stable/ghostfolio), [Umbrel](https://apps.umbrel.com/app/ghostfolio), and [Unraid](https://unraid.net/community/apps?q=ghostfolio).
|
||||
|
||||
## Development
|
||||
|
||||
@ -277,7 +275,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
|
||||
|
||||
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](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://twitter.com/ghostfolio_) on _X_. We would love to hear from you.
|
||||
|
||||
If you like to support this project, get [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) or [**Buy me a coffee**](https://www.buymeacoffee.com/ghostfolio).
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
@ -5,6 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
Body,
|
||||
Post,
|
||||
Delete,
|
||||
HttpException,
|
||||
Inject,
|
||||
@ -17,14 +20,44 @@ import { AccountBalance } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { AccountBalanceService } from './account-balance.service';
|
||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||
|
||||
@Controller('account-balance')
|
||||
export class AccountBalanceController {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly accountService: AccountService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@HasPermission(permissions.createAccountBalance)
|
||||
@Post()
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async createAccountBalance(
|
||||
@Body() data: CreateAccountBalanceDto
|
||||
): Promise<AccountBalance> {
|
||||
const account = await this.accountService.account({
|
||||
id_userId: {
|
||||
id: data.accountId,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: account.id,
|
||||
balance: data.balance,
|
||||
date: data.date,
|
||||
userId: account.userId
|
||||
});
|
||||
}
|
||||
|
||||
@HasPermission(permissions.deleteAccountBalance)
|
||||
@Delete(':id')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -32,10 +65,11 @@ export class AccountBalanceController {
|
||||
@Param('id') id: string
|
||||
): Promise<AccountBalance> {
|
||||
const accountBalance = await this.accountBalanceService.accountBalance({
|
||||
id
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (!accountBalance || accountBalance.userId !== this.request.user.id) {
|
||||
if (!accountBalance) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -43,7 +77,8 @@ export class AccountBalanceController {
|
||||
}
|
||||
|
||||
return this.accountBalanceService.deleteAccountBalance({
|
||||
id
|
||||
id: accountBalance.id,
|
||||
userId: accountBalance.userId
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
@ -10,6 +11,6 @@ import { AccountBalanceService } from './account-balance.service';
|
||||
controllers: [AccountBalanceController],
|
||||
exports: [AccountBalanceService],
|
||||
imports: [ExchangeRateDataModule, PrismaModule],
|
||||
providers: [AccountBalanceService]
|
||||
providers: [AccountBalanceService, AccountService]
|
||||
})
|
||||
export class AccountBalanceModule {}
|
||||
|
@ -1,14 +1,21 @@
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { AccountBalancesResponse, Filter } from '@ghostfolio/common/interfaces';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { AccountBalance, Prisma } from '@prisma/client';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { CreateAccountBalanceDto } from './create-account-balance.dto';
|
||||
|
||||
@Injectable()
|
||||
export class AccountBalanceService {
|
||||
public constructor(
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -24,20 +31,63 @@ export class AccountBalanceService {
|
||||
});
|
||||
}
|
||||
|
||||
public async createAccountBalance(
|
||||
data: Prisma.AccountBalanceCreateInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.create({
|
||||
data
|
||||
public async createOrUpdateAccountBalance({
|
||||
accountId,
|
||||
balance,
|
||||
date,
|
||||
userId
|
||||
}: CreateAccountBalanceDto & {
|
||||
userId: string;
|
||||
}): Promise<AccountBalance> {
|
||||
const accountBalance = await this.prismaService.accountBalance.upsert({
|
||||
create: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
},
|
||||
date: resetHours(parseISO(date)),
|
||||
value: balance
|
||||
},
|
||||
update: {
|
||||
value: balance
|
||||
},
|
||||
where: {
|
||||
accountId_date: {
|
||||
accountId,
|
||||
date: resetHours(parseISO(date))
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId
|
||||
})
|
||||
);
|
||||
|
||||
return accountBalance;
|
||||
}
|
||||
|
||||
public async deleteAccountBalance(
|
||||
where: Prisma.AccountBalanceWhereUniqueInput
|
||||
): Promise<AccountBalance> {
|
||||
return this.prismaService.accountBalance.delete({
|
||||
const accountBalance = await this.prismaService.accountBalance.delete({
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: <string>where.userId
|
||||
})
|
||||
);
|
||||
|
||||
return accountBalance;
|
||||
}
|
||||
|
||||
public async getAccountBalances({
|
||||
|
@ -0,0 +1,12 @@
|
||||
import { IsISO8601, IsNumber, IsUUID } from 'class-validator';
|
||||
|
||||
export class CreateAccountBalanceDto {
|
||||
@IsUUID()
|
||||
accountId: string;
|
||||
|
||||
@IsNumber()
|
||||
balance: number;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
}
|
@ -2,7 +2,7 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
|
@ -1,9 +1,7 @@
|
||||
import { AccountBalanceModule } from '@ghostfolio/api/app/account-balance/account-balance.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -19,13 +17,11 @@ import { AccountService } from './account.service';
|
||||
imports: [
|
||||
AccountBalanceModule,
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
UserModule
|
||||
RedactValuesInResponseModule
|
||||
],
|
||||
providers: [AccountService]
|
||||
})
|
||||
|
@ -1,11 +1,15 @@
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import { groupBy } from 'lodash';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
@ -14,6 +18,7 @@ import { CashDetails } from './interfaces/cash-details.interface';
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private readonly accountBalanceService: AccountBalanceService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -85,17 +90,20 @@ export class AccountService {
|
||||
data
|
||||
});
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: account.id, userId: aUserId }
|
||||
}
|
||||
},
|
||||
value: data.balance
|
||||
}
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: account.id,
|
||||
balance: data.balance,
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
userId: aUserId
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
@ -103,9 +111,18 @@ export class AccountService {
|
||||
where: Prisma.AccountWhereUniqueInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prismaService.account.delete({
|
||||
const account = await this.prismaService.account.delete({
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async getAccounts(aUserId: string): Promise<Account[]> {
|
||||
@ -196,21 +213,26 @@ export class AccountService {
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
|
||||
await this.prismaService.accountBalance.create({
|
||||
data: {
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: where.id_userId
|
||||
}
|
||||
},
|
||||
value: <number>data.balance
|
||||
}
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId: <string>data.id,
|
||||
balance: <number>data.balance,
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
userId: aUserId
|
||||
});
|
||||
|
||||
return this.prismaService.account.update({
|
||||
const account = await this.prismaService.account.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: account.userId
|
||||
})
|
||||
);
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
public async updateAccountBalance({
|
||||
@ -242,17 +264,11 @@ export class AccountService {
|
||||
);
|
||||
|
||||
if (amountInCurrencyOfAccount) {
|
||||
await this.accountBalanceService.createAccountBalance({
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: {
|
||||
userId,
|
||||
id: accountId
|
||||
}
|
||||
}
|
||||
},
|
||||
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
|
||||
await this.accountBalanceService.createOrUpdateAccountBalance({
|
||||
accountId,
|
||||
userId,
|
||||
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber(),
|
||||
date: date.toISOString()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
@ -27,7 +28,8 @@ import { QueueModule } from './queue/queue.module';
|
||||
PropertyModule,
|
||||
QueueModule,
|
||||
SubscriptionModule,
|
||||
SymbolProfileModule
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [AdminService],
|
||||
|
@ -313,6 +313,12 @@ export class AdminService {
|
||||
})
|
||||
]);
|
||||
|
||||
if (assetProfile) {
|
||||
assetProfile.dataProviderInfo = this.dataProviderService
|
||||
.getDataProvider(assetProfile.dataSource)
|
||||
.getDataProviderInfo();
|
||||
}
|
||||
|
||||
return {
|
||||
marketData,
|
||||
assetProfile: assetProfile ?? {
|
||||
@ -329,6 +335,7 @@ export class AdminService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
@ -349,6 +356,7 @@ export class AdminService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
symbol,
|
||||
@ -416,7 +424,7 @@ export class AdminService {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol,
|
||||
assetClass: 'CASH',
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
countriesCount: 0,
|
||||
currency: symbol.replace(DEFAULT_CURRENCY, ''),
|
||||
id: undefined,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { EventsModule } from '@ghostfolio/api/events/events.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { CronService } from '@ghostfolio/api/services/cron.service';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
@ -14,6 +15,7 @@ import {
|
||||
import { BullModule } from '@nestjs/bull';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { ServeStaticModule } from '@nestjs/serve-static';
|
||||
import { StatusCodes } from 'http-status-codes';
|
||||
@ -44,6 +46,7 @@ import { TagModule } from './tag/tag.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
|
||||
@Module({
|
||||
controllers: [AppController],
|
||||
imports: [
|
||||
AdminModule,
|
||||
AccessModule,
|
||||
@ -64,6 +67,8 @@ import { UserModule } from './user/user.module';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
EventEmitterModule.forRoot(),
|
||||
EventsModule,
|
||||
ExchangeRateModule,
|
||||
ExchangeRateDataModule,
|
||||
ExportModule,
|
||||
@ -109,7 +114,6 @@ import { UserModule } from './user/user.module';
|
||||
TwitterBotModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [CronService]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { AuthDeviceController } from '@ghostfolio/api/app/auth-device/auth-device.controller';
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -9,7 +8,6 @@ import { JwtModule } from '@nestjs/jwt';
|
||||
@Module({
|
||||
controllers: [AuthDeviceController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@ -6,10 +5,7 @@ import { AuthDevice, Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AuthDeviceService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async authDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
|
@ -3,7 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration/con
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { Strategy } from 'passport-google-oauth20';
|
||||
import { Profile, Strategy } from 'passport-google-oauth20';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
@ -11,7 +11,7 @@ import { AuthService } from './auth.service';
|
||||
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
public constructor(
|
||||
private readonly authService: AuthService,
|
||||
readonly configurationService: ConfigurationService
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {
|
||||
super({
|
||||
callbackURL: `${configurationService.get(
|
||||
@ -20,7 +20,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
clientID: configurationService.get('GOOGLE_CLIENT_ID'),
|
||||
clientSecret: configurationService.get('GOOGLE_SECRET'),
|
||||
passReqToCallback: true,
|
||||
scope: ['email', 'profile']
|
||||
scope: ['profile']
|
||||
});
|
||||
}
|
||||
|
||||
@ -28,20 +28,17 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
|
||||
request: any,
|
||||
token: string,
|
||||
refreshToken: string,
|
||||
profile,
|
||||
profile: Profile,
|
||||
done: Function,
|
||||
done2: Function
|
||||
) {
|
||||
try {
|
||||
const jwt: string = await this.authService.validateOAuthLogin({
|
||||
const jwt = await this.authService.validateOAuthLogin({
|
||||
provider: Provider.GOOGLE,
|
||||
thirdPartyId: profile.id
|
||||
});
|
||||
const user = {
|
||||
jwt
|
||||
};
|
||||
|
||||
done(null, user);
|
||||
done(null, { jwt });
|
||||
} catch (error) {
|
||||
Logger.error(error, 'GoogleStrategy');
|
||||
done(error, false);
|
||||
|
@ -2,10 +2,12 @@ import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { HEADER_KEY_TIMEZONE } from '@ghostfolio/common/config';
|
||||
import { hasRole } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { HttpException, Injectable } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import * as countriesAndTimezones from 'countries-and-timezones';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
@Injectable()
|
||||
@ -29,6 +31,13 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
if (user) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (hasRole(user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
const country =
|
||||
countriesAndTimezones.getCountryForTimezone(timezone)?.id;
|
||||
|
||||
@ -45,10 +54,20 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
|
||||
return user;
|
||||
} else {
|
||||
throw '';
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error?.getStatus() === StatusCodes.TOO_MANY_REQUESTS) {
|
||||
throw error;
|
||||
} else {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.UNAUTHORIZED),
|
||||
StatusCodes.UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
throw new UnauthorizedException('unauthorized', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import type {
|
||||
BenchmarkMarketDataDetails,
|
||||
BenchmarkResponse,
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
@ -17,7 +18,6 @@ import { BenchmarkService } from './benchmark.service';
|
||||
controllers: [BenchmarkController],
|
||||
exports: [BenchmarkService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
MarketDataModule,
|
||||
@ -25,7 +25,9 @@ import { BenchmarkService } from './benchmark.service';
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolModule,
|
||||
SymbolProfileModule
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule
|
||||
],
|
||||
providers: [BenchmarkService]
|
||||
})
|
||||
|
16
apps/api/src/app/cache/cache.module.ts
vendored
16
apps/api/src/app/cache/cache.module.ts
vendored
@ -1,10 +1,4 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@ -12,14 +6,6 @@ import { CacheController } from './cache.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CacheController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
]
|
||||
imports: [RedisCacheModule]
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@ -12,15 +8,7 @@ import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AccountModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
OrderModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
imports: [AccountModule, ApiModule, OrderModule],
|
||||
controllers: [ExportController],
|
||||
providers: [ExportService]
|
||||
})
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { DataEnhancerModule } from '@ghostfolio/api/services/data-provider/data-enhancer/data-enhancer.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
|
||||
@ -9,7 +9,11 @@ import { HealthService } from './health.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
imports: [ConfigurationModule, DataEnhancerModule, DataProviderModule],
|
||||
imports: [
|
||||
DataEnhancerModule,
|
||||
DataProviderModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
providers: [HealthService]
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ImportResponse } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
|
@ -4,6 +4,8 @@ import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -30,7 +32,9 @@ import { ImportService } from './import.service';
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule
|
||||
],
|
||||
providers: [ImportService]
|
||||
})
|
||||
|
@ -13,10 +13,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
|
||||
import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATA_GATHERING_QUEUE_PRIORITY_HIGH } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getAssetProfileIdentifier,
|
||||
@ -295,6 +292,7 @@ export class ImportService {
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
holdings,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
@ -367,6 +365,7 @@ export class ImportService {
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
holdings,
|
||||
id,
|
||||
isin,
|
||||
name,
|
||||
@ -416,6 +415,11 @@ export class ImportService {
|
||||
User: { connect: { id: user.id } },
|
||||
userId: user.id
|
||||
});
|
||||
|
||||
if (order.SymbolProfile?.symbol) {
|
||||
// Update symbol that may have been assigned in createOrder()
|
||||
assetProfile.symbol = order.SymbolProfile.symbol;
|
||||
}
|
||||
}
|
||||
|
||||
const value = new Big(quantity).mul(unitPrice).toNumber();
|
||||
@ -533,6 +537,7 @@ export class ImportService {
|
||||
assetSubClass: undefined,
|
||||
countries: undefined,
|
||||
createdAt: undefined,
|
||||
holdings: undefined,
|
||||
id: undefined,
|
||||
sectors: undefined,
|
||||
updatedAt: undefined
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Controller, Get, UseInterceptors } from '@nestjs/common';
|
||||
|
@ -2,6 +2,7 @@ import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module'
|
||||
import { PlatformModule } from '@ghostfolio/api/app/platform/platform.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -34,6 +35,7 @@ import { InfoService } from './info.service';
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
TagModule,
|
||||
TransformDataSourceInResponseModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [InfoService]
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
|
||||
import {
|
||||
Controller,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
@ -8,7 +9,11 @@ import { LogoService } from './logo.service';
|
||||
|
||||
@Module({
|
||||
controllers: [LogoController],
|
||||
imports: [ConfigurationModule, SymbolProfileModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule
|
||||
],
|
||||
providers: [LogoService]
|
||||
})
|
||||
export class LogoModule {}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
@ -11,7 +11,7 @@ import {
|
||||
DATA_GATHERING_QUEUE_PRIORITY_HIGH,
|
||||
HEADER_KEY_IMPERSONATION
|
||||
} from '@ghostfolio/common/config';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import type { DateRange, RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
@ -53,22 +53,34 @@ export class OrderController {
|
||||
@Delete()
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOrders(): Promise<number> {
|
||||
public async deleteOrders(
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<number> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
return this.orderService.deleteOrders({
|
||||
filters,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HasPermission(permissions.deleteOrder)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOrder(@Param('id') id: string): Promise<OrderModel> {
|
||||
const order = await this.orderService.order({ id });
|
||||
const order = await this.orderService.order({
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.deleteOrder) ||
|
||||
!order ||
|
||||
order.userId !== this.request.user.id
|
||||
) {
|
||||
if (!order) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
@ -88,21 +100,26 @@ export class OrderController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('range') dateRange?: DateRange,
|
||||
@Query('skip') skip?: number,
|
||||
@Query('sortColumn') sortColumn?: string,
|
||||
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('take') take?: number
|
||||
): Promise<Activities> {
|
||||
let endDate: Date;
|
||||
let startDate: Date;
|
||||
|
||||
if (dateRange) {
|
||||
({ endDate, startDate } = getInterval(dateRange));
|
||||
}
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const { endDate, startDate } = getInterval(dateRange);
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
@ -2,9 +2,10 @@ import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/accou
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
@ -23,15 +24,16 @@ import { OrderService } from './order.service';
|
||||
imports: [
|
||||
ApiModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedactValuesInResponseModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
UserModule
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule
|
||||
],
|
||||
providers: [AccountBalanceService, AccountService, OrderService]
|
||||
})
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
@ -13,6 +14,7 @@ import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
@ -27,7 +29,6 @@ import { endOfToday, isAfter } from 'date-fns';
|
||||
import { groupBy, uniqBy } from 'lodash';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -35,6 +36,7 @@ export class OrderService {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
@ -138,7 +140,8 @@ export class OrderService {
|
||||
return { id };
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
include: { SymbolProfile: true }
|
||||
});
|
||||
|
||||
if (updateAccountBalance === true) {
|
||||
@ -160,6 +163,13 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: order.userId
|
||||
})
|
||||
);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
@ -174,14 +184,48 @@ export class OrderService {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: order.userId
|
||||
})
|
||||
);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
public async deleteOrders(where: Prisma.OrderWhereInput): Promise<number> {
|
||||
const { count } = await this.prismaService.order.deleteMany({
|
||||
where
|
||||
public async deleteOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}): Promise<number> {
|
||||
const { activities } = await this.getOrders({
|
||||
filters,
|
||||
userId,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
const { count } = await this.prismaService.order.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: activities.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({ userId })
|
||||
);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
@ -455,7 +499,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
return this.prismaService.order.update({
|
||||
const order = await this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
isDraft,
|
||||
@ -467,6 +511,15 @@ export class OrderService {
|
||||
},
|
||||
where
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId: order.userId
|
||||
})
|
||||
);
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
private async orders(params: {
|
||||
|
@ -1,10 +1,6 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
|
||||
import {
|
||||
SymbolMetrics,
|
||||
TimelinePosition,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
export class MWRPortfolioCalculator extends PortfolioCalculator {
|
||||
protected calculateOverallPerformance(
|
||||
|
@ -20,7 +20,12 @@ export const symbolProfileDummyData = {
|
||||
assetSubClass: undefined,
|
||||
countries: [],
|
||||
createdAt: undefined,
|
||||
holdings: [],
|
||||
id: undefined,
|
||||
sectors: [],
|
||||
updatedAt: undefined
|
||||
};
|
||||
|
||||
export const userDummyData = {
|
||||
id: 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'
|
||||
};
|
||||
|
@ -1,7 +1,10 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
|
||||
import { DateRange, UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@ -17,37 +20,59 @@ export enum PerformanceCalculationType {
|
||||
@Injectable()
|
||||
export class PortfolioCalculatorFactory {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly currentRateService: CurrentRateService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {}
|
||||
|
||||
public createCalculator({
|
||||
accountBalanceItems = [],
|
||||
activities,
|
||||
calculationType,
|
||||
currency,
|
||||
dateRange = 'max'
|
||||
dateRange = 'max',
|
||||
hasFilters,
|
||||
isExperimentalFeatures = false,
|
||||
userId
|
||||
}: {
|
||||
accountBalanceItems?: HistoricalDataItem[];
|
||||
activities: Activity[];
|
||||
calculationType: PerformanceCalculationType;
|
||||
currency: string;
|
||||
dateRange?: DateRange;
|
||||
hasFilters: boolean;
|
||||
isExperimentalFeatures?: boolean;
|
||||
userId: string;
|
||||
}): PortfolioCalculator {
|
||||
const useCache = !hasFilters && isExperimentalFeatures;
|
||||
|
||||
switch (calculationType) {
|
||||
case PerformanceCalculationType.MWR:
|
||||
return new MWRPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
dateRange,
|
||||
useCache,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
case PerformanceCalculationType.TWR:
|
||||
return new TWRPortfolioCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
currency,
|
||||
currentRateService: this.currentRateService,
|
||||
dateRange,
|
||||
exchangeRateDataService: this.exchangeRateDataService
|
||||
useCache,
|
||||
userId,
|
||||
configurationService: this.configurationService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
redisCacheService: this.redisCacheService
|
||||
});
|
||||
default:
|
||||
throw new Error('Invalid calculation type');
|
||||
|
@ -1,13 +1,14 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
|
||||
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import {
|
||||
getFactor,
|
||||
getInterval
|
||||
} from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MAX_CHART_ITEMS } from '@ghostfolio/common/config';
|
||||
@ -23,72 +24,114 @@ import {
|
||||
InvestmentItem,
|
||||
ResponseError,
|
||||
SymbolMetrics,
|
||||
TimelinePosition,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
import { DateRange, GroupBy } from '@ghostfolio/common/types';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Big } from 'big.js';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import {
|
||||
differenceInDays,
|
||||
eachDayOfInterval,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
max,
|
||||
min,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { last, uniq, uniqBy } from 'lodash';
|
||||
import { first, last, uniq, uniqBy } from 'lodash';
|
||||
|
||||
export abstract class PortfolioCalculator {
|
||||
protected static readonly ENABLE_LOGGING = false;
|
||||
|
||||
protected orders: PortfolioOrder[];
|
||||
protected accountBalanceItems: HistoricalDataItem[];
|
||||
protected activities: PortfolioOrder[];
|
||||
|
||||
private configurationService: ConfigurationService;
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private dataProviderInfos: DataProviderInfo[];
|
||||
private dateRange: DateRange;
|
||||
private endDate: Date;
|
||||
private exchangeRateDataService: ExchangeRateDataService;
|
||||
private redisCacheService: RedisCacheService;
|
||||
private snapshot: PortfolioSnapshot;
|
||||
private snapshotPromise: Promise<void>;
|
||||
private startDate: Date;
|
||||
private transactionPoints: TransactionPoint[];
|
||||
private useCache: boolean;
|
||||
private userId: string;
|
||||
|
||||
public constructor({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
configurationService,
|
||||
currency,
|
||||
currentRateService,
|
||||
dateRange,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService,
|
||||
useCache,
|
||||
userId
|
||||
}: {
|
||||
accountBalanceItems: HistoricalDataItem[];
|
||||
activities: Activity[];
|
||||
configurationService: ConfigurationService;
|
||||
currency: string;
|
||||
currentRateService: CurrentRateService;
|
||||
dateRange: DateRange;
|
||||
exchangeRateDataService: ExchangeRateDataService;
|
||||
redisCacheService: RedisCacheService;
|
||||
useCache: boolean;
|
||||
userId: string;
|
||||
}) {
|
||||
this.accountBalanceItems = accountBalanceItems;
|
||||
this.configurationService = configurationService;
|
||||
this.currency = currency;
|
||||
this.currentRateService = currentRateService;
|
||||
this.dateRange = dateRange;
|
||||
this.exchangeRateDataService = exchangeRateDataService;
|
||||
this.orders = activities.map(
|
||||
({ date, fee, quantity, SymbolProfile, tags = [], type, unitPrice }) => {
|
||||
return {
|
||||
SymbolProfile,
|
||||
tags,
|
||||
type,
|
||||
date: format(date, DATE_FORMAT),
|
||||
fee: new Big(fee),
|
||||
quantity: new Big(quantity),
|
||||
unitPrice: new Big(unitPrice)
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.orders.sort((a, b) => {
|
||||
return a.date?.localeCompare(b.date);
|
||||
});
|
||||
this.activities = activities
|
||||
.map(
|
||||
({
|
||||
date,
|
||||
fee,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
tags = [],
|
||||
type,
|
||||
unitPrice
|
||||
}) => {
|
||||
if (isAfter(date, new Date(Date.now()))) {
|
||||
// Adapt date to today if activity is in future (e.g. liability)
|
||||
// to include it in the interval
|
||||
date = endOfDay(new Date(Date.now()));
|
||||
}
|
||||
|
||||
return {
|
||||
SymbolProfile,
|
||||
tags,
|
||||
type,
|
||||
date: format(date, DATE_FORMAT),
|
||||
fee: new Big(fee),
|
||||
quantity: new Big(quantity),
|
||||
unitPrice: new Big(unitPrice)
|
||||
};
|
||||
}
|
||||
)
|
||||
.sort((a, b) => {
|
||||
return a.date?.localeCompare(b.date);
|
||||
});
|
||||
|
||||
this.redisCacheService = redisCacheService;
|
||||
this.useCache = useCache;
|
||||
this.userId = userId;
|
||||
|
||||
const { endDate, startDate } = getInterval(dateRange);
|
||||
|
||||
@ -383,10 +426,6 @@ export abstract class PortfolioCalculator {
|
||||
dateRange?: DateRange;
|
||||
withDataDecimation?: boolean;
|
||||
}): Promise<HistoricalDataItem[]> {
|
||||
if (this.getTransactionPoints().length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const { endDate, startDate } = getInterval(dateRange, this.getStartDate());
|
||||
|
||||
const daysInMarket = differenceInDays(endDate, startDate) + 1;
|
||||
@ -485,6 +524,7 @@ export abstract class PortfolioCalculator {
|
||||
investmentValueWithCurrencyEffect: Big;
|
||||
totalCurrentValue: Big;
|
||||
totalCurrentValueWithCurrencyEffect: Big;
|
||||
totalAccountBalanceWithCurrencyEffect: Big;
|
||||
totalInvestmentValue: Big;
|
||||
totalInvestmentValueWithCurrencyEffect: Big;
|
||||
totalNetPerformanceValue: Big;
|
||||
@ -544,9 +584,24 @@ export abstract class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
let lastDate = format(this.startDate, DATE_FORMAT);
|
||||
|
||||
for (const currentDate of dates) {
|
||||
const dateString = format(currentDate, DATE_FORMAT);
|
||||
|
||||
accumulatedValuesByDate[dateString] = {
|
||||
investmentValueWithCurrencyEffect: new Big(0),
|
||||
totalAccountBalanceWithCurrencyEffect: new Big(0),
|
||||
totalCurrentValue: new Big(0),
|
||||
totalCurrentValueWithCurrencyEffect: new Big(0),
|
||||
totalInvestmentValue: new Big(0),
|
||||
totalInvestmentValueWithCurrencyEffect: new Big(0),
|
||||
totalNetPerformanceValue: new Big(0),
|
||||
totalNetPerformanceValueWithCurrencyEffect: new Big(0),
|
||||
totalTimeWeightedInvestmentValue: new Big(0),
|
||||
totalTimeWeightedInvestmentValueWithCurrencyEffect: new Big(0)
|
||||
};
|
||||
|
||||
for (const symbol of Object.keys(valuesBySymbol)) {
|
||||
const symbolValues = valuesBySymbol[symbol];
|
||||
|
||||
@ -584,49 +639,94 @@ export abstract class PortfolioCalculator {
|
||||
dateString
|
||||
] ?? new Big(0);
|
||||
|
||||
accumulatedValuesByDate[dateString] = {
|
||||
investmentValueWithCurrencyEffect: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.investmentValueWithCurrencyEffect ?? new Big(0)
|
||||
).add(investmentValueWithCurrencyEffect),
|
||||
totalCurrentValue: (
|
||||
accumulatedValuesByDate[dateString]?.totalCurrentValue ?? new Big(0)
|
||||
).add(currentValue),
|
||||
totalCurrentValueWithCurrencyEffect: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.totalCurrentValueWithCurrencyEffect ?? new Big(0)
|
||||
).add(currentValueWithCurrencyEffect),
|
||||
totalInvestmentValue: (
|
||||
accumulatedValuesByDate[dateString]?.totalInvestmentValue ??
|
||||
new Big(0)
|
||||
).add(investmentValueAccumulated),
|
||||
totalInvestmentValueWithCurrencyEffect: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.totalInvestmentValueWithCurrencyEffect ?? new Big(0)
|
||||
).add(investmentValueAccumulatedWithCurrencyEffect),
|
||||
totalNetPerformanceValue: (
|
||||
accumulatedValuesByDate[dateString]?.totalNetPerformanceValue ??
|
||||
new Big(0)
|
||||
).add(netPerformanceValue),
|
||||
totalNetPerformanceValueWithCurrencyEffect: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.totalNetPerformanceValueWithCurrencyEffect ?? new Big(0)
|
||||
).add(netPerformanceValueWithCurrencyEffect),
|
||||
totalTimeWeightedInvestmentValue: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.totalTimeWeightedInvestmentValue ?? new Big(0)
|
||||
).add(timeWeightedInvestmentValue),
|
||||
totalTimeWeightedInvestmentValueWithCurrencyEffect: (
|
||||
accumulatedValuesByDate[dateString]
|
||||
?.totalTimeWeightedInvestmentValueWithCurrencyEffect ?? new Big(0)
|
||||
).add(timeWeightedInvestmentValueWithCurrencyEffect)
|
||||
};
|
||||
accumulatedValuesByDate[dateString].investmentValueWithCurrencyEffect =
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].investmentValueWithCurrencyEffect.add(
|
||||
investmentValueWithCurrencyEffect
|
||||
);
|
||||
|
||||
accumulatedValuesByDate[dateString].totalCurrentValue =
|
||||
accumulatedValuesByDate[dateString].totalCurrentValue.add(
|
||||
currentValue
|
||||
);
|
||||
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalCurrentValueWithCurrencyEffect = accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalCurrentValueWithCurrencyEffect.add(
|
||||
currentValueWithCurrencyEffect
|
||||
);
|
||||
|
||||
accumulatedValuesByDate[dateString].totalInvestmentValue =
|
||||
accumulatedValuesByDate[dateString].totalInvestmentValue.add(
|
||||
investmentValueAccumulated
|
||||
);
|
||||
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalInvestmentValueWithCurrencyEffect = accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalInvestmentValueWithCurrencyEffect.add(
|
||||
investmentValueAccumulatedWithCurrencyEffect
|
||||
);
|
||||
|
||||
accumulatedValuesByDate[dateString].totalNetPerformanceValue =
|
||||
accumulatedValuesByDate[dateString].totalNetPerformanceValue.add(
|
||||
netPerformanceValue
|
||||
);
|
||||
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalNetPerformanceValueWithCurrencyEffect = accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalNetPerformanceValueWithCurrencyEffect.add(
|
||||
netPerformanceValueWithCurrencyEffect
|
||||
);
|
||||
|
||||
accumulatedValuesByDate[dateString].totalTimeWeightedInvestmentValue =
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalTimeWeightedInvestmentValue.add(timeWeightedInvestmentValue);
|
||||
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalTimeWeightedInvestmentValueWithCurrencyEffect =
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalTimeWeightedInvestmentValueWithCurrencyEffect.add(
|
||||
timeWeightedInvestmentValueWithCurrencyEffect
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
this.accountBalanceItems.some(({ date }) => {
|
||||
return date === dateString;
|
||||
})
|
||||
) {
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalAccountBalanceWithCurrencyEffect = new Big(
|
||||
this.accountBalanceItems.find(({ date }) => {
|
||||
return date === dateString;
|
||||
}).value
|
||||
);
|
||||
} else {
|
||||
accumulatedValuesByDate[
|
||||
dateString
|
||||
].totalAccountBalanceWithCurrencyEffect =
|
||||
accumulatedValuesByDate[lastDate]
|
||||
?.totalAccountBalanceWithCurrencyEffect ?? new Big(0);
|
||||
}
|
||||
|
||||
lastDate = dateString;
|
||||
}
|
||||
|
||||
return Object.entries(accumulatedValuesByDate).map(([date, values]) => {
|
||||
const {
|
||||
investmentValueWithCurrencyEffect,
|
||||
totalAccountBalanceWithCurrencyEffect,
|
||||
totalCurrentValue,
|
||||
totalCurrentValueWithCurrencyEffect,
|
||||
totalInvestmentValue,
|
||||
@ -661,6 +761,11 @@ export abstract class PortfolioCalculator {
|
||||
netPerformance: totalNetPerformanceValue.toNumber(),
|
||||
netPerformanceWithCurrencyEffect:
|
||||
totalNetPerformanceValueWithCurrencyEffect.toNumber(),
|
||||
// TODO: Add valuables
|
||||
netWorth: totalCurrentValueWithCurrencyEffect
|
||||
.plus(totalAccountBalanceWithCurrencyEffect)
|
||||
.toNumber(),
|
||||
totalAccountBalance: totalAccountBalanceWithCurrencyEffect.toNumber(),
|
||||
totalInvestment: totalInvestmentValue.toNumber(),
|
||||
totalInvestmentValueWithCurrencyEffect:
|
||||
totalInvestmentValueWithCurrencyEffect.toNumber(),
|
||||
@ -749,9 +854,30 @@ export abstract class PortfolioCalculator {
|
||||
}
|
||||
|
||||
public getStartDate() {
|
||||
return this.transactionPoints.length > 0
|
||||
? parseDate(this.transactionPoints[0].date)
|
||||
: new Date();
|
||||
let firstAccountBalanceDate: Date;
|
||||
let firstActivityDate: Date;
|
||||
|
||||
try {
|
||||
const firstAccountBalanceDateString = first(
|
||||
this.accountBalanceItems
|
||||
)?.date;
|
||||
firstAccountBalanceDate = firstAccountBalanceDateString
|
||||
? parseDate(firstAccountBalanceDateString)
|
||||
: new Date();
|
||||
} catch (error) {
|
||||
firstAccountBalanceDate = new Date();
|
||||
}
|
||||
|
||||
try {
|
||||
const firstActivityDateString = this.transactionPoints[0].date;
|
||||
firstActivityDate = firstActivityDateString
|
||||
? parseDate(firstActivityDateString)
|
||||
: new Date();
|
||||
} catch (error) {
|
||||
firstActivityDate = new Date();
|
||||
}
|
||||
|
||||
return min([firstAccountBalanceDate, firstActivityDate]);
|
||||
}
|
||||
|
||||
protected abstract getSymbolMetrics({
|
||||
@ -799,7 +925,7 @@ export abstract class PortfolioCalculator {
|
||||
tags,
|
||||
type,
|
||||
unitPrice
|
||||
} of this.orders) {
|
||||
} of this.activities) {
|
||||
let currentTransactionPointItem: TransactionPointSymbol;
|
||||
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
|
||||
|
||||
@ -923,6 +1049,52 @@ export abstract class PortfolioCalculator {
|
||||
}
|
||||
|
||||
private async initialize() {
|
||||
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
|
||||
if (this.useCache) {
|
||||
const startTimeTotal = performance.now();
|
||||
|
||||
const cachedSnapshot = await this.redisCacheService.get(
|
||||
this.redisCacheService.getPortfolioSnapshotKey({
|
||||
userId: this.userId
|
||||
})
|
||||
);
|
||||
|
||||
if (cachedSnapshot) {
|
||||
this.snapshot = plainToClass(
|
||||
PortfolioSnapshot,
|
||||
JSON.parse(cachedSnapshot)
|
||||
);
|
||||
|
||||
Logger.debug(
|
||||
`Fetched portfolio snapshot from cache in ${(
|
||||
(performance.now() - startTimeTotal) /
|
||||
1000
|
||||
).toFixed(3)} seconds`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
} else {
|
||||
this.snapshot = await this.computeSnapshot(
|
||||
this.startDate,
|
||||
this.endDate
|
||||
);
|
||||
|
||||
this.redisCacheService.set(
|
||||
this.redisCacheService.getPortfolioSnapshotKey({
|
||||
userId: this.userId
|
||||
}),
|
||||
JSON.stringify(this.snapshot),
|
||||
this.configurationService.get('CACHE_QUOTES_TTL')
|
||||
);
|
||||
|
||||
Logger.debug(
|
||||
`Computed portfolio snapshot in ${(
|
||||
(performance.now() - startTimeTotal) /
|
||||
1000
|
||||
).toFixed(3)} seconds`,
|
||||
'PortfolioCalculator'
|
||||
);
|
||||
}
|
||||
} else {
|
||||
this.snapshot = await this.computeSnapshot(this.startDate, this.endDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PortfolioCalculatorFactory,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -101,7 +122,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PerformanceCalculationType,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PortfolioCalculatorFactory,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PortfolioCalculatorFactory,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
@ -37,11 +50,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -99,7 +120,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PortfolioCalculatorFactory,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'USD'
|
||||
currency: 'USD',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PortfolioCalculatorFactory,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
@ -37,11 +50,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -84,7 +105,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PortfolioCalculatorFactory,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -71,7 +92,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'USD'
|
||||
currency: 'USD',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PortfolioCalculatorFactory,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -53,7 +74,7 @@ describe('PortfolioCalculator', () => {
|
||||
const activities: Activity[] = [
|
||||
{
|
||||
...activityDummyData,
|
||||
date: new Date('2022-01-01'),
|
||||
date: new Date('2023-01-01'), // Date in future
|
||||
fee: 0,
|
||||
quantity: 1,
|
||||
SymbolProfile: {
|
||||
@ -71,64 +92,17 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'USD'
|
||||
currency: 'USD',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
parseDate('2022-01-01')
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(portfolioSnapshot).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('0'),
|
||||
grossPerformancePercentage: new Big('0'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||
grossPerformanceWithCurrencyEffect: new Big('0'),
|
||||
hasErrors: true,
|
||||
netPerformance: new Big('0'),
|
||||
netPerformancePercentage: new Big('0'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big('0'),
|
||||
netPerformanceWithCurrencyEffect: new Big('0'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('3000'),
|
||||
currency: 'USD',
|
||||
dataSource: 'MANUAL',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2022-01-01',
|
||||
grossPerformance: null,
|
||||
grossPerformancePercentage: null,
|
||||
grossPerformancePercentageWithCurrencyEffect: null,
|
||||
grossPerformanceWithCurrencyEffect: null,
|
||||
investment: new Big('0'),
|
||||
investmentWithCurrencyEffect: new Big('0'),
|
||||
marketPrice: null,
|
||||
marketPriceInBaseCurrency: 3000,
|
||||
netPerformance: null,
|
||||
netPerformancePercentage: null,
|
||||
netPerformancePercentageWithCurrencyEffect: null,
|
||||
netPerformanceWithCurrencyEffect: null,
|
||||
quantity: new Big('0'),
|
||||
symbol: '55196015-1365-4560-aa60-8751ae6d18f8',
|
||||
tags: [],
|
||||
timeWeightedInvestment: new Big('0'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('0'),
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalFeesWithCurrencyEffect: new Big('0'),
|
||||
totalInterestWithCurrencyEffect: new Big('0'),
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0'),
|
||||
totalLiabilitiesWithCurrencyEffect: new Big('0'),
|
||||
totalValuablesWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
const liabilitiesInBaseCurrency =
|
||||
await portfolioCalculator.getLiabilitiesInBaseCurrency();
|
||||
|
||||
expect(liabilitiesInBaseCurrency).toEqual(new Big(3000));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PerformanceCalculationType,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { ExchangeRateDataServiceMock } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service.mock';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
@ -24,6 +28,15 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
@ -37,11 +50,15 @@ jest.mock(
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -51,9 +68,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -99,7 +120,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'USD'
|
||||
currency: 'USD',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(
|
||||
|
@ -1,9 +1,13 @@
|
||||
import { userDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PerformanceCalculationType,
|
||||
PortfolioCalculatorFactory
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -19,12 +23,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -34,9 +51,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -49,7 +70,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities: [],
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const start = subDays(new Date(Date.now()), 10);
|
||||
@ -90,7 +113,12 @@ describe('PortfolioCalculator', () => {
|
||||
|
||||
expect(investments).toEqual([]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([]);
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{
|
||||
date: '2021-12-01',
|
||||
investment: 0
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PerformanceCalculationType,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
activityDummyData,
|
||||
symbolProfileDummyData
|
||||
symbolProfileDummyData,
|
||||
userDummyData
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils';
|
||||
import {
|
||||
PerformanceCalculationType,
|
||||
@ -9,6 +10,9 @@ import {
|
||||
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
@ -23,12 +27,25 @@ jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
RedisCacheService: jest.fn().mockImplementation(() => {
|
||||
return RedisCacheServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -38,9 +55,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
@ -86,7 +107,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioCalculator = factory.createCalculator({
|
||||
activities,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: 'CHF'
|
||||
currency: 'CHF',
|
||||
hasFilters: false,
|
||||
userId: userDummyData.id
|
||||
});
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
@ -113,6 +136,8 @@ describe('PortfolioCalculator', () => {
|
||||
netPerformanceInPercentage: 0,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
netWorth: 151.6,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 151.6,
|
||||
totalInvestmentValueWithCurrencyEffect: 151.6,
|
||||
value: 151.6,
|
||||
@ -126,6 +151,8 @@ describe('PortfolioCalculator', () => {
|
||||
netPerformanceInPercentage: 13.100263852242744,
|
||||
netPerformanceInPercentageWithCurrencyEffect: 13.100263852242744,
|
||||
netPerformanceWithCurrencyEffect: 19.86,
|
||||
netWorth: 0,
|
||||
totalAccountBalance: 0,
|
||||
totalInvestment: 0,
|
||||
totalInvestmentValueWithCurrencyEffect: 0,
|
||||
value: 0,
|
||||
|
@ -1,13 +1,19 @@
|
||||
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let configurationService: ConfigurationService;
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
let factory: PortfolioCalculatorFactory;
|
||||
let redisCacheService: RedisCacheService;
|
||||
|
||||
beforeEach(() => {
|
||||
configurationService = new ConfigurationService();
|
||||
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
@ -17,9 +23,13 @@ describe('PortfolioCalculator', () => {
|
||||
null
|
||||
);
|
||||
|
||||
redisCacheService = new RedisCacheService(null, null);
|
||||
|
||||
factory = new PortfolioCalculatorFactory(
|
||||
configurationService,
|
||||
currentRateService,
|
||||
exchangeRateDataService
|
||||
exchangeRateDataService,
|
||||
redisCacheService
|
||||
);
|
||||
});
|
||||
|
||||
|
@ -1,13 +1,9 @@
|
||||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
|
||||
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order-item.interface';
|
||||
import { PortfolioSnapshot } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-snapshot.interface';
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
SymbolMetrics,
|
||||
TimelinePosition,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { SymbolMetrics, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Big } from 'big.js';
|
||||
@ -188,6 +184,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
[date: string]: Big;
|
||||
} = {};
|
||||
|
||||
let totalAccountBalanceInBaseCurrency = new Big(0);
|
||||
let totalDividend = new Big(0);
|
||||
let totalDividendInBaseCurrency = new Big(0);
|
||||
let totalInterest = new Big(0);
|
||||
@ -206,7 +203,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
let valueAtStartDateWithCurrencyEffect: Big;
|
||||
|
||||
// Clone orders to keep the original values in this.orders
|
||||
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter(
|
||||
let orders: PortfolioOrderItem[] = cloneDeep(this.activities).filter(
|
||||
({ SymbolProfile }) => {
|
||||
return SymbolProfile.symbol === symbol;
|
||||
}
|
||||
@ -237,6 +234,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalAccountBalanceInBaseCurrency: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInterest: new Big(0),
|
||||
@ -286,6 +284,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalAccountBalanceInBaseCurrency: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInterest: new Big(0),
|
||||
@ -334,8 +333,10 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
if (isChartMode) {
|
||||
const datesWithOrders = {};
|
||||
|
||||
for (const order of orders) {
|
||||
datesWithOrders[order.date] = true;
|
||||
for (const { date, type } of orders) {
|
||||
if (['BUY', 'SELL'].includes(type)) {
|
||||
datesWithOrders[date] = true;
|
||||
}
|
||||
}
|
||||
|
||||
while (isBefore(day, end)) {
|
||||
@ -396,11 +397,46 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log();
|
||||
console.log();
|
||||
console.log(i + 1, order.type, order.itemType);
|
||||
console.log(
|
||||
i + 1,
|
||||
order.date,
|
||||
order.type,
|
||||
order.itemType ? `(${order.itemType})` : ''
|
||||
);
|
||||
}
|
||||
|
||||
const exchangeRateAtOrderDate = exchangeRates[order.date];
|
||||
|
||||
if (order.type === 'DIVIDEND') {
|
||||
const dividend = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalDividend = totalDividend.plus(dividend);
|
||||
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
|
||||
dividend.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'INTEREST') {
|
||||
const interest = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalInterest = totalInterest.plus(interest);
|
||||
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
|
||||
interest.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'ITEM') {
|
||||
const valuables = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalValuables = totalValuables.plus(valuables);
|
||||
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
|
||||
valuables.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'LIABILITY') {
|
||||
const liabilities = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalLiabilities = totalLiabilities.plus(liabilities);
|
||||
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
|
||||
liabilities.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@ -483,13 +519,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
|
||||
console.log(
|
||||
'totalInvestmentWithCurrencyEffect',
|
||||
totalInvestmentWithCurrencyEffect.toNumber()
|
||||
);
|
||||
|
||||
console.log('order.quantity', order.quantity.toNumber());
|
||||
console.log('transactionInvestment', transactionInvestment.toNumber());
|
||||
|
||||
@ -536,36 +565,6 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
|
||||
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
|
||||
|
||||
if (order.type === 'DIVIDEND') {
|
||||
const dividend = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalDividend = totalDividend.plus(dividend);
|
||||
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
|
||||
dividend.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'INTEREST') {
|
||||
const interest = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalInterest = totalInterest.plus(interest);
|
||||
totalInterestInBaseCurrency = totalInterestInBaseCurrency.plus(
|
||||
interest.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'ITEM') {
|
||||
const valuables = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalValuables = totalValuables.plus(valuables);
|
||||
totalValuablesInBaseCurrency = totalValuablesInBaseCurrency.plus(
|
||||
valuables.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
} else if (order.type === 'LIABILITY') {
|
||||
const liabilities = order.quantity.mul(order.unitPrice);
|
||||
|
||||
totalLiabilities = totalLiabilities.plus(liabilities);
|
||||
totalLiabilitiesInBaseCurrency = totalLiabilitiesInBaseCurrency.plus(
|
||||
liabilities.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
|
||||
|
||||
const valueOfInvestmentWithCurrencyEffect = totalUnits.mul(
|
||||
@ -875,6 +874,7 @@ export class TWRPortfolioCalculator extends PortfolioCalculator {
|
||||
netPerformanceValuesWithCurrencyEffect,
|
||||
timeWeightedInvestmentValues,
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect,
|
||||
totalAccountBalanceInBaseCurrency,
|
||||
totalDividend,
|
||||
totalDividendInBaseCurrency,
|
||||
totalInterest,
|
||||
|
@ -8,6 +8,13 @@ import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
function mockGetValue(symbol: string, date: Date) {
|
||||
switch (symbol) {
|
||||
case '55196015-1365-4560-aa60-8751ae6d18f8':
|
||||
if (isSameDay(parseDate('2022-01-31'), date)) {
|
||||
return { marketPrice: 3000 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'BALN.SW':
|
||||
if (isSameDay(parseDate('2021-11-12'), date)) {
|
||||
return { marketPrice: 146 };
|
||||
|
@ -7,7 +7,7 @@ import {
|
||||
|
||||
import { Account, Tag } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
export interface PortfolioHoldingDetail {
|
||||
accounts: Account[];
|
||||
averagePrice: number;
|
||||
dataProviderInfo: DataProviderInfo;
|
@ -1,5 +0,0 @@
|
||||
import { Position } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioPositions {
|
||||
positions: Position[];
|
||||
}
|
@ -1,24 +0,0 @@
|
||||
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
import { Big } from 'big.js';
|
||||
|
||||
export interface PortfolioSnapshot extends ResponseError {
|
||||
currentValueInBaseCurrency: Big;
|
||||
grossPerformance: Big;
|
||||
grossPerformanceWithCurrencyEffect: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
grossPerformancePercentageWithCurrencyEffect: Big;
|
||||
netAnnualizedPerformance?: Big;
|
||||
netAnnualizedPerformanceWithCurrencyEffect?: Big;
|
||||
netPerformance: Big;
|
||||
netPerformanceWithCurrencyEffect: Big;
|
||||
netPerformancePercentage: Big;
|
||||
netPerformancePercentageWithCurrencyEffect: Big;
|
||||
positions: TimelinePosition[];
|
||||
totalFeesWithCurrencyEffect: Big;
|
||||
totalInterestWithCurrencyEffect: Big;
|
||||
totalInvestment: Big;
|
||||
totalInvestmentWithCurrencyEffect: Big;
|
||||
totalLiabilitiesWithCurrencyEffect: Big;
|
||||
totalValuablesWithCurrencyEffect: Big;
|
||||
}
|
@ -7,9 +7,9 @@ import {
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { getInterval } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
@ -27,6 +27,10 @@ import {
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
hasReadRestrictedAccessPermission,
|
||||
isRestrictedView
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import type {
|
||||
DateRange,
|
||||
GroupBy,
|
||||
@ -47,11 +51,11 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { AssetClass, AssetSubClass } from '@prisma/client';
|
||||
import { Big } from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Controller('portfolio')
|
||||
@ -84,11 +88,6 @@ export class PortfolioController {
|
||||
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
hasDetails = this.request.user.subscription.type === 'Premium';
|
||||
@ -117,8 +116,11 @@ export class PortfolioController {
|
||||
let portfolioSummary = summary;
|
||||
|
||||
if (
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
}) ||
|
||||
isRestrictedView(this.request.user)
|
||||
) {
|
||||
const totalInvestment = Object.values(holdings)
|
||||
.map(({ investment }) => {
|
||||
@ -128,14 +130,19 @@ export class PortfolioController {
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.filter(({ assetClass, assetSubClass }) => {
|
||||
return assetClass !== 'CASH' && assetSubClass !== 'CASH';
|
||||
return (
|
||||
assetClass !== AssetClass.LIQUIDITY &&
|
||||
assetSubClass !== AssetSubClass.CASH
|
||||
);
|
||||
})
|
||||
.map(({ valueInBaseCurrency }) => {
|
||||
return valueInBaseCurrency;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
.reduce((a, b) => {
|
||||
return a + b;
|
||||
}, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
for (const [, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
portfolioPosition.valueInPercentage =
|
||||
@ -153,27 +160,30 @@ export class PortfolioController {
|
||||
|
||||
if (
|
||||
hasDetails === false ||
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
}) ||
|
||||
isRestrictedView(this.request.user)
|
||||
) {
|
||||
portfolioSummary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentGrossPerformanceWithCurrencyEffect',
|
||||
'currentNetPerformance',
|
||||
'currentNetPerformanceWithCurrencyEffect',
|
||||
'currentNetWorth',
|
||||
'currentValue',
|
||||
'currentValueInBaseCurrency',
|
||||
'dividendInBaseCurrency',
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'filteredValueInBaseCurrency',
|
||||
'fireWealth',
|
||||
'grossPerformance',
|
||||
'grossPerformanceWithCurrencyEffect',
|
||||
'interest',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netPerformance',
|
||||
'netPerformanceWithCurrencyEffect',
|
||||
'totalBuy',
|
||||
'totalInvestment',
|
||||
'totalSell',
|
||||
@ -185,11 +195,11 @@ export class PortfolioController {
|
||||
holdings[symbol] = {
|
||||
...portfolioPosition,
|
||||
assetClass:
|
||||
hasDetails || portfolioPosition.assetClass === 'CASH'
|
||||
hasDetails || portfolioPosition.assetClass === AssetClass.LIQUIDITY
|
||||
? portfolioPosition.assetClass
|
||||
: undefined,
|
||||
assetSubClass:
|
||||
hasDetails || portfolioPosition.assetSubClass === 'CASH'
|
||||
hasDetails || portfolioPosition.assetSubClass === AssetSubClass.CASH
|
||||
? portfolioPosition.assetSubClass
|
||||
: undefined,
|
||||
countries: hasDetails ? portfolioPosition.countries : [],
|
||||
@ -221,12 +231,6 @@ export class PortfolioController {
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDividends> {
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -254,8 +258,11 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
if (
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
}) ||
|
||||
isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxDividend = dividends.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
@ -290,6 +297,7 @@ export class PortfolioController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('holdingType') filterByHoldingType?: string,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioHoldingsResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
@ -301,6 +309,7 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
@ -319,12 +328,6 @@ export class PortfolioController {
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioInvestments> {
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -340,8 +343,11 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
if (
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
}) ||
|
||||
isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxInvestment = investments.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
@ -390,12 +396,6 @@ export class PortfolioController {
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
});
|
||||
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
@ -411,9 +411,12 @@ export class PortfolioController {
|
||||
});
|
||||
|
||||
if (
|
||||
hasReadRestrictedAccessPermission ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user: this.request.user
|
||||
}) ||
|
||||
isRestrictedView(this.request.user) ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN'
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
({
|
||||
@ -441,10 +444,14 @@ export class PortfolioController {
|
||||
.div(performanceInformation.performance.totalInvestment)
|
||||
.toNumber(),
|
||||
valueInPercentage:
|
||||
performanceInformation.performance.currentValue === 0
|
||||
performanceInformation.performance.currentValueInBaseCurrency ===
|
||||
0
|
||||
? 0
|
||||
: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.div(
|
||||
performanceInformation.performance
|
||||
.currentValueInBaseCurrency
|
||||
)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
@ -453,12 +460,12 @@ export class PortfolioController {
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
[
|
||||
'currentGrossPerformance',
|
||||
'currentGrossPerformanceWithCurrencyEffect',
|
||||
'currentNetPerformance',
|
||||
'currentNetPerformanceWithCurrencyEffect',
|
||||
'currentNetWorth',
|
||||
'currentValue',
|
||||
'currentValueInBaseCurrency',
|
||||
'grossPerformance',
|
||||
'grossPerformanceWithCurrencyEffect',
|
||||
'netPerformance',
|
||||
'netPerformanceWithCurrencyEffect',
|
||||
'totalInvestment'
|
||||
]
|
||||
);
|
||||
@ -475,39 +482,13 @@ export class PortfolioController {
|
||||
);
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentNetPerformance', 'currentNetPerformancePercent']
|
||||
['netPerformance']
|
||||
);
|
||||
}
|
||||
|
||||
return performanceInformation;
|
||||
}
|
||||
|
||||
@Get('positions')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioPositions> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
return this.portfolioService.getPositions({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
|
||||
@Get('public/:accessId')
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPublic(
|
||||
@ -584,7 +565,7 @@ export class PortfolioController {
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Param('dataSource') dataSource,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
): Promise<PortfolioHoldingDetail> {
|
||||
const position = await this.portfolioService.getPosition(
|
||||
dataSource,
|
||||
impersonationId,
|
||||
|
@ -2,7 +2,11 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountBalanceService } from '@ghostfolio/api/app/account-balance/account-balance.service';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { RedactValuesInResponseModule } from '@ghostfolio/api/interceptors/redact-values-in-response/redact-values-in-response.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
@ -35,7 +39,11 @@ import { RulesService } from './rules.service';
|
||||
MarketDataModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedactValuesInResponseModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule,
|
||||
UserModule
|
||||
],
|
||||
providers: [
|
||||
|
@ -27,7 +27,7 @@ describe('PortfolioService', () => {
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercent: new Big(0)
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
@ -36,7 +36,7 @@ describe('PortfolioService', () => {
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercent: new Big(0)
|
||||
netPerformancePercentage: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
@ -48,7 +48,7 @@ describe('PortfolioService', () => {
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercent: new Big(0.1025)
|
||||
netPerformancePercentage: new Big(0.1025)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
@ -57,7 +57,7 @@ describe('PortfolioService', () => {
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercent: new Big(0.05)
|
||||
netPerformancePercentage: new Big(0.05)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
@ -69,7 +69,7 @@ describe('PortfolioService', () => {
|
||||
portfolioService
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercent: new Big(0.2374)
|
||||
netPerformancePercentage: new Big(0.2374)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
|
@ -29,6 +29,7 @@ import {
|
||||
EnhancedSymbolProfile,
|
||||
Filter,
|
||||
HistoricalDataItem,
|
||||
InvestmentItem,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
@ -36,10 +37,9 @@ import {
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
TimelinePosition,
|
||||
UserSettings
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import { TimelinePosition } from '@ghostfolio/common/models';
|
||||
import type {
|
||||
AccountWithValue,
|
||||
DateRange,
|
||||
@ -54,6 +54,7 @@ import {
|
||||
Account,
|
||||
Type as ActivityType,
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
@ -64,7 +65,6 @@ import {
|
||||
differenceInDays,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
parseISO,
|
||||
@ -77,7 +77,7 @@ import {
|
||||
PerformanceCalculationType,
|
||||
PortfolioCalculatorFactory
|
||||
} from './calculator/portfolio-calculator.factory';
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioHoldingDetail } from './interfaces/portfolio-holding-detail.interface';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
|
||||
@ -208,16 +208,16 @@ export class PortfolioService {
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent
|
||||
netPerformancePercentage
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercent: Big;
|
||||
netPerformancePercentage: 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)
|
||||
Math.pow(netPerformancePercentage.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
@ -277,8 +277,13 @@ export class PortfolioService {
|
||||
|
||||
const portfolioCalculator = this.calculatorFactory.createCalculator({
|
||||
activities,
|
||||
dateRange,
|
||||
userId,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: this.request.user.Settings.settings.baseCurrency
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
hasFilters: filters?.length > 0,
|
||||
isExperimentalFeatures:
|
||||
this.request.user.Settings.settings.isExperimentalFeatures
|
||||
});
|
||||
|
||||
const items = await portfolioCalculator.getChart({
|
||||
@ -352,8 +357,12 @@ export class PortfolioService {
|
||||
const portfolioCalculator = this.calculatorFactory.createCalculator({
|
||||
activities,
|
||||
dateRange,
|
||||
userId,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: userCurrency
|
||||
currency: userCurrency,
|
||||
hasFilters: true, // disable cache
|
||||
isExperimentalFeatures:
|
||||
this.request.user?.Settings.settings.isExperimentalFeatures
|
||||
});
|
||||
|
||||
const { currentValueInBaseCurrency, hasErrors, positions } =
|
||||
@ -377,7 +386,7 @@ export class PortfolioService {
|
||||
}) ?? false;
|
||||
|
||||
const isFilteredByCash = filters?.some(({ id, type }) => {
|
||||
return id === 'CASH' && type === 'ASSET_CLASS';
|
||||
return id === AssetClass.LIQUIDITY && type === 'ASSET_CLASS';
|
||||
});
|
||||
|
||||
const isFilteredByClosedHoldings =
|
||||
@ -392,8 +401,8 @@ export class PortfolioService {
|
||||
if (
|
||||
filters?.length === 0 ||
|
||||
(filters?.length === 1 &&
|
||||
filters[0].type === 'ASSET_CLASS' &&
|
||||
filters[0].id === 'CASH')
|
||||
filters[0].id === AssetClass.LIQUIDITY &&
|
||||
filters[0].type === 'ASSET_CLASS')
|
||||
) {
|
||||
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
@ -490,6 +499,7 @@ export class PortfolioService {
|
||||
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
||||
holdings: assetProfile.holdings,
|
||||
investment: investment.toNumber(),
|
||||
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||
name: assetProfile.name,
|
||||
@ -593,7 +603,7 @@ export class PortfolioService {
|
||||
aDataSource: DataSource,
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
): Promise<PortfolioHoldingDetail> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
@ -648,11 +658,15 @@ export class PortfolioService {
|
||||
]);
|
||||
|
||||
const portfolioCalculator = this.calculatorFactory.createCalculator({
|
||||
userId,
|
||||
activities: orders.filter((order) => {
|
||||
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
|
||||
}),
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: userCurrency
|
||||
currency: userCurrency,
|
||||
hasFilters: true,
|
||||
isExperimentalFeatures:
|
||||
this.request.user.Settings.settings.isExperimentalFeatures
|
||||
});
|
||||
|
||||
const portfolioStart = portfolioCalculator.getStartDate();
|
||||
@ -680,7 +694,7 @@ export class PortfolioService {
|
||||
transactionCount
|
||||
} = position;
|
||||
|
||||
const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
|
||||
const accounts: PortfolioHoldingDetail['accounts'] = uniqBy(
|
||||
orders.filter(({ Account }) => {
|
||||
return Account;
|
||||
}),
|
||||
@ -691,17 +705,21 @@ export class PortfolioService {
|
||||
|
||||
const dividendYieldPercent = this.getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercent: dividendInBaseCurrency.div(
|
||||
timeWeightedInvestment
|
||||
)
|
||||
netPerformancePercentage: timeWeightedInvestment.eq(0)
|
||||
? new Big(0)
|
||||
: dividendInBaseCurrency.div(timeWeightedInvestment)
|
||||
});
|
||||
|
||||
const dividendYieldPercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(new Date(), parseDate(firstBuyDate)),
|
||||
netPerformancePercent: dividendInBaseCurrency.div(
|
||||
timeWeightedInvestmentWithCurrencyEffect
|
||||
netPerformancePercentage: timeWeightedInvestmentWithCurrencyEffect.eq(
|
||||
0
|
||||
)
|
||||
? new Big(0)
|
||||
: dividendInBaseCurrency.div(
|
||||
timeWeightedInvestmentWithCurrencyEffect
|
||||
)
|
||||
});
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
@ -919,8 +937,12 @@ export class PortfolioService {
|
||||
const portfolioCalculator = this.calculatorFactory.createCalculator({
|
||||
activities,
|
||||
dateRange,
|
||||
userId,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: this.request.user.Settings.settings.baseCurrency
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
hasFilters: filters?.length > 0,
|
||||
isExperimentalFeatures:
|
||||
this.request.user.Settings.settings.isExperimentalFeatures
|
||||
});
|
||||
|
||||
let { hasErrors, positions } = await portfolioCalculator.getSnapshot();
|
||||
@ -1056,11 +1078,16 @@ export class PortfolioService {
|
||||
) => {
|
||||
const formattedDate = format(date, DATE_FORMAT);
|
||||
|
||||
// Store the item in the map, overwriting if the date already exists
|
||||
map[formattedDate] = {
|
||||
date: formattedDate,
|
||||
value: valueInBaseCurrency
|
||||
};
|
||||
if (map[formattedDate]) {
|
||||
// If the value exists, add the current value to the existing one
|
||||
map[formattedDate].value += valueInBaseCurrency;
|
||||
} else {
|
||||
// Otherwise, initialize the value for that date
|
||||
map[formattedDate] = {
|
||||
date: formattedDate,
|
||||
value: valueInBaseCurrency
|
||||
};
|
||||
}
|
||||
|
||||
return map;
|
||||
},
|
||||
@ -1068,7 +1095,7 @@ export class PortfolioService {
|
||||
)
|
||||
);
|
||||
|
||||
const { endDate, startDate } = getInterval(dateRange);
|
||||
const { endDate } = getInterval(dateRange);
|
||||
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
endDate,
|
||||
@ -1084,26 +1111,31 @@ export class PortfolioService {
|
||||
firstOrderDate: undefined,
|
||||
hasErrors: false,
|
||||
performance: {
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentGrossPerformancePercentWithCurrencyEffect: 0,
|
||||
currentGrossPerformanceWithCurrencyEffect: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentNetPerformancePercentWithCurrencyEffect: 0,
|
||||
currentNetPerformanceWithCurrencyEffect: 0,
|
||||
currentNetWorth: 0,
|
||||
currentValue: 0,
|
||||
currentValueInBaseCurrency: 0,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercentage: 0,
|
||||
grossPerformancePercentageWithCurrencyEffect: 0,
|
||||
grossPerformanceWithCurrencyEffect: 0,
|
||||
netPerformance: 0,
|
||||
netPerformancePercentage: 0,
|
||||
netPerformancePercentageWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
totalInvestment: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const portfolioCalculator = this.calculatorFactory.createCalculator({
|
||||
accountBalanceItems,
|
||||
activities,
|
||||
dateRange,
|
||||
userId,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: userCurrency
|
||||
currency: userCurrency,
|
||||
hasFilters: filters?.length > 0,
|
||||
isExperimentalFeatures:
|
||||
this.request.user.Settings.settings.isExperimentalFeatures
|
||||
});
|
||||
|
||||
const {
|
||||
@ -1123,14 +1155,16 @@ export class PortfolioService {
|
||||
|
||||
let currentNetPerformance = netPerformance;
|
||||
|
||||
let currentNetPerformancePercent = netPerformancePercentage;
|
||||
let currentNetPerformancePercentage = netPerformancePercentage;
|
||||
|
||||
let currentNetPerformancePercentWithCurrencyEffect =
|
||||
let currentNetPerformancePercentageWithCurrencyEffect =
|
||||
netPerformancePercentageWithCurrencyEffect;
|
||||
|
||||
let currentNetPerformanceWithCurrencyEffect =
|
||||
netPerformanceWithCurrencyEffect;
|
||||
|
||||
let currentNetWorth = 0;
|
||||
|
||||
const items = await portfolioCalculator.getChart({
|
||||
dateRange
|
||||
});
|
||||
@ -1142,62 +1176,41 @@ export class PortfolioService {
|
||||
if (itemOfToday) {
|
||||
currentNetPerformance = new Big(itemOfToday.netPerformance);
|
||||
|
||||
currentNetPerformancePercent = new Big(
|
||||
currentNetPerformancePercentage = new Big(
|
||||
itemOfToday.netPerformanceInPercentage
|
||||
).div(100);
|
||||
|
||||
currentNetPerformancePercentWithCurrencyEffect = new Big(
|
||||
currentNetPerformancePercentageWithCurrencyEffect = new Big(
|
||||
itemOfToday.netPerformanceInPercentageWithCurrencyEffect
|
||||
).div(100);
|
||||
|
||||
currentNetPerformanceWithCurrencyEffect = new Big(
|
||||
itemOfToday.netPerformanceWithCurrencyEffect
|
||||
);
|
||||
|
||||
currentNetWorth = itemOfToday.netWorth;
|
||||
}
|
||||
|
||||
accountBalanceItems = accountBalanceItems.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
|
||||
const accountBalanceItemOfToday = accountBalanceItems.find(({ date }) => {
|
||||
return date === format(new Date(), DATE_FORMAT);
|
||||
});
|
||||
|
||||
if (!accountBalanceItemOfToday) {
|
||||
accountBalanceItems.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value: last(accountBalanceItems)?.value ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
const mergedHistoricalDataItems = this.mergeHistoricalDataItems(
|
||||
accountBalanceItems,
|
||||
items
|
||||
);
|
||||
|
||||
const currentHistoricalDataItem = last(mergedHistoricalDataItems);
|
||||
const currentNetWorth = currentHistoricalDataItem?.netWorth ?? 0;
|
||||
|
||||
return {
|
||||
errors,
|
||||
hasErrors,
|
||||
chart: mergedHistoricalDataItems,
|
||||
chart: items,
|
||||
firstOrderDate: parseDate(items[0]?.date),
|
||||
performance: {
|
||||
currentNetWorth,
|
||||
currentGrossPerformance: grossPerformance.toNumber(),
|
||||
currentGrossPerformancePercent: grossPerformancePercentage.toNumber(),
|
||||
currentGrossPerformancePercentWithCurrencyEffect:
|
||||
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
|
||||
grossPerformance: grossPerformance.toNumber(),
|
||||
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
|
||||
grossPerformancePercentageWithCurrencyEffect:
|
||||
grossPerformancePercentageWithCurrencyEffect.toNumber(),
|
||||
currentGrossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect.toNumber(),
|
||||
currentNetPerformance: currentNetPerformance.toNumber(),
|
||||
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
|
||||
currentNetPerformancePercentWithCurrencyEffect:
|
||||
currentNetPerformancePercentWithCurrencyEffect.toNumber(),
|
||||
currentNetPerformanceWithCurrencyEffect:
|
||||
netPerformance: currentNetPerformance.toNumber(),
|
||||
netPerformancePercentage: currentNetPerformancePercentage.toNumber(),
|
||||
netPerformancePercentageWithCurrencyEffect:
|
||||
currentNetPerformancePercentageWithCurrencyEffect.toNumber(),
|
||||
netPerformanceWithCurrencyEffect:
|
||||
currentNetPerformanceWithCurrencyEffect.toNumber(),
|
||||
currentValue: currentValueInBaseCurrency.toNumber(),
|
||||
totalInvestment: totalInvestment.toNumber()
|
||||
}
|
||||
};
|
||||
@ -1215,8 +1228,12 @@ export class PortfolioService {
|
||||
|
||||
const portfolioCalculator = this.calculatorFactory.createCalculator({
|
||||
activities,
|
||||
userId,
|
||||
calculationType: PerformanceCalculationType.TWR,
|
||||
currency: this.request.user.Settings.settings.baseCurrency
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
hasFilters: false,
|
||||
isExperimentalFeatures:
|
||||
this.request.user.Settings.settings.isExperimentalFeatures
|
||||
});
|
||||
|
||||
let { totalFeesWithCurrencyEffect, positions, totalInvestment } =
|
||||
@ -1439,8 +1456,8 @@ export class PortfolioService {
|
||||
return {
|
||||
currency,
|
||||
allocationInPercentage: 0,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
assetSubClass: AssetSubClass.CASH,
|
||||
countries: [],
|
||||
dataSource: undefined,
|
||||
dateOfFirstActivity: undefined,
|
||||
@ -1449,6 +1466,7 @@ export class PortfolioService {
|
||||
grossPerformancePercent: 0,
|
||||
grossPerformancePercentWithCurrencyEffect: 0,
|
||||
grossPerformanceWithCurrencyEffect: 0,
|
||||
holdings: [],
|
||||
investment: balance,
|
||||
marketPrice: 0,
|
||||
marketState: 'open',
|
||||
@ -1590,11 +1608,6 @@ export class PortfolioService {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
|
||||
const performanceInformation = await this.getPerformance({
|
||||
impersonationId,
|
||||
userId
|
||||
});
|
||||
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
@ -1612,6 +1625,19 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
currentValueInBaseCurrency,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
grossPerformancePercentageWithCurrencyEffect,
|
||||
grossPerformanceWithCurrencyEffect,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
netPerformancePercentageWithCurrencyEffect,
|
||||
netPerformanceWithCurrencyEffect,
|
||||
totalInvestment
|
||||
} = await portfolioCalculator.getSnapshot();
|
||||
|
||||
const dividendInBaseCurrency =
|
||||
await portfolioCalculator.getDividendInBaseCurrency();
|
||||
|
||||
@ -1680,7 +1706,7 @@ export class PortfolioService {
|
||||
.toNumber();
|
||||
|
||||
const netWorth = new Big(balanceInBaseCurrency)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(currentValueInBaseCurrency)
|
||||
.plus(valuables)
|
||||
.plus(excludedAccountsAndActivities)
|
||||
.minus(liabilities)
|
||||
@ -1690,21 +1716,18 @@ export class PortfolioService {
|
||||
|
||||
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercent
|
||||
)
|
||||
netPerformancePercentage: new Big(netPerformancePercentage)
|
||||
})?.toNumber();
|
||||
|
||||
const annualizedPerformancePercentWithCurrencyEffect =
|
||||
this.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
|
||||
netPerformancePercentage: new Big(
|
||||
netPerformancePercentageWithCurrencyEffect
|
||||
)
|
||||
})?.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
annualizedPerformancePercent,
|
||||
annualizedPerformancePercentWithCurrencyEffect,
|
||||
cash,
|
||||
@ -1713,6 +1736,7 @@ export class PortfolioService {
|
||||
totalBuy,
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
currentValueInBaseCurrency: currentValueInBaseCurrency.toNumber(),
|
||||
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
||||
emergencyFund: {
|
||||
assets: emergencyFundPositionsValueInBaseCurrency,
|
||||
@ -1726,15 +1750,28 @@ export class PortfolioService {
|
||||
filteredValueInPercentage: netWorth
|
||||
? filteredValueInBaseCurrency.div(netWorth).toNumber()
|
||||
: undefined,
|
||||
fireWealth: new Big(performanceInformation.performance.currentValue)
|
||||
fireWealth: new Big(currentValueInBaseCurrency)
|
||||
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||
.toNumber(),
|
||||
grossPerformance: grossPerformance.toNumber(),
|
||||
grossPerformancePercentage: grossPerformancePercentage.toNumber(),
|
||||
grossPerformancePercentageWithCurrencyEffect:
|
||||
grossPerformancePercentageWithCurrencyEffect.toNumber(),
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect.toNumber(),
|
||||
interest: interest.toNumber(),
|
||||
items: valuables.toNumber(),
|
||||
liabilities: liabilities.toNumber(),
|
||||
netPerformance: netPerformance.toNumber(),
|
||||
netPerformancePercentage: netPerformancePercentage.toNumber(),
|
||||
netPerformancePercentageWithCurrencyEffect:
|
||||
netPerformancePercentageWithCurrencyEffect.toNumber(),
|
||||
netPerformanceWithCurrencyEffect:
|
||||
netPerformanceWithCurrencyEffect.toNumber(),
|
||||
ordersCount: activities.filter(({ type }) => {
|
||||
return type === 'BUY' || type === 'SELL';
|
||||
return ['BUY', 'SELL'].includes(type);
|
||||
}).length,
|
||||
totalInvestment: totalInvestment.toNumber(),
|
||||
totalValueInBaseCurrency: netWorth
|
||||
};
|
||||
}
|
||||
@ -1909,44 +1946,4 @@ export class PortfolioService {
|
||||
|
||||
return { accounts, platforms };
|
||||
}
|
||||
|
||||
private mergeHistoricalDataItems(
|
||||
accountBalanceItems: HistoricalDataItem[],
|
||||
performanceChartItems: HistoricalDataItem[]
|
||||
): HistoricalDataItem[] {
|
||||
const historicalDataItemsMap: { [date: string]: HistoricalDataItem } = {};
|
||||
let latestAccountBalance = 0;
|
||||
|
||||
for (const item of accountBalanceItems.concat(performanceChartItems)) {
|
||||
const isAccountBalanceItem = accountBalanceItems.includes(item);
|
||||
|
||||
const totalAccountBalance = isAccountBalanceItem
|
||||
? item.value
|
||||
: latestAccountBalance;
|
||||
|
||||
if (isAccountBalanceItem && performanceChartItems.length > 0) {
|
||||
latestAccountBalance = item.value;
|
||||
} else {
|
||||
historicalDataItemsMap[item.date] = {
|
||||
...item,
|
||||
totalAccountBalance,
|
||||
netWorth:
|
||||
(isAccountBalanceItem ? 0 : item.value) + totalAccountBalance
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to an array and sort by date in ascending order
|
||||
const historicalDataItems = Object.keys(historicalDataItemsMap).map(
|
||||
(date) => {
|
||||
return historicalDataItemsMap[date];
|
||||
}
|
||||
);
|
||||
|
||||
historicalDataItems.sort((a, b) => {
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
});
|
||||
|
||||
return historicalDataItems;
|
||||
}
|
||||
}
|
||||
|
13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts
Normal file
13
apps/api/src/app/redis-cache/redis-cache.service.mock.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { RedisCacheService } from './redis-cache.service';
|
||||
|
||||
export const RedisCacheServiceMock = {
|
||||
get: (key: string): Promise<string> => {
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
getPortfolioSnapshotKey: (userId: string): string => {
|
||||
return `portfolio-snapshot-${userId}`;
|
||||
},
|
||||
set: (key: string, value: string, ttlInSeconds?: number): Promise<string> => {
|
||||
return Promise.resolve(value);
|
||||
}
|
||||
};
|
@ -24,6 +24,10 @@ export class RedisCacheService {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
public getPortfolioSnapshotKey({ userId }: { userId: string }) {
|
||||
return `portfolio-snapshot-${userId}`;
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
|
||||
}
|
||||
|
@ -1,25 +1,8 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile/symbol-profile.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SitemapController } from './sitemap.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [SitemapController],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
]
|
||||
controllers: [SitemapController]
|
||||
})
|
||||
export class SitemapModule {}
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor';
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module';
|
||||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data/market-data.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
|
||||
@ -12,10 +13,11 @@ import { SymbolService } from './symbol.service';
|
||||
controllers: [SymbolController],
|
||||
exports: [SymbolService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PrismaModule
|
||||
PrismaModule,
|
||||
TransformDataSourceInRequestModule,
|
||||
TransformDataSourceInResponseModule
|
||||
],
|
||||
providers: [SymbolService]
|
||||
})
|
||||
|
6
apps/api/src/app/user/delete-own-user.dto.ts
Normal file
6
apps/api/src/app/user/delete-own-user.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class DeleteOwnUserDto {
|
||||
@IsString()
|
||||
accessToken: string;
|
||||
}
|
@ -1,12 +1,9 @@
|
||||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { User, UserSettings } 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 {
|
||||
@ -29,6 +26,7 @@ import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { size } from 'lodash';
|
||||
|
||||
import { DeleteOwnUserDto } from './delete-own-user.dto';
|
||||
import { UserItem } from './interfaces/user-item.interface';
|
||||
import { UpdateUserSettingDto } from './update-user-setting.dto';
|
||||
import { UserService } from './user.service';
|
||||
@ -36,12 +34,41 @@ import { UserService } from './user.service';
|
||||
@Controller('user')
|
||||
export class UserController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly propertyService: PropertyService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete()
|
||||
@HasPermission(permissions.deleteOwnUser)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async deleteOwnUser(
|
||||
@Body() data: DeleteOwnUserDto
|
||||
): Promise<UserModel> {
|
||||
const hashedAccessToken = this.userService.createAccessToken(
|
||||
data.accessToken,
|
||||
this.configurationService.get('ACCESS_TOKEN_SALT')
|
||||
);
|
||||
|
||||
const [user] = await this.userService.users({
|
||||
where: { accessToken: hashedAccessToken, id: this.request.user.id }
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.deleteUser({
|
||||
accessToken: hashedAccessToken,
|
||||
id: user.id
|
||||
});
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HasPermission(permissions.deleteUser)
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@ -63,13 +90,6 @@ export class UserController {
|
||||
public async getUser(
|
||||
@Headers('accept-language') acceptLanguage: string
|
||||
): Promise<User> {
|
||||
if (hasRole(this.request.user, 'INACTIVE')) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.TOO_MANY_REQUESTS),
|
||||
StatusCodes.TOO_MANY_REQUESTS
|
||||
);
|
||||
}
|
||||
|
||||
return this.userService.getUser(
|
||||
this.request.user,
|
||||
acceptLanguage?.split(',')?.[0]
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
import { PortfolioChangedEvent } from '@ghostfolio/api/events/portfolio-changed.event';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { I18nService } from '@ghostfolio/api/services/i18n/i18n.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
@ -25,6 +26,7 @@ import {
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { Prisma, Role, User } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { sortBy, without } from 'lodash';
|
||||
@ -37,6 +39,7 @@ export class UserService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly subscriptionService: SubscriptionService,
|
||||
@ -118,28 +121,6 @@ export class UserService {
|
||||
return usersWithAdminRole.length > 0;
|
||||
}
|
||||
|
||||
public hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user
|
||||
}: {
|
||||
impersonationId: string;
|
||||
user: UserWithSettings;
|
||||
}) {
|
||||
if (!impersonationId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const access = user.Access?.find(({ id }) => {
|
||||
return id === impersonationId;
|
||||
});
|
||||
|
||||
return access?.permissions?.includes('READ_RESTRICTED') ?? true;
|
||||
}
|
||||
|
||||
public isRestrictedView(aUser: UserWithSettings) {
|
||||
return aUser.Settings.settings.isRestrictedView ?? false;
|
||||
}
|
||||
|
||||
public async user(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): Promise<UserWithSettings | null> {
|
||||
@ -259,10 +240,13 @@ export class UserService {
|
||||
|
||||
// Reset benchmark
|
||||
user.Settings.settings.benchmark = undefined;
|
||||
}
|
||||
|
||||
if (user.subscription?.type === 'Premium') {
|
||||
} else if (user.subscription?.type === 'Premium') {
|
||||
currentPermissions.push(permissions.reportDataGlitch);
|
||||
|
||||
currentPermissions = without(
|
||||
currentPermissions,
|
||||
permissions.deleteOwnUser
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -437,11 +421,9 @@ export class UserService {
|
||||
userId: string;
|
||||
userSettings: UserSettings;
|
||||
}) {
|
||||
const settings = userSettings as unknown as Prisma.JsonObject;
|
||||
|
||||
await this.prismaService.settings.upsert({
|
||||
const { settings } = await this.prismaService.settings.upsert({
|
||||
create: {
|
||||
settings,
|
||||
settings: userSettings as unknown as Prisma.JsonObject,
|
||||
User: {
|
||||
connect: {
|
||||
id: userId
|
||||
@ -449,14 +431,21 @@ export class UserService {
|
||||
}
|
||||
},
|
||||
update: {
|
||||
settings
|
||||
settings: userSettings as unknown as Prisma.JsonObject
|
||||
},
|
||||
where: {
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
this.eventEmitter.emit(
|
||||
PortfolioChangedEvent.getName(),
|
||||
new PortfolioChangedEvent({
|
||||
userId
|
||||
})
|
||||
);
|
||||
|
||||
return settings;
|
||||
}
|
||||
|
||||
private getRandomString(length: number) {
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -146,6 +146,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-koyfin</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-kubera</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -170,6 +174,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-monse</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-navexa</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-parqet</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -182,6 +190,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-dividend-tracker</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portfolio-visualizer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-portseido</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -210,6 +222,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stock-events</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockle</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -218,6 +234,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stockmarketeye</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-stonksfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -234,6 +254,10 @@
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wallmine</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/ressourcen/personal-finance-tools/open-source-alternative-zu-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -500,6 +524,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-koyfin</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -524,6 +552,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-navexa</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -536,6 +568,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-visualizer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -564,6 +600,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stock-events</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockle</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -572,6 +612,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stockmarketeye</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-stonksfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -588,6 +632,10 @@
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wallmine</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -866,6 +914,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-koyfin</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-kubera</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -890,6 +942,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-monse</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-navexa</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-parqet</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -902,6 +958,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-dividend-tracker</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portfolio-visualizer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-portseido</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -930,6 +990,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stock-events</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockle</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -938,6 +1002,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stockmarketeye</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-stonksfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -954,6 +1022,10 @@
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wallmine</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/it/risorse/personal-finance-tools/alternativa-open-source-a-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1078,6 +1150,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-justetf</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-koyfin</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-kubera</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1102,6 +1178,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-monse</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-navexa</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-parqet</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1114,6 +1194,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-dividend-tracker</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portfolio-visualizer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-portseido</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1142,6 +1226,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-snowball-analytics</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stock-events</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockle</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1150,6 +1238,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stockmarketeye</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-stonksfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-sumio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
@ -1166,6 +1258,10 @@
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-vyzer</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wallmine</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/nl/bronnen/personal-finance-tools/open-source-alternatief-voor-wealthfolio</loc>
|
||||
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
|
||||
|
11
apps/api/src/events/events.module.ts
Normal file
11
apps/api/src/events/events.module.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PortfolioChangedListener } from './portfolio-changed.listener';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
providers: [PortfolioChangedListener]
|
||||
})
|
||||
export class EventsModule {}
|
15
apps/api/src/events/portfolio-changed.event.ts
Normal file
15
apps/api/src/events/portfolio-changed.event.ts
Normal file
@ -0,0 +1,15 @@
|
||||
export class PortfolioChangedEvent {
|
||||
private userId: string;
|
||||
|
||||
public constructor({ userId }: { userId: string }) {
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public static getName() {
|
||||
return 'portfolio.changed';
|
||||
}
|
||||
|
||||
public getUserId() {
|
||||
return this.userId;
|
||||
}
|
||||
}
|
25
apps/api/src/events/portfolio-changed.listener.ts
Normal file
25
apps/api/src/events/portfolio-changed.listener.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent } from '@nestjs/event-emitter';
|
||||
|
||||
import { PortfolioChangedEvent } from './portfolio-changed.event';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioChangedListener {
|
||||
public constructor(private readonly redisCacheService: RedisCacheService) {}
|
||||
|
||||
@OnEvent(PortfolioChangedEvent.getName())
|
||||
handlePortfolioChangedEvent(event: PortfolioChangedEvent) {
|
||||
Logger.log(
|
||||
`Portfolio of user '${event.getUserId()}' has changed`,
|
||||
'PortfolioChangedListener'
|
||||
);
|
||||
|
||||
this.redisCacheService.remove(
|
||||
this.redisCacheService.getPortfolioSnapshotKey({
|
||||
userId: event.getUserId()
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +1,9 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { redactAttributes } from '@ghostfolio/api/helper/object.helper';
|
||||
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
|
||||
import {
|
||||
hasReadRestrictedAccessPermission,
|
||||
isRestrictedView
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { UserWithSettings } from '@ghostfolio/common/types';
|
||||
|
||||
import {
|
||||
@ -16,7 +19,7 @@ import { map } from 'rxjs/operators';
|
||||
export class RedactValuesInResponseInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
{
|
||||
public constructor(private userService: UserService) {}
|
||||
public constructor() {}
|
||||
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
@ -29,15 +32,13 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
|
||||
const impersonationId =
|
||||
headers?.[HEADER_KEY_IMPERSONATION.toLowerCase()];
|
||||
const hasReadRestrictedPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user
|
||||
});
|
||||
|
||||
if (
|
||||
hasReadRestrictedPermission ||
|
||||
this.userService.isRestrictedView(user)
|
||||
hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
user
|
||||
}) ||
|
||||
isRestrictedView(user)
|
||||
) {
|
||||
data = redactAttributes({
|
||||
object: data,
|
||||
@ -57,6 +58,7 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
'quantity',
|
||||
'symbolMapping',
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
@ -0,0 +1,4 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({})
|
||||
export class RedactValuesInResponseModule {}
|
@ -0,0 +1,11 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: [ConfigurationService],
|
||||
imports: [ConfigurationModule],
|
||||
providers: [ConfigurationService]
|
||||
})
|
||||
export class TransformDataSourceInRequestModule {}
|
@ -0,0 +1,11 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: [ConfigurationService],
|
||||
imports: [ConfigurationModule],
|
||||
providers: [ConfigurationService]
|
||||
})
|
||||
export class TransformDataSourceInResponseModule {}
|
@ -2,7 +2,7 @@ import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import type { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import * as bodyParser from 'body-parser';
|
||||
import { json } from 'body-parser';
|
||||
import helmet from 'helmet';
|
||||
|
||||
import { AppModule } from './app/app.module';
|
||||
@ -34,7 +34,7 @@ async function bootstrap() {
|
||||
);
|
||||
|
||||
// Support 10mb csv/json files for importing activities
|
||||
app.use(bodyParser.json({ limit: '10mb' }));
|
||||
app.use(json({ limit: '10mb' }));
|
||||
|
||||
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
|
||||
app.use(
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { groupBy } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Settings> {
|
||||
private positions: TimelinePosition[];
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { TimelinePosition, UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { UserSettings } from '@ghostfolio/common/interfaces';
|
||||
import { TimelinePosition } from '@ghostfolio/common/models';
|
||||
|
||||
export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
private positions: TimelinePosition[];
|
||||
|
@ -181,6 +181,7 @@ export class DataGatheringService {
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
holdings,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
@ -198,6 +199,7 @@ export class DataGatheringService {
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
holdings,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
@ -212,6 +214,7 @@ export class DataGatheringService {
|
||||
figi,
|
||||
figiComposite,
|
||||
figiShareClass,
|
||||
holdings,
|
||||
isin,
|
||||
name,
|
||||
sectors,
|
||||
|
@ -50,7 +50,9 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
isPremium: false,
|
||||
name: 'Alpha Vantage',
|
||||
url: 'https://www.alphavantage.co'
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -59,7 +59,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
const response: Partial<SymbolProfile> = {
|
||||
symbol,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataSource: this.getName()
|
||||
@ -243,7 +243,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
return {
|
||||
name,
|
||||
symbol,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetClass: AssetClass.LIQUIDITY,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataProviderInfo: this.getDataProviderInfo(),
|
||||
|
@ -36,6 +36,7 @@ export class DataEnhancerService {
|
||||
|
||||
if (
|
||||
(assetProfile.countries as unknown as Prisma.JsonArray)?.length > 0 &&
|
||||
(assetProfile.holdings as unknown as Prisma.JsonArray)?.length > 0 &&
|
||||
(assetProfile.sectors as unknown as Prisma.JsonArray)?.length > 0
|
||||
) {
|
||||
return true;
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { Holding } from '@ghostfolio/common/interfaces';
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
|
||||
@ -155,11 +156,26 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!response.holdings ||
|
||||
(response.holdings as unknown as Holding[]).length === 0
|
||||
) {
|
||||
response.holdings = [];
|
||||
|
||||
for (const { label, weight } of holdings?.topHoldings ?? []) {
|
||||
response.holdings.push({
|
||||
weight,
|
||||
name: label
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!response.sectors ||
|
||||
(response.sectors as unknown as Sector[]).length === 0
|
||||
) {
|
||||
response.sectors = [];
|
||||
|
||||
for (const [name, value] of Object.entries<any>(
|
||||
holdings?.sectors ?? {}
|
||||
)) {
|
||||
|
@ -266,7 +266,7 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
|
||||
switch (quoteType?.toLowerCase()) {
|
||||
case 'cryptocurrency':
|
||||
assetClass = AssetClass.CASH;
|
||||
assetClass = AssetClass.LIQUIDITY;
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
break;
|
||||
case 'equity':
|
||||
|
@ -399,7 +399,8 @@ export class DataProviderService {
|
||||
numberOfItemsInCache > 1 ? 's' : ''
|
||||
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
|
||||
3
|
||||
)} seconds`
|
||||
)} seconds`,
|
||||
'DataProviderService'
|
||||
);
|
||||
}
|
||||
|
||||
@ -505,7 +506,8 @@ export class DataProviderService {
|
||||
} from ${dataSource} in ${(
|
||||
(performance.now() - startTimeDataSource) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
).toFixed(3)} seconds`,
|
||||
'DataProviderService'
|
||||
);
|
||||
|
||||
try {
|
||||
@ -535,14 +537,15 @@ export class DataProviderService {
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
Logger.debug('------------------------------------------------');
|
||||
Logger.debug('--------------------------------------------------------');
|
||||
Logger.debug(
|
||||
`Fetched ${items.length} quote${items.length > 1 ? 's' : ''} in ${(
|
||||
(performance.now() - startTimeTotal) /
|
||||
1000
|
||||
).toFixed(3)} seconds`
|
||||
).toFixed(3)} seconds`,
|
||||
'DataProviderService'
|
||||
);
|
||||
Logger.debug('================================================');
|
||||
Logger.debug('========================================================');
|
||||
|
||||
return response;
|
||||
}
|
||||
@ -595,10 +598,14 @@ export class DataProviderService {
|
||||
return name1?.toLowerCase().localeCompare(name2?.toLowerCase());
|
||||
})
|
||||
.map((lookupItem) => {
|
||||
if (
|
||||
!this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') ||
|
||||
user.subscription.type === 'Premium'
|
||||
) {
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (user.subscription.type === 'Premium') {
|
||||
lookupItem.dataProviderInfo.isPremium = false;
|
||||
}
|
||||
|
||||
lookupItem.dataProviderInfo.name = undefined;
|
||||
lookupItem.dataProviderInfo.url = undefined;
|
||||
} else {
|
||||
lookupItem.dataProviderInfo.isPremium = false;
|
||||
}
|
||||
|
||||
|
@ -66,7 +66,9 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: true
|
||||
isPremium: true,
|
||||
name: 'EOD Historical Data',
|
||||
url: 'https://eodhd.com'
|
||||
};
|
||||
}
|
||||
|
||||
@ -468,7 +470,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
assetSubClass = AssetSubClass.STOCK;
|
||||
break;
|
||||
case 'currency':
|
||||
assetClass = AssetClass.CASH;
|
||||
assetClass = AssetClass.LIQUIDITY;
|
||||
|
||||
if (Exchange?.toLowerCase() === 'cc') {
|
||||
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
|
||||
|
@ -46,7 +46,9 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
isPremium: false,
|
||||
name: 'Google Sheets',
|
||||
url: 'https://docs.google.com/spreadsheets'
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -91,7 +91,7 @@ export class ManualService implements DataProviderInterface {
|
||||
headers = {},
|
||||
selector,
|
||||
url
|
||||
} = symbolProfile.scraperConfiguration ?? {};
|
||||
} = symbolProfile?.scraperConfiguration ?? {};
|
||||
|
||||
if (defaultMarketPrice) {
|
||||
const historical: {
|
||||
|
@ -43,7 +43,9 @@ export class RapidApiService implements DataProviderInterface {
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
isPremium: false,
|
||||
name: 'Rapid API',
|
||||
url: 'https://rapidapi.com'
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -43,7 +43,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
return {
|
||||
isPremium: false
|
||||
isPremium: false,
|
||||
name: 'Yahoo Finance',
|
||||
url: 'https://finance.yahoo.com'
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -78,7 +78,7 @@ export class ExchangeRateDataService {
|
||||
);
|
||||
const lastDateString = dateStrings.reduce((a, b) => {
|
||||
return a > b ? a : b;
|
||||
});
|
||||
}, undefined);
|
||||
|
||||
let previousExchangeRate =
|
||||
exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[
|
||||
@ -449,13 +449,16 @@ export class ExchangeRateDataService {
|
||||
factors[format(date, DATE_FORMAT)] = factor;
|
||||
}
|
||||
} catch {
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom} and ${DEFAULT_CURRENCY}${currencyTo}.`,
|
||||
'ExchangeRateDataService'
|
||||
);
|
||||
let errorMessage = `No exchange rate has been found for ${currencyFrom}${currencyTo} at ${format(
|
||||
date,
|
||||
DATE_FORMAT
|
||||
)}. Please complement market data for ${DEFAULT_CURRENCY}${currencyFrom}`;
|
||||
|
||||
if (DEFAULT_CURRENCY !== currencyTo) {
|
||||
errorMessage = `${errorMessage} and ${DEFAULT_CURRENCY}${currencyTo}`;
|
||||
}
|
||||
|
||||
Logger.error(`${errorMessage}.`, 'ExchangeRateDataService');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
EnhancedSymbolProfile,
|
||||
Holding,
|
||||
ScraperConfiguration,
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -61,7 +62,9 @@ export class SymbolProfileService {
|
||||
})
|
||||
}
|
||||
})
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
.then((symbolProfiles) => {
|
||||
return this.enhanceSymbolProfiles(symbolProfiles);
|
||||
});
|
||||
}
|
||||
|
||||
public async getSymbolProfilesByIds(
|
||||
@ -83,7 +86,9 @@ export class SymbolProfileService {
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
.then((symbolProfiles) => {
|
||||
return this.enhanceSymbolProfiles(symbolProfiles);
|
||||
});
|
||||
}
|
||||
|
||||
public updateSymbolProfile({
|
||||
@ -93,6 +98,7 @@ export class SymbolProfileService {
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
holdings,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
@ -108,6 +114,7 @@ export class SymbolProfileService {
|
||||
comment,
|
||||
countries,
|
||||
currency,
|
||||
holdings,
|
||||
name,
|
||||
scraperConfiguration,
|
||||
sectors,
|
||||
@ -119,7 +126,7 @@ export class SymbolProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
private getSymbols(
|
||||
private enhanceSymbolProfiles(
|
||||
symbolProfiles: (SymbolProfile & {
|
||||
_count: { Order: number };
|
||||
Order?: {
|
||||
@ -136,6 +143,7 @@ export class SymbolProfileService {
|
||||
symbolProfile?.countries as unknown as Prisma.JsonArray
|
||||
),
|
||||
dateOfFirstActivity: <Date>undefined,
|
||||
holdings: this.getHoldings(symbolProfile),
|
||||
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
|
||||
sectors: this.getSectors(symbolProfile),
|
||||
symbolMapping: this.getSymbolMapping(symbolProfile)
|
||||
@ -163,6 +171,14 @@ export class SymbolProfileService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(item.SymbolProfileOverrides.holdings as unknown as Holding[])
|
||||
?.length > 0
|
||||
) {
|
||||
item.holdings = item.SymbolProfileOverrides
|
||||
.holdings as unknown as Holding[];
|
||||
}
|
||||
|
||||
item.name = item.SymbolProfileOverrides?.name ?? item.name;
|
||||
|
||||
if (
|
||||
@ -199,6 +215,19 @@ export class SymbolProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
private getHoldings(symbolProfile: SymbolProfile): Holding[] {
|
||||
return ((symbolProfile?.holdings as Prisma.JsonArray) ?? []).map(
|
||||
(holding) => {
|
||||
const { name, weight } = holding as Prisma.JsonObject;
|
||||
|
||||
return {
|
||||
name: (name as string) ?? UNKNOWN_KEY,
|
||||
valueInBaseCurrency: weight as number
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private getScraperConfiguration(
|
||||
symbolProfile: SymbolProfile
|
||||
): ScraperConfiguration {
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
import { paths } from '@ghostfolio/client/core/paths';
|
||||
import { PageTitleStrategy } from '@ghostfolio/client/services/page-title.strategy';
|
||||
|
||||
import { NgModule } from '@angular/core';
|
||||
@ -5,18 +7,6 @@ import { RouterModule, Routes, TitleStrategy } from '@angular/router';
|
||||
|
||||
import { ModulePreloadService } from './core/module-preload.service';
|
||||
|
||||
export const paths = {
|
||||
about: $localize`about`,
|
||||
faq: $localize`faq`,
|
||||
features: $localize`features`,
|
||||
license: $localize`license`,
|
||||
markets: $localize`markets`,
|
||||
pricing: $localize`pricing`,
|
||||
privacyPolicy: $localize`privacy-policy`,
|
||||
register: $localize`register`,
|
||||
resources: $localize`resources`
|
||||
};
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: paths.about,
|
||||
@ -53,9 +43,12 @@ const routes: Routes = [
|
||||
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
import('./pages/demo/demo-page.module').then((m) => m.DemoPageModule)
|
||||
canActivate: [AuthGuard],
|
||||
loadComponent: () =>
|
||||
import('./pages/demo/demo-page.component').then(
|
||||
(c) => c.GfDemoPageComponent
|
||||
),
|
||||
path: 'demo'
|
||||
},
|
||||
{
|
||||
path: paths.faq,
|
||||
@ -63,11 +56,13 @@ const routes: Routes = [
|
||||
import('./pages/faq/faq-page.module').then((m) => m.FaqPageModule)
|
||||
},
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
loadComponent: () =>
|
||||
import('./pages/features/features-page.component').then(
|
||||
(c) => c.GfFeaturesPageComponent
|
||||
),
|
||||
path: paths.features,
|
||||
loadChildren: () =>
|
||||
import('./pages/features/features-page.module').then(
|
||||
(m) => m.FeaturesPageModule
|
||||
)
|
||||
title: $localize`Features`
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
@ -75,9 +70,13 @@ const routes: Routes = [
|
||||
import('./pages/home/home-page.module').then((m) => m.HomePageModule)
|
||||
},
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
loadComponent: () =>
|
||||
import('./pages/i18n/i18n-page.component').then(
|
||||
(c) => c.GfI18nPageComponent
|
||||
),
|
||||
path: 'i18n',
|
||||
loadChildren: () =>
|
||||
import('./pages/i18n/i18n-page.module').then((m) => m.I18nPageModule)
|
||||
title: $localize`Internationalization`
|
||||
},
|
||||
{
|
||||
path: paths.markets,
|
||||
@ -134,11 +133,12 @@ const routes: Routes = [
|
||||
)
|
||||
},
|
||||
{
|
||||
loadComponent: () =>
|
||||
import('./pages/webauthn/webauthn-page.component').then(
|
||||
(c) => c.GfWebauthnPageComponent
|
||||
),
|
||||
path: 'webauthn',
|
||||
loadChildren: () =>
|
||||
import('./pages/webauthn/webauthn-page.module').then(
|
||||
(m) => m.WebauthnPageModule
|
||||
)
|
||||
title: $localize`Sign in`
|
||||
},
|
||||
{
|
||||
path: 'zen',
|
||||
|
@ -158,11 +158,9 @@
|
||||
<li>
|
||||
<a href="../pt" title="Ghostfolio in Português">Português</a>
|
||||
</li>
|
||||
<!--
|
||||
<li>
|
||||
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
||||
</li>
|
||||
-->
|
||||
<li>
|
||||
<a href="../tr" title="Ghostfolio in Türkçe">Türkçe</a>
|
||||
</li>
|
||||
<!--
|
||||
<li>
|
||||
<a href="../zh" title="Ghostfolio in Chinese">Chinese</a>
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { GfHoldingDetailDialogComponent } from '@ghostfolio/client/components/holding-detail-dialog/holding-detail-dialog.component';
|
||||
import { HoldingDetailDialogParams } from '@ghostfolio/client/components/holding-detail-dialog/interfaces/interfaces';
|
||||
import { getCssVariable } from '@ghostfolio/common/helper';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
@ -13,13 +15,21 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { Title } from '@angular/platform-browser';
|
||||
import { NavigationEnd, PRIMARY_OUTLET, Router } from '@angular/router';
|
||||
import {
|
||||
ActivatedRoute,
|
||||
NavigationEnd,
|
||||
PRIMARY_OUTLET,
|
||||
Router
|
||||
} from '@angular/router';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { filter, takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { DataService } from './services/data.service';
|
||||
import { ImpersonationStorageService } from './services/impersonation-storage.service';
|
||||
import { TokenStorageService } from './services/token-storage.service';
|
||||
import { UserService } from './services/user/user.service';
|
||||
|
||||
@ -38,6 +48,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
public currentRoute: string;
|
||||
public currentYear = new Date().getFullYear();
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasInfoMessage: boolean;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
@ -67,7 +78,10 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService,
|
||||
private dialog: MatDialog,
|
||||
@Inject(DOCUMENT) private document: Document,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private route: ActivatedRoute,
|
||||
private router: Router,
|
||||
private title: Title,
|
||||
private tokenStorageService: TokenStorageService,
|
||||
@ -75,6 +89,21 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
) {
|
||||
this.initializeTheme();
|
||||
this.user = undefined;
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (
|
||||
params['dataSource'] &&
|
||||
params['holdingDetailDialog'] &&
|
||||
params['symbol']
|
||||
) {
|
||||
this.openHoldingDetailDialog({
|
||||
dataSource: params['dataSource'],
|
||||
symbol: params['symbol']
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
@ -96,6 +125,13 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
permissions.enableFearAndGreedIndex
|
||||
);
|
||||
|
||||
this.impersonationStorageService
|
||||
.onChangeHasImpersonation()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((impersonationId) => {
|
||||
this.hasImpersonationId = !!impersonationId;
|
||||
});
|
||||
|
||||
this.router.events
|
||||
.pipe(filter((event) => event instanceof NavigationEnd))
|
||||
.subscribe(() => {
|
||||
@ -197,6 +233,55 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
private openHoldingDetailDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
symbol: string;
|
||||
}) {
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
const dialogRef = this.dialog.open(GfHoldingDetailDialogComponent, {
|
||||
autoFocus: false,
|
||||
data: <HoldingDetailDialogParams>{
|
||||
dataSource,
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
colorScheme: this.user?.settings?.colorScheme,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToReportDataGlitch: hasPermission(
|
||||
this.user?.permissions,
|
||||
permissions.reportDataGlitch
|
||||
),
|
||||
locale: this.user?.settings?.locale
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.router.navigate([], {
|
||||
queryParams: {
|
||||
dataSource: null,
|
||||
holdingDetailDialog: null,
|
||||
symbol: null
|
||||
},
|
||||
queryParamsHandling: 'merge',
|
||||
relativeTo: this.route
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private toggleTheme(isDarkTheme: boolean) {
|
||||
const themeColor = getCssVariable(
|
||||
isDarkTheme ? '--dark-background' : '--light-background'
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||
import { GfLogoComponent } from '@ghostfolio/ui/logo';
|
||||
|
||||
import { Platform } from '@angular/cdk/platform';
|
||||
import { HttpClientModule } from '@angular/common/http';
|
||||
@ -43,7 +43,7 @@ export function NgxStripeFactory(): string {
|
||||
BrowserAnimationsModule,
|
||||
BrowserModule,
|
||||
GfHeaderModule,
|
||||
GfLogoModule,
|
||||
GfLogoComponent,
|
||||
GfSubscriptionInterstitialDialogModule,
|
||||
HttpClientModule,
|
||||
MarkdownModule.forRoot(),
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user