Compare commits
61 Commits
Author | SHA1 | Date | |
---|---|---|---|
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 |
122
CHANGELOG.md
122
CHANGELOG.md
@ -5,15 +5,133 @@ 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.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 an index for `accountId`, `date` and `updatedAt` to the account balance 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 an index for `marketPrice` and `state` to the market data 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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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": {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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() {
|
||||
|
@ -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,37 +595,33 @@ export class ImportService {
|
||||
}
|
||||
}
|
||||
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol]
|
||||
};
|
||||
if (!assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })]) {
|
||||
const assetProfile = {
|
||||
currency,
|
||||
...(
|
||||
await this.dataProviderService.getAssetProfiles([
|
||||
{ dataSource, symbol }
|
||||
])
|
||||
)?.[symbol]
|
||||
};
|
||||
|
||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
if (type === 'BUY' || type === 'DIVIDEND' || type === 'SELL') {
|
||||
if (!assetProfile?.name) {
|
||||
throw new Error(
|
||||
`activities.${index}.symbol ("${symbol}") is not valid for the specified data source ("${dataSource}")`
|
||||
);
|
||||
}
|
||||
|
||||
if (assetProfile.currency !== currency) {
|
||||
throw new Error(
|
||||
`activities.${index}.currency ("${currency}") does not match with currency of ${assetProfile.symbol} ("${assetProfile.currency}")`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
assetProfile.currency !== currency &&
|
||||
!this.exchangeRateDataService.hasCurrencyPair(
|
||||
currency,
|
||||
assetProfile.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}"`
|
||||
);
|
||||
}
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
assetProfile;
|
||||
}
|
||||
|
||||
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
|
||||
assetProfile;
|
||||
}
|
||||
|
||||
return assetProfiles;
|
||||
|
@ -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,13 +125,9 @@ export class OrderService {
|
||||
|
||||
const orderData: Prisma.OrderCreateInput = data;
|
||||
|
||||
const isDraft =
|
||||
data.type === 'FEE' ||
|
||||
data.type === 'INTEREST' ||
|
||||
data.type === 'ITEM' ||
|
||||
data.type === 'LIABILITY'
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
const isDraft = ['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(data.type)
|
||||
? false
|
||||
: isAfter(data.date as Date, endOfToday());
|
||||
|
||||
const order = await this.prismaService.order.create({
|
||||
data: {
|
||||
@ -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;
|
||||
|
||||
|
@ -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)
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
const averagePrice = oldAccumulatedSymbol.investment.div(
|
||||
oldAccumulatedSymbol.quantity
|
||||
);
|
||||
investment = oldAccumulatedSymbol.investment.minus(
|
||||
order.quantity.mul(averagePrice)
|
||||
);
|
||||
}
|
||||
if (order.type === 'BUY') {
|
||||
investment = oldAccumulatedSymbol.investment.plus(
|
||||
order.quantity.mul(order.unitPrice)
|
||||
);
|
||||
} else if (order.type === 'SELL') {
|
||||
investment = oldAccumulatedSymbol.investment.minus(
|
||||
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,15 +593,17 @@ 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
|
||||
]
|
||||
);
|
||||
|
||||
const {
|
||||
dividend,
|
||||
dividendInBaseCurrency,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
grossPerformancePercentageWithCurrencyEffect,
|
||||
@ -597,9 +618,9 @@ export class PortfolioCalculator {
|
||||
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,
|
||||
dividendInBaseCurrency,
|
||||
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,
|
||||
@ -860,6 +861,8 @@ export class PortfolioCalculator {
|
||||
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
|
||||
const currentValues: { [date: string]: Big } = {};
|
||||
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
|
||||
let dividend = new Big(0);
|
||||
let dividendInBaseCurrency = new Big(0);
|
||||
let fees = new Big(0);
|
||||
let feesAtStartDate = new Big(0);
|
||||
let feesAtStartDateWithCurrencyEffect = new Big(0);
|
||||
@ -890,13 +893,10 @@ export class PortfolioCalculator {
|
||||
} = {};
|
||||
|
||||
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;
|
||||
@ -912,6 +912,8 @@ export class PortfolioCalculator {
|
||||
return {
|
||||
currentValues: {},
|
||||
currentValuesWithCurrencyEffect: {},
|
||||
dividend: new Big(0),
|
||||
dividendInBaseCurrency: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: new Big(0),
|
||||
@ -952,6 +954,8 @@ export class PortfolioCalculator {
|
||||
return {
|
||||
currentValues: {},
|
||||
currentValuesWithCurrencyEffect: {},
|
||||
dividend: new Big(0),
|
||||
dividendInBaseCurrency: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
grossPerformancePercentageWithCurrencyEffect: 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
|
||||
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
|
||||
.mul(this.getFactor(order.type))
|
||||
: totalUnits.gt(0)
|
||||
? totalInvestmentWithCurrencyEffect
|
||||
.div(totalUnits)
|
||||
.mul(order.quantity)
|
||||
.mul(this.getFactor(order.type))
|
||||
: new Big(0);
|
||||
if (order.type === 'BUY') {
|
||||
transactionInvestment = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrency)
|
||||
.mul(getFactor(order.type));
|
||||
|
||||
transactionInvestmentWithCurrencyEffect = order.quantity
|
||||
.mul(order.unitPriceInBaseCurrencyWithCurrencyEffect)
|
||||
.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(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,14 @@ 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') {
|
||||
dividend = dividend.plus(order.quantity.mul(order.unitPrice));
|
||||
dividendInBaseCurrency = dividendInBaseCurrency.plus(
|
||||
dividend.mul(exchangeRateAtOrderDate ?? 1)
|
||||
);
|
||||
}
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
|
||||
|
||||
@ -1213,14 +1232,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 +1254,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 +1302,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 +1314,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 +1495,7 @@ export class PortfolioCalculator {
|
||||
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
|
||||
2
|
||||
)}
|
||||
Total dividend: ${dividend.toFixed(2)}
|
||||
Gross performance: ${totalGrossPerformance.toFixed(
|
||||
2
|
||||
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
|
||||
@ -1515,6 +1520,8 @@ export class PortfolioCalculator {
|
||||
return {
|
||||
currentValues,
|
||||
currentValuesWithCurrencyEffect,
|
||||
dividend,
|
||||
dividendInBaseCurrency,
|
||||
grossPerformancePercentage,
|
||||
grossPerformancePercentageWithCurrencyEffect,
|
||||
initialValue,
|
||||
|
@ -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,21 +99,15 @@ export class PortfolioController {
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
platforms,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
const { accounts, hasErrors, holdings, platforms, summary } =
|
||||
await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
withLiabilities,
|
||||
userId: this.request.user.id,
|
||||
withSummary: true
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
@ -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,33 @@ 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('query') filterBySearchQuery?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioHoldingsResponse> {
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
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 +380,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 +403,7 @@ export class PortfolioController {
|
||||
filters,
|
||||
impersonationId,
|
||||
withExcludedAccounts,
|
||||
withItems,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
@ -429,6 +468,10 @@ export class PortfolioController {
|
||||
return nullifyValuesInObject(item, ['totalInvestment', 'value']);
|
||||
}
|
||||
);
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentNetPerformance', 'currentNetPerformancePercent']
|
||||
);
|
||||
}
|
||||
|
||||
return performanceInformation;
|
||||
@ -483,7 +526,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 +557,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) {
|
||||
@ -340,13 +337,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,11 +357,16 @@ export class PortfolioService {
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
const { activities, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
withExcludedAccounts,
|
||||
types: withLiabilities
|
||||
? undefined
|
||||
: getAllActivityTypes().filter((activityType) => {
|
||||
return activityType !== 'LIABILITY';
|
||||
})
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -386,9 +392,11 @@ export class PortfolioService {
|
||||
});
|
||||
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalValueInBaseCurrency = currentPositions.currentValue.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
|
||||
const totalValueInBaseCurrency =
|
||||
currentPositions.currentValueInBaseCurrency.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
|
||||
const isFilteredByAccount =
|
||||
filters?.some((filter) => {
|
||||
@ -397,7 +405,7 @@ export class PortfolioService {
|
||||
|
||||
let filteredValueInBaseCurrency = isFilteredByAccount
|
||||
? totalValueInBaseCurrency
|
||||
: currentPositions.currentValue;
|
||||
: currentPositions.currentValueInBaseCurrency;
|
||||
|
||||
if (
|
||||
filters?.length === 0 ||
|
||||
@ -434,15 +442,34 @@ 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,
|
||||
marketPriceInBaseCurrency,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
netPerformancePercentageWithCurrencyEffect,
|
||||
netPerformanceWithCurrencyEffect,
|
||||
quantity,
|
||||
symbol,
|
||||
tags,
|
||||
transactionCount,
|
||||
valueInBaseCurrency
|
||||
} of currentPositions.positions) {
|
||||
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,42 +533,50 @@ 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()
|
||||
};
|
||||
}
|
||||
|
||||
@ -562,8 +597,8 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const { accounts, platforms } = await this.getValueOfAccountsAndPlatforms({
|
||||
activities,
|
||||
filters,
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId,
|
||||
@ -605,28 +640,29 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
const summary = await this.getSummary({
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId,
|
||||
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency:
|
||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||
holdings
|
||||
})
|
||||
});
|
||||
let summary: PortfolioSummary;
|
||||
|
||||
if (withSummary) {
|
||||
summary = await this.getSummary({
|
||||
filteredValueInBaseCurrency,
|
||||
holdings,
|
||||
impersonationId,
|
||||
userCurrency,
|
||||
userId,
|
||||
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
|
||||
emergencyFundPositionsValueInBaseCurrency:
|
||||
this.getEmergencyFundPositionsValueInBaseCurrency({
|
||||
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 +728,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 +769,7 @@ export class PortfolioService {
|
||||
averagePrice,
|
||||
currency,
|
||||
dataSource,
|
||||
dividendInBaseCurrency,
|
||||
fee,
|
||||
firstBuyDate,
|
||||
marketPrice,
|
||||
@ -741,22 +778,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 +819,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 +972,8 @@ export class PortfolioService {
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId
|
||||
userId,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
@ -1075,13 +1103,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 +1146,8 @@ export class PortfolioService {
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
withExcludedAccounts,
|
||||
types: withItems ? ['BUY', 'ITEM', 'SELL'] : ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -1160,7 +1191,7 @@ export class PortfolioService {
|
||||
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const {
|
||||
currentValue,
|
||||
currentValueInBaseCurrency,
|
||||
errors,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
@ -1255,7 +1286,7 @@ export class PortfolioService {
|
||||
currentNetPerformancePercentWithCurrencyEffect.toNumber(),
|
||||
currentNetPerformanceWithCurrencyEffect:
|
||||
currentNetPerformanceWithCurrencyEffect.toNumber(),
|
||||
currentValue: currentValue.toNumber(),
|
||||
currentValue: currentValueInBaseCurrency.toNumber(),
|
||||
totalInvestment: totalInvestment.toNumber()
|
||||
}
|
||||
};
|
||||
@ -1266,9 +1297,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 +1329,7 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const { accounts } = await this.getValueOfAccountsAndPlatforms({
|
||||
orders,
|
||||
activities,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
@ -1307,7 +1339,7 @@ export class PortfolioService {
|
||||
|
||||
return {
|
||||
rules: {
|
||||
accountClusterRisk: isEmpty(orders)
|
||||
accountClusterRisk: isEmpty(activities)
|
||||
? undefined
|
||||
: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -1322,7 +1354,7 @@ export class PortfolioService {
|
||||
],
|
||||
userSettings
|
||||
),
|
||||
currencyClusterRisk: isEmpty(orders)
|
||||
currencyClusterRisk: isEmpty(activities)
|
||||
? undefined
|
||||
: await this.rulesService.evaluate(
|
||||
[
|
||||
@ -1351,7 +1383,7 @@ export class PortfolioService {
|
||||
new FeeRatioInitialInvestment(
|
||||
this.exchangeRateDataService,
|
||||
currentPositions.totalInvestment.toNumber(),
|
||||
this.getFees({ userCurrency, activities: orders }).toNumber()
|
||||
this.getFees({ activities, userCurrency }).toNumber()
|
||||
)
|
||||
],
|
||||
userSettings
|
||||
@ -1558,29 +1590,26 @@ 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));
|
||||
})
|
||||
.map(({ fee, SymbolProfile }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
return getSum(
|
||||
activities
|
||||
.filter(({ isDraft }) => {
|
||||
return isDraft === false;
|
||||
})
|
||||
.map(({ fee, SymbolProfile }) => {
|
||||
return new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
fee,
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private getInitialCashPosition({
|
||||
@ -1598,14 +1627,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 +1720,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 +1743,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 +1831,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 +1884,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 +1921,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
|
||||
);
|
||||
})
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
);
|
||||
})
|
||||
.reduce(
|
||||
(previous, current) => new Big(previous).plus(current),
|
||||
new Big(0)
|
||||
);
|
||||
return getSum(
|
||||
activities
|
||||
.filter(({ isDraft, type }) => {
|
||||
return isDraft === false && type === activityType;
|
||||
})
|
||||
.map(({ quantity, SymbolProfile, unitPrice }) => {
|
||||
return new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
new Big(quantity).mul(unitPrice).toNumber(),
|
||||
SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
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 +1984,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 +2017,8 @@ export class PortfolioService {
|
||||
portfolioCalculator.computeTransactionPoints();
|
||||
|
||||
return {
|
||||
activities,
|
||||
portfolioOrders,
|
||||
orders: activities,
|
||||
transactionPoints: portfolioCalculator.getTransactionPoints()
|
||||
};
|
||||
}
|
||||
@ -1954,29 +2039,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 +2070,7 @@ export class PortfolioService {
|
||||
});
|
||||
} else {
|
||||
const accountIds = uniq(
|
||||
orders
|
||||
activities
|
||||
.filter(({ accountId }) => {
|
||||
return accountId;
|
||||
})
|
||||
@ -2014,19 +2090,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 +2125,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'),
|
||||
|
@ -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,
|
||||
|
@ -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: {
|
||||
|
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) {
|
||||
|
@ -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' })
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -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 = [];
|
||||
|
@ -241,37 +241,44 @@ 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 (isNumber(close)) {
|
||||
result[this.convertFromEodSymbol(code)] = {
|
||||
currency:
|
||||
currency ??
|
||||
this.convertFromEodSymbol(code)?.replace(DEFAULT_CURRENCY, ''),
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000))
|
||||
? 'open'
|
||||
: 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
|
||||
'EodHistoricalDataService'
|
||||
);
|
||||
if (!currency) {
|
||||
const { items } = await this.search({ query: code });
|
||||
|
||||
if (items.length === 1) {
|
||||
currency = items[0].currency;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
{}
|
||||
);
|
||||
if (isNumber(close)) {
|
||||
response[this.convertFromEodSymbol(code)] = {
|
||||
currency,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: close,
|
||||
marketState: isToday(new Date(timestamp * 1000)) ? 'open' : 'closed'
|
||||
};
|
||||
} else {
|
||||
Logger.error(
|
||||
`Could not get quote for ${this.convertFromEodSymbol(code)} (${this.getName()})`,
|
||||
'EodHistoricalDataService'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
|
@ -166,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'
|
||||
};
|
||||
}
|
||||
|
@ -38,17 +38,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}: {
|
||||
symbol: string;
|
||||
}): Promise<Partial<SymbolProfile>> {
|
||||
const { assetClass, assetSubClass, currency, name } =
|
||||
await this.yahooFinanceDataEnhancerService.getAssetProfile(symbol);
|
||||
|
||||
return {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency,
|
||||
name,
|
||||
symbol,
|
||||
dataSource: this.getName()
|
||||
};
|
||||
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)];
|
||||
|
||||
factors[format(date, DATE_FORMAT)] = factor;
|
||||
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 }) => {
|
||||
|
@ -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">
|
||||
|
@ -35,12 +35,20 @@
|
||||
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-inline-block d-sm-none text-monospace">{{
|
||||
(element.id | slice: 0 : 5) + '...'
|
||||
}}</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"
|
||||
[ngClass]="{
|
||||
'text-line-through': element.role === 'INACTIVE'
|
||||
}"
|
||||
>{{ (element.id | slice: 0 : 5) + '...' }}</span
|
||||
>
|
||||
<gf-premium-indicator
|
||||
*ngIf="element?.subscription?.type === 'Premium'"
|
||||
class="ml-1"
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -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;
|
||||
|
@ -40,7 +40,11 @@
|
||||
[colorizeSign]="true"
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : performance?.currentNetPerformance"
|
||||
[value]="
|
||||
isLoading
|
||||
? undefined
|
||||
: performance?.currentNetPerformanceWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="col">
|
||||
@ -49,7 +53,9 @@
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="
|
||||
isLoading ? undefined : performance?.currentNetPerformancePercent
|
||||
isLoading
|
||||
? undefined
|
||||
: performance?.currentNetPerformancePercentWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
|
@ -63,7 +63,8 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
} else if (this.showDetails === false) {
|
||||
new CountUp(
|
||||
'value',
|
||||
this.performance?.currentNetPerformancePercent * 100,
|
||||
this.performance?.currentNetPerformancePercentWithCurrencyEffect *
|
||||
100,
|
||||
{
|
||||
decimal: getNumberFormatDecimal(this.locale),
|
||||
decimalPlaces: 2,
|
||||
|
@ -64,7 +64,11 @@
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.currentGrossPerformance"
|
||||
[value]="
|
||||
isLoading
|
||||
? undefined
|
||||
: summary?.currentGrossPerformanceWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -85,7 +89,9 @@
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="
|
||||
isLoading ? undefined : summary?.currentGrossPerformancePercent
|
||||
isLoading
|
||||
? undefined
|
||||
: summary?.currentGrossPerformancePercentWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
@ -114,7 +120,11 @@
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.currentNetPerformance"
|
||||
[value]="
|
||||
isLoading
|
||||
? undefined
|
||||
: summary?.currentNetPerformanceWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -134,7 +144,11 @@
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : summary?.currentNetPerformancePercent"
|
||||
[value]="
|
||||
isLoading
|
||||
? undefined
|
||||
: summary?.currentNetPerformancePercentWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -268,7 +282,7 @@
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.netWorth"
|
||||
[value]="isLoading ? undefined : summary?.totalValueInBaseCurrency"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -283,7 +297,11 @@
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent"
|
||||
[value]="
|
||||
isLoading
|
||||
? undefined
|
||||
: summary?.annualizedPerformancePercentWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -310,7 +328,7 @@
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="isLoading ? undefined : summary?.dividend"
|
||||
[value]="isLoading ? undefined : summary?.dividendInBaseCurrency"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -50,15 +50,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public dividendInBaseCurrency: number;
|
||||
public feeInBaseCurrency: number;
|
||||
public firstBuyDate: string;
|
||||
public grossPerformance: number;
|
||||
public grossPerformancePercent: number;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public investment: number;
|
||||
public marketPrice: number;
|
||||
public maxPrice: number;
|
||||
public minPrice: number;
|
||||
public netPerformance: number;
|
||||
public netPerformancePercent: number;
|
||||
public netPerformancePercentWithCurrencyEffect: number;
|
||||
public netPerformanceWithCurrencyEffect: number;
|
||||
public quantity: number;
|
||||
public quantityPrecision = 2;
|
||||
public reportDataGlitchMail: string;
|
||||
@ -99,15 +97,13 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
dividendInBaseCurrency,
|
||||
feeInBaseCurrency,
|
||||
firstBuyDate,
|
||||
grossPerformance,
|
||||
grossPerformancePercent,
|
||||
historicalData,
|
||||
investment,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
netPerformance,
|
||||
netPerformancePercent,
|
||||
netPerformancePercentWithCurrencyEffect,
|
||||
netPerformanceWithCurrencyEffect,
|
||||
orders,
|
||||
quantity,
|
||||
SymbolProfile,
|
||||
@ -125,8 +121,6 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
this.dividendInBaseCurrency = dividendInBaseCurrency;
|
||||
this.feeInBaseCurrency = feeInBaseCurrency;
|
||||
this.firstBuyDate = firstBuyDate;
|
||||
this.grossPerformance = grossPerformance;
|
||||
this.grossPerformancePercent = grossPerformancePercent;
|
||||
this.historicalDataItems = historicalData.map(
|
||||
(historicalDataItem) => {
|
||||
this.benchmarkDataItems.push({
|
||||
@ -144,8 +138,10 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
this.marketPrice = marketPrice;
|
||||
this.maxPrice = maxPrice;
|
||||
this.minPrice = minPrice;
|
||||
this.netPerformance = netPerformance;
|
||||
this.netPerformancePercent = netPerformancePercent;
|
||||
this.netPerformancePercentWithCurrencyEffect =
|
||||
netPerformancePercentWithCurrencyEffect;
|
||||
this.netPerformanceWithCurrencyEffect =
|
||||
netPerformanceWithCurrencyEffect;
|
||||
this.quantity = quantity;
|
||||
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
|
||||
this.sectors = {};
|
||||
|
@ -44,7 +44,7 @@
|
||||
[isCurrency]="true"
|
||||
[locale]="data.locale"
|
||||
[unit]="data.baseCurrency"
|
||||
[value]="netPerformance"
|
||||
[value]="netPerformanceWithCurrencyEffect"
|
||||
>Change</gf-value
|
||||
>
|
||||
</div>
|
||||
@ -55,7 +55,7 @@
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="data.locale"
|
||||
[value]="netPerformancePercent"
|
||||
[value]="netPerformancePercentWithCurrencyEffect"
|
||||
>Performance</gf-value
|
||||
>
|
||||
</div>
|
||||
|
@ -17,7 +17,7 @@
|
||||
[isLoading]="isLoading"
|
||||
[marketState]="position?.marketState"
|
||||
[range]="range"
|
||||
[value]="position?.netPerformancePercentage"
|
||||
[value]="position?.netPerformancePercentageWithCurrencyEffect"
|
||||
/>
|
||||
</div>
|
||||
<div *ngIf="isLoading" class="flex-grow-1">
|
||||
@ -49,13 +49,13 @@
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[unit]="baseCurrency"
|
||||
[value]="position?.netPerformance"
|
||||
[value]="position?.netPerformanceWithCurrencyEffect"
|
||||
/>
|
||||
<gf-value
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="position?.netPerformancePercentage"
|
||||
[value]="position?.netPerformancePercentageWithCurrencyEffect"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -18,6 +18,95 @@
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title
|
||||
>Which home server systems is Ghostfolio available
|
||||
on?</mat-card-title
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
The community has made Ghostfolio available on various home server
|
||||
systems, including
|
||||
<a href="https://github.com/bigbeartechworld/big-bear-casaos"
|
||||
>CasaOS</a
|
||||
>, <a href="https://www.runtipi.io/docs/apps-available">Runtipi</a>,
|
||||
<a href="https://truecharts.org/charts/stable/ghostfolio"
|
||||
>TrueCharts</a
|
||||
>, <a href="https://apps.umbrel.com/app/ghostfolio">Umbrel</a>, and
|
||||
<a href="https://unraid.net/community/apps?q=ghostfolio">Unraid</a>.
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>How do I add a new currency?</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>
|
||||
Ghostfolio manages currencies automatically based on all the
|
||||
recorded activities. If you need an additional currency, you can
|
||||
manually enter it.
|
||||
</p>
|
||||
<ol>
|
||||
<li>Go to the <i>Admin Control</i> panel</li>
|
||||
<li>Click on the <i>Add Currency</i> button</li>
|
||||
<li>Insert e.g. <code>EUR</code> in the prompt</li>
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title
|
||||
>How do I resolve
|
||||
<i>No exchange rate has been found</i> errors?</mat-card-title
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<p>
|
||||
In Ghostfolio, you are responsible for providing the relevant
|
||||
historical exchange rates. This can be done with a one-time import
|
||||
of the data. If you see errors like
|
||||
<i
|
||||
>Historical exchange rate at 2024-01-01 is not available from
|
||||
"EUR" to "USD"</i
|
||||
>
|
||||
do the following:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Go to the <i>Admin Control</i> panel</li>
|
||||
<li>Go to the <i>Market Data</i> section</li>
|
||||
<li>Select <i>Filter by Currencies</i></li>
|
||||
<li>Find the entry <i>USDEUR</i></li>
|
||||
<li>
|
||||
Click the menu item <i>Gather Historical Data</i> in the dialog
|
||||
</li>
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>How do I add a new platform?</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<ol>
|
||||
<li>Go to the <i>Admin Control</i> panel</li>
|
||||
<li>Go to the <i>Settings</i> section</li>
|
||||
<li>Click on the <i>Add Platform</i> button</li>
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>How do I add a new tag?</mat-card-title>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<ol>
|
||||
<li>Go to the <i>Admin Control</i> panel</li>
|
||||
<li>Go to the <i>Settings</i> section</li>
|
||||
<li>Click on the <i>Add Tag</i> button</li>
|
||||
</ol>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title>Which devices are supported?</mat-card-title>
|
||||
|
@ -10,6 +10,7 @@
|
||||
app, asset, cryptocurrency, dashboard, etf, finance, management,
|
||||
performance, portfolio, software, stock, trading, wealth, web3
|
||||
</li>
|
||||
<li i18n="@@myAccount">My Account</li>
|
||||
<li i18n="@@slogan">Open Source Wealth Management Software</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -36,7 +36,6 @@ import { ImportActivitiesDialogParams } from './import-activities-dialog/interfa
|
||||
export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
public activities: Activity[];
|
||||
public dataSource: MatTableDataSource<Activity>;
|
||||
public defaultAccountId: string;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateActivity: boolean;
|
||||
@ -323,7 +322,7 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
accounts: this.user?.accounts,
|
||||
activity: {
|
||||
...aActivity,
|
||||
accountId: aActivity?.accountId ?? this.defaultAccountId,
|
||||
accountId: aActivity?.accountId,
|
||||
date: new Date(),
|
||||
id: null,
|
||||
fee: 0,
|
||||
@ -399,10 +398,6 @@ export class ActivitiesPageComponent implements OnDestroy, OnInit {
|
||||
private updateUser(aUser: User) {
|
||||
this.user = aUser;
|
||||
|
||||
this.defaultAccountId = this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id;
|
||||
|
||||
this.hasPermissionToCreateActivity =
|
||||
!this.hasImpersonationId &&
|
||||
hasPermission(this.user.permissions, permissions.createOrder);
|
||||
|
@ -20,6 +20,7 @@ import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { AssetClass, AssetSubClass, Tag, Type } from '@prisma/client';
|
||||
import { isUUID } from 'class-validator';
|
||||
import { isToday } from 'date-fns';
|
||||
import { EMPTY, Observable, Subject, lastValueFrom, of } from 'rxjs';
|
||||
import { catchError, delay, map, startWith, takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -48,6 +49,7 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
|
||||
public defaultDateFormat: string;
|
||||
public filteredTagsObservable: Observable<Tag[]> = of([]);
|
||||
public isLoading = false;
|
||||
public isToday = isToday;
|
||||
public platforms: { id: string; name: string }[];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public tags: Tag[] = [];
|
||||
|
@ -240,7 +240,8 @@
|
||||
<button
|
||||
*ngIf="
|
||||
currentMarketPrice &&
|
||||
(data.activity.type === 'BUY' || data.activity.type === 'SELL')
|
||||
(data.activity.type === 'BUY' || data.activity.type === 'SELL') &&
|
||||
isToday(activityForm.controls['date']?.value)
|
||||
"
|
||||
class="ml-2 mt-1 no-min-width"
|
||||
mat-button
|
||||
|
@ -281,7 +281,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
this.platforms = {};
|
||||
this.portfolioDetails = {
|
||||
accounts: {},
|
||||
filteredValueInPercentage: 0,
|
||||
holdings: {},
|
||||
platforms: {},
|
||||
summary: undefined
|
||||
|
@ -18,7 +18,7 @@
|
||||
[value]="
|
||||
isLoading
|
||||
? undefined
|
||||
: portfolioDetails?.filteredValueInPercentage
|
||||
: portfolioDetails?.summary?.filteredValueInPercentage
|
||||
"
|
||||
/>
|
||||
</mat-card-header>
|
||||
@ -26,10 +26,11 @@
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[title]="
|
||||
(portfolioDetails?.filteredValueInPercentage * 100).toFixed(2) +
|
||||
'%'
|
||||
(
|
||||
portfolioDetails?.summary?.filteredValueInPercentage * 100
|
||||
).toFixed(2) + '%'
|
||||
"
|
||||
[value]="portfolioDetails?.filteredValueInPercentage * 100"
|
||||
[value]="portfolioDetails?.summary?.filteredValueInPercentage * 100"
|
||||
/>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
@ -270,23 +270,28 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
index,
|
||||
{
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
value,
|
||||
valueInPercentage
|
||||
netPerformanceInPercentageWithCurrencyEffect,
|
||||
totalInvestmentValueWithCurrencyEffect,
|
||||
valueInPercentage,
|
||||
valueWithCurrencyEffect
|
||||
}
|
||||
] of chart.entries()) {
|
||||
if (index > 0 || this.user?.settings?.dateRange === 'max') {
|
||||
// Ignore first item where value is 0
|
||||
this.investments.push({ date, investment: totalInvestment });
|
||||
this.investments.push({
|
||||
date,
|
||||
investment: totalInvestmentValueWithCurrencyEffect
|
||||
});
|
||||
this.performanceDataItems.push({
|
||||
date,
|
||||
value: isNumber(value) ? value : valueInPercentage
|
||||
value: isNumber(valueWithCurrencyEffect)
|
||||
? valueWithCurrencyEffect
|
||||
: valueInPercentage
|
||||
});
|
||||
}
|
||||
this.performanceDataItemsInPercentage.push({
|
||||
date,
|
||||
value: netPerformanceInPercentage
|
||||
value: netPerformanceInPercentageWithCurrencyEffect
|
||||
});
|
||||
}
|
||||
|
||||
@ -305,10 +310,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ positions }) => {
|
||||
const positionsSorted = sortBy(
|
||||
positions.filter(({ netPerformancePercentage }) => {
|
||||
return isNumber(netPerformancePercentage);
|
||||
positions.filter(({ netPerformancePercentageWithCurrencyEffect }) => {
|
||||
return isNumber(netPerformancePercentageWithCurrencyEffect);
|
||||
}),
|
||||
'netPerformancePercentage'
|
||||
'netPerformancePercentageWithCurrencyEffect'
|
||||
).reverse();
|
||||
|
||||
this.top3 = positionsSorted.slice(0, 3);
|
||||
|
@ -18,136 +18,147 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (user?.settings?.isExperimentalFeatures) {
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="d-flex py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Absolute Asset Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformance
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<mat-card appearance="outlined" class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="d-flex py-1">
|
||||
<div
|
||||
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
|
||||
>
|
||||
<span i18n>Absolute Asset Performance</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex mb-3 ml-3 py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Asset Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformancePercent
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformance
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Absolute Currency Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformanceWithCurrencyEffect ===
|
||||
null
|
||||
? null
|
||||
: performance?.currentNetPerformanceWithCurrencyEffect -
|
||||
performance?.currentNetPerformance
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex mb-3 ml-3 py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Asset Performance
|
||||
</div>
|
||||
<div class="d-flex ml-3 py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Currency Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformancePercent
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex py-1">
|
||||
<div
|
||||
class="align-items-center d-flex flex-grow-1 mr-2 text-truncate"
|
||||
>
|
||||
<span i18n>Absolute Currency Performance</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformance === null
|
||||
? null
|
||||
: performance?.currentNetPerformanceWithCurrencyEffect -
|
||||
performance?.currentNetPerformance
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex ml-3 py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Currency Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformancePercent === null
|
||||
? null
|
||||
: performance?.currentNetPerformancePercentWithCurrencyEffect -
|
||||
performance?.currentNetPerformancePercent
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div><hr /></div>
|
||||
<div class="d-flex py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Absolute Net Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformanceWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div><hr /></div>
|
||||
<div class="d-flex py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Absolute Net Performance
|
||||
</div>
|
||||
<div class="d-flex ml-3 py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Net Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformancePercentWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[isCurrency]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[unit]="user?.settings?.baseCurrency"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformanceWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex ml-3 py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate" i18n>
|
||||
Net Performance
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="
|
||||
isLoadingInvestmentChart
|
||||
? undefined
|
||||
: performance?.currentNetPerformancePercentWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mb-5 row">
|
||||
<div class="col-md-6">
|
||||
@ -177,7 +188,9 @@
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
[value]="
|
||||
position.netPerformancePercentageWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
@ -223,7 +236,9 @@
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
[value]="
|
||||
position.netPerformancePercentageWithCurrencyEffect
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</a>
|
||||
|
@ -3,11 +3,7 @@ import { PositionDetailDialog } from '@ghostfolio/client/components/position/pos
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { PortfolioPosition, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
@ -28,8 +24,6 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public holdings: PortfolioPosition[];
|
||||
public isLoading = false;
|
||||
public portfolioDetails: PortfolioDetails;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -83,12 +77,10 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.holdings = undefined;
|
||||
|
||||
this.fetchPortfolioDetails()
|
||||
this.fetchHoldings()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((portfolioDetails) => {
|
||||
this.portfolioDetails = portfolioDetails;
|
||||
|
||||
this.initialize();
|
||||
.subscribe(({ holdings }) => {
|
||||
this.holdings = holdings;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
@ -103,22 +95,12 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private fetchPortfolioDetails() {
|
||||
return this.dataService.fetchPortfolioDetails({
|
||||
private fetchHoldings() {
|
||||
return this.dataService.fetchPortfolioHoldings({
|
||||
filters: this.userService.getFilters()
|
||||
});
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.holdings = [];
|
||||
|
||||
for (const [symbol, holding] of Object.entries(
|
||||
this.portfolioDetails.holdings
|
||||
)) {
|
||||
this.holdings.push(holding);
|
||||
}
|
||||
}
|
||||
|
||||
private openPositionDialog({
|
||||
dataSource,
|
||||
symbol
|
||||
|
@ -14,6 +14,7 @@ import { Subject } from 'rxjs';
|
||||
export class ResourcesPageComponent implements OnInit {
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public info: InfoItem;
|
||||
public routerLinkFaq = ['/' + $localize`faq`];
|
||||
public routerLinkResourcesPersonalFinanceTools = [
|
||||
'/' + $localize`resources`,
|
||||
'personal-finance-tools'
|
||||
|
@ -2,6 +2,23 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<h1 class="d-none d-sm-block h3 mb-4 text-center" i18n>Resources</h1>
|
||||
<h2 class="h4 mb-3" i18n>Ghostfolio</h2>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
<div class="media-body">
|
||||
<h3 class="h5 mt-0">Frequently Asked Questions (FAQ)</h3>
|
||||
<div class="mb-1">
|
||||
Find quick answers to commonly asked questions about Ghostfolio in
|
||||
our Frequently Asked Questions (FAQ) section.
|
||||
</div>
|
||||
<div>
|
||||
<a [routerLink]="routerLinkFaq"
|
||||
>Frequently Asked Questions (FAQ) →</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 class="h4 mb-3" i18n>Guides</h2>
|
||||
<div class="mb-5">
|
||||
<div class="mb-4 media">
|
||||
|
@ -27,6 +27,7 @@ import {
|
||||
OAuthResponse,
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioHoldingsResponse,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
@ -389,13 +390,21 @@ export class DataService {
|
||||
}
|
||||
|
||||
public fetchPortfolioDetails({
|
||||
filters
|
||||
filters,
|
||||
withLiabilities = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
withLiabilities?: boolean;
|
||||
} = {}): Observable<PortfolioDetails> {
|
||||
let params = this.buildFiltersAsQueryParams({ filters });
|
||||
|
||||
if (withLiabilities) {
|
||||
params = params.append('withLiabilities', withLiabilities);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<any>('/api/v1/portfolio/details', {
|
||||
params: this.buildFiltersAsQueryParams({ filters })
|
||||
params
|
||||
})
|
||||
.pipe(
|
||||
map((response) => {
|
||||
@ -434,14 +443,56 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPortfolioHoldings({
|
||||
filters
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
} = {}) {
|
||||
return this.http
|
||||
.get<PortfolioHoldingsResponse>('/api/v1/portfolio/holdings', {
|
||||
params: this.buildFiltersAsQueryParams({ filters })
|
||||
})
|
||||
.pipe(
|
||||
map((response) => {
|
||||
if (response.holdings) {
|
||||
for (const symbol of Object.keys(response.holdings)) {
|
||||
response.holdings[symbol].assetClassLabel = translate(
|
||||
response.holdings[symbol].assetClass
|
||||
);
|
||||
|
||||
response.holdings[symbol].assetSubClassLabel = translate(
|
||||
response.holdings[symbol].assetSubClass
|
||||
);
|
||||
|
||||
response.holdings[symbol].dateOfFirstActivity = response.holdings[
|
||||
symbol
|
||||
].dateOfFirstActivity
|
||||
? parseISO(response.holdings[symbol].dateOfFirstActivity)
|
||||
: undefined;
|
||||
|
||||
response.holdings[symbol].value = isNumber(
|
||||
response.holdings[symbol].value
|
||||
)
|
||||
? response.holdings[symbol].value
|
||||
: response.holdings[symbol].valueInPercentage;
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPortfolioPerformance({
|
||||
filters,
|
||||
range,
|
||||
withExcludedAccounts = false
|
||||
withExcludedAccounts = false,
|
||||
withItems = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
range: DateRange;
|
||||
withExcludedAccounts?: boolean;
|
||||
withItems?: boolean;
|
||||
}): Observable<PortfolioPerformanceResponse> {
|
||||
let params = this.buildFiltersAsQueryParams({ filters });
|
||||
params = params.append('range', range);
|
||||
@ -450,6 +501,10 @@ export class DataService {
|
||||
params = params.append('withExcludedAccounts', withExcludedAccounts);
|
||||
}
|
||||
|
||||
if (withItems) {
|
||||
params = params.append('withItems', withItems);
|
||||
}
|
||||
|
||||
return this.http
|
||||
.get<any>(`/api/v2/portfolio/performance`, {
|
||||
params
|
||||
|
@ -5,7 +5,7 @@ import { parseDate as parseDateHelper } from '@ghostfolio/common/helper';
|
||||
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Account, DataSource, Type } from '@prisma/client';
|
||||
import { Account, DataSource, Type as ActivityType } from '@prisma/client';
|
||||
import { isFinite } from 'lodash';
|
||||
import { parse as csvToJson } from 'papaparse';
|
||||
import { EMPTY } from 'rxjs';
|
||||
@ -328,26 +328,26 @@ export class ImportActivitiesService {
|
||||
content: any[];
|
||||
index: number;
|
||||
item: any;
|
||||
}) {
|
||||
}): ActivityType {
|
||||
item = this.lowercaseKeys(item);
|
||||
|
||||
for (const key of ImportActivitiesService.TYPE_KEYS) {
|
||||
if (item[key]) {
|
||||
switch (item[key].toLowerCase()) {
|
||||
case 'buy':
|
||||
return Type.BUY;
|
||||
return 'BUY';
|
||||
case 'dividend':
|
||||
return Type.DIVIDEND;
|
||||
return 'DIVIDEND';
|
||||
case 'fee':
|
||||
return Type.FEE;
|
||||
return 'FEE';
|
||||
case 'interest':
|
||||
return Type.INTEREST;
|
||||
return 'INTEREST';
|
||||
case 'item':
|
||||
return Type.ITEM;
|
||||
return 'ITEM';
|
||||
case 'liability':
|
||||
return Type.LIABILITY;
|
||||
return 'LIABILITY';
|
||||
case 'sell':
|
||||
return Type.SELL;
|
||||
return 'SELL';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
{
|
||||
"createdAt": "2024-02-16T00:00:00.000Z",
|
||||
"createdAt": "2024-03-11T00:00:00.000Z",
|
||||
"data": [
|
||||
{
|
||||
"name": "Aptabase",
|
||||
@ -8,7 +8,7 @@
|
||||
},
|
||||
{
|
||||
"name": "Argos",
|
||||
"description": "Argos provides the developer tools to debug tests and detect visual regressions..",
|
||||
"description": "Argos provides the developer tools to debug tests and detect visual regressions.",
|
||||
"href": "https://argos-ci.com"
|
||||
},
|
||||
{
|
||||
@ -76,11 +76,6 @@
|
||||
"description": "Open-Source Webhooks-as-a-service (WaaS) that makes it easy for developers to send webhooks.",
|
||||
"href": "https://www.hook0.com"
|
||||
},
|
||||
{
|
||||
"name": "HTMX",
|
||||
"description": "HTMX is a dependency-free JavaScript library that allows you to access AJAX, CSS Transitions, WebSockets, and Server Sent Events directly in HTML.",
|
||||
"href": "https://htmx.org"
|
||||
},
|
||||
{
|
||||
"name": "Inbox Zero",
|
||||
"description": "Inbox Zero makes it easy to clean up your inbox and reach inbox zero fast. It provides bulk newsletter unsubscribe, cold email blocking, email analytics, and AI automations.",
|
||||
@ -116,11 +111,6 @@
|
||||
"description": "Open-source monitoring platform with beautiful status pages",
|
||||
"href": "https://www.openstatus.dev"
|
||||
},
|
||||
{
|
||||
"name": "Papermark",
|
||||
"description": "Open-Source Docsend Alternative to securely share documents with real-time analytics.",
|
||||
"href": "https://www.papermark.io"
|
||||
},
|
||||
{
|
||||
"name": "Prisma",
|
||||
"description": "Simplify working with databases. Build, optimize, and grow your app easily with an intuitive data model, type-safety, automated migrations, connection pooling, caching, and real-time db subscriptions.",
|
||||
@ -156,6 +146,11 @@
|
||||
"description": "The .NET Web Framework for Makers. Build production ready, full-stack web applications fast without sweating the small stuff.",
|
||||
"href": "https://spark-framework.net"
|
||||
},
|
||||
{
|
||||
"name": "Tiledesk",
|
||||
"description": "The innovative open-source framework for developing LLM-enabled chatbots, Tiledesk empowers developers to create advanced, conversational AI agents.",
|
||||
"href": "https://tiledesk.com"
|
||||
},
|
||||
{
|
||||
"name": "Tolgee",
|
||||
"description": "Software localization from A to Z made really easy.",
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -587,6 +587,10 @@ ngx-skeleton-loader {
|
||||
text-decoration: underline !important;
|
||||
}
|
||||
|
||||
.text-line-through {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.with-placeholder-as-option {
|
||||
.mat-mdc-select-placeholder {
|
||||
color: rgba(var(--dark-primary-text));
|
||||
|
@ -1,6 +1,6 @@
|
||||
import * as currencies from '@dinero.js/currencies';
|
||||
import { NumberParser } from '@internationalized/number';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource, MarketData, Type as ActivityType } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
getDate,
|
||||
@ -138,6 +138,10 @@ export function extractNumberFromString({
|
||||
}
|
||||
}
|
||||
|
||||
export function getAllActivityTypes(): ActivityType[] {
|
||||
return ['BUY', 'DIVIDEND', 'FEE', 'ITEM', 'LIABILITY', 'SELL'];
|
||||
}
|
||||
|
||||
export function getAssetProfileIdentifier({ dataSource, symbol }: UniqueAsset) {
|
||||
return `${dataSource}-${symbol}`;
|
||||
}
|
||||
@ -393,6 +397,6 @@ export function resolveMarketCondition(
|
||||
} else if (aMarketCondition === 'BEAR_MARKET') {
|
||||
return { emoji: '🐻' };
|
||||
} else {
|
||||
return { emoji: '⚪' };
|
||||
return { emoji: undefined };
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,5 @@
|
||||
import { Role } from '@prisma/client';
|
||||
|
||||
import { UniqueAsset } from './unique-asset.interface';
|
||||
|
||||
export interface AdminData {
|
||||
@ -16,6 +18,7 @@ export interface AdminData {
|
||||
engagement: number;
|
||||
id: string;
|
||||
lastActivity: Date;
|
||||
role: Role;
|
||||
transactionCount: number;
|
||||
}[];
|
||||
version: string;
|
||||
|
@ -5,7 +5,7 @@ export interface Export {
|
||||
date: string;
|
||||
version: string;
|
||||
};
|
||||
accounts: Omit<Account, 'createdAt' | 'isDefault' | 'updatedAt' | 'userId'>[];
|
||||
accounts: Omit<Account, 'createdAt' | 'updatedAt' | 'userId'>[];
|
||||
activities: (Omit<
|
||||
Order,
|
||||
| 'accountUserId'
|
||||
|
@ -40,6 +40,7 @@ import type { BenchmarkResponse } from './responses/benchmark-response.interface
|
||||
import type { ResponseError } from './responses/errors.interface';
|
||||
import type { ImportResponse } from './responses/import-response.interface';
|
||||
import type { OAuthResponse } from './responses/oauth-response.interface';
|
||||
import type { PortfolioHoldingsResponse } from './responses/portfolio-holdings-response.interface';
|
||||
import type { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
|
||||
import type { ScraperConfiguration } from './scraper-configuration.interface';
|
||||
import type { Statistics } from './statistics.interface';
|
||||
@ -81,6 +82,7 @@ export {
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioDividends,
|
||||
PortfolioHoldingsResponse,
|
||||
PortfolioInvestments,
|
||||
PortfolioItem,
|
||||
PortfolioOverview,
|
||||
|
@ -13,8 +13,6 @@ export interface PortfolioDetails {
|
||||
valueInPercentage?: number;
|
||||
};
|
||||
};
|
||||
filteredValueInBaseCurrency?: number;
|
||||
filteredValueInPercentage: number;
|
||||
holdings: { [symbol: string]: PortfolioPosition };
|
||||
platforms: {
|
||||
[id: string]: {
|
||||
@ -25,6 +23,5 @@ export interface PortfolioDetails {
|
||||
valueInPercentage?: number;
|
||||
};
|
||||
};
|
||||
summary: PortfolioSummary;
|
||||
totalValueInBaseCurrency?: number;
|
||||
summary?: PortfolioSummary;
|
||||
}
|
||||
|
@ -14,9 +14,12 @@ export interface PortfolioPosition {
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
dateOfFirstActivity: Date;
|
||||
dividend: number;
|
||||
exchange?: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
grossPerformancePercentWithCurrencyEffect: number;
|
||||
grossPerformanceWithCurrencyEffect: number;
|
||||
investment: number;
|
||||
marketChange?: number;
|
||||
marketChangePercent?: number;
|
||||
@ -27,6 +30,8 @@ export interface PortfolioPosition {
|
||||
name: string;
|
||||
netPerformance: number;
|
||||
netPerformancePercent: number;
|
||||
netPerformancePercentWithCurrencyEffect: number;
|
||||
netPerformanceWithCurrencyEffect: number;
|
||||
quantity: number;
|
||||
sectors: Sector[];
|
||||
symbol: string;
|
||||
|
@ -13,7 +13,7 @@ export interface PortfolioPublicDetails {
|
||||
| 'dateOfFirstActivity'
|
||||
| 'markets'
|
||||
| 'name'
|
||||
| 'netPerformancePercent'
|
||||
| 'netPerformancePercentWithCurrencyEffect'
|
||||
| 'sectors'
|
||||
| 'symbol'
|
||||
| 'url'
|
||||
|
@ -2,9 +2,10 @@ import { PortfolioPerformance } from './portfolio-performance.interface';
|
||||
|
||||
export interface PortfolioSummary extends PortfolioPerformance {
|
||||
annualizedPerformancePercent: number;
|
||||
annualizedPerformancePercentWithCurrencyEffect: number;
|
||||
cash: number;
|
||||
committedFunds: number;
|
||||
dividend: number;
|
||||
dividendInBaseCurrency: number;
|
||||
emergencyFund: {
|
||||
assets: number;
|
||||
cash: number;
|
||||
@ -12,13 +13,15 @@ export interface PortfolioSummary extends PortfolioPerformance {
|
||||
};
|
||||
excludedAccountsAndActivities: number;
|
||||
fees: number;
|
||||
filteredValueInBaseCurrency?: number;
|
||||
filteredValueInPercentage?: number;
|
||||
fireWealth: number;
|
||||
firstOrderDate: Date;
|
||||
interest: number;
|
||||
items: number;
|
||||
liabilities: number;
|
||||
netWorth: number;
|
||||
ordersCount: number;
|
||||
totalBuy: number;
|
||||
totalSell: number;
|
||||
totalValueInBaseCurrency?: number;
|
||||
}
|
||||
|
@ -18,6 +18,8 @@ export interface Position {
|
||||
name?: string;
|
||||
netPerformance?: number;
|
||||
netPerformancePercentage?: number;
|
||||
netPerformancePercentageWithCurrencyEffect?: number;
|
||||
netPerformanceWithCurrencyEffect?: number;
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioHoldingsResponse {
|
||||
holdings: PortfolioPosition[];
|
||||
}
|
@ -7,6 +7,8 @@ export interface SymbolMetrics {
|
||||
currentValuesWithCurrencyEffect: {
|
||||
[date: string]: Big;
|
||||
};
|
||||
dividend: Big;
|
||||
dividendInBaseCurrency: Big;
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
grossPerformancePercentageWithCurrencyEffect: Big;
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user