Compare commits

...

84 Commits

Author SHA1 Message Date
0581b8b9ec Release 2.68.0 (#3205) 2024-03-29 17:54:17 +01:00
63a61fb492 Move portfolio calculator (#3204)
* Move portfolio calculator
2024-03-29 17:47:44 +01:00
5788c6474e Refactor portfolio calculator (#3203)
* Refactor portfolio calculator

* Consume Activity[]
* Change computeTransactionPoints() to private
* Eliminate getTransactionPoints()

* Update changelog
2024-03-29 17:34:22 +01:00
5529fdc0ee Move transaction points to constructor (#3202) 2024-03-29 13:50:24 +01:00
88a9b518f6 Bugfix/fix issue with overridden names in activities table (#3200)
* Fix issue with overridden names

* Update changelog
2024-03-29 09:50:04 +01:00
98de2355c4 Feature/Support overriding name of asset profile dialog (#3199)
* Support overriding name of asset profile dialog

* Update changelog
2024-03-29 09:35:41 +01:00
b41eb60348 Fix chart tooltip of benchmark comparator (#3167)
* Fix chart tooltip of benchmark comparator

* Update changelog
2024-03-28 18:23:56 +01:00
0edebe30e1 Feature/Add user account currency to export (#3194)
* Add user account currency to export

* Update changelog
2024-03-27 18:08:34 +01:00
e3abe4feee Release 2.67.0 (#3197) 2024-03-26 17:45:32 +01:00
50391e199a Feature/improve generation of random strings (#3196)
* Replace Math.random() with crypto.randomBytes()

* Update changelog
2024-03-26 17:43:42 +01:00
a33f8d5bed Upgrade @types/big.js to version 6.2.2 (#3191)
* Upgrade @types/big.js to version 6.2.2

* Refactor imports
2024-03-25 08:34:30 +01:00
636be8441e Feature/upgrade zone.js to version 0.14.4 (#3184)
* Upgrade zone.js to version 0.14.4

* Update changelog
2024-03-24 10:30:06 +01:00
654dc2ba32 Feature/upgrade yahoo finance2 to version 2.11.0 (#3183)
* Upgrade yahoo-finance2 to version 2.11.0

* Update changelog
2024-03-24 09:50:45 +01:00
458ef169f4 Feature/add support for toncoin cryptocurrency (#3189)
* Add Toincoin

* Update changelog
2024-03-24 09:50:00 +01:00
5bb01bb03c Feature/upgrade ionicons to version 7.3.0 (#3182)
* Upgrade ionicons to version 7.3.0

* Update changelog
2024-03-24 09:09:09 +01:00
43e9528d8c Release 2.66.3 (#3188) 2024-03-23 20:56:22 +01:00
522c54c9b4 Release 2.66.2 (#3187) 2024-03-23 20:40:09 +01:00
0004ced4e1 Release 2.66.1 (#3186) 2024-03-23 20:26:10 +01:00
274c60e961 Release 2.66.0 (#3185) 2024-03-23 19:55:17 +01:00
754e98099c Feature/Set up tini (#3168)
* Set up tini to avoid zombie processes and perform signal forwarding in docker image

* Update changelog
2024-03-23 19:53:27 +01:00
87bf8df1c3 Bugfix/missing performance chart in presenter view (#3181)
* Fix missing performance chart in presenter view

* Update changelog
2024-03-23 19:41:28 +01:00
3f7d6b25c7 Feature/extend faq by backup strategy (#3180)
* Add backup strategy

* Update changelog
2024-03-23 19:10:23 +01:00
8a062e03ab Feature/add benchmark name to tooltip of benchmark comparator (#3177)
* Add benchmark name to chart tooltip

* Update changelog
2024-03-23 09:38:35 +01:00
a70f45cbf3 Feature/add index for data source symbol to market data table (#3179)
* Add index

* Update changelog
2024-03-23 09:23:59 +01:00
f268264c46 Feature/upgrade nx to version 18.1.2 (#3174)
* Upgrade angular and Nx dependencies

* Update changelog
2024-03-23 09:09:10 +01:00
bbe5d70720 Rename Twitter to X (#3009) 2024-03-21 15:10:35 +01:00
f1d2a52cba Release 2.65.0 (#3170) 2024-03-19 20:13:22 +01:00
87cc887865 Feature/Set meta theme color dynamically to respect appearance (#3129)
* Set meta theme color dynamically to respect appearance (dark mode)

* Update changelog
2024-03-19 20:11:34 +01:00
61ecd15f1d Feature/change edit button to a in admin market data page (#3164)
* Change edit button to a

* Update changelog
2024-03-19 19:55:08 +01:00
eb853f05ae Feature/add support to delete asset profile from dialog (#3165)
* Add support to delete asset profile from dialog

* Update changelog
2024-03-19 19:27:16 +01:00
6285417903 Feature/change grant private access with permissions to general availability (#3169)
* Change grant private access with permissions from experimental to general availability

* Update changelog
2024-03-19 19:25:43 +01:00
ca674a654e Feature/add symbol and isin to position detail dialog (#3163)
* Add symbol and ISIN

* Update translations

* Update changelog
2024-03-18 13:58:46 +01:00
2729c5651f Release 2.64.0 (#3161) 2024-03-16 19:00:50 +01:00
7e28e42995 Feature/exclude fees from holdings (#3160) 2024-03-16 18:59:23 +01:00
e21563d903 Feature/increase timeout to load benchmarks (#3158)
* Increase request timeout

* Update changelog
2024-03-16 18:15:34 +01:00
3ede69650c Feature/upgrade prisma to version 5.11.0 (#3159)
* Upgrade prisma to version 5.11.0

* Update changelog
2024-03-16 16:51:07 +01:00
c289793c6d Feature/switch between active and closed holdings (#3146)
* Switch between active and closed holdings on the portfolio holdings page

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-03-16 14:20:58 +01:00
a90c067da0 Clean up (#3157) 2024-03-16 13:40:23 +01:00
38c2baf943 Feature/improve exception handling of current investments in various rules (#3156)
* Improve exception handling

* Update changelog
2024-03-16 12:29:21 +01:00
82c78cad6b Format (#3155) 2024-03-16 12:28:58 +01:00
bffe6060bd Pass portfolio calculator to getChart() (#3153) 2024-03-16 10:05:16 +01:00
841bd5c33f Bugfix/fix dividend accumulation in symbol metrics (#3152)
* Fix total dividend calculation

* Update changelog
2024-03-16 10:04:57 +01:00
3b895afc9e Enable account balance update for fee and interest activities (#3145)
* Enable account balance update for fee and interest activities

* Update changelog
2024-03-15 18:56:17 +01:00
00c2ede85e Feature/improve usability of platform and tag management (#3144)
* Improve usability

* Update changelog
2024-03-15 08:37:41 +01:00
8420cb830c Feature/add product roadmap to faq (#3143)
* Add product roadmap

* Update changelog
2024-03-14 14:09:20 +01:00
a0ddd1f9b9 Fix date conversion in import of historical market data (#3117)
* Fix date conversion in import of historical market data

* Update changelog
2024-03-13 20:44:33 +01:00
40d93066ff Introduce .env.dev (#3120) 2024-03-13 20:21:54 +01:00
671e4e316b Release 2.63.2 (#3138) 2024-03-12 20:50:32 +01:00
473136e9aa Release 2.63.1 (#3135)
* Release 2.63.1
2024-03-11 21:35:43 +01:00
9a3db91982 Release 2.63.0 (#3134) 2024-03-11 20:19:23 +01:00
d23cb5f190 Feature/upgrade simplewebauthn dependencies to version 9.0 (#3130)
* Upgrade @simplewebauthn/browser and @simplewebauthn/server to version 9.0

* Update changelog
2024-03-11 20:17:47 +01:00
7a364472c8 Bugfix/fix liability issue in allocations (#3133)
* Remove liabilities from allocations calculation

* Update changelog
2024-03-11 20:16:56 +01:00
59c064e3c8 Feature/upgrade yahoo finance2 to version 2.10.0 (#3127)
* Upgrade yahoo-finance2 to version 2.10.0

* Update changelog
2024-03-11 20:15:55 +01:00
e792924606 Update OSS friends (#3132) 2024-03-11 20:15:32 +01:00
d32dd5e860 Feature/upgrade countries list to version 3.1.0 (#3131)
* Upgrade countries-list to version 3.1.0

* Update changelog
2024-03-11 19:16:20 +01:00
bb86f85203 Feature/add available home server systems to faq (#3126)
* Add available home server systems

* Update changelog

* Add CasaOS to README.md
2024-03-10 09:50:43 +01:00
0bca8897d6 Fix average price calculation by only considering BUY transactions (#3125)
* Fix average price calculation by only considering buy transactions

* Update changelog
2024-03-10 09:35:47 +01:00
ba73f6de2e Release 2.62.0 (#3124) 2024-03-09 19:57:56 +01:00
eb75be8535 Optimize details endpoint (#3123)
* Make summary optional

* Introduce dedicated holdings endpoint

* Update changelog
2024-03-09 19:56:26 +01:00
6d2a897366 Refactor orders with activities (#3122) 2024-03-09 17:17:52 +01:00
d8bfb23f20 Refactor reduce() with getSum() (#3121) 2024-03-09 16:53:59 +01:00
d9d71e7827 Fix issue with removing account from activity (#3112)
* Fix issue with removing account from activity

* Update changelog

---------

Co-authored-by: Thomas Kaul <4159106+dtslvr@users.noreply.github.com>
2024-03-09 15:52:05 +01:00
b642ce08e5 Refactor item type (#3119) 2024-03-09 12:32:56 +01:00
bc8d8309d4 Improve handling of future liabilities (#3118)
* Improve handling of future liabilities

* Refactor currentValue to currentValueInBaseCurrency

* Update changelog
2024-03-09 11:07:01 +01:00
1f2f9f22f2 Feature/remove environment variable web auth rp (#3115)
* Remove environment variable WEB_AUTH_RP_ID

* Update changelog
2024-03-08 19:00:21 +01:00
7a3237f1ff Adapt style of inactive users (#3114) 2024-03-08 18:59:23 +01:00
07661d9262 Feature/integrate dividend into transaction point concept (#3092)
* Integrate dividend into transaction point concept

* Update changelog
2024-03-07 20:07:50 +01:00
77358eed65 Feature/Include user role in admin endpoint (#3107)
* Include user role in admin endpoint
2024-03-07 19:38:57 +01:00
c641c28b12 Release 2.61.1 (#3110) 2024-03-06 22:08:00 +01:00
c54392b7bb Bugfix/fix exception in account value calculation (#3109)
* Fix exception in value of account calculation caused by liabilities

* Update changelog
2024-03-06 22:06:27 +01:00
f3a8822a77 Feature/remove-v-from-version-in-admin-endpoint (#3101)
* Remove "v" from version in admin endpoint
2024-03-06 11:11:10 +01:00
f1dc075c36 Update translations (#3093) 2024-03-05 10:16:59 +01:00
144d831954 Release 2.61.0 (#3097) 2024-03-04 20:17:05 +01:00
c37ad9bad4 Bugfix/fix activities import (#3095)
* Fix query parameter handling of booleans

* Update changelog
2024-03-04 20:15:41 +01:00
4ab3f81384 Extract getFactor() (#3089)
* Extract getFactor()

* Refactoring
2024-03-03 20:04:49 +01:00
b932bac9aa Feature/optimize summary calculation (#3088)
* Optimize calculation

* Update changelog
2024-03-03 08:24:51 +01:00
bcdd873222 Add missing title (#3087) 2024-03-02 17:32:32 +01:00
25b3de5828 Release 2.60.0 (#3086) 2024-03-02 14:44:00 +01:00
40b454d2f3 Feature/refresh cryptocurrencies list 20240302 (#3085)
* Update cryptocurrencies.json

* Add UNI7083

* Update changelog
2024-03-02 14:42:40 +01:00
5596e5f03b Feature/integrate wealth items into transaction point concept (#3084)
* Integrate (wealth) items into transaction point concept

* Update changelog
2024-03-02 14:29:03 +01:00
66992ef915 Bugfix/change show condition of button to fetch current market price (#3079)
* Change show condition of button to fetch current market price

* Update changelog
2024-03-02 12:59:54 +01:00
7f67430685 Bugfix/readd value in base currency to activity (#3078)
* Readd valueInBaseCurrency

* Update changelog
2024-03-02 10:03:10 +01:00
8a49a04324 Feature/improve usability of benchmarks in markets overview (#3077)
* Improve icons, localize label

* Update changelog
2024-03-02 09:48:53 +01:00
5d7c19b0ed Fix typo (#3076) 2024-03-02 09:42:41 +01:00
144 changed files with 5709 additions and 3066 deletions

25
.env.dev Normal file
View File

@ -0,0 +1,25 @@
COMPOSE_PROJECT_NAME=ghostfolio-development
# CACHE
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
# POSTGRES
POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
# DEVELOPMENT
# Nx 18 enables using plugins to infer targets by default
# This is disabled for existing workspaces to maintain compatibility
# For more info, see: https://nx.dev/concepts/inferred-tasks
NX_ADD_PLUGINS=false
NX_NATIVE_COMMAND_RUNNER=false

View File

@ -1,4 +1,4 @@
COMPOSE_PROJECT_NAME=ghostfolio-development
COMPOSE_PROJECT_NAME=ghostfolio
# CACHE
REDIS_HOST=localhost
@ -10,6 +10,7 @@ POSTGRES_DB=ghostfolio-db
POSTGRES_USER=user
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
# VARIOUS
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>

View File

@ -5,6 +5,154 @@ 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.68.0 - 2024-03-29
### Added
- Extended the export functionality by the user accounts currency
- Added support to override the name of an asset profile in the asset profile details dialog of the admin control
### Changed
- Optimized the portfolio calculations
### Fixed
- Fixed the chart tooltip of the benchmark comparator
- Fixed an issue with names in the activities table on the portfolio activities page while using symbol profile overrides
## 2.67.0 - 2024-03-26
### Added
- Added support for the cryptocurrency _Toncoin_ (`TON11419-USD`)
### Changed
- Replaced `Math.random()` with `crypto.randomBytes()` for generating cryptographically secure random strings
- Upgraded `ionicons` from version `7.1.0` to `7.3.0`
- Upgraded `yahoo-finance2` from version `2.10.0` to `2.11.0`
- Upgraded `zone.js` from version `0.14.3` to `0.14.4`
## 2.66.3 - 2024-03-23
### Added
- Extended the content of the _SaaS_ and _Self-Hosting_ sections by the backup strategy on the Frequently Asked Questions (FAQ) page
- Added an index for `dataSource` / `symbol` to the market data database table
### Changed
- Improved the chart tooltip of the benchmark comparator by adding the benchmark name
- Upgraded `angular` from version `17.1.3` to `17.2.4`
- Upgraded `Nx` from version `18.0.4` to `18.1.2`
### Fixed
- Fixed the missing portfolio performance chart in the _Presenter View_ / _Zen Mode_
## 2.65.0 - 2024-03-19
### Added
- Added the symbol and ISIN number to the position detail dialog
- Added support to delete an asset profile in the asset profile details dialog of the admin control
### Changed
- Moved the support to grant private access with permissions from experimental to general availability
- Set the meta theme color dynamically to respect the appearance (dark mode)
- Improved the usability to edit market data in the admin control panel
## 2.64.0 - 2024-03-16
### Added
- Added a toggle to switch between active and closed holdings on the portfolio holdings page
- Added support to update the cash balance of an account when adding a fee activity
- Added support to update the cash balance of an account when adding an interest activity
- Extended the content of the _General_ section by the product roadmap on the Frequently Asked Questions (FAQ) page
### Changed
- Improved the usability of the platform management in the admin control panel
- Improved the usability of the tag management in the admin control panel
- Improved the exception handling of various rules in the _X-ray_ section
- Increased the timeout to load benchmarks
- Upgraded `prisma` from version `5.10.2` to `5.11.0`
### Fixed
- Fixed an issue in the dividend calculation of the portfolio holdings
- Fixed the date conversion of the import of historical market data in the admin control panel
## 2.63.2 - 2024-03-12
### Added
- Extended the content of the _Self-Hosting_ section by available home server systems on the Frequently Asked Questions (FAQ) page
- Added support for the cryptocurrency _Real Smurf Cat_ (`SMURFCAT-USD`)
### Changed
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `8.3` to `9.0`
- Upgraded `countries-list` from version `2.6.1` to `3.1.0`
- Upgraded `yahoo-finance2` from version `2.9.1` to `2.10.0`
### Fixed
- Fixed an issue in the performance calculation caused by multiple `SELL` activities on the same day
- Fixed an issue in the calculation on the allocations page caused by liabilities
- Fixed an issue with the currency in the request to get quotes from _EOD Historical Data_
## 2.62.0 - 2024-03-09
### Changed
- Optimized the calculation of the accounts table
- Optimized the calculation of the portfolio holdings
- Integrated dividend into the transaction point concept in the portfolio service
- Removed the environment variable `WEB_AUTH_RP_ID`
### Fixed
- Fixed an issue in the calculation of the portfolio summary caused by future liabilities
- Fixed an issue with removing a linked account from a (wealth) item activity
## 2.61.1 - 2024-03-06
### Fixed
- Fixed an issue in the account value calculation caused by liabilities
## 2.61.0 - 2024-03-04
### Changed
- Optimized the calculation of the portfolio summary
### Fixed
- Fixed the activities import (query parameter handling)
## 2.60.0 - 2024-03-02
### Added
- Added support for the cryptocurrency _Uniswap_ (`UNI7083-USD`)
### Changed
- Improved the usability of the benchmarks in the markets overview
- Integrated (wealth) items into the transaction point concept in the portfolio service
- Refreshed the cryptocurrencies list
### Fixed
- Fixed a missing value in the activities table on mobile
- Fixed a missing value on the public page
- Displayed the button to fetch the current market price only if the activity is from today
## 2.59.0 - 2024-02-29
### Added

View File

@ -7,7 +7,7 @@
**Open Source Wealth Management Software**
[**Ghostfol.io**](https://ghostfol.io) | [**Live Demo**](https://ghostfol.io/en/demo) | [**Ghostfolio Premium**](https://ghostfol.io/en/pricing) | [**FAQ**](https://ghostfol.io/en/faq) |
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**Twitter**](https://twitter.com/ghostfolio_)
[**Blog**](https://ghostfol.io/en/blog) | [**Slack**](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) | [**X**](https://twitter.com/ghostfolio_)
[![Shield: Buy me a coffee](https://img.shields.io/badge/Buy%20me%20a%20coffee-Support-yellow?logo=buymeacoffee)](https://www.buymeacoffee.com/ghostfolio)
[![Shield: Contributions Welcome](https://img.shields.io/badge/Contributions-Welcome-orange.svg)](#contributing)
@ -144,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
@ -154,7 +154,7 @@ Ghostfolio is available for various home server systems, including [Runtipi](htt
- [Node.js](https://nodejs.org/en/download) (version 18+)
- [Yarn](https://yarnpkg.com/en/docs/install)
- Create a local copy of this Git repository (clone)
- Copy the file `.env.example` to `.env` and populate it with your data (`cp .env.example .env`)
- Copy the file `.env.dev` to `.env` and populate it with your data (`cp .env.dev .env`)
### Setup

View File

@ -13,7 +13,6 @@ export default {
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../coverage/apps/api',
testTimeout: 10000,
testEnvironment: 'node',
preset: '../../jest.preset.js'
};

View File

@ -83,7 +83,7 @@ export class AccessController {
}
try {
return await this.accessService.createAccess({
return this.accessService.createAccess({
alias: data.alias || undefined,
GranteeUser: data.granteeUserId
? { connect: { id: data.granteeUserId } }

View File

@ -5,7 +5,7 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { Big } from 'big.js';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';

View File

@ -339,6 +339,6 @@ export class AdminController {
@Param('key') key: string,
@Body() data: PropertyDto
) {
return await this.adminService.putSetting(key, data.value);
return this.adminService.putSetting(key, data.value);
}
}

View File

@ -71,7 +71,7 @@ export class AdminService {
);
}
return await this.symbolProfileService.add(
return this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
} catch (error) {
@ -331,19 +331,35 @@ export class AdminService {
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping
});
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput & UniqueAsset =
{
assetClass,
assetSubClass,
comment,
countries,
currency,
dataSource,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
...(dataSource === 'MANUAL'
? { name }
: {
SymbolProfileOverrides: {
upsert: {
create: {
name: name as string
},
update: {
name: name as string
}
}
}
})
};
await this.symbolProfileService.updateSymbolProfile(updatedSymbolProfile);
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles([
{
@ -440,13 +456,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 +483,7 @@ export class AdminService {
createdAt,
engagement,
id,
role,
subscription,
accountCount: _count.Account || 0,
country: Analytics?.country,

View File

@ -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() {

View File

@ -13,7 +13,8 @@ import {
import {
DATE_FORMAT,
calculateBenchmarkTrend,
parseDate
parseDate,
resetHours
} from '@ghostfolio/common/helper';
import {
Benchmark,
@ -26,8 +27,14 @@ import { BenchmarkTrend } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { format, isSameDay, subDays } from 'date-fns';
import { Big } from 'big.js';
import {
differenceInDays,
eachDayOfInterval,
format,
isSameDay,
subDays
} from 'date-fns';
import { isNumber, last, uniqBy } from 'lodash';
import ms from 'ms';
@ -110,7 +117,9 @@ export class BenchmarkService {
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
}),
requestTimeout: ms('30 seconds'),
useCache: false
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
@ -163,7 +172,7 @@ export class BenchmarkService {
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks),
ms('4 hours') / 1000
ms('2 hours') / 1000
);
}
@ -206,15 +215,28 @@ export class BenchmarkService {
public async getMarketDataBySymbol({
dataSource,
endDate = new Date(),
startDate,
symbol,
userCurrency
}: {
endDate?: Date;
startDate: Date;
userCurrency: string;
} & UniqueAsset): Promise<BenchmarkMarketDataDetails> {
const marketData: { date: string; value: number }[] = [];
const days = differenceInDays(endDate, startDate) + 1;
const dates = eachDayOfInterval(
{
start: startDate,
end: endDate
},
{ step: Math.round(days / Math.min(days, MAX_CHART_ITEMS)) }
).map((date) => {
return resetHours(date);
});
const [currentSymbolItem, marketDataItems] = await Promise.all([
this.symbolService.get({
dataGatheringItem: {
@ -230,7 +252,7 @@ export class BenchmarkService {
dataSource,
symbol,
date: {
gte: startDate
in: dates
}
}
})
@ -264,17 +286,7 @@ export class BenchmarkService {
return { marketData };
}
const step = Math.round(
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
);
let i = 0;
for (let marketDataItem of marketDataItems) {
if (i % step !== 0) {
continue;
}
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(marketDataItem.date, DATE_FORMAT)
@ -297,15 +309,15 @@ export class BenchmarkService {
});
}
const includesToday = isSameDay(
const includesEndDate = isSameDay(
parseDate(last(marketData).date),
new Date()
endDate
);
if (currentSymbolItem?.marketPrice && !includesToday) {
if (currentSymbolItem?.marketPrice && !includesEndDate) {
const exchangeRate =
exchangeRates[`${currentSymbolItem.currency}${userCurrency}`]?.[
format(new Date(), DATE_FORMAT)
format(endDate, DATE_FORMAT)
];
const exchangeRateFactor =
@ -314,7 +326,7 @@ export class BenchmarkService {
: 1;
marketData.push({
date: format(new Date(), DATE_FORMAT),
date: format(endDate, DATE_FORMAT),
value:
this.calculateChangeInPercentage(
marketPriceAtStartDate,

View File

@ -95,7 +95,10 @@ export class ExportService {
: SymbolProfile.symbol
};
}
)
),
user: {
settings: { currency: userCurrency }
}
};
}
}

View File

@ -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)
) {

View File

@ -27,7 +27,7 @@ import {
import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { Big } from 'big.js';
import { endOfToday, format, isAfter, isSameSecond, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@ -117,7 +117,7 @@ export class ImportService {
feeInBaseCurrency: 0,
id: assetProfile.id,
isDraft: false,
SymbolProfile: <SymbolProfile>(<unknown>assetProfile),
SymbolProfile: assetProfile,
symbolProfileId: assetProfile.id,
type: 'DIVIDEND',
unitPrice: marketPrice,
@ -521,22 +521,14 @@ export class ImportService {
currency,
dataSource,
symbol,
assetClass: null,
assetSubClass: null,
comment: null,
countries: null,
activitiesCount: undefined,
assetClass: undefined,
assetSubClass: undefined,
countries: undefined,
createdAt: undefined,
figi: null,
figiComposite: null,
figiShareClass: null,
id: undefined,
isin: null,
name: null,
scraperConfiguration: null,
sectors: null,
symbolMapping: null,
updatedAt: undefined,
url: null
sectors: undefined,
updatedAt: undefined
}
};
}

View File

@ -1,14 +1,22 @@
import { OrderWithAccount } from '@ghostfolio/common/types';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { AccountWithPlatform } from '@ghostfolio/common/types';
import { Order, Tag } from '@prisma/client';
export interface Activities {
activities: Activity[];
count: number;
}
export interface Activity extends OrderWithAccount {
export interface Activity extends Order {
Account?: AccountWithPlatform;
error?: ActivityError;
feeInBaseCurrency: number;
SymbolProfile?: EnhancedSymbolProfile;
tags?: Tag[];
updateAccountBalance?: boolean;
value: number;
valueInBaseCurrency: number;
}
export interface ActivityError {

View File

@ -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,11 +19,11 @@ import {
Order,
Prisma,
Tag,
Type as TypeOfOrder
Type as ActivityType
} from '@prisma/client';
import Big from 'big.js';
import { Big } from 'big.js';
import { endOfToday, isAfter } from 'date-fns';
import { groupBy } from 'lodash';
import { groupBy, uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { Activities } from './interfaces/activities.interface';
@ -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: {
@ -157,7 +148,7 @@ export class OrderService {
.plus(data.fee)
.toNumber();
if (data.type === 'BUY') {
if (['BUY', 'FEE'].includes(data.type)) {
amount = new Big(amount).mul(-1).toNumber();
}
@ -180,12 +171,7 @@ export class OrderService {
where
});
if (
order.type === 'FEE' ||
order.type === 'INTEREST' ||
order.type === 'ITEM' ||
order.type === 'LIABILITY'
) {
if (['FEE', 'INTEREST', 'ITEM', 'LIABILITY'].includes(order.type)) {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
@ -200,6 +186,17 @@ export class OrderService {
return count;
}
public async getLatestOrder({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.order.findFirst({
orderBy: {
date: 'desc'
},
where: {
SymbolProfile: { dataSource, symbol }
}
});
}
public async getOrders({
filters,
includeDrafts = false,
@ -218,7 +215,7 @@ export class OrderService {
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
types?: TypeOfOrder[];
types?: ActivityType[];
userCurrency: string;
userId: string;
withExcludedAccounts?: boolean;
@ -323,12 +320,50 @@ export class OrderService {
this.prismaService.order.count({ where })
]);
const uniqueAssets = uniqBy(
orders.map(({ SymbolProfile }) => {
return {
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
}),
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({
dataSource,
symbol
});
}
);
const assetProfiles =
await this.symbolProfileService.getSymbolProfiles(uniqueAssets);
const activities = orders.map((order) => {
const assetProfile = assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === order.SymbolProfile.dataSource &&
symbol === order.SymbolProfile.symbol
);
});
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return {
...order,
value
value,
// TODO: Use exchange rate of date
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.SymbolProfile.currency,
userCurrency
),
SymbolProfile: assetProfile,
// TODO: Use exchange rate of date
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.SymbolProfile.currency,
userCurrency
)
};
});
@ -354,13 +389,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;
}
@ -369,13 +401,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;

View File

@ -0,0 +1,171 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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,
activities: <Activity[]>[
{
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: 142.9
},
{
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 1,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
},
{
date: new Date('2021-11-30'),
fee: 0,
quantity: 1,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: 136.6
}
],
currency: 'CHF'
});
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 }
]);
});
});
});

View File

@ -1,10 +1,11 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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', () => {
@ -21,7 +22,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -36,35 +37,37 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
activities: <Activity[]>[
{
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',
date: new Date('2021-11-22'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: new Big(142.9)
unitPrice: 142.9
},
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.65),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
date: new Date('2021-11-30'),
fee: 1.65,
quantity: 2,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'SELL',
unitPrice: new Big(136.6)
unitPrice: 136.6
}
]
],
currency: 'CHF'
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
@ -87,7 +90,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 +110,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 +134,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'),

View File

@ -1,10 +1,11 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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', () => {
@ -21,7 +22,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -36,24 +37,24 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
activities: <Activity[]>[
{
currency: 'CHF',
date: '2021-11-30',
dataSource: 'YAHOO',
fee: new Big(1.55),
name: 'Bâloise Holding AG',
quantity: new Big(2),
symbol: 'BALN.SW',
date: new Date('2021-11-30'),
fee: 1.55,
quantity: 2,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Bâloise Holding AG',
symbol: 'BALN.SW'
},
type: 'BUY',
unitPrice: new Big(136.6)
unitPrice: 136.6
}
]
],
currency: 'CHF'
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
@ -76,7 +77,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 +97,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 +121,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'),

View File

@ -1,11 +1,12 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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', () => {
@ -34,7 +35,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -49,35 +50,37 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
activities: <Activity[]>[
{
currency: 'USD',
date: '2015-01-01',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(2),
symbol: 'BTCUSD',
date: new Date('2015-01-01'),
fee: 0,
quantity: 2,
SymbolProfile: {
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
type: 'BUY',
unitPrice: new Big(320.43)
unitPrice: 320.43
},
{
currency: 'USD',
date: '2017-12-31',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Bitcoin USD',
quantity: new Big(1),
symbol: 'BTCUSD',
date: new Date('2017-12-31'),
fee: 0,
quantity: 1,
SymbolProfile: {
currency: 'USD',
dataSource: 'YAHOO',
name: 'Bitcoin USD',
symbol: 'BTCUSD'
},
type: 'SELL',
unitPrice: new Big(14156.4)
unitPrice: 14156.4
}
]
],
currency: 'CHF'
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2018-01-01').getTime());
@ -100,7 +103,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 +123,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 +154,8 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big(
'636.79469348020066587024'
),
transactionCount: 2
transactionCount: 2,
valueInBaseCurrency: new Big('13298.425356')
}
],
totalInvestment: new Big('320.43'),

View File

@ -1,11 +1,12 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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', () => {
@ -34,7 +35,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -49,24 +50,24 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
activities: <Activity[]>[
{
currency: 'USD',
date: '2023-01-03',
dataSource: 'YAHOO',
fee: new Big(1),
name: 'Alphabet Inc.',
quantity: new Big(1),
symbol: 'GOOGL',
date: new Date('2023-01-03'),
fee: 1,
quantity: 1,
SymbolProfile: {
currency: 'USD',
dataSource: 'YAHOO',
name: 'Alphabet Inc.',
symbol: 'GOOGL'
},
type: 'BUY',
unitPrice: new Big(89.12)
unitPrice: 89.12
}
]
],
currency: 'CHF'
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2023-07-10').getTime());
@ -89,7 +90,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 +110,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 +135,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'),

View File

@ -0,0 +1,121 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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,
activities: <Activity[]>[
{
date: new Date('2021-09-16'),
fee: 19,
quantity: 1,
SymbolProfile: {
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'BUY',
unitPrice: 298.58
},
{
date: new Date('2021-11-16'),
fee: 0,
quantity: 1,
SymbolProfile: {
currency: 'USD',
dataSource: 'YAHOO',
name: 'Microsoft Inc.',
symbol: 'MSFT'
},
type: 'DIVIDEND',
unitPrice: 0.62
}
],
currency: 'USD'
});
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')
});
});
});
});

View File

@ -1,10 +1,11 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { Big } from 'big.js';
import { subDays } from 'date-fns';
import { CurrentRateServiceMock } from './current-rate.service.mock';
import { PortfolioCalculator } from './portfolio-calculator';
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => {
@ -21,7 +22,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -36,23 +37,20 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: []
activities: [],
currency: 'CHF'
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2021-12-18').getTime());
const chartData = await portfolioCalculator.getChartData({
start: new Date()
});
const start = subDays(new Date(Date.now()), 10);
const currentPositions = await portfolioCalculator.getCurrentPositions(
new Date()
);
const chartData = await portfolioCalculator.getChartData({ start });
const currentPositions =
await portfolioCalculator.getCurrentPositions(start);
const investments = portfolioCalculator.getInvestments();
@ -64,7 +62,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),

View File

@ -1,10 +1,11 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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', () => {
@ -21,7 +22,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -36,35 +37,37 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
activities: <Activity[]>[
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(1.3),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
date: new Date('2022-03-07'),
fee: 1.3,
quantity: 2,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'BUY',
unitPrice: new Big(75.8)
unitPrice: 75.8
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(2.95),
name: 'Novartis AG',
quantity: new Big(1),
symbol: 'NOVN.SW',
date: new Date('2022-04-08'),
fee: 2.95,
quantity: 1,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: new Big(85.73)
unitPrice: 85.73
}
]
],
currency: 'CHF'
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
@ -87,7 +90,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 +110,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 +136,8 @@ describe('PortfolioCalculator', () => {
timeWeightedInvestmentWithCurrencyEffect: new Big(
'145.10285714285714285714'
),
transactionCount: 2
transactionCount: 2,
valueInBaseCurrency: new Big('87.8')
}
],
totalInvestment: new Big('75.80'),

View File

@ -1,10 +1,11 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock';
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 { 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', () => {
@ -21,7 +22,7 @@ describe('PortfolioCalculator', () => {
let exchangeRateDataService: ExchangeRateDataService;
beforeEach(() => {
currentRateService = new CurrentRateService(null, null, null);
currentRateService = new CurrentRateService(null, null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
@ -36,35 +37,37 @@ describe('PortfolioCalculator', () => {
const portfolioCalculator = new PortfolioCalculator({
currentRateService,
exchangeRateDataService,
currency: 'CHF',
orders: [
activities: <Activity[]>[
{
currency: 'CHF',
date: '2022-03-07',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
date: new Date('2022-03-07'),
fee: 0,
quantity: 2,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'BUY',
unitPrice: new Big(75.8)
unitPrice: 75.8
},
{
currency: 'CHF',
date: '2022-04-08',
dataSource: 'YAHOO',
fee: new Big(0),
name: 'Novartis AG',
quantity: new Big(2),
symbol: 'NOVN.SW',
date: new Date('2022-04-08'),
fee: 0,
quantity: 2,
SymbolProfile: {
currency: 'CHF',
dataSource: 'YAHOO',
name: 'Novartis AG',
symbol: 'NOVN.SW'
},
type: 'SELL',
unitPrice: new Big(85.73)
unitPrice: 85.73
}
]
],
currency: 'CHF'
});
portfolioCalculator.computeTransactionPoints();
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => parseDate('2022-04-11').getTime());
@ -113,7 +116,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 +136,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 +160,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'),

View File

@ -1,8 +1,8 @@
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import Big from 'big.js';
import { Big } from 'big.js';
import { CurrentRateService } from './current-rate.service';
import { PortfolioCalculator } from './portfolio-calculator';
describe('PortfolioCalculator', () => {
@ -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,
@ -22,10 +22,10 @@ describe('PortfolioCalculator', () => {
describe('annualized performance percentage', () => {
const portfolioCalculator = new PortfolioCalculator({
activities: [],
currentRateService,
exchangeRateDataService,
currency: 'USD',
orders: []
currency: 'USD'
});
it('Get annualized performance', async () => {

View File

@ -1,3 +1,11 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { PortfolioOrderItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { 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';
@ -7,32 +15,27 @@ import {
InvestmentItem,
ResponseError,
SymbolMetrics,
TimelinePosition
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { GroupBy } from '@ghostfolio/common/types';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { Big } from 'big.js';
import {
addDays,
addMilliseconds,
differenceInDays,
eachDayOfInterval,
endOfDay,
format,
isBefore,
isSameDay,
max,
subDays
} from 'date-fns';
import { cloneDeep, first, isNumber, last, sortBy, uniq } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator {
private static readonly ENABLE_LOGGING = false;
@ -44,108 +47,37 @@ export class PortfolioCalculator {
private transactionPoints: TransactionPoint[];
public constructor({
activities,
currency,
currentRateService,
exchangeRateDataService,
orders
exchangeRateDataService
}: {
activities: Activity[];
currency: string;
currentRateService: CurrentRateService;
exchangeRateDataService: ExchangeRateDataService;
orders: PortfolioOrder[];
}) {
this.currency = currency;
this.currentRateService = currentRateService;
this.exchangeRateDataService = exchangeRateDataService;
this.orders = orders;
this.orders = activities.map(
({ date, fee, quantity, SymbolProfile, type, unitPrice }) => {
return {
SymbolProfile,
type,
date: format(date, DATE_FORMAT),
fee: new Big(fee),
quantity: new Big(quantity),
unitPrice: new Big(unitPrice)
};
}
);
this.orders.sort((a, b) => {
return a.date?.localeCompare(b.date);
});
}
public computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const order of this.orders) {
const currentDate = order.date;
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[order.symbol];
const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
let investment = new Big(0);
if (newQuantity.gt(0)) {
if (order.type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
order.quantity.mul(unitPrice)
);
} else if (order.type === 'SELL') {
const averagePrice = oldAccumulatedSymbol.investment.div(
oldAccumulatedSymbol.quantity
);
investment = oldAccumulatedSymbol.investment.minus(
order.quantity.mul(averagePrice)
);
}
}
currentTransactionPointItem = {
investment,
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
quantity: newQuantity,
symbol: order.symbol,
tags: order.tags,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
currency: order.currency,
dataSource: order.dataSource,
fee: order.fee,
firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
tags: order.tags,
transactionCount: 1
};
}
symbols[order.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
);
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => {
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;
}
this.computeTransactionPoints();
}
public getAnnualizedPerformancePercent({
@ -165,14 +97,6 @@ export class PortfolioCalculator {
return new Big(0);
}
public getTransactionPoints(): TransactionPoint[] {
return this.transactionPoints;
}
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
this.transactionPoints = transactionPoints;
}
public async getChartData({
end = new Date(Date.now()),
start,
@ -190,29 +114,31 @@ export class PortfolioCalculator {
}) ?? [];
const currencies: { [symbol: string]: string } = {};
const dates: Date[] = [];
const dataGatheringItems: IDataGatheringItem[] = [];
const firstIndex = transactionPointsBeforeEndDate.length;
let day = start;
let dates = eachDayOfInterval({ start, end }, { step }).map((date) => {
return resetHours(date);
});
while (isBefore(day, end)) {
dates.push(resetHours(day));
day = addDays(day, step);
}
const includesEndDate = isSameDay(last(dates), end);
if (!isSameDay(last(dates), end)) {
if (!includesEndDate) {
dates.push(resetHours(end));
}
if (transactionPointsBeforeEndDate.length > 0) {
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
for (const {
currency,
dataSource,
symbol
} of transactionPointsBeforeEndDate[firstIndex - 1].items) {
dataGatheringItems.push({
dataSource: item.dataSource,
symbol: item.symbol
dataSource,
symbol
});
currencies[item.symbol] = item.currency;
symbols[item.symbol] = true;
currencies[symbol] = currency;
symbols[symbol] = true;
}
}
@ -234,7 +160,7 @@ export class PortfolioCalculator {
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
endDate: endOfDay(end),
startDate: parseDate(this.transactionPoints?.[0]?.date),
startDate: this.getStartDate(),
targetCurrency: this.currency
});
@ -295,6 +221,7 @@ export class PortfolioCalculator {
start,
step,
symbol,
dataSource: null,
exchangeRates:
exchangeRatesByCurrency[`${currencies[symbol]}${this.currency}`],
isChartMode: true
@ -441,16 +368,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 +403,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,8 +463,8 @@ export class PortfolioCalculator {
let exchangeRatesByCurrency =
await this.exchangeRateDataService.getExchangeRatesByCurrency({
currencies: uniq(Object.values(currencies)),
endDate: endOfDay(end),
startDate: parseDate(this.transactionPoints?.[0]?.date),
endDate: endOfDay(endDate),
startDate: this.getStartDate(),
targetCurrency: this.currency
});
@ -562,7 +499,7 @@ export class PortfolioCalculator {
}
}
const endDateString = format(end, DATE_FORMAT);
const endDateString = format(endDate, DATE_FORMAT);
if (firstIndex > 0) {
firstIndex--;
@ -574,9 +511,9 @@ export class PortfolioCalculator {
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketPriceInBaseCurrency = marketSymbolMap[endDateString]?.[
item.symbol
]?.mul(
const marketPriceInBaseCurrency = (
marketSymbolMap[endDateString]?.[item.symbol] ?? item.averagePrice
).mul(
exchangeRatesByCurrency[`${item.currency}${this.currency}`]?.[
endDateString
]
@ -594,12 +531,15 @@ export class PortfolioCalculator {
netPerformanceWithCurrencyEffect,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
totalDividend,
totalDividendInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect
} = this.getSymbolMetrics({
end,
marketSymbolMap,
start,
dataSource: item.dataSource,
end: endDate,
exchangeRates:
exchangeRatesByCurrency[`${item.currency}${this.currency}`],
symbol: item.symbol
@ -608,11 +548,11 @@ export class PortfolioCalculator {
hasAnySymbolMetricsErrors = hasAnySymbolMetricsErrors || hasErrors;
positions.push({
dividend: totalDividend,
dividendInBaseCurrency: totalDividendInBaseCurrency,
timeWeightedInvestment,
timeWeightedInvestmentWithCurrencyEffect,
averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
averagePrice: item.averagePrice,
currency: item.currency,
dataSource: item.dataSource,
fee: item.fee,
@ -646,7 +586,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 +658,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 +670,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 +729,7 @@ export class PortfolioCalculator {
}
return {
currentValue,
currentValueInBaseCurrency,
grossPerformance,
grossPerformanceWithCurrencyEffect,
hasErrors,
@ -820,25 +758,116 @@ export class PortfolioCalculator {
};
}
private getFactor(type: TypeOfOrder) {
let factor: number;
public getStartDate() {
return this.transactionPoints.length > 0
? parseDate(this.transactionPoints[0].date)
: new Date();
}
switch (type) {
case 'BUY':
factor = 1;
break;
case 'SELL':
factor = -1;
break;
default:
factor = 0;
break;
public getTransactionPoints() {
return this.transactionPoints;
}
private computeTransactionPoints() {
this.transactionPoints = [];
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
let lastDate: string = null;
let lastTransactionPoint: TransactionPoint = null;
for (const {
fee,
date,
quantity,
SymbolProfile,
tags,
type,
unitPrice
} of this.orders) {
let currentTransactionPointItem: TransactionPointSymbol;
const oldAccumulatedSymbol = symbols[SymbolProfile.symbol];
const factor = getFactor(type);
if (oldAccumulatedSymbol) {
let investment = oldAccumulatedSymbol.investment;
const newQuantity = quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
if (type === 'BUY') {
investment = oldAccumulatedSymbol.investment.plus(
quantity.mul(unitPrice)
);
} else if (type === 'SELL') {
investment = oldAccumulatedSymbol.investment.minus(
quantity.mul(oldAccumulatedSymbol.averagePrice)
);
}
currentTransactionPointItem = {
investment,
tags,
averagePrice: newQuantity.gt(0)
? investment.div(newQuantity)
: new Big(0),
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0),
fee: fee.plus(oldAccumulatedSymbol.fee),
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
quantity: newQuantity,
symbol: SymbolProfile.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
currentTransactionPointItem = {
fee,
tags,
averagePrice: unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
dividend: new Big(0),
firstBuyDate: date,
investment: unitPrice.mul(quantity).mul(factor),
quantity: quantity.mul(factor),
symbol: SymbolProfile.symbol,
transactionCount: 1
};
}
symbols[SymbolProfile.symbol] = currentTransactionPointItem;
const items = lastTransactionPoint?.items ?? [];
const newItems = items.filter(({ symbol }) => {
return symbol !== SymbolProfile.symbol;
});
newItems.push(currentTransactionPointItem);
newItems.sort((a, b) => {
return a.symbol?.localeCompare(b.symbol);
});
if (lastDate !== date || lastTransactionPoint === null) {
lastTransactionPoint = {
date,
items: newItems
};
this.transactionPoints.push(lastTransactionPoint);
} else {
lastTransactionPoint.items = newItems;
}
lastDate = date;
}
return factor;
}
private getSymbolMetrics({
dataSource,
end,
exchangeRates,
isChartMode = false,
@ -855,8 +884,7 @@ export class PortfolioCalculator {
};
start: Date;
step?: number;
symbol: string;
}): SymbolMetrics {
} & UniqueAsset): SymbolMetrics {
const currentExchangeRate = exchangeRates[format(new Date(), DATE_FORMAT)];
const currentValues: { [date: string]: Big } = {};
const currentValuesWithCurrencyEffect: { [date: string]: Big } = {};
@ -889,22 +917,21 @@ export class PortfolioCalculator {
[date: string]: Big;
} = {};
let totalDividend = new Big(0);
let totalDividendInBaseCurrency = new Big(0);
let totalInvestment = new Big(0);
let totalInvestmentFromBuyTransactions = new Big(0);
let totalInvestmentFromBuyTransactionsWithCurrencyEffect = new Big(0);
let totalInvestmentWithCurrencyEffect = new Big(0);
let totalInvestmentWithGrossPerformanceFromSell = new Big(0);
let totalInvestmentWithGrossPerformanceFromSellWithCurrencyEffect = new Big(
0
);
let totalQuantityFromBuyTransactions = new Big(0);
let totalUnits = new Big(0);
let valueAtStartDate: Big;
let valueAtStartDateWithCurrencyEffect: Big;
// Clone orders to keep the original values in this.orders
let orders: PortfolioOrderItem[] = cloneDeep(this.orders).filter(
(order) => {
return order.symbol === symbol;
({ SymbolProfile }) => {
return SymbolProfile.symbol === symbol;
}
);
@ -932,6 +959,8 @@ export class PortfolioCalculator {
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0)
};
@ -972,6 +1001,8 @@ export class PortfolioCalculator {
timeWeightedInvestmentValues: {},
timeWeightedInvestmentValuesWithCurrencyEffect: {},
timeWeightedInvestmentWithCurrencyEffect: new Big(0),
totalDividend: new Big(0),
totalDividendInBaseCurrency: new Big(0),
totalInvestment: new Big(0),
totalInvestmentWithCurrencyEffect: new Big(0)
};
@ -979,30 +1010,30 @@ export class PortfolioCalculator {
// Add a synthetic order at the start and the end date
orders.push({
symbol,
currency: null,
date: format(start, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'start',
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice: unitPriceAtStartDate
});
orders.push({
symbol,
currency: null,
date: format(end, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
itemType: 'end',
name: '',
SymbolProfile: {
dataSource,
symbol
},
quantity: new Big(0),
type: TypeOfOrder.BUY,
type: 'BUY',
unitPrice: unitPriceAtEndDate
});
@ -1021,15 +1052,15 @@ export class PortfolioCalculator {
if (!hasDate) {
orders.push({
symbol,
currency: null,
date: format(day, DATE_FORMAT),
dataSource: null,
fee: new Big(0),
feeInBaseCurrency: new Big(0),
name: '',
quantity: new Big(0),
type: TypeOfOrder.BUY,
SymbolProfile: {
dataSource,
symbol
},
type: 'BUY',
unitPrice:
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
lastUnitPrice
@ -1042,28 +1073,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 +1155,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 +1243,16 @@ export class PortfolioCalculator {
order.feeInBaseCurrencyWithCurrencyEffect ?? 0
);
totalUnits = totalUnits.plus(
order.quantity.mul(this.getFactor(order.type))
);
totalUnits = totalUnits.plus(order.quantity.mul(getFactor(order.type)));
if (order.type === 'DIVIDEND') {
const dividend = order.quantity.mul(order.unitPrice);
totalDividend = totalDividend.plus(dividend);
totalDividendInBaseCurrency = totalDividendInBaseCurrency.plus(
dividend.mul(exchangeRateAtOrderDate ?? 1)
);
}
const valueOfInvestment = totalUnits.mul(order.unitPriceInBaseCurrency);
@ -1213,14 +1261,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 +1283,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 +1331,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 +1343,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 +1524,7 @@ export class PortfolioCalculator {
Time weighted investment with currency effect: ${timeWeightedAverageInvestmentBetweenStartAndEndDateWithCurrencyEffect.toFixed(
2
)}
Total dividend: ${totalDividend.toFixed(2)}
Gross performance: ${totalGrossPerformance.toFixed(
2
)} / ${grossPerformancePercentage.mul(100).toFixed(2)}%
@ -1528,6 +1562,8 @@ export class PortfolioCalculator {
netPerformanceValuesWithCurrencyEffect,
timeWeightedInvestmentValues,
timeWeightedInvestmentValuesWithCurrencyEffect,
totalDividend,
totalDividendInBaseCurrency,
totalInvestment,
totalInvestmentWithCurrencyEffect,
grossPerformance: totalGrossPerformance,

View File

@ -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 };

View File

@ -108,6 +108,7 @@ describe('CurrentRateService', () => {
currentRateService = new CurrentRateService(
dataProviderService,
marketDataService,
null,
null
);
});

View File

@ -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
) {}
@ -32,7 +34,7 @@ export class CurrentRateService {
}: GetValuesParams): Promise<GetValuesObject> {
const dataProviderInfos: DataProviderInfo[] = [];
const includeToday =
const includesToday =
(!dateQuery.lt || isBefore(new Date(), dateQuery.lt)) &&
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
@ -41,7 +43,7 @@ export class CurrentRateService {
const quoteErrors: ResponseError['errors'] = [];
const today = resetHours(new Date());
if (includeToday) {
if (includesToday) {
promises.push(
this.dataProviderService
.getQuotes({ items: dataGatheringItems, user: this.request?.user })
@ -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);

View File

@ -1,9 +1,9 @@
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
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;
}

View File

@ -1,11 +1,11 @@
import Big from 'big.js';
import { Big } from 'big.js';
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;
}

View File

@ -1,15 +1,12 @@
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
export interface PortfolioOrder {
currency: string;
export interface PortfolioOrder extends Pick<Activity, 'tags' | 'type'> {
date: string;
dataSource: DataSource;
fee: Big;
name: string;
quantity: Big;
symbol: string;
tags?: Tag[];
type: TypeOfOrder;
SymbolProfile: Pick<
Activity['SymbolProfile'],
'currency' | 'dataSource' | 'name' | 'symbol'
>;
unitPrice: Big;
}

View File

@ -1,9 +1,9 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
DataProviderInfo,
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Account, Tag } from '@prisma/client';
@ -27,7 +27,7 @@ export interface PortfolioPositionDetail {
netPerformancePercent: number;
netPerformancePercentWithCurrencyEffect: number;
netPerformanceWithCurrencyEffect: number;
orders: OrderWithAccount[];
orders: Activity[];
quantity: number;
SymbolProfile: EnhancedSymbolProfile;
tags: Tag[];

View File

@ -1,9 +1,11 @@
import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js';
import { Big } from 'big.js';
export interface TransactionPointSymbol {
averagePrice: Big;
currency: string;
dataSource: DataSource;
dividend: Big;
fee: Big;
firstBuyDate: string;
investment: Big;

View File

@ -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,
@ -43,7 +46,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import Big from 'big.js';
import { Big } from 'big.js';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
@ -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;
@ -160,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'
]);
}
@ -199,12 +203,9 @@ export class PortfolioController {
return {
accounts,
filteredValueInBaseCurrency,
filteredValueInPercentage,
hasError,
holdings,
platforms,
totalValueInBaseCurrency,
summary: portfolioSummary
};
}
@ -231,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 (
@ -265,6 +276,35 @@ export class PortfolioController {
return { dividends };
}
@Get('holdings')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getHoldings(
@Headers(HEADER_KEY_IMPERSONATION.toLowerCase()) impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('holdingType') filterByHoldingType?: string,
@Query('query') filterBySearchQuery?: string,
@Query('tags') filterByTags?: string
): Promise<PortfolioHoldingsResponse> {
const filters = this.apiService.buildFiltersFromQueryParams({
filterByAccounts,
filterByAssetClasses,
filterByHoldingType,
filterBySearchQuery,
filterByTags
});
const { holdings } = await this.portfolioService.getDetails({
filters,
impersonationId,
userId: this.request.user.id
});
return { holdings: Object.values(holdings) };
}
@Get('investments')
@UseGuards(AuthGuard('jwt'), HasPermissionGuard)
public async getInvestments(
@ -342,8 +382,12 @@ export class PortfolioController {
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') dateRange: DateRange = 'max',
@Query('tags') filterByTags?: string,
@Query('withExcludedAccounts') withExcludedAccounts = false
@Query('withExcludedAccounts') withExcludedAccountsParam = 'false',
@Query('withItems') withItemsParam = 'false'
): Promise<PortfolioPerformanceResponse> {
const withExcludedAccounts = withExcludedAccountsParam === 'true';
const withItems = withItemsParam === 'true';
const hasReadRestrictedAccessPermission =
this.userService.hasReadRestrictedAccessPermission({
impersonationId,
@ -361,6 +405,7 @@ export class PortfolioController {
filters,
impersonationId,
withExcludedAccounts,
withItems,
userId: this.request.user.id
});
@ -373,6 +418,7 @@ export class PortfolioController {
({
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorth,
totalInvestment,
value
@ -380,6 +426,7 @@ export class PortfolioController {
return {
date,
netPerformanceInPercentage,
netPerformanceInPercentageWithCurrencyEffect,
netWorthInPercentage:
performanceInformation.performance.currentNetWorth === 0
? 0
@ -483,7 +530,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 +561,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,

File diff suppressed because it is too large Load Diff

View File

@ -21,7 +21,7 @@ export class RedisCacheService {
}
public async get(key: string): Promise<string> {
return await this.cache.get(key);
return this.cache.get(key);
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
@ -29,15 +29,15 @@ export class RedisCacheService {
}
public async remove(key: string) {
await this.cache.del(key);
return this.cache.del(key);
}
public async reset() {
await this.cache.reset();
return this.cache.reset();
}
public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set(
return this.cache.set(
key,
value,
ttlInSeconds ?? this.configurationService.get('CACHE_TTL')

View File

@ -116,7 +116,7 @@ export class SubscriptionController {
@Body() { couponId, priceId }: { couponId: string; priceId: string }
) {
try {
return await this.subscriptionService.createCheckoutSession({
return this.subscriptionService.createCheckoutSession({
couponId,
priceId,
user: this.request.user

View File

@ -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,

View File

@ -135,7 +135,7 @@ export class UserController {
}
}
return await this.userService.updateUserSetting({
return this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id
});

View File

@ -452,14 +452,15 @@ export class UserService {
}
private getRandomString(length: number) {
const bytes = crypto.randomBytes(length);
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const result = [];
for (let i = 0; i < length; i++) {
result.push(
characters.charAt(Math.floor(Math.random() * characters.length))
);
const randomByte = bytes[i];
result.push(characters[randomByte % characters.length]);
}
return result.join('');
}
}

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,9 @@
"LUNA1": "Terra",
"LUNA2": "Terra",
"SGB1": "Songbird",
"SMURFCAT": "Real Smurf Cat",
"TON11419": "Toncoin",
"UNI1": "Uniswap",
"UNI7083": "Uniswap",
"UST": "TerraUSD"
}

View File

@ -1,4 +1,4 @@
export const environment = {
production: true,
version: `v${require('../../../../package.json').version}`
version: `${require('../../../../package.json').version}`
};

View File

@ -1,4 +1,4 @@
import Big from 'big.js';
import { Big } from 'big.js';
import { cloneDeep, isArray, isObject } from 'lodash';
export function hasNotDefinedValuesInObject(aObject: Object): boolean {

View 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;
}

View File

@ -49,7 +49,6 @@ export class RedactValuesInResponseInterceptor<T>
'dividendInBaseCurrency',
'fee',
'feeInBaseCurrency',
'filteredValueInBaseCurrency',
'grossPerformance',
'grossPerformanceWithCurrencyEffect',
'investment',
@ -58,7 +57,6 @@ export class RedactValuesInResponseInterceptor<T>
'quantity',
'symbolMapping',
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency',
'unitPrice',
'value',
'valueInBaseCurrency'

View File

@ -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) {

View File

@ -35,7 +35,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
};
}
let maxItem;
let maxItem: (typeof accounts)[0];
let totalInvestment = 0;
for (const account of Object.values(accounts)) {
@ -52,7 +52,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}
}
const maxInvestmentRatio = maxItem.investment / totalInvestment;
const maxInvestmentRatio = maxItem?.investment / totalInvestment || 0;
if (maxInvestmentRatio > ruleSettings.threshold) {
return {

View File

@ -43,7 +43,7 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
const baseCurrencyValueRatio = baseCurrencyItem?.value / totalValue || 0;
if (maxItem.groupKey !== ruleSettings.baseCurrency) {
if (maxItem?.groupKey !== ruleSettings.baseCurrency) {
return {
evaluation: `The major part of your current investment is not in your base currency (${(
baseCurrencyValueRatio * 100

View File

@ -37,7 +37,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
}
});
const maxValueRatio = maxItem.value / totalValue;
const maxValueRatio = maxItem?.value / totalValue || 0;
if (maxValueRatio > ruleSettings.threshold) {
return {
@ -52,7 +52,7 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
return {
evaluation: `The major part of your current investment is in ${
maxItem.groupKey
maxItem?.groupKey ?? ruleSettings.baseCurrency
} (${(maxValueRatio * 100).toPrecision(3)}%) and does not exceed ${
ruleSettings.threshold * 100
}%`,

View File

@ -10,18 +10,21 @@ export class ApiService {
filterByAccounts,
filterByAssetClasses,
filterByAssetSubClasses,
filterByHoldingType,
filterBySearchQuery,
filterByTags
}: {
filterByAccounts?: string;
filterByAssetClasses?: string;
filterByAssetSubClasses?: string;
filterByHoldingType?: string;
filterBySearchQuery?: string;
filterByTags?: string;
}): Filter[] {
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const assetSubClasses = filterByAssetSubClasses?.split(',') ?? [];
const holdingType = filterByHoldingType;
const searchQuery = filterBySearchQuery?.toLowerCase();
const tagIds = filterByTags?.split(',') ?? [];
@ -52,6 +55,13 @@ export class ApiService {
})
];
if (holdingType) {
filters.push({
id: holdingType,
type: 'HOLDING_TYPE'
});
}
if (searchQuery) {
filters.push({
id: searchQuery,

View File

@ -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 {
@ -48,14 +48,13 @@ export class ConfigurationService {
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' })
});
}

View File

@ -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;
}
}

View File

@ -20,7 +20,7 @@ import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { Big } from 'big.js';
import { eachDayOfInterval, format, isValid } from 'date-fns';
import { groupBy, isEmpty, isNumber, uniqWith } from 'lodash';
import ms from 'ms';
@ -204,13 +204,14 @@ export class DataProviderService {
});
try {
const queryRaw = `SELECT *
FROM "MarketData"
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
AND "symbol" IN ('${symbols.join(
`','`
)}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const queryRaw = `
SELECT *
FROM "MarketData"
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
AND "symbol" IN ('${symbols.join(
`','`
)}') ${granularityQuery} ${rangeQuery}
ORDER BY date;`;
const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRawUnsafe(queryRaw);

View File

@ -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) {

View File

@ -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'
};
}

View File

@ -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({});

View File

@ -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'
);
}

View File

@ -42,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;
}

View File

@ -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;
}

View File

@ -97,7 +97,8 @@ export class SymbolProfileService {
scraperConfiguration,
sectors,
symbol,
symbolMapping
symbolMapping,
SymbolProfileOverrides
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({
data: {
@ -109,7 +110,8 @@ export class SymbolProfileService {
name,
scraperConfiguration,
sectors,
symbolMapping
symbolMapping,
SymbolProfileOverrides
},
where: { dataSource_symbol: { dataSource, symbol } }
});
@ -189,9 +191,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
};
});
}

View File

@ -1,3 +1,4 @@
import { getCssVariable } from '@ghostfolio/common/helper';
import { InfoItem, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ColorScheme } from '@ghostfolio/common/types';
@ -187,20 +188,28 @@ export class AppComponent implements OnDestroy, OnInit {
? userPreferredColorScheme === 'DARK'
: window.matchMedia('(prefers-color-scheme: dark)').matches;
this.toggleThemeStyleClass(isDarkTheme);
this.toggleTheme(isDarkTheme);
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
if (!this.user?.settings.colorScheme) {
this.toggleThemeStyleClass(event.matches);
this.toggleTheme(event.matches);
}
});
}
private toggleThemeStyleClass(isDarkTheme: boolean) {
private toggleTheme(isDarkTheme: boolean) {
const themeColor = getCssVariable(
isDarkTheme ? '--dark-background' : '--light-background'
);
if (isDarkTheme) {
this.document.body.classList.add('is-dark-theme');
} else {
this.document.body.classList.remove('is-dark-theme');
}
this.document
.querySelector('meta[name="theme-color"]')
.setAttribute('content', themeColor);
}
}

View File

@ -1,3 +1,4 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { downloadAsFile } from '@ghostfolio/common/helper';
@ -21,7 +22,7 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Sort, SortDirection } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import Big from 'big.js';
import { Big } from 'big.js';
import { format, parseISO } from 'date-fns';
import { isNumber } from 'lodash';
import { Subject } from 'rxjs';
@ -41,7 +42,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
public activities: OrderWithAccount[];
public balance: number;
public currency: string;
public dataSource: MatTableDataSource<OrderWithAccount>;
public dataSource: MatTableDataSource<Activity>;
public equity: number;
public hasPermissionToDeleteAccountBalance: boolean;
public historicalDataItems: HistoricalDataItem[];
@ -115,7 +116,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
);
this.dataService
.fetchPortfolioDetails({
.fetchPortfolioHoldings({
filters: [
{
type: 'ACCOUNT',
@ -125,11 +126,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 +224,8 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
}
],
range: 'max',
withExcludedAccounts: true
withExcludedAccounts: true,
withItems: true
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ chart }) => {

View File

@ -155,15 +155,14 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
day: string;
yearMonth: string;
}) {
const date = parseISO(`${yearMonth}-${day}`);
const marketPrice = this.marketDataByMonth[yearMonth]?.[day]?.marketPrice;
const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: <MarketDataDetailDialogParams>{
date,
marketPrice,
currency: this.currency,
dataSource: this.dataSource,
dateString: `${yearMonth}-${day}`,
symbol: this.symbol,
user: this.user
},

View File

@ -5,7 +5,7 @@ import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams {
currency: string;
dataSource: DataSource;
date: Date;
dateString: string;
marketPrice: number;
symbol: string;
user: User;

View File

@ -45,7 +45,7 @@ export class MarketDataDetailDialog implements OnDestroy {
this.adminService
.fetchSymbolForDate({
dataSource: this.data.dataSource,
date: this.data.date,
dateString: this.data.dateString,
symbol: this.data.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
@ -63,7 +63,7 @@ export class MarketDataDetailDialog implements OnDestroy {
marketData: {
marketData: [
{
date: this.data.date.toISOString(),
date: this.data.dateString,
marketPrice: this.data.marketPrice
}
]

View File

@ -9,7 +9,7 @@
matInput
name="date"
[matDatepicker]="date"
[(ngModel)]="data.date"
[(ngModel)]="data.dateString"
/>
<mat-datepicker-toggle class="mr-2" matSuffix [for]="date">
<ion-icon

View File

@ -26,6 +26,7 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AdminMarketDataService } from './admin-market-data.service';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
@ -108,6 +109,7 @@ export class AdminMarketDataComponent
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminMarketDataService: AdminMarketDataService,
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private deviceService: DeviceDetectorService,
@ -181,20 +183,7 @@ export class AdminMarketDataComponent
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
}
public onGather7Days() {

View File

@ -161,20 +161,20 @@
<ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu #assetProfileActionsMenu="matMenu" xPosition="before">
<button
<a
mat-menu-item
(click)="
onOpenAssetProfileDialog({
dataSource: element.dataSource,
symbol: element.symbol
})
"
[queryParams]="{
assetProfileDialog: true,
dataSource: element.dataSource,
symbol: element.symbol
}"
[routerLink]="[]"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span i18n>Edit</span>
</span>
</button>
</a>
<button
mat-menu-item
[disabled]="element.activitiesCount !== 0"

View File

@ -12,6 +12,7 @@ import { RouterModule } from '@angular/router';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminMarketDataComponent } from './admin-market-data.component';
import { AdminMarketDataService } from './admin-market-data.service';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
@ -31,6 +32,7 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
NgxSkeletonLoaderModule,
RouterModule
],
providers: [AdminMarketDataService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAdminMarketDataModule {}

View File

@ -0,0 +1,26 @@
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@angular/core';
import { takeUntil } from 'rxjs';
@Injectable()
export class AdminMarketDataService {
public constructor(private adminService: AdminService) {}
public deleteProfileData({ dataSource, symbol }: UniqueAsset) {
const confirmation = confirm(
$localize`Do you really want to delete this asset profile?`
);
if (confirmation) {
this.adminService
.deleteProfileData({ dataSource, symbol })
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
}
}

View File

@ -1,7 +1,9 @@
import { UpdateAssetProfileDto } from '@ghostfolio/api/app/admin/update-asset-profile.dto';
import { UpdateMarketDataDto } from '@ghostfolio/api/app/admin/update-market-data.dto';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AdminMarketDataDetails,
Currency,
@ -82,6 +84,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminMarketDataService: AdminMarketDataService,
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: AssetProfileDialogParams,
@ -171,6 +174,12 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.dialogRef.close();
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
this.adminMarketDataService.deleteProfileData({ dataSource, symbol });
this.dialogRef.close();
}
public onGatherProfileDataBySymbol({ dataSource, symbol }: UniqueAsset) {
this.adminService
.gatherProfileDataBySymbol({ dataSource, symbol })
@ -195,15 +204,13 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
header: true,
skipEmptyLines: true
}
).data;
).data as UpdateMarketDataDto[];
this.adminService
.postMarketData({
dataSource: this.data.dataSource,
marketData: {
marketData: marketData.map(({ date, marketPrice }) => {
return { marketPrice, date: parseDate(date).toISOString() };
})
marketData
},
symbol: this.data.symbol
})

View File

@ -44,6 +44,19 @@
>
<ng-container i18n>Gather Profile Data</ng-container>
</button>
<button
mat-menu-item
type="button"
[disabled]="assetProfile?.activitiesCount !== 0"
(click)="
onDeleteProfileData({
dataSource: data.dataSource,
symbol: data.symbol
})
"
>
<ng-container i18n>Delete</ng-container>
</button>
</mat-menu>
</div>
@ -192,7 +205,7 @@
}
</ng-container>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'" class="mt-3">
<div class="mt-3">
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Name</mat-label>
<input formControlName="name" matInput type="text" />

View File

@ -1,4 +1,5 @@
import { GfAdminMarketDataDetailModule } from '@ghostfolio/client/components/admin-market-data-detail/admin-market-data-detail.module';
import { AdminMarketDataService } from '@ghostfolio/client/components/admin-market-data/admin-market-data.service';
import { GfCurrencySelectorModule } from '@ghostfolio/ui/currency-selector/currency-selector.module';
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -36,6 +37,7 @@ import { AssetProfileDialog } from './asset-profile-dialog.component';
ReactiveFormsModule,
TextFieldModule
],
providers: [AdminMarketDataService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfAssetProfileDialogModule {}

View File

@ -91,7 +91,11 @@
<span i18n>Edit</span>
</span>
</button>
<button mat-menu-item (click)="onDeletePlatform(element.id)">
<button
mat-menu-item
[disabled]="element.accountCount > 0"
(click)="onDeletePlatform(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>

View File

@ -71,7 +71,11 @@
<span i18n>Edit</span>
</span>
</button>
<button mat-menu-item (click)="onDeleteTag(element.id)">
<button
mat-menu-item
[disabled]="element.activityCount > 0"
(click)="onDeleteTag(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>

View File

@ -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"

View File

@ -20,7 +20,7 @@
<mat-select
name="benchmark"
[disabled]="user?.subscription?.type === 'Basic'"
[value]="benchmark"
[value]="benchmark?.id"
(selectionChange)="onChangeBenchmark($event.value)"
>
<mat-option [value]="null" />

View File

@ -46,8 +46,8 @@ import annotationPlugin from 'chartjs-plugin-annotation';
styleUrls: ['./benchmark-comparator.component.scss']
})
export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
@Input() benchmark: Partial<SymbolProfile>;
@Input() benchmarkDataItems: LineChartItem[] = [];
@Input() benchmark: string;
@Input() benchmarks: Partial<SymbolProfile>[];
@Input() colorScheme: ColorScheme;
@Input() daysInMarket: number;
@ -98,6 +98,12 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
}
private initialize() {
const benchmarkDataValues: { [date: string]: number } = {};
for (const { date, value } of this.benchmarkDataItems) {
benchmarkDataValues[date] = value;
}
const data: ChartData<'line'> = {
datasets: [
{
@ -113,10 +119,13 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
borderWidth: 2,
data: this.benchmarkDataItems.map(({ date, value }) => {
return { x: parseDate(date).getTime(), y: value };
data: this.performanceDataItems.map(({ date }) => {
return {
x: parseDate(date).getTime(),
y: benchmarkDataValues[date]
};
}),
label: $localize`Benchmark`
label: this.benchmark?.name ?? $localize`Benchmark`
}
]
};
@ -228,7 +237,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
locale: this.locale,
unit: '%'
}),
mode: 'x',
mode: 'index',
position: <unknown>'top',
xAlign: 'center',
yAlign: 'bottom'

View File

@ -124,7 +124,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.subscribe(({ chart, errors, performance }) => {
this.errors = errors;
this.performance = performance;
this.isLoadingPerformance = false;
this.historicalDataItems = chart.map(
({ date, netPerformanceInPercentageWithCurrencyEffect }) => {
@ -135,6 +134,8 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
}
);
this.isLoadingPerformance = false;
this.changeDetectorRef.markForCheck();
});

View File

@ -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;

View File

@ -282,7 +282,7 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.netWorth"
[value]="isLoading ? undefined : summary?.totalValueInBaseCurrency"
/>
</div>
</div>
@ -328,7 +328,7 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.dividend"
[value]="isLoading ? undefined : summary?.dividendInBaseCurrency"
/>
</div>
</div>

View File

@ -1,3 +1,4 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
@ -7,7 +8,6 @@ import {
LineChartItem,
User
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { translate } from '@ghostfolio/ui/i18n';
import {
@ -37,7 +37,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
})
export class PositionDetailDialog implements OnDestroy, OnInit {
public accounts: Account[];
public activities: OrderWithAccount[];
public activities: Activity[];
public assetClass: string;
public assetSubClass: string;
public averagePrice: number;
@ -46,7 +46,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
[code: string]: { name: string; value: number };
};
public dataProviderInfo: DataProviderInfo;
public dataSource: MatTableDataSource<OrderWithAccount>;
public dataSource: MatTableDataSource<Activity>;
public dividendInBaseCurrency: number;
public feeInBaseCurrency: number;
public firstBuyDate: string;

View File

@ -171,11 +171,13 @@
size="medium"
[locale]="data.locale"
[value]="transactionCount"
><ng-container *ngIf="transactionCount === 1">Activity</ng-container
><ng-container *ngIf="transactionCount !== 1"
>Activities</ng-container
></gf-value
>
@if (transactionCount === 1) {
<ng-container i18n>Activity</ng-container>
} @else {
<ng-container i18n>Activities</ng-container>
}
</gf-value>
</div>
<div class="col-6 mb-3">
<gf-value i18n size="medium" [hidden]="!assetClass" [value]="assetClass"
@ -191,17 +193,15 @@
>Asset Sub Class</gf-value
>
</div>
<ng-container
*ngIf="
SymbolProfile?.countries?.length > 0 ||
SymbolProfile?.sectors?.length > 0
"
>
@if (
SymbolProfile?.countries?.length > 0 ||
SymbolProfile?.sectors?.length > 0
) {
@if (
SymbolProfile?.countries?.length === 1 &&
SymbolProfile?.sectors?.length === 1
) {
<div *ngIf="SymbolProfile?.sectors?.length === 1" class="col-6 mb-3">
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
@ -210,18 +210,17 @@
>Sector</gf-value
>
</div>
<div
*ngIf="SymbolProfile?.countries?.length === 1"
class="col-6 mb-3"
>
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.countries[0].name"
>Country</gf-value
>
</div>
@if (SymbolProfile?.countries?.length === 1) {
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[locale]="data.locale"
[value]="SymbolProfile.countries[0].name"
>Country</gf-value
>
</div>
}
} @else {
<div class="col-md-6 mb-3">
<div class="h5" i18n>Sectors</div>
@ -248,13 +247,32 @@
/>
</div>
}
</ng-container>
<div *ngIf="dataProviderInfo" class="col-md-12 mb-3 text-center">
<hr />
<gf-data-provider-credits [dataProviderInfos]="[dataProviderInfo]">
</gf-data-provider-credits>
<hr />
}
<div class="col-6 mb-3">
<gf-value
i18n
size="medium"
[hidden]="!SymbolProfile?.symbol"
[value]="SymbolProfile?.symbol"
>Symbol</gf-value
>
</div>
<div class="col-6 mb-3">
<gf-value
size="medium"
[hidden]="!SymbolProfile?.isin"
[value]="SymbolProfile?.isin"
>ISIN</gf-value
>
</div>
@if (dataProviderInfo) {
<div class="col-md-12 mb-3 text-center">
<hr />
<gf-data-provider-credits [dataProviderInfos]="[dataProviderInfo]" />
<hr />
</div>
}
</div>
<mat-tab-group
@ -308,31 +326,32 @@
</mat-tab>
</mat-tab-group>
<div *ngIf="tags?.length > 0" class="row">
<div class="col">
<div class="h5" i18n>Tags</div>
<mat-chip-listbox>
<mat-chip-option *ngFor="let tag of tags" disabled>{{
tag.name
}}</mat-chip-option>
</mat-chip-listbox>
@if (tags?.length > 0) {
<div class="row">
<div class="col">
<div class="h5" i18n>Tags</div>
<mat-chip-listbox>
<mat-chip-option *ngFor="let tag of tags" disabled>{{
tag.name
}}</mat-chip-option>
</mat-chip-listbox>
</div>
</div>
</div>
}
<div
*ngIf="
activities?.length > 0 && data.hasPermissionToReportDataGlitch === true
"
class="row"
>
<div class="col">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span></a
>
@if (
activities?.length > 0 && data.hasPermissionToReportDataGlitch === true
) {
<div class="row">
<div class="col">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span></a
>
</div>
</div>
</div>
}
</div>
</div>

View File

@ -29,7 +29,7 @@ export class CreateOrUpdateAccessDialog implements OnDestroy {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccessDialogParams,
@Inject(MAT_DIALOG_DATA) private data: CreateOrUpdateAccessDialogParams,
public dialogRef: MatDialogRef<CreateOrUpdateAccessDialog>,
private dataService: DataService,
private formBuilder: FormBuilder

View File

@ -35,9 +35,7 @@
<mat-option i18n value="READ_RESTRICTED"
>Restricted view</mat-option
>
@if (data?.user?.settings?.isExperimentalFeatures) {
<mat-option i18n value="READ">View</mat-option>
}
<mat-option i18n value="READ">View</mat-option>
</mat-select>
</mat-form-field>
</div>

View File

@ -1,6 +1,5 @@
import { Access, User } from '@ghostfolio/common/interfaces';
import { Access } from '@ghostfolio/common/interfaces';
export interface CreateOrUpdateAccessDialogParams {
access: Access;
user: User;
}

View File

@ -107,8 +107,7 @@ export class UserAccountAccessComponent implements OnDestroy, OnInit {
alias: '',
permissions: ['READ_RESTRICTED'],
type: 'PRIVATE'
},
user: this.user
}
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -33,10 +33,8 @@
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What else is included in Ghostfolio?</mat-card-title
></mat-card-header
>
<mat-card-title>What else is included in Ghostfolio?</mat-card-title>
</mat-card-header>
<mat-card-content>
Please find a feature overview to manage your wealth
<a [routerLink]="routerLinkFeatures">here</a>.
@ -44,10 +42,8 @@
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>Can I use Ghostfolio anonymously?</mat-card-title
></mat-card-header
>
<mat-card-title>Can I use Ghostfolio anonymously?</mat-card-title>
</mat-card-header>
<mat-card-content>
Yes, the authentication system via security token enables you to sign
in securely and anonymously to Ghostfolio. There is no need for an
@ -56,10 +52,8 @@
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>How can Ghostfolio be free?</mat-card-title
></mat-card-header
>
<mat-card-title>How can Ghostfolio be free?</mat-card-title>
</mat-card-header>
<mat-card-content
>This project is driven by the efforts of contributors from around the
world. The
@ -75,8 +69,8 @@
<mat-card-header>
<mat-card-title
>Do you monetize or sell my financial data?</mat-card-title
></mat-card-header
>
>
</mat-card-header>
<mat-card-content
>No, we value your privacy. We do not sell or share your financial
data with any third parties.</mat-card-content
@ -84,10 +78,8 @@
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is your business model?</mat-card-title
></mat-card-header
>
<mat-card-title>What is your business model?</mat-card-title>
</mat-card-header>
<mat-card-content
>By offering
<a href="https://ghostfol.io/en/pricing">Ghostfolio Premium</a>, a
@ -96,6 +88,15 @@
users.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>What is your product roadmap?</mat-card-title>
</mat-card-header>
<mat-card-content
>At this time, we do not have a public roadmap
available.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title

View File

@ -66,8 +66,9 @@
<mat-card-content
>Yes, you can try
<a [routerLink]="routerLinkPricing">Ghostfolio Premium</a> by signing
up for Ghostfolio and applying for a trial (see “My Ghostfolio”). It
is easy, free and there is no commitment. You can stop using it at any
up for Ghostfolio and applying for a trial (see
<a [routerLink]="['/account', 'membership']">Membership</a>). It is
easy, free and there is no commitment. You can stop using it at any
time.</mat-card-content
>
</mat-card>
@ -128,6 +129,18 @@
>.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is the best approach to managing backups?</mat-card-title
>
</mat-card-header>
<mat-card-content
>It is suggested to regularly back up your data via
<a [routerLink]="['/account']">My Ghostfolio</a>
<i>Export Data</i>.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Got any other questions?</mat-card-title>

View File

@ -18,15 +18,34 @@
<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 recored
activities. If you need an additional currency, you can manually
enter it.
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>
@ -97,6 +116,17 @@
and desktop computers.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title
>What is the best approach to managing backups?</mat-card-title
>
</mat-card-header>
<mat-card-content
>It is suggested to regularly back up your data by utilizing full
database dumps.</mat-card-content
>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-header>
<mat-card-title>Got any other questions?</mat-card-title>

View File

@ -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[] = [];
@ -258,6 +260,17 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['currency'].setValue(currency);
this.activityForm.controls['currencyOfFee'].setValue(currency);
this.activityForm.controls['currencyOfUnitPrice'].setValue(currency);
if (['FEE', 'INTEREST'].includes(type)) {
if (this.activityForm.controls['accountId'].value) {
this.activityForm.controls['updateAccountBalance'].enable();
} else {
this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(
false
);
}
}
}
}
);
@ -372,8 +385,15 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.activityForm.controls['unitPriceInCustomCurrency'].setValue(0);
}
this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false);
if (
['FEE', 'INTEREST'].includes(type) &&
this.activityForm.controls['accountId'].value
) {
this.activityForm.controls['updateAccountBalance'].enable();
} else {
this.activityForm.controls['updateAccountBalance'].disable();
this.activityForm.controls['updateAccountBalance'].setValue(false);
}
} else {
this.activityForm.controls['accountId'].setValidators(
Validators.required

View File

@ -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

View File

@ -281,7 +281,6 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
this.platforms = {};
this.portfolioDetails = {
accounts: {},
filteredValueInPercentage: 0,
holdings: {},
platforms: {},
summary: undefined

View File

@ -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>

View File

@ -32,6 +32,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html'
})
export class AnalysisPageComponent implements OnDestroy, OnInit {
public benchmark: Partial<SymbolProfile>;
public benchmarkDataItems: HistoricalDataItem[] = [];
public benchmarks: Partial<SymbolProfile>[];
public bottom3: Position[];
@ -122,6 +123,10 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
if (state?.user) {
this.user = state.user;
this.benchmark = this.benchmarks.find(({ id }) => {
return id === this.user.settings?.benchmark;
});
this.update();
}
});

View File

@ -4,7 +4,7 @@
<div class="col-lg">
<gf-benchmark-comparator
class="h-100"
[benchmark]="user?.settings?.benchmark"
[benchmark]="benchmark"
[benchmarkDataItems]="benchmarkDataItems"
[benchmarks]="benchmarks"
[colorScheme]="user?.settings?.colorScheme"

View File

@ -5,7 +5,7 @@ import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import Big from 'big.js';
import { Big } from 'big.js';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

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