Compare commits
85 Commits
Author | SHA1 | Date | |
---|---|---|---|
2729c5651f | |||
7e28e42995 | |||
e21563d903 | |||
3ede69650c | |||
c289793c6d | |||
a90c067da0 | |||
38c2baf943 | |||
82c78cad6b | |||
bffe6060bd | |||
841bd5c33f | |||
3b895afc9e | |||
00c2ede85e | |||
8420cb830c | |||
a0ddd1f9b9 | |||
40d93066ff | |||
671e4e316b | |||
473136e9aa | |||
9a3db91982 | |||
d23cb5f190 | |||
7a364472c8 | |||
59c064e3c8 | |||
e792924606 | |||
d32dd5e860 | |||
bb86f85203 | |||
0bca8897d6 | |||
ba73f6de2e | |||
eb75be8535 | |||
6d2a897366 | |||
d8bfb23f20 | |||
d9d71e7827 | |||
b642ce08e5 | |||
bc8d8309d4 | |||
1f2f9f22f2 | |||
7a3237f1ff | |||
07661d9262 | |||
77358eed65 | |||
c641c28b12 | |||
c54392b7bb | |||
f3a8822a77 | |||
f1dc075c36 | |||
144d831954 | |||
c37ad9bad4 | |||
4ab3f81384 | |||
b932bac9aa | |||
bcdd873222 | |||
25b3de5828 | |||
40b454d2f3 | |||
5596e5f03b | |||
66992ef915 | |||
7f67430685 | |||
8a49a04324 | |||
5d7c19b0ed | |||
cde74b6c62 | |||
633c65e33c | |||
d1617f2d87 | |||
68e558f198 | |||
12ca01c862 | |||
2115745471 | |||
2cabd21315 | |||
3615e2f057 | |||
d3679d41b3 | |||
f2d431a6b8 | |||
2bc8bebfb8 | |||
5b20ba3382 | |||
15cc294581 | |||
b060b81204 | |||
a8d557eb1b | |||
6ae3a47b54 | |||
88c19eb45e | |||
7728706bc8 | |||
2e9d40c201 | |||
c002e37285 | |||
6be38a1c19 | |||
a3178fb213 | |||
e7158f6e16 | |||
dbea0456bc | |||
fefee11301 | |||
40836b745b | |||
07eabac059 | |||
48b412cfb8 | |||
b62488628c | |||
982c71c728 | |||
5aa16a3779 | |||
93de25e5b6 | |||
9acdb41aa2 |
25
.env.dev
Normal file
25
.env.dev
Normal file
@ -0,0 +1,25 @@
|
||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
# POSTGRES
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
# VARIOUS
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
||||
# DEVELOPMENT
|
||||
|
||||
# Nx 18 enables using plugins to infer targets by default
|
||||
# This is disabled for existing workspaces to maintain compatibility
|
||||
# For more info, see: https://nx.dev/concepts/inferred-tasks
|
||||
NX_ADD_PLUGINS=false
|
||||
|
||||
NX_NATIVE_COMMAND_RUNNER=false
|
@ -1,4 +1,4 @@
|
||||
COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
COMPOSE_PROJECT_NAME=ghostfolio
|
||||
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
# VARIOUS
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
|
@ -12,6 +12,12 @@
|
||||
"importOrder": ["^@ghostfolio/(.*)$", "<THIRD_PARTY_MODULES>", "^[./]"],
|
||||
"importOrderSeparation": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.ts",
|
||||
"options": {
|
||||
|
170
CHANGELOG.md
170
CHANGELOG.md
@ -5,6 +5,176 @@ 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.64.0 - 2024-03-16
|
||||
|
||||
### Added
|
||||
|
||||
- Added a toggle to switch between active and closed holdings on the portfolio holdings page
|
||||
- Added support to update the cash balance of an account when adding a fee activity
|
||||
- Added support to update the cash balance of an account when adding an interest activity
|
||||
- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the platform management in the admin control panel
|
||||
- Improved the usability of the tag management in the admin control panel
|
||||
- Improved the exception handling of various rules in the _X-ray_ section
|
||||
- Increased the timeout to load benchmarks
|
||||
- Upgraded `prisma` from version `5.10.2` to `5.11.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the dividend calculation of the portfolio holdings
|
||||
- Fixed the date conversion of the import of historical market data in the admin control panel
|
||||
|
||||
## 2.63.2 - 2024-03-12
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page
|
||||
- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0`
|
||||
- Upgraded `countries-list` from version `2.6.1` to `3.1.0`
|
||||
- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day
|
||||
- Fixed an issue in the calculation on the allocations page caused by liabilities
|
||||
- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_
|
||||
|
||||
## 2.62.0 - 2024-03-09
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the calculation of the accounts table
|
||||
- Optimized the calculation of the portfolio holdings
|
||||
- Integrated dividend into the transaction point concept in the portfolio service
|
||||
- Removed the environment variable `WEB_AUTH_RP_ID`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
|
||||
- Fixed an issue with removing a linked account from a (wealth) item activity
|
||||
|
||||
## 2.61.1 - 2024-03-06
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the account value calculation caused by liabilities
|
||||
|
||||
## 2.61.0 - 2024-03-04
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the calculation of the portfolio summary
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the activities import (query parameter handling)
|
||||
|
||||
## 2.60.0 - 2024-03-02
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for the cryptocurrency _Uniswap_ (`UNI7083-USD`)
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usability of the benchmarks in the markets overview
|
||||
- Integrated (wealth) items into the transaction point concept in the portfolio service
|
||||
- Refreshed the cryptocurrencies list
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a missing value in the activities table on mobile
|
||||
- Fixed a missing value on the public page
|
||||
- Displayed the button to fetch the current market price only if the activity is from today
|
||||
|
||||
## 2.59.0 - 2024-02-29
|
||||
|
||||
### Added
|
||||
|
||||
- Added an index for `isExcluded` to the account database table
|
||||
- Extended the content of the _Self-Hosting_ section on the Frequently Asked Questions (FAQ) page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the activities import by `isin` in the _Yahoo Finance_ service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the exchange rate calculation of (wealth) items in accounts
|
||||
|
||||
## 2.58.0 - 2024-02-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the handling of activities without account
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the query to filter activities of excluded accounts
|
||||
- Improved the asset profile validation in the activities import
|
||||
|
||||
## 2.57.0 - 2024-02-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the break down of the performance into asset and currency on the analysis page from experimental to general availability
|
||||
- Restructured the `copy-assets` `Nx` target
|
||||
|
||||
### Fixed
|
||||
|
||||
- Changed the performances of the _Top 3_ and _Bottom 3_ performers on the analysis page to take the currency effects into account
|
||||
|
||||
## 2.56.0 - 2024-02-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Switched the performance calculations to take the currency effects into account
|
||||
- Removed the `isDefault` flag from the `Account` database schema
|
||||
- Exposed the database index of _Redis_ as an environment variable (`REDIS_DB`)
|
||||
- Improved the language localization for German (`de`)
|
||||
- Upgraded `prisma` from version `5.9.1` to `5.10.2`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Added the missing default currency to the prepare currencies function in the exchange rate data service
|
||||
|
||||
## 2.55.0 - 2024-02-22
|
||||
|
||||
### Added
|
||||
|
||||
- Added indexes for `alias`, `granteeUserId` and `userId` to the access database table
|
||||
- Added indexes for `currency`, `name` and `userId` to the account database table
|
||||
- Added indexes for `accountId`, `date` and `updatedAt` to the account balance database table
|
||||
- Added an index for `userId` to the auth device database table
|
||||
- Added indexes for `marketPrice` and `state` to the market data database table
|
||||
- Added indexes for `date`, `isDraft` and `userId` to the order database table
|
||||
- Added an index for `name` to the platform database table
|
||||
- Added indexes for `assetClass`, `currency`, `dataSource`, `isin`, `name` and `symbol` to the symbol profile database table
|
||||
- Added an index for `userId` to the subscription database table
|
||||
- Added an index for `name` to the tag database table
|
||||
- Added indexes for `accessToken`, `createdAt`, `provider`, `role` and `thirdPartyId` to the user database table
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the validation for `currency` in various endpoints
|
||||
- Harmonized the setting of a default locale in various components
|
||||
- Set the parser to `angular` in the `prettier` options
|
||||
|
||||
## 2.54.0 - 2024-02-19
|
||||
|
||||
### Added
|
||||
|
||||
- Added an index for `id` to the account database table
|
||||
- Added indexes for `dataSource` and `date` to the market data database table
|
||||
- Added an index for `accountId` to the order database table
|
||||
|
||||
## 2.53.1 - 2024-02-18
|
||||
|
||||
### Added
|
||||
|
@ -99,6 +99,7 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c
|
||||
| `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 |
|
||||
@ -143,7 +144,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 [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), [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
|
||||
|
||||
@ -153,7 +154,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
|
||||
- [Node.js](https://nodejs.org/en/download) (version 18+)
|
||||
- [Yarn](https://yarnpkg.com/en/docs/install)
|
||||
- Create a local copy of this Git repository (clone)
|
||||
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
|
||||
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
|
||||
|
||||
### Setup
|
||||
|
||||
|
@ -13,7 +13,6 @@ export default {
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../coverage/apps/api',
|
||||
testTimeout: 10000,
|
||||
testEnvironment: 'node',
|
||||
preset: '../../jest.preset.js'
|
||||
};
|
||||
|
@ -9,12 +9,13 @@
|
||||
"build": {
|
||||
"executor": "@nx/webpack:webpack",
|
||||
"options": {
|
||||
"outputPath": "dist/apps/api",
|
||||
"main": "apps/api/src/main.ts",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"assets": ["apps/api/src/assets"],
|
||||
"target": "node",
|
||||
"compiler": "tsc",
|
||||
"deleteOutputPath": false,
|
||||
"main": "apps/api/src/main.ts",
|
||||
"outputPath": "dist/apps/api",
|
||||
"sourceMap": true,
|
||||
"target": "node",
|
||||
"tsConfig": "apps/api/tsconfig.app.json",
|
||||
"webpackConfig": "apps/api/webpack.config.js"
|
||||
},
|
||||
"configurations": {
|
||||
@ -33,6 +34,26 @@
|
||||
},
|
||||
"outputs": ["{options.outputPath}"]
|
||||
},
|
||||
"copy-assets": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "shx rm -rf dist/apps/api"
|
||||
},
|
||||
{
|
||||
"command": "shx mkdir -p dist/apps/api/assets/locales"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/api/src/assets/* dist/apps/api/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/locales/* dist/apps/api/assets/locales"
|
||||
}
|
||||
],
|
||||
"parallel": false
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
"executor": "@nx/js:node",
|
||||
"options": {
|
||||
|
@ -83,7 +83,7 @@ export class AccessController {
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.accessService.createAccess({
|
||||
return this.accessService.createAccess({
|
||||
alias: data.alias || undefined,
|
||||
GranteeUser: data.granteeUserId
|
||||
? { connect: { id: data.granteeUserId } }
|
||||
|
@ -63,7 +63,7 @@ export class AccountController {
|
||||
{ Order: true }
|
||||
);
|
||||
|
||||
if (account?.isDefault || account?.Order.length > 0) {
|
||||
if (!account || account?.Order.length > 0) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
|
@ -21,10 +21,8 @@ export class AccountService {
|
||||
public async account({
|
||||
id_userId
|
||||
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
|
||||
const { id, userId } = id_userId;
|
||||
|
||||
const [account] = await this.accounts({
|
||||
where: { id, userId }
|
||||
where: id_userId
|
||||
});
|
||||
|
||||
return account;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -19,7 +20,7 @@ export class CreateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@ -19,7 +20,7 @@ export class UpdateAccountDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -339,6 +339,6 @@ export class AdminController {
|
||||
@Param('key') key: string,
|
||||
@Body() data: PropertyDto
|
||||
) {
|
||||
return await this.adminService.putSetting(key, data.value);
|
||||
return this.adminService.putSetting(key, data.value);
|
||||
}
|
||||
}
|
||||
|
@ -71,7 +71,7 @@ export class AdminService {
|
||||
);
|
||||
}
|
||||
|
||||
return await this.symbolProfileService.add(
|
||||
return this.symbolProfileService.add(
|
||||
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
|
||||
);
|
||||
} catch (error) {
|
||||
@ -440,13 +440,14 @@ export class AdminService {
|
||||
},
|
||||
createdAt: true,
|
||||
id: true,
|
||||
role: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30
|
||||
});
|
||||
|
||||
return usersWithAnalytics.map(
|
||||
({ _count, Analytics, createdAt, id, Subscription }) => {
|
||||
({ _count, Analytics, createdAt, id, role, Subscription }) => {
|
||||
const daysSinceRegistration =
|
||||
differenceInDays(new Date(), createdAt) + 1;
|
||||
const engagement = Analytics
|
||||
@ -466,6 +467,7 @@ export class AdminService {
|
||||
createdAt,
|
||||
engagement,
|
||||
id,
|
||||
role,
|
||||
subscription,
|
||||
accountCount: _count.Account || 0,
|
||||
country: Analytics?.country,
|
||||
|
@ -2,6 +2,7 @@ import { AssetClass, AssetSubClass, Prisma } from '@prisma/client';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString
|
||||
@ -24,7 +25,7 @@ export class UpdateAssetProfileDto {
|
||||
@IsOptional()
|
||||
countries?: Prisma.InputJsonArray;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsOptional()
|
||||
currency?: string;
|
||||
|
||||
|
@ -53,6 +53,7 @@ import { UserModule } from './user/user.module';
|
||||
BenchmarkModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
db: parseInt(process.env.REDIS_DB ?? '0', 10),
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT ?? '6379', 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
|
@ -41,7 +41,7 @@ export class WebAuthService {
|
||||
) {}
|
||||
|
||||
get rpID() {
|
||||
return this.configurationService.get('WEB_AUTH_RP_ID');
|
||||
return new URL(this.configurationService.get('ROOT_URL')).hostname;
|
||||
}
|
||||
|
||||
get expectedOrigin() {
|
||||
|
@ -110,7 +110,9 @@ export class BenchmarkService {
|
||||
const quotes = await this.dataProviderService.getQuotes({
|
||||
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
}),
|
||||
requestTimeout: ms('30 seconds'),
|
||||
useCache: false
|
||||
});
|
||||
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
@ -163,7 +165,7 @@ export class BenchmarkService {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
ms('2 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -43,8 +43,10 @@ export class ImportController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async import(
|
||||
@Body() importData: ImportDataDto,
|
||||
@Query('dryRun') isDryRun?: boolean
|
||||
@Query('dryRun') isDryRunParam = 'false'
|
||||
): Promise<ImportResponse> {
|
||||
const isDryRun = isDryRunParam === 'true';
|
||||
|
||||
if (
|
||||
!hasPermission(this.request.user.permissions, permissions.createAccount)
|
||||
) {
|
||||
|
@ -570,17 +570,10 @@ export class ImportService {
|
||||
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
|
||||
} = {};
|
||||
|
||||
const uniqueActivitiesDto = uniqBy(
|
||||
activitiesDto,
|
||||
({ dataSource, symbol }) => {
|
||||
return getAssetProfileIdentifier({ dataSource, symbol });
|
||||
}
|
||||
);
|
||||
|
||||
for (const [
|
||||
index,
|
||||
{ currency, dataSource, symbol, type }
|
||||
] of uniqueActivitiesDto.entries()) {
|
||||
] of activitiesDto.entries()) {
|
||||
if (!this.configurationService.get('DATA_SOURCES').includes(dataSource)) {
|
||||
throw new Error(
|
||||
`activities.${index}.dataSource ("${dataSource}") is not valid`
|
||||
@ -602,6 +595,7 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
@ -618,15 +612,9 @@ export class ImportService {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
assetProfile.currency !== currency &&
|
||||
!this.exchangeRateDataService.hasCurrencyPair(
|
||||
currency,
|
||||
assetProfile.currency
|
||||
)
|
||||
) {
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with "${assetProfile.currency}" and no exchange rate is available from "${currency}" to "${assetProfile.currency}"`
|
||||
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -634,6 +622,7 @@ export class ImportService {
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
assetProfile;
|
||||
}
|
||||
}
|
||||
|
||||
return assetProfiles;
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -38,7 +39,7 @@ export class CreateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsOptional()
|
||||
|
@ -8,7 +8,7 @@ import {
|
||||
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Filter, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@ -19,7 +19,7 @@ import {
|
||||
Order,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
Type as ActivityType
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
@ -70,12 +70,7 @@ export class OrderService {
|
||||
const updateAccountBalance = data.updateAccountBalance ?? false;
|
||||
const userId = data.userId;
|
||||
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
const assetClass = data.assetClass;
|
||||
const assetSubClass = data.assetSubClass;
|
||||
currency = data.SymbolProfile.connectOrCreate.create.currency;
|
||||
@ -130,11 +125,7 @@ export class OrderService {
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
const isDraft =
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
@ -157,7 +148,7 @@ export class OrderService {
|
||||
.plus(data.fee)
|
||||
.toNumber();
|
||||
|
||||
if (data.type === 'BUY') {
|
||||
if (['BUY', 'FEE'].includes(data.type)) {
|
||||
amount = new Big(amount).mul(-1).toNumber();
|
||||
}
|
||||
|
||||
@ -180,12 +171,7 @@ export class OrderService {
|
||||
where
|
||||
});
|
||||
|
||||
if (
|
||||
order.type === 'FEE' ||
|
||||
order.type === 'INTEREST' ||
|
||||
order.type === 'ITEM' ||
|
||||
order.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
|
||||
await this.symbolProfileService.deleteById(order.symbolProfileId);
|
||||
}
|
||||
|
||||
@ -200,6 +186,17 @@ export class OrderService {
|
||||
return count;
|
||||
}
|
||||
|
||||
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
|
||||
return this.prismaService.order.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
where: {
|
||||
SymbolProfile: { dataSource, symbol }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async getOrders({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
@ -218,7 +215,7 @@ export class OrderService {
|
||||
sortColumn?: string;
|
||||
sortDirection?: Prisma.SortOrder;
|
||||
take?: number;
|
||||
types?: TypeOfOrder[];
|
||||
types?: ActivityType[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
@ -292,19 +289,14 @@ export class OrderService {
|
||||
}
|
||||
|
||||
if (types) {
|
||||
where.OR = types.map((type) => {
|
||||
return {
|
||||
type: {
|
||||
equals: type
|
||||
}
|
||||
};
|
||||
});
|
||||
where.type = { in: types };
|
||||
}
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.Account = {
|
||||
NOT: { isExcluded: true }
|
||||
};
|
||||
where.OR = [
|
||||
{ Account: null },
|
||||
{ Account: { NOT: { isExcluded: true } } }
|
||||
];
|
||||
}
|
||||
|
||||
const [orders, count] = await Promise.all([
|
||||
@ -334,11 +326,13 @@ export class OrderService {
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
// TODO: Use exchange rate of date
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
// TODO: Use exchange rate of date
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
@ -369,13 +363,10 @@ export class OrderService {
|
||||
dataSource?: DataSource;
|
||||
symbol?: string;
|
||||
tags?: Tag[];
|
||||
type?: ActivityType;
|
||||
};
|
||||
where: Prisma.OrderWhereUniqueInput;
|
||||
}): Promise<Order> {
|
||||
if (data.Account.connect.id_userId.id === null) {
|
||||
delete data.Account;
|
||||
}
|
||||
|
||||
if (!data.comment) {
|
||||
data.comment = null;
|
||||
}
|
||||
@ -384,13 +375,12 @@ export class OrderService {
|
||||
|
||||
let isDraft = false;
|
||||
|
||||
if (
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
) {
|
||||
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)) {
|
||||
delete data.SymbolProfile.connect;
|
||||
|
||||
if (data.Account?.connect?.id_userId?.id === null) {
|
||||
data.Account = { disconnect: true };
|
||||
}
|
||||
} else {
|
||||
delete data.SymbolProfile.update;
|
||||
|
||||
|
@ -9,6 +9,7 @@ import { Transform, TransformFnParams } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
@ -37,7 +38,7 @@ export class UpdateOrderDto {
|
||||
)
|
||||
comment?: string;
|
||||
|
||||
@IsString()
|
||||
@IsISO4217CurrencyCode()
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -43,6 +43,17 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'MSFT':
|
||||
if (isSameDay(parseDate('2021-09-16'), date)) {
|
||||
return { marketPrice: 89.12 };
|
||||
} else if (isSameDay(parseDate('2021-11-16'), date)) {
|
||||
return { marketPrice: 339.51 };
|
||||
} else if (isSameDay(parseDate('2023-07-10'), date)) {
|
||||
return { marketPrice: 331.83 };
|
||||
}
|
||||
|
||||
return { marketPrice: 0 };
|
||||
|
||||
case 'NOVN.SW':
|
||||
if (isSameDay(parseDate('2022-04-11'), date)) {
|
||||
return { marketPrice: 87.8 };
|
||||
|
@ -108,6 +108,7 @@ describe('CurrentRateService', () => {
|
||||
currentRateService = new CurrentRateService(
|
||||
dataProviderService,
|
||||
marketDataService,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
@ -22,6 +23,7 @@ export class CurrentRateService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
@ -121,11 +123,17 @@ export class CurrentRateService {
|
||||
});
|
||||
|
||||
if (!value) {
|
||||
// Fallback to unit price of latest activity
|
||||
const latestActivity = await this.orderService.getLatestOrder({
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
value = {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice: 0
|
||||
marketPrice: latestActivity?.unitPrice ?? 0
|
||||
};
|
||||
|
||||
response.values.push(value);
|
||||
|
@ -3,7 +3,7 @@ import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface CurrentPositions extends ResponseError {
|
||||
positions: TimelinePosition[];
|
||||
currentValueInBaseCurrency: Big;
|
||||
grossPerformance: Big;
|
||||
grossPerformanceWithCurrencyEffect: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
@ -14,6 +14,6 @@ export interface CurrentPositions extends ResponseError {
|
||||
netPerformanceWithCurrencyEffect: Big;
|
||||
netPerformancePercentage: Big;
|
||||
netPerformancePercentageWithCurrencyEffect: Big;
|
||||
currentValue: Big;
|
||||
positions: TimelinePosition[];
|
||||
totalInvestment: Big;
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import { PortfolioOrder } from './portfolio-order.interface';
|
||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||
feeInBaseCurrency?: Big;
|
||||
feeInBaseCurrencyWithCurrencyEffect?: Big;
|
||||
itemType?: '' | 'start' | 'end';
|
||||
itemType?: 'end' | 'start';
|
||||
unitPriceInBaseCurrency?: Big;
|
||||
unitPriceInBaseCurrencyWithCurrencyEffect?: Big;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { DataSource, Tag, Type as ActivityType } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
@ -10,6 +10,6 @@ export interface PortfolioOrder {
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
tags?: Tag[];
|
||||
type: TypeOfOrder;
|
||||
type: ActivityType;
|
||||
unitPrice: Big;
|
||||
}
|
||||
|
@ -2,8 +2,10 @@ import { DataSource, Tag } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
averagePrice: Big;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
dividend: Big;
|
||||
fee: Big;
|
||||
firstBuyDate: string;
|
||||
investment: Big;
|
||||
|
@ -0,0 +1,166 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with BALN.SW buy and sell in two activities', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'CHF',
|
||||
orders: [
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-22',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.55),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(2),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(142.9)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(1.65),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
},
|
||||
{
|
||||
currency: 'CHF',
|
||||
date: '2021-11-30',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Bâloise Holding AG',
|
||||
quantity: new Big(1),
|
||||
symbol: 'BALN.SW',
|
||||
type: 'SELL',
|
||||
unitPrice: new Big(136.6)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2021-12-18').getTime());
|
||||
|
||||
const chartData = await portfolioCalculator.getChartData({
|
||||
start: parseDate('2021-11-22')
|
||||
});
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2021-11-22')
|
||||
);
|
||||
|
||||
const investments = portfolioCalculator.getInvestments();
|
||||
|
||||
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({
|
||||
data: chartData,
|
||||
groupBy: 'month'
|
||||
});
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.04408677396780965649'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||
hasErrors: false,
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.05528341497550734703'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.04408677396780965649'),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.04408677396780965649'
|
||||
),
|
||||
grossPerformanceWithCurrencyEffect: new Big('-12.6'),
|
||||
investment: new Big('0'),
|
||||
investmentWithCurrencyEffect: new Big('0'),
|
||||
netPerformance: new Big('-15.8'),
|
||||
netPerformancePercentage: new Big('-0.05528341497550734703'),
|
||||
netPerformancePercentageWithCurrencyEffect: new Big(
|
||||
'-0.05528341497550734703'
|
||||
),
|
||||
netPerformanceWithCurrencyEffect: new Big('-15.8'),
|
||||
marketPrice: 148.9,
|
||||
marketPriceInBaseCurrency: 148.9,
|
||||
quantity: new Big('0'),
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('285.80000000000000396627'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'285.80000000000000396627'
|
||||
),
|
||||
transactionCount: 3,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('0')
|
||||
});
|
||||
|
||||
expect(investments).toEqual([
|
||||
{ date: '2021-11-22', investment: new Big('285.8') },
|
||||
{ date: '2021-11-30', investment: new Big('0') }
|
||||
]);
|
||||
|
||||
expect(investmentsByMonth).toEqual([
|
||||
{ date: '2021-11-01', investment: 0 },
|
||||
{ date: '2021-12-01', investment: 0 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('-12.6'),
|
||||
grossPerformancePercentage: new Big('-0.0440867739678096571'),
|
||||
@ -107,6 +107,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('3.2'),
|
||||
firstBuyDate: '2021-11-22',
|
||||
grossPerformance: new Big('-12.6'),
|
||||
@ -129,7 +131,8 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('285.8'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('285.8'),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0'),
|
||||
|
@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -76,7 +76,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('297.8'),
|
||||
currentValueInBaseCurrency: new Big('297.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('24.6'),
|
||||
grossPerformancePercentage: new Big('0.09004392386530014641'),
|
||||
@ -96,6 +96,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('136.6'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1.55'),
|
||||
firstBuyDate: '2021-11-30',
|
||||
grossPerformance: new Big('24.6'),
|
||||
@ -118,7 +120,8 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'BALN.SW',
|
||||
timeWeightedInvestment: new Big('273.2'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('273.2'),
|
||||
transactionCount: 1
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('297.8')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('273.2'),
|
||||
|
@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -100,7 +100,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('13298.425356'),
|
||||
currentValueInBaseCurrency: new Big('13298.425356'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27172.74'),
|
||||
grossPerformancePercentage: new Big('42.41978276196153750666'),
|
||||
@ -120,6 +120,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('320.43'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2015-01-01',
|
||||
grossPerformance: new Big('27172.74'),
|
||||
@ -149,7 +151,8 @@ describe('PortfolioCalculator', () => {
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'636.79469348020066587024'
|
||||
),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('13298.425356')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('320.43'),
|
||||
|
@ -34,7 +34,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -89,7 +89,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('103.10483'),
|
||||
currentValueInBaseCurrency: new Big('103.10483'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('27.33'),
|
||||
grossPerformancePercentage: new Big('0.3066651705565529623'),
|
||||
@ -109,6 +109,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('89.12'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('1'),
|
||||
firstBuyDate: '2023-01-03',
|
||||
grossPerformance: new Big('27.33'),
|
||||
@ -132,7 +134,8 @@ describe('PortfolioCalculator', () => {
|
||||
tags: undefined,
|
||||
timeWeightedInvestment: new Big('89.12'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('82.329056'),
|
||||
transactionCount: 1
|
||||
transactionCount: 1,
|
||||
valueInBaseCurrency: new Big('103.10483')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('89.12'),
|
||||
|
@ -0,0 +1,118 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.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';
|
||||
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateServiceMock } from './current-rate.service.mock';
|
||||
import { PortfolioCalculator } from './portfolio-calculator';
|
||||
|
||||
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return CurrentRateServiceMock;
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock(
|
||||
'@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service',
|
||||
() => {
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
ExchangeRateDataService: jest.fn().mockImplementation(() => {
|
||||
return ExchangeRateDataServiceMock;
|
||||
})
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
describe('PortfolioCalculator', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
describe('get current positions', () => {
|
||||
it.only('with MSFT buy', async () => {
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currentRateService,
|
||||
exchangeRateDataService,
|
||||
currency: 'USD',
|
||||
orders: [
|
||||
{
|
||||
currency: 'USD',
|
||||
date: '2021-09-16',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(19),
|
||||
name: 'Microsoft Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'MSFT',
|
||||
type: 'BUY',
|
||||
unitPrice: new Big(298.58)
|
||||
},
|
||||
{
|
||||
currency: 'USD',
|
||||
date: '2021-11-16',
|
||||
dataSource: 'YAHOO',
|
||||
fee: new Big(0),
|
||||
name: 'Microsoft Inc.',
|
||||
quantity: new Big(1),
|
||||
symbol: 'MSFT',
|
||||
type: 'DIVIDEND',
|
||||
unitPrice: new Big(0.62)
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
const spy = jest
|
||||
.spyOn(Date, 'now')
|
||||
.mockImplementation(() => parseDate('2023-07-10').getTime());
|
||||
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
parseDate('2023-07-10')
|
||||
);
|
||||
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toMatchObject({
|
||||
errors: [],
|
||||
hasErrors: false,
|
||||
positions: [
|
||||
{
|
||||
averagePrice: new Big('298.58'),
|
||||
currency: 'USD',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0.62'),
|
||||
dividendInBaseCurrency: new Big('0.62'),
|
||||
fee: new Big('19'),
|
||||
firstBuyDate: '2021-09-16',
|
||||
investment: new Big('298.58'),
|
||||
investmentWithCurrencyEffect: new Big('298.58'),
|
||||
marketPrice: 331.83,
|
||||
marketPriceInBaseCurrency: 331.83,
|
||||
quantity: new Big('1'),
|
||||
symbol: 'MSFT',
|
||||
tags: undefined,
|
||||
transactionCount: 2
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('298.58'),
|
||||
totalInvestmentWithCurrencyEffect: new Big('298.58')
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -64,7 +64,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big(0),
|
||||
currentValueInBaseCurrency: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
|
@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -87,7 +87,7 @@ describe('PortfolioCalculator', () => {
|
||||
spy.mockRestore();
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('87.8'),
|
||||
currentValueInBaseCurrency: new Big('87.8'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('21.93'),
|
||||
grossPerformancePercentage: new Big('0.15113417083448194384'),
|
||||
@ -107,6 +107,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('75.80'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('4.25'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('21.93'),
|
||||
@ -131,7 +133,8 @@ describe('PortfolioCalculator', () => {
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(
|
||||
'145.10285714285714285714'
|
||||
),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('87.8')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('75.80'),
|
||||
|
@ -21,7 +21,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
@ -113,7 +113,7 @@ describe('PortfolioCalculator', () => {
|
||||
});
|
||||
|
||||
expect(currentPositions).toEqual({
|
||||
currentValue: new Big('0'),
|
||||
currentValueInBaseCurrency: new Big('0'),
|
||||
errors: [],
|
||||
grossPerformance: new Big('19.86'),
|
||||
grossPerformancePercentage: new Big('0.13100263852242744063'),
|
||||
@ -133,6 +133,8 @@ describe('PortfolioCalculator', () => {
|
||||
averagePrice: new Big('0'),
|
||||
currency: 'CHF',
|
||||
dataSource: 'YAHOO',
|
||||
dividend: new Big('0'),
|
||||
dividendInBaseCurrency: new Big('0'),
|
||||
fee: new Big('0'),
|
||||
firstBuyDate: '2022-03-07',
|
||||
grossPerformance: new Big('19.86'),
|
||||
@ -155,7 +157,8 @@ describe('PortfolioCalculator', () => {
|
||||
symbol: 'NOVN.SW',
|
||||
timeWeightedInvestment: new Big('151.6'),
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big('151.6'),
|
||||
transactionCount: 2
|
||||
transactionCount: 2,
|
||||
valueInBaseCurrency: new Big('0')
|
||||
}
|
||||
],
|
||||
totalInvestment: new Big('0'),
|
||||
|
@ -10,7 +10,7 @@ describe('PortfolioCalculator', () => {
|
||||
let exchangeRateDataService: ExchangeRateDataService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
currentRateService = new CurrentRateService(null, null, null, null);
|
||||
|
||||
exchangeRateDataService = new ExchangeRateDataService(
|
||||
null,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
@ -12,7 +13,6 @@ import {
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
@ -22,6 +22,7 @@ import {
|
||||
format,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
max,
|
||||
subDays
|
||||
} from 'date-fns';
|
||||
import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash';
|
||||
@ -70,40 +71,40 @@ export class PortfolioCalculator {
|
||||
|
||||
let lastDate: string = null;
|
||||
let lastTransactionPoint: TransactionPoint = null;
|
||||
|
||||
for (const order of this.orders) {
|
||||
const currentDate = order.date;
|
||||
|
||||
let currentTransactionPointItem: TransactionPointSymbol;
|
||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
||||
|
||||
const factor = this.getFactor(order.type);
|
||||
const unitPrice = new Big(order.unitPrice);
|
||||
const factor = getFactor(order.type);
|
||||
|
||||
if (oldAccumulatedSymbol) {
|
||||
let investment = oldAccumulatedSymbol.investment;
|
||||
|
||||
const newQuantity = order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
|
||||
let investment = new Big(0);
|
||||
|
||||
if (newQuantity.gt(0)) {
|
||||
if (order.type === 'BUY') {
|
||||
investment = oldAccumulatedSymbol.investment.plus(
|
||||
order.quantity.mul(unitPrice)
|
||||
order.quantity.mul(order.unitPrice)
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
const averagePrice = oldAccumulatedSymbol.investment.div(
|
||||
oldAccumulatedSymbol.quantity
|
||||
);
|
||||
investment = oldAccumulatedSymbol.investment.minus(
|
||||
order.quantity.mul(averagePrice)
|
||||
order.quantity.mul(oldAccumulatedSymbol.averagePrice)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
currentTransactionPointItem = {
|
||||
investment,
|
||||
averagePrice: newQuantity.gt(0)
|
||||
? investment.div(newQuantity)
|
||||
: new Big(0),
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
dividend: new Big(0),
|
||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
quantity: newQuantity,
|
||||
@ -113,11 +114,13 @@ export class PortfolioCalculator {
|
||||
};
|
||||
} else {
|
||||
currentTransactionPointItem = {
|
||||
averagePrice: order.unitPrice,
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
dividend: new Big(0),
|
||||
fee: order.fee,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
investment: order.unitPrice.mul(order.quantity).mul(factor),
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
tags: order.tags,
|
||||
@ -128,22 +131,28 @@ export class PortfolioCalculator {
|
||||
symbols[order.symbol] = currentTransactionPointItem;
|
||||
|
||||
const items = lastTransactionPoint?.items ?? [];
|
||||
|
||||
const newItems = items.filter(
|
||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||
);
|
||||
|
||||
newItems.push(currentTransactionPointItem);
|
||||
|
||||
newItems.sort((a, b) => {
|
||||
return a.symbol?.localeCompare(b.symbol);
|
||||
});
|
||||
|
||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||
lastTransactionPoint = {
|
||||
date: currentDate,
|
||||
items: newItems
|
||||
};
|
||||
|
||||
this.transactionPoints.push(lastTransactionPoint);
|
||||
} else {
|
||||
lastTransactionPoint.items = newItems;
|
||||
}
|
||||
|
||||
lastDate = currentDate;
|
||||
}
|
||||
}
|
||||
@ -441,16 +450,27 @@ export class PortfolioCalculator {
|
||||
|
||||
public async getCurrentPositions(
|
||||
start: Date,
|
||||
end = new Date(Date.now())
|
||||
end?: Date
|
||||
): Promise<CurrentPositions> {
|
||||
const transactionPointsBeforeEndDate =
|
||||
this.transactionPoints?.filter((transactionPoint) => {
|
||||
return isBefore(parseDate(transactionPoint.date), end);
|
||||
}) ?? [];
|
||||
const lastTransactionPoint = last(this.transactionPoints);
|
||||
|
||||
if (!transactionPointsBeforeEndDate.length) {
|
||||
let endDate = end;
|
||||
|
||||
if (!endDate) {
|
||||
endDate = new Date(Date.now());
|
||||
|
||||
if (lastTransactionPoint) {
|
||||
endDate = max([endDate, parseDate(lastTransactionPoint.date)]);
|
||||
}
|
||||
}
|
||||
|
||||
const transactionPoints = this.transactionPoints?.filter(({ date }) => {
|
||||
return isBefore(parseDate(date), endDate);
|
||||
});
|
||||
|
||||
if (!transactionPoints.length) {
|
||||
return {
|
||||
currentValue: new Big(0),
|
||||
currentValueInBaseCurrency: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
@ -465,41 +485,40 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
const lastTransactionPoint =
|
||||
transactionPointsBeforeEndDate[transactionPointsBeforeEndDate.length - 1];
|
||||
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
let dates: Date[] = [];
|
||||
let firstIndex = transactionPointsBeforeEndDate.length;
|
||||
let firstIndex = transactionPoints.length;
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||
|
||||
for (const { currency, dataSource, symbol } of transactionPoints[
|
||||
firstIndex - 1
|
||||
].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
dataSource,
|
||||
symbol
|
||||
});
|
||||
|
||||
currencies[item.symbol] = item.currency;
|
||||
currencies[symbol] = currency;
|
||||
}
|
||||
|
||||
for (let i = 0; i < transactionPointsBeforeEndDate.length; i++) {
|
||||
for (let i = 0; i < transactionPoints.length; i++) {
|
||||
if (
|
||||
!isBefore(parseDate(transactionPointsBeforeEndDate[i].date), start) &&
|
||||
!isBefore(parseDate(transactionPoints[i].date), start) &&
|
||||
firstTransactionPoint === null
|
||||
) {
|
||||
firstTransactionPoint = transactionPointsBeforeEndDate[i];
|
||||
firstTransactionPoint = transactionPoints[i];
|
||||
firstIndex = i;
|
||||
}
|
||||
|
||||
if (firstTransactionPoint !== null) {
|
||||
dates.push(
|
||||
resetHours(parseDate(transactionPointsBeforeEndDate[i].date))
|
||||
);
|
||||
dates.push(resetHours(parseDate(transactionPoints[i].date)));
|
||||
}
|
||||
}
|
||||
|
||||
dates.push(resetHours(end));
|
||||
dates.push(resetHours(endDate));
|
||||
|
||||
// Add dates of last week for fallback
|
||||
dates.push(subDays(resetHours(new Date()), 7));
|
||||
@ -526,7 +545,7 @@ export class PortfolioCalculator {
|
||||
let exchangeRatesByCurrency =
|
||||
await this.exchangeRateDataService.getExchangeRatesByCurrency({
|
||||
currencies: uniq(Object.values(currencies)),
|
||||
endDate: endOfDay(end),
|
||||
endDate: endOfDay(endDate),
|
||||
startDate: parseDate(this.transactionPoints?.[0]?.date),
|
||||
targetCurrency: this.currency
|
||||
});
|
||||
@ -562,7 +581,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
const endDateString = format(end, DATE_FORMAT);
|
||||
const endDateString = format(endDate, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
@ -574,9 +593,9 @@ export class PortfolioCalculator {
|
||||
const errors: ResponseError['errors'] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[
|
||||
item.symbol
|
||||
]?.mul(
|
||||
const marketPriceInBaseCurrency = (
|
||||
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
|
||||
).mul(
|
||||
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
|
||||
endDateString
|
||||
]
|
||||
@ -594,12 +613,14 @@ export class PortfolioCalculator {
|
||||
netPerformanceWithCurrencyEffect,
|
||||
timeWeightedInvestment,
|
||||
timeWeightedInvestmentWithCurrencyEffect,
|
||||
totalDividend,
|
||||
totalDividendInBaseCurrency,
|
||||
totalInvestment,
|
||||
totalInvestmentWithCurrencyEffect
|
||||
} = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
end: endDate,
|
||||
exchangeRates:
|
||||
exchangeRatesByCurrency[`${item.currency}${this.currency}`],
|
||||
symbol: item.symbol
|
||||
@ -608,11 +629,11 @@ export class PortfolioCalculator {
|
||||
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
|
||||
|
||||
positions.push({
|
||||
dividend: totalDividend,
|
||||
dividendInBaseCurrency: totalDividendInBaseCurrency,
|
||||
timeWeightedInvestment,
|
||||
timeWeightedInvestmentWithCurrencyEffect,
|
||||
averagePrice: item.quantity.eq(0)
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
averagePrice: item.averagePrice,
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
fee: item.fee,
|
||||
@ -646,7 +667,10 @@ export class PortfolioCalculator {
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
tags: item.tags,
|
||||
transactionCount: item.transactionCount
|
||||
transactionCount: item.transactionCount,
|
||||
valueInBaseCurrency: new Big(marketPriceInBaseCurrency).mul(
|
||||
item.quantity
|
||||
)
|
||||
});
|
||||
|
||||
if (
|
||||
@ -715,7 +739,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
private calculateOverallPerformance(positions: TimelinePosition[]) {
|
||||
let currentValue = new Big(0);
|
||||
let currentValueInBaseCurrency = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceWithCurrencyEffect = new Big(0);
|
||||
let hasErrors = false;
|
||||
@ -727,14 +751,9 @@ export class PortfolioCalculator {
|
||||
let totalTimeWeightedInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (
|
||||
currentPosition.investment &&
|
||||
currentPosition.marketPriceInBaseCurrency
|
||||
) {
|
||||
currentValue = currentValue.plus(
|
||||
new Big(currentPosition.marketPriceInBaseCurrency).mul(
|
||||
currentPosition.quantity
|
||||
)
|
||||
if (currentPosition.valueInBaseCurrency) {
|
||||
currentValueInBaseCurrency = currentValueInBaseCurrency.plus(
|
||||
currentPosition.valueInBaseCurrency
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
@ -791,7 +810,7 @@ export class PortfolioCalculator {
|
||||
}
|
||||
|
||||
return {
|
||||
currentValue,
|
||||
currentValueInBaseCurrency,
|
||||
grossPerformance,
|
||||
grossPerformanceWithCurrencyEffect,
|
||||
hasErrors,
|
||||
@ -820,24 +839,6 @@ export class PortfolioCalculator {
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case 'BUY':
|
||||
factor = 1;
|
||||
break;
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
factor = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
private getSymbolMetrics({
|
||||
end,
|
||||
exchangeRates,
|
||||
@ -889,14 +890,13 @@ export class PortfolioCalculator {
|
||||
[date: string]: Big;
|
||||
} = {};
|
||||
|
||||
let totalDividend = new Big(0);
|
||||
let totalDividendInBaseCurrency = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalInvestmentFromBuyTransactions = new Big(0);
|
||||
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
|
||||
let totalInvestmentWithCurrencyEffect = new Big(0);
|
||||
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
|
||||
|
||||
let totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = new Big(
|
||||
0
|
||||
);
|
||||
|
||||
let totalQuantityFromBuyTransactions = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
let valueAtStartDate: Big;
|
||||
let valueAtStartDateWithCurrencyEffect: Big;
|
||||
@ -932,6 +932,8 @@ export class PortfolioCalculator {
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0)
|
||||
};
|
||||
@ -972,6 +974,8 @@ export class PortfolioCalculator {
|
||||
timeWeightedInvestmentValues: {},
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect: {},
|
||||
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
|
||||
totalDividend: new Big(0),
|
||||
totalDividendInBaseCurrency: new Big(0),
|
||||
totalInvestment: new Big(0),
|
||||
totalInvestmentWithCurrencyEffect: new Big(0)
|
||||
};
|
||||
@ -988,7 +992,7 @@ export class PortfolioCalculator {
|
||||
itemType: 'start',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
type: 'BUY',
|
||||
unitPrice: unitPriceAtStartDate
|
||||
});
|
||||
|
||||
@ -1002,7 +1006,7 @@ export class PortfolioCalculator {
|
||||
itemType: 'end',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
type: 'BUY',
|
||||
unitPrice: unitPriceAtEndDate
|
||||
});
|
||||
|
||||
@ -1029,7 +1033,7 @@ export class PortfolioCalculator {
|
||||
feeInBaseCurrency: new Big(0),
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
type: 'BUY',
|
||||
unitPrice:
|
||||
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
|
||||
lastUnitPrice
|
||||
@ -1042,28 +1046,26 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the right
|
||||
// Sort orders so that the start and end placeholder order are at the correct
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
let sortIndex = new Date(order.date);
|
||||
orders = sortBy(orders, ({ date, itemType }) => {
|
||||
let sortIndex = new Date(date);
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
if (order.itemType === 'end') {
|
||||
if (itemType === 'end') {
|
||||
sortIndex = addMilliseconds(sortIndex, 1);
|
||||
} else if (itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
return sortIndex.getTime();
|
||||
});
|
||||
|
||||
const indexOfStartOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'start';
|
||||
const indexOfStartOrder = orders.findIndex(({ itemType }) => {
|
||||
return itemType === 'start';
|
||||
});
|
||||
|
||||
const indexOfEndOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'end';
|
||||
const indexOfEndOrder = orders.findIndex(({ itemType }) => {
|
||||
return itemType === 'end';
|
||||
});
|
||||
|
||||
let totalInvestmentDays = 0;
|
||||
@ -1126,29 +1128,41 @@ export class PortfolioCalculator {
|
||||
valueOfInvestmentBeforeTransactionWithCurrencyEffect;
|
||||
}
|
||||
|
||||
const transactionInvestment =
|
||||
order.type === 'BUY'
|
||||
? order.quantity
|
||||
.mul(order.unitPriceInBaseCurrency)
|
||||
.mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
let transactionInvestment = new Big(0);
|
||||
let transactionInvestmentWithCurrencyEffect = new Big(0);
|
||||
|
||||
const transactionInvestmentWithCurrencyEffect =
|
||||
order.type === 'BUY'
|
||||
? order.quantity
|
||||
if (order.type === 'BUY') {
|
||||
transactionInvestment = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrency)
|
||||
.mul(getFactor(order.type));
|
||||
|
||||
transactionInvestmentWithCurrencyEffect = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
|
||||
.mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestmentWithCurrencyEffect
|
||||
.mul(getFactor(order.type));
|
||||
|
||||
totalQuantityFromBuyTransactions =
|
||||
totalQuantityFromBuyTransactions.plus(order.quantity);
|
||||
|
||||
totalInvestmentFromBuyTransactions =
|
||||
totalInvestmentFromBuyTransactions.plus(transactionInvestment);
|
||||
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect =
|
||||
totalInvestmentFromBuyTransactionsWithCurrencyEffect.plus(
|
||||
transactionInvestmentWithCurrencyEffect
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
if (totalUnits.gt(0)) {
|
||||
transactionInvestment = totalInvestment
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
.mul(getFactor(order.type));
|
||||
transactionInvestmentWithCurrencyEffect =
|
||||
totalInvestmentWithCurrencyEffect
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(getFactor(order.type));
|
||||
}
|
||||
}
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log('totalInvestment', totalInvestment.toNumber());
|
||||
@ -1202,9 +1216,16 @@ export class PortfolioCalculator {
|
||||
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
|
||||
);
|
||||
|
||||
totalUnits = totalUnits.plus(
|
||||
order.quantity.mul(this.getFactor(order.type))
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
|
||||
|
||||
@ -1213,14 +1234,14 @@ export class PortfolioCalculator {
|
||||
);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === TypeOfOrder.SELL
|
||||
order.type === 'SELL'
|
||||
? order.unitPriceInBaseCurrency
|
||||
.minus(lastAveragePrice)
|
||||
.mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
const grossPerformanceFromSellWithCurrencyEffect =
|
||||
order.type === TypeOfOrder.SELL
|
||||
order.type === 'SELL'
|
||||
? order.unitPriceInBaseCurrencyWithCurrencyEffect
|
||||
.minus(lastAveragePriceWithCurrencyEffect)
|
||||
.mul(order.quantity)
|
||||
@ -1235,35 +1256,21 @@ export class PortfolioCalculator {
|
||||
grossPerformanceFromSellWithCurrencyEffect
|
||||
);
|
||||
|
||||
totalInvestmentWithGrossPerformanceFromSell =
|
||||
totalInvestmentWithGrossPerformanceFromSell
|
||||
.plus(transactionInvestment)
|
||||
.plus(grossPerformanceFromSell);
|
||||
|
||||
totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect =
|
||||
totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect
|
||||
.plus(transactionInvestmentWithCurrencyEffect)
|
||||
.plus(grossPerformanceFromSellWithCurrencyEffect);
|
||||
|
||||
lastAveragePrice = totalUnits.eq(0)
|
||||
lastAveragePrice = totalQuantityFromBuyTransactions.eq(0)
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSell.div(totalUnits);
|
||||
: totalInvestmentFromBuyTransactions.div(
|
||||
totalQuantityFromBuyTransactions
|
||||
);
|
||||
|
||||
lastAveragePriceWithCurrencyEffect = totalUnits.eq(0)
|
||||
lastAveragePriceWithCurrencyEffect = totalQuantityFromBuyTransactions.eq(
|
||||
0
|
||||
)
|
||||
? new Big(0)
|
||||
: totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.div(
|
||||
totalUnits
|
||||
: totalInvestmentFromBuyTransactionsWithCurrencyEffect.div(
|
||||
totalQuantityFromBuyTransactions
|
||||
);
|
||||
|
||||
if (PortfolioCalculator.ENABLE_LOGGING) {
|
||||
console.log(
|
||||
'totalInvestmentWithGrossPerformanceFromSell',
|
||||
totalInvestmentWithGrossPerformanceFromSell.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect',
|
||||
totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect.toNumber()
|
||||
);
|
||||
console.log(
|
||||
'grossPerformanceFromSells',
|
||||
grossPerformanceFromSells.toNumber()
|
||||
@ -1297,7 +1304,7 @@ export class PortfolioCalculator {
|
||||
grossPerformanceWithCurrencyEffect;
|
||||
}
|
||||
|
||||
if (i > indexOfStartOrder) {
|
||||
if (i > indexOfStartOrder && ['BUY', 'SELL'].includes(order.type)) {
|
||||
// Only consider periods with an investment for the calculation of
|
||||
// the time weighted investment
|
||||
if (valueOfInvestmentBeforeTransaction.gt(0)) {
|
||||
@ -1309,11 +1316,10 @@ export class PortfolioCalculator {
|
||||
orderDate,
|
||||
previousOrderDate
|
||||
);
|
||||
|
||||
// Set to at least 1 day, otherwise the transactions on the same day
|
||||
// would not be considered in the time weighted calculation
|
||||
if (daysSinceLastOrder <= 0) {
|
||||
daysSinceLastOrder = 1;
|
||||
// The time between two activities on the same day is unknown
|
||||
// -> Set it to the smallest floating point number greater than 0
|
||||
daysSinceLastOrder = Number.EPSILON;
|
||||
}
|
||||
|
||||
// Sum up the total investment days since the start date to calculate
|
||||
@ -1491,6 +1497,7 @@ export class PortfolioCalculator {
|
||||
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Total dividend: ${totalDividend.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||
@ -1528,6 +1535,8 @@ export class PortfolioCalculator {
|
||||
netPerformanceValuesWithCurrencyEffect,
|
||||
timeWeightedInvestmentValues,
|
||||
timeWeightedInvestmentValuesWithCurrencyEffect,
|
||||
totalDividend,
|
||||
totalDividendInBaseCurrency,
|
||||
totalInvestment,
|
||||
totalInvestmentWithCurrencyEffect,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AccessService } from '@ghostfolio/api/app/access/access.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard';
|
||||
import {
|
||||
@ -11,6 +12,7 @@ import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interc
|
||||
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';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
HEADER_KEY_IMPERSONATION
|
||||
@ -18,6 +20,7 @@ import {
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioHoldingsResponse,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
@ -57,6 +60,8 @@ export class PortfolioController {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
@ -71,8 +76,11 @@ export class PortfolioController {
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withLiabilities') withLiabilitiesParam = 'false'
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
const withLiabilities = withLiabilitiesParam === 'true';
|
||||
|
||||
let hasDetails = true;
|
||||
let hasError = false;
|
||||
const hasReadRestrictedAccessPermission =
|
||||
@ -91,20 +99,14 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
const { accounts, hasErrors, holdings, platforms, summary } =
|
||||
await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
withLiabilities,
|
||||
userId: this.request.user.id,
|
||||
withSummary: true
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
@ -118,27 +120,23 @@ export class PortfolioController {
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const totalInvestment = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return portfolioPosition.investment;
|
||||
.map(({ investment }) => {
|
||||
return investment;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
const totalValue = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user.Settings.settings.baseCurrency
|
||||
);
|
||||
.filter(({ assetClass, assetSubClass }) => {
|
||||
return assetClass !== 'CASH' && assetSubClass !== 'CASH';
|
||||
})
|
||||
.map(({ valueInBaseCurrency }) => {
|
||||
return valueInBaseCurrency;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
|
||||
portfolioPosition.grossPerformance = null;
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.valueInPercentage =
|
||||
portfolioPosition.valueInBaseCurrency / totalValue;
|
||||
}
|
||||
@ -164,19 +162,21 @@ export class PortfolioController {
|
||||
'currentGrossPerformanceWithCurrencyEffect',
|
||||
'currentNetPerformance',
|
||||
'currentNetPerformanceWithCurrencyEffect',
|
||||
'currentNetWorth',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'dividendInBaseCurrency',
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'filteredValueInBaseCurrency',
|
||||
'fireWealth',
|
||||
'interest',
|
||||
'items',
|
||||
'liabilities',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalInvestment',
|
||||
'totalSell'
|
||||
'totalSell',
|
||||
'totalValueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
@ -203,12 +203,9 @@ export class PortfolioController {
|
||||
|
||||
return {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasError,
|
||||
holdings,
|
||||
platforms,
|
||||
totalValueInBaseCurrency,
|
||||
summary: portfolioSummary
|
||||
};
|
||||
}
|
||||
@ -235,11 +232,21 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
dateRange,
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(impersonationId);
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
userCurrency,
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
types: ['DIVIDEND']
|
||||
});
|
||||
|
||||
let dividends = await this.portfolioService.getDividends({
|
||||
activities,
|
||||
dateRange,
|
||||
groupBy
|
||||
});
|
||||
|
||||
if (
|
||||
@ -269,6 +276,35 @@ export class PortfolioController {
|
||||
return { dividends };
|
||||
}
|
||||
|
||||
@Get('holdings')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getHoldings(
|
||||
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('holdingType') filterByHoldingType?: string,
|
||||
@Query('query') filterBySearchQuery?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioHoldingsResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByHoldingType,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
return { holdings: Object.values(holdings) };
|
||||
}
|
||||
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
public async getInvestments(
|
||||
@ -346,8 +382,12 @@ export class PortfolioController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('tags') filterByTags?: string,
|
||||
@Query('withExcludedAccounts') withExcludedAccounts = false
|
||||
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false',
|
||||
@Query('withItems') withItemsParam = 'false'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const withExcludedAccounts = withExcludedAccountsParam === 'true';
|
||||
const withItems = withItemsParam === 'true';
|
||||
|
||||
const hasReadRestrictedAccessPermission =
|
||||
this.userService.hasReadRestrictedAccessPermission({
|
||||
impersonationId,
|
||||
@ -365,6 +405,7 @@ export class PortfolioController {
|
||||
filters,
|
||||
impersonationId,
|
||||
withExcludedAccounts,
|
||||
withItems,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -429,6 +470,10 @@ export class PortfolioController {
|
||||
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||
}
|
||||
);
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentNetPerformance', 'currentNetPerformancePercent']
|
||||
);
|
||||
}
|
||||
|
||||
return performanceInformation;
|
||||
@ -483,7 +528,6 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange: 'max',
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: user.id
|
||||
@ -515,7 +559,8 @@ export class PortfolioController {
|
||||
dateOfFirstActivity: portfolioPosition.dateOfFirstActivity,
|
||||
markets: hasDetails ? portfolioPosition.markets : undefined,
|
||||
name: portfolioPosition.name,
|
||||
netPerformancePercent: portfolioPosition.netPerformancePercent,
|
||||
netPerformancePercentWithCurrencyEffect:
|
||||
portfolioPosition.netPerformancePercentWithCurrencyEffect,
|
||||
sectors: hasDetails ? portfolioPosition.sectors : [],
|
||||
symbol: portfolioPosition.symbol,
|
||||
url: portfolioPosition.url,
|
||||
|
@ -7,6 +7,7 @@ import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.s
|
||||
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
|
||||
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper';
|
||||
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
|
||||
import { AccountClusterRiskSingleAccount } from '@ghostfolio/api/models/rules/account-cluster-risk/single-account';
|
||||
import { CurrencyClusterRiskBaseCurrencyCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/base-currency-current-investment';
|
||||
@ -23,7 +24,12 @@ import {
|
||||
MAX_CHART_ITEMS,
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getSum, parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getAllActivityTypes,
|
||||
getSum,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Accounts,
|
||||
EnhancedSymbolProfile,
|
||||
@ -62,6 +68,7 @@ import {
|
||||
Tag
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { isUUID } from 'class-validator';
|
||||
import {
|
||||
differenceInDays,
|
||||
format,
|
||||
@ -119,7 +126,7 @@ export class PortfolioService {
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<AccountWithValue[]> {
|
||||
const where: Prisma.AccountWhereInput = { userId: userId };
|
||||
const where: Prisma.AccountWhereInput = { userId };
|
||||
|
||||
const accountFilter = filters?.find(({ type }) => {
|
||||
return type === 'ACCOUNT';
|
||||
@ -216,29 +223,18 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getDividends({
|
||||
dateRange,
|
||||
filters,
|
||||
groupBy,
|
||||
impersonationId
|
||||
activities,
|
||||
dateRange = 'max',
|
||||
groupBy
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
filters?: Filter[];
|
||||
activities: Activity[];
|
||||
dateRange?: DateRange;
|
||||
groupBy?: GroupBy;
|
||||
impersonationId: string;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
filters,
|
||||
userId,
|
||||
types: ['DIVIDEND'],
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency
|
||||
});
|
||||
|
||||
let dividends = activities.map((dividend) => {
|
||||
let dividends = activities.map(({ date, valueInBaseCurrency }) => {
|
||||
return {
|
||||
date: format(dividend.date, DATE_FORMAT),
|
||||
investment: dividend.valueInBaseCurrency
|
||||
date: format(date, DATE_FORMAT),
|
||||
investment: valueInBaseCurrency
|
||||
};
|
||||
});
|
||||
|
||||
@ -275,7 +271,8 @@ export class PortfolioService {
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
includeDrafts: true
|
||||
includeDrafts: true,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
if (transactionPoints.length === 0) {
|
||||
@ -297,10 +294,9 @@ export class PortfolioService {
|
||||
const { items } = await this.getChart({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
portfolioOrders,
|
||||
portfolioCalculator,
|
||||
transactionPoints,
|
||||
userId,
|
||||
userCurrency: this.request.user.Settings.settings.baseCurrency,
|
||||
withDataDecimation: false
|
||||
});
|
||||
|
||||
@ -340,13 +336,17 @@ export class PortfolioService {
|
||||
filters,
|
||||
impersonationId,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
withExcludedAccounts = false,
|
||||
withLiabilities = false,
|
||||
withSummary = false
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
withLiabilities?: boolean;
|
||||
withSummary?: boolean;
|
||||
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
@ -356,9 +356,20 @@ export class PortfolioService {
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
let types = getAllActivityTypes().filter((activityType) => {
|
||||
return activityType !== 'FEE';
|
||||
});
|
||||
|
||||
if (withLiabilities === false) {
|
||||
types = types.filter((activityType) => {
|
||||
return activityType !== 'LIABILITY';
|
||||
});
|
||||
}
|
||||
|
||||
const { activities, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
types,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
@ -386,18 +397,29 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalValueInBaseCurrency = currentPositions.currentValue.plus(
|
||||
|
||||
const totalValueInBaseCurrency =
|
||||
currentPositions.currentValueInBaseCurrency.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
|
||||
const isFilteredByAccount =
|
||||
filters?.some((filter) => {
|
||||
return filter.type === 'ACCOUNT';
|
||||
filters?.some(({ type }) => {
|
||||
return type === 'ACCOUNT';
|
||||
}) ?? false;
|
||||
|
||||
const isFilteredByCash = filters?.some(({ id, type }) => {
|
||||
return id === 'CASH' && type === 'ASSET_CLASS';
|
||||
});
|
||||
|
||||
const isFilteredByClosedHoldings =
|
||||
filters?.some(({ id, type }) => {
|
||||
return id === 'CLOSED' && type === 'HOLDING_TYPE';
|
||||
}) ?? false;
|
||||
|
||||
let filteredValueInBaseCurrency = isFilteredByAccount
|
||||
? totalValueInBaseCurrency
|
||||
: currentPositions.currentValue;
|
||||
: currentPositions.currentValueInBaseCurrency;
|
||||
|
||||
if (
|
||||
filters?.length === 0 ||
|
||||
@ -434,15 +456,40 @@ export class PortfolioService {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
|
||||
for (const item of currentPositions.positions) {
|
||||
if (item.quantity.lte(0)) {
|
||||
for (const {
|
||||
currency,
|
||||
dividend,
|
||||
firstBuyDate,
|
||||
grossPerformance,
|
||||
grossPerformanceWithCurrencyEffect,
|
||||
grossPerformancePercentage,
|
||||
grossPerformancePercentageWithCurrencyEffect,
|
||||
investment,
|
||||
marketPrice,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
netPerformancePercentageWithCurrencyEffect,
|
||||
netPerformanceWithCurrencyEffect,
|
||||
quantity,
|
||||
symbol,
|
||||
tags,
|
||||
transactionCount,
|
||||
valueInBaseCurrency
|
||||
} of currentPositions.positions) {
|
||||
if (isFilteredByClosedHoldings === true) {
|
||||
if (!quantity.eq(0)) {
|
||||
// Ignore positions with a quantity
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
if (quantity.eq(0)) {
|
||||
// Ignore positions without any quantity
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const value = item.quantity.mul(item.marketPriceInBaseCurrency ?? 0);
|
||||
const symbolProfile = symbolProfileMap[item.symbol];
|
||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||
const symbolProfile = symbolProfileMap[symbol];
|
||||
const dataProviderResponse = dataProviderResponses[symbol];
|
||||
|
||||
const markets: PortfolioPosition['markets'] = {
|
||||
[UNKNOWN_KEY]: 0,
|
||||
@ -506,49 +553,53 @@ export class PortfolioService {
|
||||
}
|
||||
} else {
|
||||
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
|
||||
.plus(value)
|
||||
.plus(valueInBaseCurrency)
|
||||
.toNumber();
|
||||
|
||||
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
|
||||
.plus(value)
|
||||
.plus(valueInBaseCurrency)
|
||||
.toNumber();
|
||||
}
|
||||
|
||||
holdings[item.symbol] = {
|
||||
holdings[symbol] = {
|
||||
currency,
|
||||
markets,
|
||||
marketsAdvanced,
|
||||
marketPrice,
|
||||
symbol,
|
||||
tags,
|
||||
transactionCount,
|
||||
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
|
||||
? 0
|
||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||
: valueInBaseCurrency.div(filteredValueInBaseCurrency).toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
dateOfFirstActivity: parseDate(item.firstBuyDate),
|
||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent:
|
||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||
investment: item.investment.toNumber(),
|
||||
marketPrice: item.marketPrice,
|
||||
dateOfFirstActivity: parseDate(firstBuyDate),
|
||||
dividend: dividend?.toNumber() ?? 0,
|
||||
grossPerformance: grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent: grossPerformancePercentage?.toNumber() ?? 0,
|
||||
grossPerformancePercentWithCurrencyEffect:
|
||||
grossPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
||||
grossPerformanceWithCurrencyEffect:
|
||||
grossPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
||||
investment: investment.toNumber(),
|
||||
marketState: dataProviderResponse?.marketState ?? 'delayed',
|
||||
name: symbolProfile.name,
|
||||
netPerformance: item.netPerformance?.toNumber() ?? 0,
|
||||
netPerformancePercent: item.netPerformancePercentage?.toNumber() ?? 0,
|
||||
quantity: item.quantity.toNumber(),
|
||||
netPerformance: netPerformance?.toNumber() ?? 0,
|
||||
netPerformancePercent: netPerformancePercentage?.toNumber() ?? 0,
|
||||
netPerformancePercentWithCurrencyEffect:
|
||||
netPerformancePercentageWithCurrencyEffect?.toNumber() ?? 0,
|
||||
netPerformanceWithCurrencyEffect:
|
||||
netPerformanceWithCurrencyEffect?.toNumber() ?? 0,
|
||||
quantity: quantity.toNumber(),
|
||||
sectors: symbolProfile.sectors,
|
||||
symbol: item.symbol,
|
||||
tags: item.tags,
|
||||
transactionCount: item.transactionCount,
|
||||
url: symbolProfile.url,
|
||||
valueInBaseCurrency: value.toNumber()
|
||||
valueInBaseCurrency: valueInBaseCurrency.toNumber()
|
||||
};
|
||||
}
|
||||
|
||||
const isFilteredByCash = filters?.some((filter) => {
|
||||
return filter.type === 'ASSET_CLASS' && filter.id === 'CASH';
|
||||
});
|
||||
|
||||
if (filters?.length === 0 || isFilteredByAccount || isFilteredByCash) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
@ -562,8 +613,8 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
|
||||
activities,
|
||||
filters,
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId,
|
||||
@ -605,7 +656,12 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
const summary = await this.getSummary({
|
||||
let summary: PortfolioSummary;
|
||||
|
||||
if (withSummary) {
|
||||
summary = await this.getSummary({
|
||||
filteredValueInBaseCurrency,
|
||||
holdings,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId,
|
||||
@ -615,18 +671,14 @@ export class PortfolioService {
|
||||
holdings
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accounts,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
||||
filteredValueInPercentage: summary.netWorth
|
||||
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
|
||||
: 0,
|
||||
hasErrors: currentPositions.hasErrors,
|
||||
totalValueInBaseCurrency: summary.netWorth
|
||||
hasErrors: currentPositions.hasErrors
|
||||
};
|
||||
}
|
||||
|
||||
@ -692,7 +744,7 @@ export class PortfolioService {
|
||||
.filter((order) => {
|
||||
tags = tags.concat(order.tags);
|
||||
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
return ['BUY', 'DIVIDEND', 'ITEM', 'SELL'].includes(order.type);
|
||||
})
|
||||
.map((order) => ({
|
||||
currency: order.SymbolProfile.currency,
|
||||
@ -733,6 +785,7 @@ export class PortfolioService {
|
||||
averagePrice,
|
||||
currency,
|
||||
dataSource,
|
||||
dividendInBaseCurrency,
|
||||
fee,
|
||||
firstBuyDate,
|
||||
marketPrice,
|
||||
@ -741,22 +794,14 @@ export class PortfolioService {
|
||||
} = position;
|
||||
|
||||
const accounts: PortfolioPositionDetail['accounts'] = uniqBy(
|
||||
orders,
|
||||
orders.filter(({ Account }) => {
|
||||
return Account;
|
||||
}),
|
||||
'Account.id'
|
||||
).map(({ Account }) => {
|
||||
return Account;
|
||||
});
|
||||
|
||||
const dividendInBaseCurrency = getSum(
|
||||
orders
|
||||
.filter(({ type }) => {
|
||||
return type === 'DIVIDEND';
|
||||
})
|
||||
.map(({ valueInBaseCurrency }) => {
|
||||
return new Big(valueInBaseCurrency);
|
||||
})
|
||||
);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
[{ dataSource, symbol: aSymbol }],
|
||||
'day',
|
||||
@ -790,9 +835,7 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
if (currentSymbol) {
|
||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||
? 0
|
||||
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
||||
currentAveragePrice = currentSymbol.averagePrice.toNumber();
|
||||
currentQuantity = currentSymbol.quantity.toNumber();
|
||||
}
|
||||
|
||||
@ -945,7 +988,8 @@ export class PortfolioService {
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
userId,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
@ -1075,13 +1119,15 @@ export class PortfolioService {
|
||||
filters,
|
||||
impersonationId,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
withExcludedAccounts = false,
|
||||
withItems = false
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
withItems?: boolean;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
@ -1116,7 +1162,8 @@ export class PortfolioService {
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
withExcludedAccounts,
|
||||
types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -1160,7 +1207,7 @@ export class PortfolioService {
|
||||
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const {
|
||||
currentValue,
|
||||
currentValueInBaseCurrency,
|
||||
errors,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
@ -1187,9 +1234,8 @@ export class PortfolioService {
|
||||
const { items } = await this.getChart({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
portfolioOrders,
|
||||
portfolioCalculator,
|
||||
transactionPoints,
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
@ -1255,7 +1301,7 @@ export class PortfolioService {
|
||||
currentNetPerformancePercentWithCurrencyEffect.toNumber(),
|
||||
currentNetPerformanceWithCurrencyEffect:
|
||||
currentNetPerformanceWithCurrencyEffect.toNumber(),
|
||||
currentValue: currentValue.toNumber(),
|
||||
currentValue: currentValueInBaseCurrency.toNumber(),
|
||||
totalInvestment: totalInvestment.toNumber()
|
||||
}
|
||||
};
|
||||
@ -1266,9 +1312,10 @@ export class PortfolioService {
|
||||
const user = await this.userService.user({ id: userId });
|
||||
const userCurrency = this.getUserCurrency(user);
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
const { activities, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId
|
||||
userId,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -1297,7 +1344,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const { accounts } = await this.getValueOfAccountsAndPlatforms({
|
||||
orders,
|
||||
activities,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
@ -1307,7 +1354,7 @@ export class PortfolioService {
|
||||
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: isEmpty(orders)
|
||||
accountClusterRisk: isEmpty(activities)
|
||||
? undefined
|
||||
: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -1322,7 +1369,7 @@ export class PortfolioService {
|
||||
],
|
||||
userSettings
|
||||
),
|
||||
currencyClusterRisk: isEmpty(orders)
|
||||
currencyClusterRisk: isEmpty(activities)
|
||||
? undefined
|
||||
: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -1351,7 +1398,7 @@ export class PortfolioService {
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees({ userCurrency, activities: orders }).toNumber()
|
||||
this.getFees({ activities, userCurrency }).toNumber()
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
@ -1413,17 +1460,15 @@ export class PortfolioService {
|
||||
private async getChart({
|
||||
dateRange = 'max',
|
||||
impersonationId,
|
||||
portfolioOrders,
|
||||
portfolioCalculator,
|
||||
transactionPoints,
|
||||
userCurrency,
|
||||
userId,
|
||||
withDataDecimation = true
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
portfolioOrders: PortfolioOrder[];
|
||||
portfolioCalculator: PortfolioCalculator;
|
||||
transactionPoints: TransactionPoint[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withDataDecimation?: boolean;
|
||||
}): Promise<HistoricalDataContainer> {
|
||||
@ -1437,15 +1482,6 @@ export class PortfolioService {
|
||||
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
orders: portfolioOrders
|
||||
});
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const endDate = new Date();
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
@ -1558,28 +1594,25 @@ export class PortfolioService {
|
||||
|
||||
private getFees({
|
||||
activities,
|
||||
date = new Date(0),
|
||||
userCurrency
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
date?: Date;
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date (drafts)
|
||||
return isBefore(date, new Date(activity.date));
|
||||
return getSum(
|
||||
activities
|
||||
.filter(({ isDraft }) => {
|
||||
return isDraft === false;
|
||||
})
|
||||
.map(({ fee, SymbolProfile }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
return new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
@ -1598,14 +1631,19 @@ export class PortfolioService {
|
||||
countries: [],
|
||||
dataSource: undefined,
|
||||
dateOfFirstActivity: undefined,
|
||||
dividend: 0,
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
grossPerformancePercentWithCurrencyEffect: 0,
|
||||
grossPerformanceWithCurrencyEffect: 0,
|
||||
investment: balance,
|
||||
marketPrice: 0,
|
||||
marketState: 'open',
|
||||
name: currency,
|
||||
netPerformance: 0,
|
||||
netPerformancePercent: 0,
|
||||
netPerformancePercentWithCurrencyEffect: 0,
|
||||
netPerformanceWithCurrencyEffect: 0,
|
||||
quantity: 0,
|
||||
sectors: [],
|
||||
symbol: currency,
|
||||
@ -1686,12 +1724,16 @@ export class PortfolioService {
|
||||
private async getSummary({
|
||||
balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
filteredValueInBaseCurrency,
|
||||
holdings,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
balanceInBaseCurrency: number;
|
||||
emergencyFundPositionsValueInBaseCurrency: number;
|
||||
filteredValueInBaseCurrency: Big;
|
||||
holdings: PortfolioDetails['holdings'];
|
||||
impersonationId: string;
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
@ -1705,57 +1747,87 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const { activities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
let { activities: excludedActivities } = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
excludedActivities = excludedActivities.filter(({ Account: account }) => {
|
||||
return account?.isExcluded ?? false;
|
||||
});
|
||||
const excludedActivities: Activity[] = [];
|
||||
const nonExcludedActivities: Activity[] = [];
|
||||
|
||||
for (const activity of activities) {
|
||||
if (activity.Account?.isExcluded) {
|
||||
excludedActivities.push(activity);
|
||||
} else {
|
||||
nonExcludedActivities.push(activity);
|
||||
}
|
||||
}
|
||||
|
||||
const dividendInBaseCurrency = getSum(
|
||||
(
|
||||
await this.getDividends({
|
||||
activities: activities.filter(({ type }) => {
|
||||
return type === 'DIVIDEND';
|
||||
})
|
||||
})
|
||||
).map(({ investment }) => {
|
||||
return new Big(investment);
|
||||
})
|
||||
);
|
||||
|
||||
const dividend = this.getSumOfActivityType({
|
||||
activities,
|
||||
userCurrency,
|
||||
activityType: 'DIVIDEND'
|
||||
}).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
Math.max(
|
||||
emergencyFundPositionsValueInBaseCurrency,
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
)
|
||||
);
|
||||
|
||||
const fees = this.getFees({ activities, userCurrency }).toNumber();
|
||||
const firstOrderDate = activities[0]?.date;
|
||||
|
||||
const interest = this.getSumOfActivityType({
|
||||
activities,
|
||||
userCurrency,
|
||||
activityType: 'INTEREST'
|
||||
}).toNumber();
|
||||
const items = this.getSumOfActivityType({
|
||||
activities,
|
||||
userCurrency,
|
||||
activityType: 'ITEM'
|
||||
}).toNumber();
|
||||
const liabilities = this.getSumOfActivityType({
|
||||
activities,
|
||||
userCurrency,
|
||||
activityType: 'LIABILITY'
|
||||
}).toNumber();
|
||||
|
||||
const items = getSum(
|
||||
Object.keys(holdings)
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
isUUID(symbol) &&
|
||||
holdings[symbol].dataSource === 'MANUAL' &&
|
||||
holdings[symbol].valueInBaseCurrency > 0
|
||||
);
|
||||
})
|
||||
.map((symbol) => {
|
||||
return new Big(holdings[symbol].valueInBaseCurrency).abs();
|
||||
})
|
||||
).toNumber();
|
||||
|
||||
const liabilities = getSum(
|
||||
Object.keys(holdings)
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
isUUID(symbol) &&
|
||||
holdings[symbol].dataSource === 'MANUAL' &&
|
||||
holdings[symbol].valueInBaseCurrency < 0
|
||||
);
|
||||
})
|
||||
.map((symbol) => {
|
||||
return new Big(holdings[symbol].valueInBaseCurrency).abs();
|
||||
})
|
||||
).toNumber();
|
||||
|
||||
const totalBuy = this.getSumOfActivityType({
|
||||
activities,
|
||||
userCurrency,
|
||||
activities: nonExcludedActivities,
|
||||
activityType: 'BUY'
|
||||
}).toNumber();
|
||||
|
||||
const totalSell = this.getSumOfActivityType({
|
||||
activities,
|
||||
userCurrency,
|
||||
activities: nonExcludedActivities,
|
||||
activityType: 'SELL'
|
||||
}).toNumber();
|
||||
|
||||
@ -1763,7 +1835,9 @@ export class PortfolioService {
|
||||
.minus(emergencyFund)
|
||||
.plus(emergencyFundPositionsValueInBaseCurrency)
|
||||
.toNumber();
|
||||
|
||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||
|
||||
const totalOfExcludedActivities = this.getSumOfActivityType({
|
||||
userCurrency,
|
||||
activities: excludedActivities,
|
||||
@ -1814,21 +1888,36 @@ export class PortfolioService {
|
||||
})
|
||||
?.toNumber();
|
||||
|
||||
const annualizedPerformancePercentWithCurrencyEffect =
|
||||
new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
exchangeRateDataService: this.exchangeRateDataService,
|
||||
orders: []
|
||||
})
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercentWithCurrencyEffect
|
||||
)
|
||||
})
|
||||
?.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
annualizedPerformancePercent,
|
||||
annualizedPerformancePercentWithCurrencyEffect,
|
||||
cash,
|
||||
dividend,
|
||||
excludedAccountsAndActivities,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
interest,
|
||||
items,
|
||||
liabilities,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
dividendInBaseCurrency: dividendInBaseCurrency.toNumber(),
|
||||
emergencyFund: {
|
||||
assets: emergencyFundPositionsValueInBaseCurrency,
|
||||
cash: emergencyFund
|
||||
@ -1836,61 +1925,61 @@ export class PortfolioService {
|
||||
.toNumber(),
|
||||
total: emergencyFund.toNumber()
|
||||
},
|
||||
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
||||
filteredValueInPercentage: netWorth
|
||||
? filteredValueInBaseCurrency.div(netWorth).toNumber()
|
||||
: undefined,
|
||||
fireWealth: new Big(performanceInformation.performance.currentValue)
|
||||
.minus(emergencyFundPositionsValueInBaseCurrency)
|
||||
.toNumber(),
|
||||
ordersCount: activities.filter(({ type }) => {
|
||||
return type === 'BUY' || type === 'SELL';
|
||||
}).length
|
||||
}).length,
|
||||
totalValueInBaseCurrency: netWorth
|
||||
};
|
||||
}
|
||||
|
||||
private getSumOfActivityType({
|
||||
activities,
|
||||
activityType,
|
||||
date = new Date(0),
|
||||
userCurrency
|
||||
}: {
|
||||
activities: OrderWithAccount[];
|
||||
activityType: ActivityType;
|
||||
date?: Date;
|
||||
userCurrency: string;
|
||||
}) {
|
||||
return activities
|
||||
.filter((activity) => {
|
||||
// Filter out all activities before given date (drafts) and
|
||||
// activity type
|
||||
return (
|
||||
isBefore(date, new Date(activity.date)) &&
|
||||
activity.type === activityType
|
||||
);
|
||||
return getSum(
|
||||
activities
|
||||
.filter(({ isDraft, type }) => {
|
||||
return isDraft === false && type === activityType;
|
||||
})
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
return new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
}
|
||||
|
||||
private async getTransactionPoints({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
types = getAllActivityTypes(),
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
types?: ActivityType[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<{
|
||||
activities: Activity[];
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: Activity[];
|
||||
portfolioOrders: PortfolioOrder[];
|
||||
}> {
|
||||
const userCurrency =
|
||||
@ -1899,14 +1988,14 @@ export class PortfolioService {
|
||||
const { activities, count } = await this.orderService.getOrders({
|
||||
filters,
|
||||
includeDrafts,
|
||||
types,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['BUY', 'SELL']
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
if (count <= 0) {
|
||||
return { transactionPoints: [], orders: [], portfolioOrders: [] };
|
||||
return { activities: [], transactionPoints: [], portfolioOrders: [] };
|
||||
}
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = activities.map((order) => ({
|
||||
@ -1932,8 +2021,8 @@ export class PortfolioService {
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
return {
|
||||
activities,
|
||||
portfolioOrders,
|
||||
orders: activities,
|
||||
transactionPoints: portfolioCalculator.getTransactionPoints()
|
||||
};
|
||||
}
|
||||
@ -1954,29 +2043,20 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
private async getValueOfAccountsAndPlatforms({
|
||||
activities,
|
||||
filters = [],
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
activities: Activity[];
|
||||
filters?: Filter[];
|
||||
orders: OrderWithAccount[];
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition };
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}) {
|
||||
const { activities: ordersOfTypeItemOrLiability } =
|
||||
await this.orderService.getOrders({
|
||||
filters,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['ITEM', 'LIABILITY']
|
||||
});
|
||||
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
const platforms: PortfolioDetails['platforms'] = {};
|
||||
|
||||
@ -1994,7 +2074,7 @@ export class PortfolioService {
|
||||
});
|
||||
} else {
|
||||
const accountIds = uniq(
|
||||
orders
|
||||
activities
|
||||
.filter(({ accountId }) => {
|
||||
return accountId;
|
||||
})
|
||||
@ -2014,19 +2094,10 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
for (const account of currentAccounts) {
|
||||
let ordersByAccount = orders.filter(({ accountId }) => {
|
||||
const ordersByAccount = activities.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
});
|
||||
|
||||
const ordersOfTypeItemOrLiabilityByAccount =
|
||||
ordersOfTypeItemOrLiability.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
});
|
||||
|
||||
ordersByAccount = ordersByAccount.concat(
|
||||
ordersOfTypeItemOrLiabilityByAccount
|
||||
);
|
||||
|
||||
accounts[account.id] = {
|
||||
balance: account.balance,
|
||||
currency: account.currency,
|
||||
@ -2058,41 +2129,39 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
for (const order of ordersByAccount) {
|
||||
for (const {
|
||||
Account,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
type
|
||||
} of ordersByAccount) {
|
||||
let currentValueOfSymbolInBaseCurrency =
|
||||
order.quantity *
|
||||
(portfolioItemsNow[order.SymbolProfile.symbol]
|
||||
?.marketPriceInBaseCurrency ??
|
||||
order.unitPrice ??
|
||||
getFactor(type) *
|
||||
quantity *
|
||||
(portfolioItemsNow[SymbolProfile.symbol]?.marketPriceInBaseCurrency ??
|
||||
0);
|
||||
|
||||
if (order.type === 'LIABILITY' || order.type === 'SELL') {
|
||||
currentValueOfSymbolInBaseCurrency *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
if (accounts[Account?.id || UNKNOWN_KEY]?.valueInBaseCurrency) {
|
||||
accounts[Account?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
currentValueOfSymbolInBaseCurrency;
|
||||
} else {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||
accounts[Account?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: order.Account?.currency,
|
||||
currency: Account?.currency,
|
||||
name: account.name,
|
||||
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
platforms[order.Account?.Platform?.id || UNKNOWN_KEY]
|
||||
?.valueInBaseCurrency
|
||||
platforms[Account?.Platform?.id || UNKNOWN_KEY]?.valueInBaseCurrency
|
||||
) {
|
||||
platforms[
|
||||
order.Account?.Platform?.id || UNKNOWN_KEY
|
||||
].valueInBaseCurrency += currentValueOfSymbolInBaseCurrency;
|
||||
platforms[Account?.Platform?.id || UNKNOWN_KEY].valueInBaseCurrency +=
|
||||
currentValueOfSymbolInBaseCurrency;
|
||||
} else {
|
||||
platforms[order.Account?.Platform?.id || UNKNOWN_KEY] = {
|
||||
platforms[Account?.Platform?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: order.Account?.currency,
|
||||
currency: Account?.currency,
|
||||
name: account.Platform?.name,
|
||||
valueInBaseCurrency: currentValueOfSymbolInBaseCurrency
|
||||
};
|
||||
|
@ -15,6 +15,7 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
inject: [ConfigurationService],
|
||||
useFactory: async (configurationService: ConfigurationService) => {
|
||||
return <RedisClientOptions>{
|
||||
db: configurationService.get('REDIS_DB'),
|
||||
host: configurationService.get('REDIS_HOST'),
|
||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||
password: configurationService.get('REDIS_PASSWORD'),
|
||||
|
@ -21,7 +21,7 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async get(key: string): Promise<string> {
|
||||
return await this.cache.get(key);
|
||||
return this.cache.get(key);
|
||||
}
|
||||
|
||||
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
|
||||
@ -29,15 +29,15 @@ export class RedisCacheService {
|
||||
}
|
||||
|
||||
public async remove(key: string) {
|
||||
await this.cache.del(key);
|
||||
return this.cache.del(key);
|
||||
}
|
||||
|
||||
public async reset() {
|
||||
await this.cache.reset();
|
||||
return this.cache.reset();
|
||||
}
|
||||
|
||||
public async set(key: string, value: string, ttlInSeconds?: number) {
|
||||
await this.cache.set(
|
||||
return this.cache.set(
|
||||
key,
|
||||
value,
|
||||
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
|
||||
|
@ -116,7 +116,7 @@ export class SubscriptionController {
|
||||
@Body() { couponId, priceId }: { couponId: string; priceId: string }
|
||||
) {
|
||||
try {
|
||||
return await this.subscriptionService.createCheckoutSession({
|
||||
return this.subscriptionService.createCheckoutSession({
|
||||
couponId,
|
||||
priceId,
|
||||
user: this.request.user
|
||||
|
@ -39,9 +39,11 @@ export class SymbolController {
|
||||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async lookupSymbol(
|
||||
@Query('includeIndices') includeIndices: boolean = false,
|
||||
@Query('includeIndices') includeIndicesParam = 'false',
|
||||
@Query('query') query = ''
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
const includeIndices = includeIndicesParam === 'true';
|
||||
|
||||
try {
|
||||
return this.symbolService.lookup({
|
||||
includeIndices,
|
||||
|
@ -7,6 +7,7 @@ import type {
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsISO4217CurrencyCode,
|
||||
IsISO8601,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
@ -19,8 +20,8 @@ export class UpdateUserSettingDto {
|
||||
@IsOptional()
|
||||
annualInterestRate?: number;
|
||||
|
||||
@IsISO4217CurrencyCode()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
baseCurrency?: string;
|
||||
|
||||
@IsString()
|
||||
|
@ -135,7 +135,7 @@ export class UserController {
|
||||
}
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSetting({
|
||||
return this.userService.updateUserSetting({
|
||||
userSettings,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
@ -1,11 +1,13 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { environment } from '@ghostfolio/api/environments/environment';
|
||||
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';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
|
||||
import {
|
||||
DEFAULT_CURRENCY,
|
||||
DEFAULT_LANGUAGE_CODE,
|
||||
PROPERTY_IS_READ_ONLY_MODE,
|
||||
PROPERTY_SYSTEM_MESSAGE,
|
||||
locale
|
||||
@ -31,6 +33,8 @@ const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private i18nService = new I18nService();
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
@ -325,8 +329,10 @@ export class UserService {
|
||||
Account: {
|
||||
create: {
|
||||
currency: DEFAULT_CURRENCY,
|
||||
isDefault: true,
|
||||
name: 'Default Account'
|
||||
name: this.i18nService.getTranslation({
|
||||
id: 'myAccount',
|
||||
languageCode: DEFAULT_LANGUAGE_CODE // TODO
|
||||
})
|
||||
}
|
||||
},
|
||||
Settings: {
|
||||
@ -438,7 +444,7 @@ export class UserService {
|
||||
settings
|
||||
},
|
||||
where: {
|
||||
userId: userId
|
||||
userId
|
||||
}
|
||||
});
|
||||
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,8 @@
|
||||
"LUNA1": "Terra",
|
||||
"LUNA2": "Terra",
|
||||
"SGB1": "Songbird",
|
||||
"SMURFCAT": "Real Smurf Cat",
|
||||
"UNI1": "Uniswap",
|
||||
"UNI7083": "Uniswap",
|
||||
"UST": "TerraUSD"
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
version: `v${require('../../../../package.json').version}`
|
||||
version: `${require('../../../../package.json').version}`
|
||||
};
|
||||
|
21
apps/api/src/helper/portfolio.helper.ts
Normal file
21
apps/api/src/helper/portfolio.helper.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Type as ActivityType } from '@prisma/client';
|
||||
|
||||
export function getFactor(activityType: ActivityType) {
|
||||
let factor: number;
|
||||
|
||||
switch (activityType) {
|
||||
case 'BUY':
|
||||
case 'ITEM':
|
||||
factor = 1;
|
||||
break;
|
||||
case 'LIABILITY':
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
factor = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return factor;
|
||||
}
|
@ -49,14 +49,14 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
'dividendInBaseCurrency',
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'filteredValueInBaseCurrency',
|
||||
'grossPerformance',
|
||||
'grossPerformanceWithCurrencyEffect',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'netPerformanceWithCurrencyEffect',
|
||||
'quantity',
|
||||
'symbolMapping',
|
||||
'totalBalanceInBaseCurrency',
|
||||
'totalValueInBaseCurrency',
|
||||
'unitPrice',
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
|
@ -1,6 +1,6 @@
|
||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
import { Account, SymbolProfile, Type as TypeOfOrder } from '@prisma/client';
|
||||
import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export class Order {
|
||||
@ -14,7 +14,7 @@ export class Order {
|
||||
private symbol: string;
|
||||
private symbolProfile: SymbolProfile;
|
||||
private total: number;
|
||||
private type: TypeOfOrder;
|
||||
private type: ActivityType;
|
||||
private unitPrice: number;
|
||||
|
||||
public constructor(data: IOrder) {
|
||||
|
@ -35,7 +35,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let maxItem: (typeof accounts)[0];
|
||||
let totalInvestment = 0;
|
||||
|
||||
for (const account of Object.values(accounts)) {
|
||||
@ -52,7 +52,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
}
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
|
||||
|
||||
if (maxInvestmentRatio > ruleSettings.threshold) {
|
||||
return {
|
||||
|
@ -43,7 +43,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
|
||||
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
|
||||
|
||||
if (maxItem.groupKey !== ruleSettings.baseCurrency) {
|
||||
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
|
||||
return {
|
||||
evaluation: `The major part of your current investment is not in your base currency (${(
|
||||
baseCurrencyValueRatio * 100
|
||||
|
@ -37,7 +37,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
}
|
||||
});
|
||||
|
||||
const maxValueRatio = maxItem.value / totalValue;
|
||||
const maxValueRatio = maxItem?.value / totalValue || 0;
|
||||
|
||||
if (maxValueRatio > ruleSettings.threshold) {
|
||||
return {
|
||||
@ -52,7 +52,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
|
||||
return {
|
||||
evaluation: `The major part of your current investment is in ${
|
||||
maxItem.groupKey
|
||||
maxItem?.groupKey ?? ruleSettings.baseCurrency
|
||||
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
|
||||
ruleSettings.threshold * 100
|
||||
}%`,
|
||||
|
@ -10,18 +10,21 @@ export class ApiService {
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByAssetSubClasses,
|
||||
filterByHoldingType,
|
||||
filterBySearchQuery,
|
||||
filterByTags
|
||||
}: {
|
||||
filterByAccounts?: string;
|
||||
filterByAssetClasses?: string;
|
||||
filterByAssetSubClasses?: string;
|
||||
filterByHoldingType?: string;
|
||||
filterBySearchQuery?: string;
|
||||
filterByTags?: string;
|
||||
}): Filter[] {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
|
||||
const holdingType = filterByHoldingType;
|
||||
const searchQuery = filterBySearchQuery?.toLowerCase();
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
@ -52,6 +55,13 @@ export class ApiService {
|
||||
})
|
||||
];
|
||||
|
||||
if (holdingType) {
|
||||
filters.push({
|
||||
id: holdingType,
|
||||
type: 'HOLDING_TYPE'
|
||||
});
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
filters.push({
|
||||
id: searchQuery,
|
||||
|
@ -3,7 +3,7 @@ import { DEFAULT_ROOT_URL } from '@ghostfolio/common/config';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
|
||||
import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid';
|
||||
|
||||
@Injectable()
|
||||
export class ConfigurationService {
|
||||
@ -43,18 +43,18 @@ export class ConfigurationService {
|
||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
PORT: port({ default: 3333 }),
|
||||
REDIS_DB: num({ default: 0 }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
REQUEST_TIMEOUT: num({ default: 2000 }),
|
||||
ROOT_URL: str({ default: DEFAULT_ROOT_URL }),
|
||||
ROOT_URL: url({ default: DEFAULT_ROOT_URL }),
|
||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||
STRIPE_SECRET_KEY: str({ default: '' }),
|
||||
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
|
||||
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
|
||||
TWITTER_API_KEY: str({ default: 'dummyApiKey' }),
|
||||
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' }),
|
||||
WEB_AUTH_RP_ID: host({ default: 'localhost' })
|
||||
TWITTER_API_SECRET: str({ default: 'dummyApiSecret' })
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -37,12 +37,14 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return !!this.configurationService.get('API_KEY_ALPHA_VANTAGE');
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -52,15 +52,17 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
const response: Partial<SymbolProfile> = {
|
||||
symbol,
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetSubClass.CRYPTOCURRENCY,
|
||||
currency: DEFAULT_CURRENCY,
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
dataSource: this.getName()
|
||||
};
|
||||
|
||||
try {
|
||||
@ -70,7 +72,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
abortController.abort();
|
||||
}, this.configurationService.get('REQUEST_TIMEOUT'));
|
||||
|
||||
const { name } = await got(`${this.apiUrl}/coins/${aSymbol}`, {
|
||||
const { name } = await got(`${this.apiUrl}/coins/${symbol}`, {
|
||||
headers: this.headers,
|
||||
// @ts-ignore
|
||||
signal: abortController.signal
|
||||
@ -81,7 +83,7 @@ export class CoinGeckoService implements DataProviderInterface {
|
||||
let message = error;
|
||||
|
||||
if (error?.code === 'ABORT_ERR') {
|
||||
message = `RequestError: The operation to get the asset profile for ${aSymbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
message = `RequestError: The operation to get the asset profile for ${symbol} was aborted because the request to the data provider took more than ${this.configurationService.get(
|
||||
'REQUEST_TIMEOUT'
|
||||
)}ms`;
|
||||
}
|
||||
|
@ -5,12 +5,12 @@ import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { countries } from 'countries-list';
|
||||
import got from 'got';
|
||||
|
||||
@Injectable()
|
||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
private static baseUrl = 'https://www.trackinsight.com/data-api';
|
||||
private static countries = require('countries-list/dist/countries.json');
|
||||
private static countriesMapping = {
|
||||
'Russian Federation': 'Russia'
|
||||
};
|
||||
@ -131,20 +131,19 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
(response.countries as unknown as Country[]).length === 0
|
||||
) {
|
||||
response.countries = [];
|
||||
|
||||
for (const [name, value] of Object.entries<any>(
|
||||
holdings?.countries ?? {}
|
||||
)) {
|
||||
let countryCode: string;
|
||||
|
||||
for (const [key, country] of Object.entries<any>(
|
||||
TrackinsightDataEnhancerService.countries
|
||||
)) {
|
||||
for (const [code, country] of Object.entries(countries)) {
|
||||
if (
|
||||
country.name === name ||
|
||||
country.name ===
|
||||
TrackinsightDataEnhancerService.countriesMapping[name]
|
||||
) {
|
||||
countryCode = key;
|
||||
countryCode = code;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -196,7 +196,9 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
|
||||
shortName: assetProfile.price.shortName,
|
||||
symbol: assetProfile.price.symbol
|
||||
});
|
||||
response.symbol = assetProfile.price.symbol;
|
||||
response.symbol = this.convertFromYahooFinanceSymbol(
|
||||
assetProfile.price.symbol
|
||||
);
|
||||
|
||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||
response.sectors = [];
|
||||
|
@ -92,7 +92,9 @@ export class DataProviderService {
|
||||
|
||||
for (const symbol of symbols) {
|
||||
const promise = Promise.resolve(
|
||||
this.getDataProvider(DataSource[dataSource]).getAssetProfile(symbol)
|
||||
this.getDataProvider(DataSource[dataSource]).getAssetProfile({
|
||||
symbol
|
||||
})
|
||||
);
|
||||
|
||||
promises.push(
|
||||
@ -202,7 +204,8 @@ export class DataProviderService {
|
||||
});
|
||||
|
||||
try {
|
||||
const queryRaw = `SELECT *
|
||||
const queryRaw = `
|
||||
SELECT *
|
||||
FROM "MarketData"
|
||||
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
||||
AND "symbol" IN ('${symbols.join(
|
||||
|
@ -46,19 +46,21 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
const [searchResult] = await this.getSearchResult(aSymbol);
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
const [searchResult] = await this.getSearchResult(symbol);
|
||||
|
||||
return {
|
||||
symbol,
|
||||
assetClass: searchResult?.assetClass,
|
||||
assetSubClass: searchResult?.assetSubClass,
|
||||
currency: this.convertCurrency(searchResult?.currency),
|
||||
dataSource: this.getName(),
|
||||
isin: searchResult?.isin,
|
||||
name: searchResult?.name,
|
||||
symbol: aSymbol
|
||||
name: searchResult?.name
|
||||
};
|
||||
}
|
||||
|
||||
@ -239,25 +241,36 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
})
|
||||
);
|
||||
|
||||
response = quotes.reduce(
|
||||
(
|
||||
result: { [symbol: string]: IDataProviderResponse },
|
||||
{ close, code, timestamp }
|
||||
) => {
|
||||
const currency = symbolProfiles.find(({ symbol }) => {
|
||||
for (const { close, code, timestamp } of quotes) {
|
||||
let currency: string;
|
||||
|
||||
if (code.endsWith('.FOREX')) {
|
||||
currency = this.convertFromEodSymbol(code)?.replace(
|
||||
DEFAULT_CURRENCY,
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
if (!currency) {
|
||||
currency = symbolProfiles.find(({ symbol }) => {
|
||||
return symbol === code;
|
||||
})?.currency;
|
||||
}
|
||||
|
||||
if (!currency) {
|
||||
const { items } = await this.search({ query: code });
|
||||
|
||||
if (items.length === 1) {
|
||||
currency = items[0].currency;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(close)) {
|
||||
result[this.convertFromEodSymbol(code)] = {
|
||||
currency:
|
||||
currency ??
|
||||
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
|
||||
response[this.convertFromEodSymbol(code)] = {
|
||||
currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000))
|
||||
? 'open'
|
||||
: 'closed'
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
@ -265,11 +278,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
|
||||
'EodHistoricalDataService'
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
@ -37,12 +37,14 @@ export class FinancialModelingPrepService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -33,12 +33,14 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -11,7 +11,11 @@ import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
||||
|
||||
getAssetProfile(aSymbol: string): Promise<Partial<SymbolProfile>>;
|
||||
getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>>;
|
||||
|
||||
getDataProviderInfo(): DataProviderInfo;
|
||||
|
||||
|
@ -43,16 +43,18 @@ export class ManualService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
const assetProfile: Partial<SymbolProfile> = {
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
|
||||
{ dataSource: this.getName(), symbol: aSymbol }
|
||||
{ symbol, dataSource: this.getName() }
|
||||
]);
|
||||
|
||||
if (symbolProfile) {
|
||||
@ -164,13 +166,15 @@ export class ManualService implements DataProviderInterface {
|
||||
}
|
||||
});
|
||||
|
||||
for (const symbolProfile of symbolProfiles) {
|
||||
response[symbolProfile.symbol] = {
|
||||
currency: symbolProfile.currency,
|
||||
for (const { currency, symbol } of symbolProfiles) {
|
||||
let marketPrice = marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbol;
|
||||
})?.marketPrice;
|
||||
|
||||
response[symbol] = {
|
||||
currency,
|
||||
marketPrice,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: marketData.find((marketDataItem) => {
|
||||
return marketDataItem.symbol === symbolProfile.symbol;
|
||||
})?.marketPrice,
|
||||
marketState: 'delayed'
|
||||
};
|
||||
}
|
||||
|
@ -30,12 +30,14 @@ export class RapidApiService implements DataProviderInterface {
|
||||
return !!this.configurationService.get('API_KEY_RAPID_API');
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName(),
|
||||
symbol: aSymbol
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -33,20 +33,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
const { assetClass, assetSubClass, currency, name, symbol } =
|
||||
await this.yahooFinanceDataEnhancerService.getAssetProfile(aSymbol);
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
name,
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
public async getAssetProfile({
|
||||
symbol
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
return this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
|
||||
}
|
||||
|
||||
public getDataProviderInfo(): DataProviderInfo {
|
||||
|
@ -22,6 +22,14 @@ export const ExchangeRateDataServiceMock = {
|
||||
'2023-07-10': 0.8854
|
||||
}
|
||||
});
|
||||
} else if (targetCurrency === 'USD') {
|
||||
return Promise.resolve({
|
||||
USDUSD: {
|
||||
'2018-01-01': 1,
|
||||
'2021-11-16': 1,
|
||||
'2023-07-10': 1
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return Promise.resolve({});
|
||||
|
@ -73,7 +73,17 @@ export class ExchangeRateDataService {
|
||||
currencyTo: targetCurrency
|
||||
});
|
||||
|
||||
let previousExchangeRate = 1;
|
||||
const dateStrings = Object.keys(
|
||||
exchangeRatesByCurrency[`${currency}${targetCurrency}`]
|
||||
);
|
||||
const lastDateString = dateStrings.reduce((a, b) => {
|
||||
return a > b ? a : b;
|
||||
});
|
||||
|
||||
let previousExchangeRate =
|
||||
exchangeRatesByCurrency[`${currency}${targetCurrency}`]?.[
|
||||
lastDateString
|
||||
] ?? 1;
|
||||
|
||||
// Start from the most recent date and fill in missing exchange rates
|
||||
// using the latest available rate
|
||||
@ -94,7 +104,7 @@ export class ExchangeRateDataService {
|
||||
exchangeRatesByCurrency[`${currency}${targetCurrency}`][dateString] =
|
||||
previousExchangeRate;
|
||||
|
||||
if (currency === DEFAULT_CURRENCY) {
|
||||
if (currency === DEFAULT_CURRENCY && isBefore(date, new Date())) {
|
||||
Logger.error(
|
||||
`No exchange rate has been found for ${currency}${targetCurrency} at ${dateString}`,
|
||||
'ExchangeRateDataService'
|
||||
@ -433,13 +443,17 @@ export class ExchangeRateDataService {
|
||||
]) *
|
||||
marketPriceBaseCurrencyToCurrency[format(date, DATE_FORMAT)];
|
||||
|
||||
if (isNaN(factor)) {
|
||||
throw new Error('Exchange rate is not a number');
|
||||
} else {
|
||||
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'
|
||||
);
|
||||
}
|
||||
@ -451,7 +465,7 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
|
||||
private async prepareCurrencies(): Promise<string[]> {
|
||||
let currencies: string[] = [];
|
||||
let currencies: string[] = [DEFAULT_CURRENCY];
|
||||
|
||||
(
|
||||
await this.prismaService.account.findMany({
|
||||
|
@ -30,6 +30,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
PORT: number;
|
||||
REDIS_DB: number;
|
||||
REDIS_HOST: string;
|
||||
REDIS_PASSWORD: string;
|
||||
REDIS_PORT: number;
|
||||
@ -41,5 +42,4 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
TWITTER_ACCESS_TOKEN_SECRET: string;
|
||||
TWITTER_API_KEY: string;
|
||||
TWITTER_API_SECRET: string;
|
||||
WEB_AUTH_RP_ID: string;
|
||||
}
|
||||
|
@ -5,7 +5,7 @@ import {
|
||||
Account,
|
||||
DataSource,
|
||||
SymbolProfile,
|
||||
Type as TypeOfOrder
|
||||
Type as ActivityType
|
||||
} from '@prisma/client';
|
||||
|
||||
export interface IOrder {
|
||||
@ -18,7 +18,7 @@ export interface IOrder {
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
symbolProfile: SymbolProfile;
|
||||
type: TypeOfOrder;
|
||||
type: ActivityType;
|
||||
unitPrice: number;
|
||||
}
|
||||
|
||||
|
@ -189,9 +189,8 @@ export class SymbolProfileService {
|
||||
return {
|
||||
code,
|
||||
weight,
|
||||
continent:
|
||||
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code as string]?.name ?? UNKNOWN_KEY
|
||||
continent: continents[countries[code]?.continent] ?? UNKNOWN_KEY,
|
||||
name: countries[code]?.name ?? UNKNOWN_KEY
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -13,13 +13,13 @@
|
||||
"build": {
|
||||
"executor": "@nx/angular:webpack-browser",
|
||||
"options": {
|
||||
"deleteOutputPath": false,
|
||||
"localize": true,
|
||||
"outputPath": "dist/apps/client",
|
||||
"index": "apps/client/src/index.html",
|
||||
"main": "apps/client/src/main.ts",
|
||||
"polyfills": "apps/client/src/polyfills.ts",
|
||||
"tsConfig": "apps/client/tsconfig.app.json",
|
||||
"assets": [],
|
||||
"styles": [
|
||||
"apps/client/src/assets/fonts/inter.css",
|
||||
"apps/client/src/styles/theme.scss",
|
||||
@ -108,13 +108,22 @@
|
||||
"options": {
|
||||
"commands": [
|
||||
{
|
||||
"command": "shx mkdir -p dist/apps/client"
|
||||
"command": "shx rm -rf dist/apps/client"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/assets dist/apps/client"
|
||||
"command": "shx mkdir -p dist/apps/client/.well-known"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/assets/.well-known dist/apps/client"
|
||||
"command": "shx mkdir -p dist/apps/client/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx mkdir -p dist/apps/client/ionicons"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/assets/* dist/apps/client/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/assets/.well-known/* dist/apps/client/.well-known"
|
||||
},
|
||||
{
|
||||
"command": "shx cp apps/client/src/assets/favicon.ico dist/apps/client"
|
||||
@ -128,9 +137,6 @@
|
||||
{
|
||||
"command": "shx cp apps/client/src/assets/site.webmanifest dist/apps/client"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r apps/client/src/locales dist/apps/api/assets"
|
||||
},
|
||||
{
|
||||
"command": "shx cp node_modules/ionicons/dist/index.js dist/apps/client"
|
||||
},
|
||||
@ -138,7 +144,7 @@
|
||||
"command": "shx cp node_modules/ionicons/dist/ionicons.js dist/apps/client"
|
||||
},
|
||||
{
|
||||
"command": "shx cp -r node_modules/ionicons/dist/ionicons dist/apps/client/ionicons"
|
||||
"command": "shx cp -r node_modules/ionicons/dist/ionicons/* dist/apps/client/ionicons"
|
||||
},
|
||||
{
|
||||
"command": "shx cp CHANGELOG.md dist/apps/client/assets"
|
||||
@ -146,7 +152,8 @@
|
||||
{
|
||||
"command": "shx cp LICENSE dist/apps/client/assets"
|
||||
}
|
||||
]
|
||||
],
|
||||
"parallel": false
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
|
@ -115,7 +115,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
);
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioDetails({
|
||||
.fetchPortfolioHoldings({
|
||||
filters: [
|
||||
{
|
||||
type: 'ACCOUNT',
|
||||
@ -125,11 +125,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = [];
|
||||
|
||||
for (const [symbol, holding] of Object.entries(holdings)) {
|
||||
this.holdings.push(holding);
|
||||
}
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
@ -227,7 +223,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
}
|
||||
],
|
||||
range: 'max',
|
||||
withExcludedAccounts: true
|
||||
withExcludedAccounts: true,
|
||||
withItems: true
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ chart }) => {
|
||||
|
@ -25,7 +25,9 @@
|
||||
class="h-100"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isInPercent]="data.hasImpersonationId || user.settings.isRestrictedView"
|
||||
[isInPercent]="
|
||||
data.hasImpersonationId || user.settings.isRestrictedView
|
||||
"
|
||||
[isLoading]="isLoadingChart"
|
||||
[locale]="user?.settings?.locale"
|
||||
/>
|
||||
@ -92,7 +94,9 @@
|
||||
[dataSource]="dataSource"
|
||||
[deviceType]="data.deviceType"
|
||||
[hasPermissionToCreateActivity]="false"
|
||||
[hasPermissionToExportActivities]="!data.hasImpersonationId && !user.settings.isRestrictedView"
|
||||
[hasPermissionToExportActivities]="
|
||||
!data.hasImpersonationId && !user.settings.isRestrictedView
|
||||
"
|
||||
[hasPermissionToFilter]="false"
|
||||
[hasPermissionToOpenDetails]="false"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -113,7 +117,11 @@
|
||||
[accountBalances]="accountBalances"
|
||||
[accountId]="data.accountId"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!data.hasImpersonationId && hasPermissionToDeleteAccountBalance && !user.settings.isRestrictedView"
|
||||
[showActions]="
|
||||
!data.hasImpersonationId &&
|
||||
hasPermissionToDeleteAccountBalance &&
|
||||
!user.settings.isRestrictedView
|
||||
"
|
||||
(accountBalanceDeleted)="onDeleteAccountBalance($event)"
|
||||
/>
|
||||
</mat-tab>
|
||||
|
@ -40,12 +40,7 @@
|
||||
[tooltip]="element.Platform?.name"
|
||||
[url]="element.Platform?.url"
|
||||
/>
|
||||
<span>{{ element.name }} </span>
|
||||
<span
|
||||
*ngIf="element.isDefault"
|
||||
class="d-lg-inline-block d-none text-muted"
|
||||
>(Default)</span
|
||||
>
|
||||
<span>{{ element.name }}</span>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
||||
</ng-container>
|
||||
@ -261,7 +256,7 @@
|
||||
</button>
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.isDefault || element.transactionCount > 0"
|
||||
[disabled]="element.transactionCount > 0"
|
||||
(click)="onDeleteAccount(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { getLocale } from '@ghostfolio/common/helper';
|
||||
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
@ -27,7 +29,7 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToOpenDetails = true;
|
||||
@Input() locale: string;
|
||||
@Input() locale = getLocale();
|
||||
@Input() showActions: boolean;
|
||||
@Input() showBalance = true;
|
||||
@Input() showFooter = true;
|
||||
|
@ -155,15 +155,14 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
|
||||
day: string;
|
||||
yearMonth: string;
|
||||
}) {
|
||||
const date = parseISO(`${yearMonth}-${day}`);
|
||||
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
|
||||
|
||||
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
|
||||
data: <MarketDataDetailDialogParams>{
|
||||
date,
|
||||
marketPrice,
|
||||
currency: this.currency,
|
||||
dataSource: this.dataSource,
|
||||
dateString: `${yearMonth}-${day}`,
|
||||
symbol: this.symbol,
|
||||
user: this.user
|
||||
},
|
||||
|
@ -5,7 +5,7 @@ import { DataSource } from '@prisma/client';
|
||||
export interface MarketDataDetailDialogParams {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
date: Date;
|
||||
dateString: string;
|
||||
marketPrice: number;
|
||||
symbol: string;
|
||||
user: User;
|
||||
|
@ -45,7 +45,7 @@ export class MarketDataDetailDialog implements OnDestroy {
|
||||
this.adminService
|
||||
.fetchSymbolForDate({
|
||||
dataSource: this.data.dataSource,
|
||||
date: this.data.date,
|
||||
dateString: this.data.dateString,
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -63,7 +63,7 @@ export class MarketDataDetailDialog implements OnDestroy {
|
||||
marketData: {
|
||||
marketData: [
|
||||
{
|
||||
date: this.data.date.toISOString(),
|
||||
date: this.data.dateString,
|
||||
marketPrice: this.data.marketPrice
|
||||
}
|
||||
]
|
||||
|
@ -9,7 +9,7 @@
|
||||
matInput
|
||||
name="date"
|
||||
[matDatepicker]="date"
|
||||
[(ngModel)]="data.date"
|
||||
[(ngModel)]="data.dateString"
|
||||
/>
|
||||
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
|
||||
<ion-icon
|
||||
|
@ -163,7 +163,12 @@
|
||||
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
|
||||
<button
|
||||
mat-menu-item
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: element.dataSource, symbol: element.symbol })"
|
||||
(click)="
|
||||
onOpenAssetProfileDialog({
|
||||
dataSource: element.dataSource,
|
||||
symbol: element.symbol
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="create-outline" />
|
||||
@ -173,7 +178,12 @@
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.activitiesCount !== 0"
|
||||
(click)="onDeleteProfileData({dataSource: element.dataSource, symbol: element.symbol})"
|
||||
(click)="
|
||||
onDeleteProfileData({
|
||||
dataSource: element.dataSource,
|
||||
symbol: element.symbol
|
||||
})
|
||||
"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
@ -189,16 +199,19 @@
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
class="cursor-pointer"
|
||||
mat-row
|
||||
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
|
||||
(click)="
|
||||
onOpenAssetProfileDialog({
|
||||
dataSource: row.dataSource,
|
||||
symbol: row.symbol
|
||||
})
|
||||
"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<mat-paginator
|
||||
[length]="totalItems"
|
||||
[ngClass]="{
|
||||
'd-none':
|
||||
(isLoading && totalItems === 0) ||
|
||||
totalItems <= pageSize
|
||||
'd-none': (isLoading && totalItems === 0) || totalItems <= pageSize
|
||||
}"
|
||||
[pageSize]="pageSize"
|
||||
[showFirstLastButtons]="true"
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
|
||||
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
|
||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
@ -195,15 +196,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
|
||||
header: true,
|
||||
skipEmptyLines: true
|
||||
}
|
||||
).data;
|
||||
).data as UpdateMarketDataDto[];
|
||||
|
||||
this.adminService
|
||||
.postMarketData({
|
||||
dataSource: this.data.dataSource,
|
||||
marketData: {
|
||||
marketData: marketData.map(({ date, marketPrice }) => {
|
||||
return { marketPrice, date: parseDate(date).toISOString() };
|
||||
})
|
||||
marketData
|
||||
},
|
||||
symbol: this.data.symbol
|
||||
})
|
||||
|
@ -25,7 +25,9 @@
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="assetProfileForm.dirty"
|
||||
(click)="onGatherSymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||
(click)="
|
||||
onGatherSymbol({ dataSource: data.dataSource, symbol: data.symbol })
|
||||
"
|
||||
>
|
||||
<ng-container i18n>Gather Historical Data</ng-container>
|
||||
</button>
|
||||
@ -33,7 +35,12 @@
|
||||
mat-menu-item
|
||||
type="button"
|
||||
[disabled]="assetProfileForm.dirty"
|
||||
(click)="onGatherProfileDataBySymbol({dataSource: data.dataSource, symbol: data.symbol})"
|
||||
(click)="
|
||||
onGatherProfileDataBySymbol({
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
})
|
||||
"
|
||||
>
|
||||
<ng-container i18n>Gather Profile Data</ng-container>
|
||||
</button>
|
||||
@ -73,7 +80,12 @@
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="!assetProfileForm.controls['historicalData']?.controls['csvString'].touched || assetProfileForm.controls['historicalData']?.controls['csvString']?.value === ''"
|
||||
[disabled]="
|
||||
!assetProfileForm.controls['historicalData']?.controls['csvString']
|
||||
.touched ||
|
||||
assetProfileForm.controls['historicalData']?.controls['csvString']
|
||||
?.value === ''
|
||||
"
|
||||
(click)="onImportHistoricalData()"
|
||||
>
|
||||
<ng-container i18n>Import</ng-container>
|
||||
@ -129,10 +141,15 @@
|
||||
>
|
||||
</div>
|
||||
<ng-container
|
||||
*ngIf="assetProfile?.countries?.length > 0 || assetProfile?.sectors?.length > 0"
|
||||
*ngIf="
|
||||
assetProfile?.countries?.length > 0 ||
|
||||
assetProfile?.sectors?.length > 0
|
||||
"
|
||||
>
|
||||
@if (assetProfile?.countries?.length === 1 &&
|
||||
assetProfile?.sectors?.length === 1 ) {
|
||||
@if (
|
||||
assetProfile?.countries?.length === 1 &&
|
||||
assetProfile?.sectors?.length === 1
|
||||
) {
|
||||
<div *ngIf="assetProfile?.sectors?.length === 1" class="col-6 mb-3">
|
||||
<gf-value
|
||||
i18n
|
||||
@ -222,7 +239,17 @@
|
||||
color="primary"
|
||||
i18n
|
||||
[checked]="isBenchmark"
|
||||
(change)="isBenchmark ? onUnsetBenchmark({dataSource: data.dataSource, symbol: data.symbol}) : onSetBenchmark({dataSource: data.dataSource, symbol: data.symbol})"
|
||||
(change)="
|
||||
isBenchmark
|
||||
? onUnsetBenchmark({
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
})
|
||||
: onSetBenchmark({
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
})
|
||||
"
|
||||
>Benchmark</mat-checkbox
|
||||
>
|
||||
</div>
|
||||
@ -253,7 +280,9 @@
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
type="button"
|
||||
[disabled]="assetProfileForm.controls['scraperConfiguration'].value === '{}'"
|
||||
[disabled]="
|
||||
assetProfileForm.controls['scraperConfiguration'].value === '{}'
|
||||
"
|
||||
(click)="onTestMarketData()"
|
||||
>
|
||||
<ng-container i18n>Test</ng-container>
|
||||
|
@ -28,7 +28,7 @@
|
||||
[value]="transactionCount"
|
||||
/>
|
||||
<div *ngIf="transactionCount && userCount">
|
||||
{{ transactionCount / userCount | number : '1.2-2' }}
|
||||
{{ transactionCount / userCount | number: '1.2-2' }}
|
||||
<span i18n>per User</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -112,7 +112,9 @@
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
hideIcon="true"
|
||||
[checked]="info.globalPermissions.includes(permissions.createUserAccount)"
|
||||
[checked]="
|
||||
info.globalPermissions.includes(permissions.createUserAccount)
|
||||
"
|
||||
(change)="onEnableUserSignupModeChange($event)"
|
||||
/>
|
||||
</div>
|
||||
@ -143,7 +145,7 @@
|
||||
<div class="w-50" i18n>System Message</div>
|
||||
<div class="w-50">
|
||||
<div *ngIf="systemMessage" class="align-items-center d-flex">
|
||||
<div class="text-truncate">{{ systemMessage | json }}</div>
|
||||
<div class="text-truncate">{{ systemMessage | json }}</div>
|
||||
<button
|
||||
class="h-100 mx-1 no-min-width px-2"
|
||||
mat-button
|
||||
|
@ -91,7 +91,11 @@
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeletePlatform(element.id)">
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.accountCount > 0"
|
||||
(click)="onDeletePlatform(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
|
@ -71,7 +71,11 @@
|
||||
<span i18n>Edit</span>
|
||||
</span>
|
||||
</button>
|
||||
<button mat-menu-item (click)="onDeleteTag(element.id)">
|
||||
<button
|
||||
mat-menu-item
|
||||
[disabled]="element.activityCount > 0"
|
||||
(click)="onDeleteTag(element.id)"
|
||||
>
|
||||
<span class="align-items-center d-flex">
|
||||
<ion-icon class="mr-2" name="trash-outline" />
|
||||
<span i18n>Delete</span>
|
||||
|
@ -12,7 +12,7 @@
|
||||
#
|
||||
</th>
|
||||
<td
|
||||
*matCellDef="let element; let i=index"
|
||||
*matCellDef="let element; let i = index"
|
||||
class="mat-mdc-cell px-1 py-2 text-right"
|
||||
mat-cell
|
||||
>
|
||||
@ -35,17 +35,31 @@
|
||||
mat-cell
|
||||
>
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="d-none d-sm-inline-block text-monospace"
|
||||
>{{ element.id }}</span
|
||||
<span
|
||||
class="d-none d-sm-inline-block text-monospace"
|
||||
[ngClass]="{
|
||||
'text-line-through': element.role === 'INACTIVE'
|
||||
}"
|
||||
>{{ element.id }}</span
|
||||
>
|
||||
<span class="d-inline-block d-sm-none text-monospace"
|
||||
>{{ (element.id | slice:0:5) + '...' }}</span
|
||||
<span
|
||||
class="d-inline-block d-sm-none text-monospace"
|
||||
[ngClass]="{
|
||||
'text-line-through': element.role === 'INACTIVE'
|
||||
}"
|
||||
>{{ (element.id | slice: 0 : 5) + '...' }}</span
|
||||
>
|
||||
<gf-premium-indicator
|
||||
*ngIf="element?.subscription?.type === 'Premium'"
|
||||
class="ml-1"
|
||||
[enableLink]="false"
|
||||
[title]="'Expires ' + formatDistanceToNow(element.subscription.expiresAt) + ' (' + (element.subscription.expiresAt | date: defaultDateFormat) + ')'"
|
||||
[title]="
|
||||
'Expires ' +
|
||||
formatDistanceToNow(element.subscription.expiresAt) +
|
||||
' (' +
|
||||
(element.subscription.expiresAt | date: defaultDateFormat) +
|
||||
')'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
@ -67,9 +81,9 @@
|
||||
class="mat-mdc-cell px-1 py-2"
|
||||
mat-cell
|
||||
>
|
||||
<span class="h5" [title]="element.country"
|
||||
>{{ getEmojiFlag(element.country) }}</span
|
||||
>
|
||||
<span class="h5" [title]="element.country">{{
|
||||
getEmojiFlag(element.country)
|
||||
}}</span>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
|
@ -7,6 +7,7 @@ import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||
import {
|
||||
getBackgroundColor,
|
||||
getDateFormatString,
|
||||
getLocale,
|
||||
getTextColor,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -51,7 +52,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
@Input() colorScheme: ColorScheme;
|
||||
@Input() daysInMarket: number;
|
||||
@Input() isLoading: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() locale = getLocale();
|
||||
@Input() performanceDataItems: LineChartItem[];
|
||||
@Input() user: User;
|
||||
|
||||
|
@ -154,8 +154,8 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
this.dataService
|
||||
.fetchPositions({ range: this.user?.settings?.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response) => {
|
||||
this.positions = response.positions;
|
||||
.subscribe(({ positions }) => {
|
||||
this.positions = positions;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
@ -127,10 +127,10 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.historicalDataItems = chart.map(
|
||||
({ date, netPerformanceInPercentage }) => {
|
||||
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
|
||||
return {
|
||||
date,
|
||||
value: netPerformanceInPercentage
|
||||
value: netPerformanceInPercentageWithCurrencyEffect
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -1,7 +1,7 @@
|
||||
<div
|
||||
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
|
||||
>
|
||||
@if(hasPermissionToCreateOrder && historicalDataItems?.length === 0) {
|
||||
@if (hasPermissionToCreateOrder && historicalDataItems?.length === 0) {
|
||||
<div class="justify-content-center row w-100">
|
||||
<div class="col introduction">
|
||||
<h4 i18n>Welcome to Ghostfolio</h4>
|
||||
@ -32,8 +32,8 @@
|
||||
<a class="d-block" [routerLink]="['/portfolio']">
|
||||
<span i18n>Monitor and analyze your portfolio</span><br />
|
||||
<span class="font-weight-normal" i18n
|
||||
>Track your progress in real-time with comprehensive analysis and
|
||||
insights.</span
|
||||
>Track your progress in real-time with comprehensive analysis
|
||||
and insights.</span
|
||||
>
|
||||
</a>
|
||||
</li>
|
||||
|
@ -102,7 +102,7 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
|
||||
this.isLoading = true;
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioDetails()
|
||||
.fetchPortfolioDetails({ withLiabilities: true })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ summary }) => {
|
||||
this.summary = summary;
|
||||
|
@ -6,7 +6,9 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-summary
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[hasPermissionToUpdateUserSettings]="!hasImpersonationId && hasPermissionToUpdateUserSettings"
|
||||
[hasPermissionToUpdateUserSettings]="
|
||||
!hasImpersonationId && hasPermissionToUpdateUserSettings
|
||||
"
|
||||
[isLoading]="isLoading"
|
||||
[language]="user?.settings?.language"
|
||||
[locale]="user?.settings?.locale"
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user