Compare commits

...

63 Commits

Author SHA1 Message Date
695c378b48 Release 1.43.0 (#316) 2021-08-24 21:31:19 +02:00
fe975945d1 Feature/add fallback for loading currencies (#315)
* Add fallback for loading currencies

* Update changelog
2021-08-24 21:09:02 +02:00
d8782b0d4c Feature/automate countries for stocks in symbol profile data (#314)
* Automate countries for stocks in symbol profile data

* Update changelog
2021-08-24 20:24:18 +02:00
e14f08a8fb Release 1.42.0 (#313) 2021-08-22 22:37:44 +02:00
72c065a59d Feature/introduce asset sub class (#312)
* Introduce asset sub class

* Update changelog
2021-08-22 22:19:10 +02:00
98dac4052a Feature/add subscription type to the admin user table (#311)
* Add the subscription type to the user table in the admin control panel

* Update changelog
2021-08-22 22:11:05 +02:00
2083d28d02 Feature/minor improvements in the page components (#310)
* Move permissions to constructor

* Sort imports
2021-08-22 10:25:34 +02:00
addd5c36d9 Release 1.41.0 (#309) 2021-08-21 15:35:59 +02:00
aad8f77093 Feature/improve allocations by account (#308)
* Improve allocations by account

* Eliminate accounts from PortfolioPosition

* Ignore cash assets in the allocation chart by sector, continent and country

* Add missing accounts to portfolio details

* Update changelog
2021-08-21 15:03:55 +02:00
a904208d06 Feature/improve table styling (#307)
* Improve table styling

* Update changelog
2021-08-21 14:56:50 +02:00
2733b78044 Minor improvement (#306) 2021-08-21 14:56:11 +02:00
b43b515df1 Feature/add link to system status page (#305)
* Add link to system status page

* Update changelog
2021-08-21 08:57:12 +02:00
70e14b4d3c Feature/improve restricted view mode (#304)
* Improve wording and padding

* Update changelog
2021-08-20 20:58:33 +02:00
0f7d1b7d59 Release 1.40.0 (#303) 2021-08-19 21:56:21 +02:00
c2ab6a6c44 Feature/improve portfolio details endpoint (#302)
* Make details endpoint fault tolerant (do not throw error)

* Update changelog
2021-08-19 21:44:10 +02:00
c71a4c078e Bugfix/convert g bp to gbp in yahoo finance service (#301)
* Fix currency inconsistency in the yahoo finance service (GBp to GBP)

* Update changelog
2021-08-18 18:22:01 +02:00
e17b217032 Bugfix/fix issue on buy date in position detail dialog (#297)
* Fix issue on buy date

* Update changelog
2021-08-17 21:31:32 +02:00
408e08d43c Bugfix/fix node engine version mismatch (#299)
* Fix node engine version mismatch

* Update changelog
2021-08-17 20:32:12 +02:00
2fa324702f Release 1.39.0 (#296) 2021-08-16 21:57:51 +02:00
05b0efef82 Feature/add restricted view (#295)
* Add restricted view

* Update changelog
2021-08-16 21:40:29 +02:00
7c91727eb1 Feature/restructure allocations page (#294)
* Restructure allocations page

* Update changelog
2021-08-15 09:55:46 +02:00
0ee2258af8 Feature/improve impersonation mode (#293)
* Improve the impersonation mode

* Update changelog
2021-08-14 19:15:26 +02:00
308b218487 introduce basic module structure for data provider (#278)
* introduce basic module structure for data provider

* introduce DataGatheringModule

* introduce ExchangeRateDataModule

* introduce ImpersonationModule

* move RulesService

* cleanup portfolio module

* Sort imports

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-14 16:55:40 +02:00
26694adb8e Release 1.38.0 (#292) 2021-08-14 11:25:50 +02:00
77936e3bf3 Feature/improve users table (#291)
* Improve users table
  * Engagement / Day
  * Registration

* Update changelog
2021-08-14 11:12:08 +02:00
13090bf6b5 Feature/add overview menu item on mobile (#290)
* Add overview menu item

* Update changelog
2021-08-14 11:09:53 +02:00
b898c0678d Feature/refactor exchange rate service (#289)
* Refactor exchange rate service

* Update changelog
2021-08-14 11:06:21 +02:00
3330ae70b6 Release 1.37.0 (#288) 2021-08-13 20:49:05 +02:00
96a615dc5d Bugfix/only allow supported currencies in symbol search (#287)
* Only allow supported currencies in symbol search

* Update changelog
2021-08-13 20:29:57 +02:00
98f44323da Feature/improve usability of tabs on home page (#283)
* Improve usability: lazy load endpoints on tab change

* Feature/improve portfolio summary (#285)

* Update changelog
2021-08-13 19:26:48 +02:00
8adacd9760 Feature/upgrade angular material css vars to version 2.1.2 (#282)
* Upgrade angular-material-css-vars from version 2.1.0 to 2.1.2

* Update changelog
2021-08-13 19:05:22 +02:00
908aba170d Fix position chart for missing historical data (#284)
* Fix position chart for missing historical data

* Update changelog
2021-08-12 23:30:04 +02:00
d599797a65 Release 1.36.0 (#281) 2021-08-09 22:06:19 +02:00
8ac1272a9d Feature/eliminate name from scraper config (#277)
* Eliminate name from scraper config

* Update changelog
2021-08-09 21:33:58 +02:00
0a85a56c67 Respect cash balance in allocations, do not hide cryptocurrency holdings (#280)
* Respect cash balance in allocations, do not hide cryptocurrency holdings

* Update changelog
2021-08-09 21:26:41 +02:00
4ad5590838 Feature/improve data gathering (#276)
* Improve data gathering
  * Refactoring
  * On server restart, only reset if hanging in LOCKED_DATA_GATHERING state

* Update changelog
2021-08-09 21:11:35 +02:00
5b8af68e71 Release 1.35.0 (#279) 2021-08-08 21:23:48 +02:00
80d043729d Feature/replace type with asset class (#274)
* Improved the asset classification
  * Add assetClass to symbolProfile
  * Remove type from position

* Update changelog
2021-08-08 19:27:58 +02:00
178166d86b Extend investment chart by three months (#273) 2021-08-08 19:25:38 +02:00
37358fb480 Bugfix/add fallback if exchange service is not initialized (#264)
* Add fallback and log error

* Update changelog
2021-08-08 19:24:51 +02:00
616d601cf6 Improve wording (#275) 2021-08-08 12:20:30 +02:00
e88b889fdd Feature/optimize accounts table for mobile (#271)
* Optimize accounts table

* Update changelog
2021-08-08 09:24:47 +02:00
f6cdc4ff47 Feature/disable pagination of tabs (#272)
* Disable pagination

* Update changelog
2021-08-08 09:24:30 +02:00
818c40fc61 Feature/upgrade chart.js to version 3.5.0 (#268)
* Upgrade chart.js

* Update changelog
2021-08-08 08:33:55 +02:00
3589e72aea Harmonize prisma service (#266) 2021-08-07 22:38:07 +02:00
e68aa1fa68 Clean up imports (#267) 2021-08-07 22:37:39 +02:00
bb76ace95d Feature/improve support for draft transactions (#265)
* Improve support for draft transactions

* Update changelog
2021-08-07 20:52:55 +02:00
2bd9309827 Release 1.34.0 (#263) 2021-08-07 07:28:16 +02:00
f743c03e17 Feature/restructure page hierarchy (#259)
* Introduce portfolio group, remove tools

* Extract allocations to separate page

* Update changelog
2021-08-07 07:12:40 +02:00
dfcf826b4f implement support for buy-sell(-buy) scenario (#262)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-07 07:07:12 +02:00
218efbb5bd fix position diagram for sold items (#261)
* fix position diagram for sold items

* Add missing data to position from the past

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-07 07:03:35 +02:00
ba3b4564cd Bugfix/dashboard currencies (#260)
* filter list of orders only for relevant orders

* Update changelog

Co-authored-by: Valentin Zickner <github@zickner.ch>
Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2021-08-06 21:47:27 +02:00
3723a5c725 Release 1.33.0 (#257) 2021-08-05 22:54:12 +02:00
bf256ae50c fix position currency conversion (#256)
Co-authored-by: Valentin Zickner <github@zickner.ch>
2021-08-05 22:36:18 +02:00
c4b6273886 Clean up viewport scroller (#255) 2021-08-05 22:32:55 +02:00
7f047362cc Bugfix/fix division by zero in calculate overall gross performance (#253)
* Fix error with division by zero

* Update changelog
2021-08-05 07:49:25 +02:00
66900ffa24 Release 1.32.0 (#254) 2021-08-04 21:42:50 +02:00
33e05e949a Add links (#252)
* Ghostfolio Premium
* Blog
* Twitter
2021-08-04 20:59:59 +02:00
94d2310217 Bugfix/fix spike of positions with differing currency of user (#248)
* Convert market price back to currency of position

* Update changelog
2021-08-04 20:43:00 +02:00
b7d950f3f9 Bugfix/fix missing market state in positions (#250)
* Fix missing market state in positions

* Update changelog
2021-08-04 17:55:34 +02:00
b7943889da Feature/add screenshot (#251)
* Add screenshot

* Update changelog
2021-08-03 21:02:51 +02:00
84aa542560 Extend changelog (#247) 2021-08-02 21:56:36 +02:00
4bd41ffa41 Feature/eliminate name from timeline position (#245)
* Eliminate name from timeline position: Get the name from the symbolProfileService instead

* Update changelog
2021-08-01 20:43:12 +02:00
190 changed files with 3348 additions and 2273 deletions

View File

@ -5,6 +5,167 @@ 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/), 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). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.43.0 - 24.08.2021
### Added
- Extended the data management of symbol profile data by countries (automated for stocks)
- Added a fallback for initially loading currencies if historical data is not yet available
## 1.42.0 - 22.08.2021
### Added
- Added the subscription type to the users table of the admin control panel
- Introduced the sub classification of assets
### Todo
- Apply data migration (`yarn database:push`)
## 1.41.0 - 21.08.2021
### Added
- Added a link to the system status page
### Changed
- Improved the wording for the _Restricted View_: _Presenter View_
- Improved the styling of the tables
- Ignored cash assets in the allocation chart by sector, continent and country
### Fixed
- Fixed an issue in the allocation chart by account (wrong calculation)
- Fixed an issue in the allocation chart by account (missing cash accounts)
## 1.40.0 - 19.08.2021
### Changed
- Improved the fault tolerance of the portfolio details endpoint
### Fixed
- Fixed the node engine version mismatch in `package.json`
- Fixed an issue on the buy date in the position detail dialog
- Fixed an issue with the currency inconsistency in the _Yahoo Finance_ service (convert from `GBp` to `GBP`)
## 1.39.0 - 16.08.2021
### Added
- Added an option to hide absolute values like performances and quantities (_Restricted View_)
### Changed
- Restructured the allocations page
### Fixed
- Fixed an issue with the performance in the portfolio summary tab on the home page (impersonation mode)
- Fixed various values in the impersonation mode which have not been nullified
### Removed
- Removed the current net performance
- Removed the read foreign portfolio permission
### Todo
- Apply data migration (`yarn database:push`)
## 1.38.0 - 14.08.2021
### Added
- Added the overview menu item on mobile
### Changed
- Refactored the exchange rate service
- Improved the users table in the admin control panel
## 1.37.0 - 13.08.2021
### Added
- Added the calculated net worth to the portfolio summary tab on the home page
- Added the calculated time in market to the portfolio summary tab on the home page
### Changed
- Improved the usability of the tabs on the home page
- Restructured the portfolio summary tab on the home page
- Upgraded `angular-material-css-vars` from version `2.1.0` to `2.1.2`
### Fixed
- Fixed the position detail chart if there are missing historical data around the first buy date
- Fixed the snack bar background color in dark mode
- Fixed the search functionality for symbols (filter for supported currencies)
## 1.36.0 - 09.08.2021
### Changed
- Improved the data gathering handling on server restart
- Respected the cash balance on the allocations page
- Eliminated the name from the scraper configuration
### Fixed
- Fixed hidden cryptocurrency holdings
## 1.35.0 - 08.08.2021
### Changed
- Hid the pagination of tabs
- Improved the classification of assets
- Improved the support for future transactions (drafts)
- Optimized the accounts table for mobile
- Upgraded `chart.js` from version `3.3.2` to `3.5.0`
### Fixed
- Added a fallback if the exchange rate service has not been initialized correctly
### Todo
- Apply data migration (`yarn database:push`)
## 1.34.0 - 07.08.2021
### Changed
- Restructured the page hierarchy
### Fixed
- Fixed an issue with the currency conversion of the market price in the position detail dialog
- Fixed the chart and missing data of positions from the past in the position detail dialog
## 1.33.0 - 05.08.2021
### Fixed
- Fixed an issue of a division by zero in the portfolio calculations
- Fixed an issue with the currency conversion in the position detail dialog
## 1.32.0 - 04.08.2021
### Added
- Added the name to the position detail dialog when opened from the transactions table
- Added a screenshot to the blog posts
### Fixed
- Fixed the missing market state in the positions tab
- Fixed the chart of positions with differing currency from user
## 1.31.1 - 01.08.2021 ## 1.31.1 - 01.08.2021
### Fixed ### Fixed
@ -19,8 +180,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed ### Changed
- Refactored the core engine for the portfolio calculations - Rewritten the core engine for the portfolio calculations
- Improved the performance of the portfolio calculations - Switched to [Time-Weighted Rate of Return](https://www.investopedia.com/terms/t/time-weightedror.asp) (TWR) for the performance calculation
- Improved the performance of the portfolio calculations
## 1.30.0 - 31.07.2021 ## 1.30.0 - 31.07.2021

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software made for Humans</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -26,6 +26,10 @@
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions. **Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Ghostfolio Premium ## Ghostfolio Premium
Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs. Our official **[Ghostfolio Premium](https://ghostfol.io/pricing)** cloud offering is the easiest way to get started. Due to the time it saves, this will be the best option for most people. The revenue is used for covering the hosting costs.

View File

@ -5,7 +5,7 @@ import { Prisma } from '@prisma/client';
@Injectable() @Injectable()
export class AccessService { export class AccessService {
public constructor(private prisma: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async accesses(params: { public async accesses(params: {
include?: Prisma.AccessInclude; include?: Prisma.AccessInclude;
@ -17,7 +17,7 @@ export class AccessService {
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.access.findMany({ return this.prismaService.access.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,

View File

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import {
@ -33,7 +34,8 @@ export class AccountController {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -84,25 +86,22 @@ export class AccountController {
public async getAllAccounts( public async getAllAccounts(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> { ): Promise<AccountModel[]> {
const impersonationUserId = await this.impersonationService.validateImpersonationId( const impersonationUserId =
impersonationId, await this.impersonationService.validateImpersonationId(
this.request.user.id impersonationId,
this.request.user.id
);
let accounts = await this.accountService.getAccounts(
impersonationUserId || this.request.user.id
); );
let accounts = await this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: impersonationUserId || this.request.user.id }
});
if ( if (
impersonationUserId && impersonationUserId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
accounts = nullifyValuesInObjects(accounts, [ accounts = nullifyValuesInObjects(accounts, [
'balance',
'fee', 'fee',
'quantity', 'quantity',
'unitPrice' 'unitPrice'

View File

@ -1,32 +1,26 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { AccountController } from './account.controller'; import { AccountController } from './account.controller';
import { AccountService } from './account.service'; import { AccountService } from './account.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
RedisCacheModule,
PrismaModule,
UserModule
],
controllers: [AccountController], controllers: [AccountController],
providers: [ providers: [AccountService]
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class AccountModule {} export class AccountModule {}

View File

@ -1,23 +1,21 @@
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Account, Currency, Order, Prisma } from '@prisma/client'; import { Account, Currency, Order, Platform, Prisma } from '@prisma/client';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CashDetails } from './interfaces/cash-details.interface'; import { CashDetails } from './interfaces/cash-details.interface';
@Injectable() @Injectable()
export class AccountService { export class AccountService {
public constructor( public constructor(
private exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly redisCacheService: RedisCacheService, private readonly prismaService: PrismaService
private prisma: PrismaService
) {} ) {}
public async account( public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> { ): Promise<Account | null> {
return this.prisma.account.findUnique({ return this.prismaService.account.findUnique({
where: accountWhereUniqueInput where: accountWhereUniqueInput
}); });
} }
@ -30,7 +28,7 @@ export class AccountService {
Order?: Order[]; Order?: Order[];
} }
> { > {
return this.prisma.account.findUnique({ return this.prismaService.account.findUnique({
include: accountInclude, include: accountInclude,
where: accountWhereUniqueInput where: accountWhereUniqueInput
}); });
@ -43,10 +41,15 @@ export class AccountService {
cursor?: Prisma.AccountWhereUniqueInput; cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput; orderBy?: Prisma.AccountOrderByInput;
}): Promise<Account[]> { }): Promise<
(Account & {
Order?: Order[];
Platform?: Platform;
})[]
> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.account.findMany({ return this.prismaService.account.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,
@ -60,7 +63,7 @@ export class AccountService {
data: Prisma.AccountCreateInput, data: Prisma.AccountCreateInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
return this.prisma.account.create({ return this.prismaService.account.create({
data data
}); });
} }
@ -69,13 +72,27 @@ export class AccountService {
where: Prisma.AccountWhereUniqueInput, where: Prisma.AccountWhereUniqueInput,
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
this.redisCacheService.remove(`${aUserId}.portfolio`); return this.prismaService.account.delete({
return this.prisma.account.delete({
where where
}); });
} }
public async getAccounts(aUserId: string) {
const accounts = await this.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
});
return accounts.map((account) => {
const result = { ...account, transactionCount: account.Order.length };
delete result.Order;
return result;
});
}
public async getCashDetails( public async getCashDetails(
aUserId: string, aUserId: string,
aCurrency: Currency aCurrency: Currency
@ -105,7 +122,7 @@ export class AccountService {
aUserId: string aUserId: string
): Promise<Account> { ): Promise<Account> {
const { data, where } = params; const { data, where } = params;
return this.prisma.account.update({ return this.prismaService.account.update({
data, data,
where where
}); });

View File

@ -1,31 +1,25 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
import { AdminService } from './admin.service'; import { AdminService } from './admin.service';
@Module({ @Module({
imports: [], imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
PrismaModule,
SubscriptionModule
],
controllers: [AdminController], controllers: [AdminController],
providers: [ providers: [AdminService],
AdminService, exports: [AdminService]
AlphaVantageService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -1,14 +1,21 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { AdminData } from '@ghostfolio/common/interfaces'; import { AdminData } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private exchangeRateDataService: ExchangeRateDataService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
@ -61,24 +68,22 @@ export class AdminService {
} }
], ],
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
transactionCount: await this.prisma.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prisma.user.count(), userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics() users: await this.getUsersWithAnalytics()
}; };
} }
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({ const lastDataGathering =
where: { key: 'LAST_DATA_GATHERING' } await this.dataGatheringService.getLastDataGathering();
});
if (lastDataGathering?.value) { if (lastDataGathering) {
return new Date(lastDataGathering.value); return lastDataGathering;
} }
const dataGatheringInProgress = await this.prisma.property.findUnique({ const dataGatheringInProgress =
where: { key: 'LOCKED_DATA_GATHERING' } await this.dataGatheringService.getIsInProgress();
});
if (dataGatheringInProgress) { if (dataGatheringInProgress) {
return 'IN_PROGRESS'; return 'IN_PROGRESS';
@ -87,8 +92,8 @@ export class AdminService {
return null; return null;
} }
private async getUsersWithAnalytics() { private async getUsersWithAnalytics(): Promise<AdminData['users']> {
return await this.prisma.user.findMany({ const usersWithAnalytics = await this.prismaService.user.findMany({
orderBy: { orderBy: {
Analytics: { Analytics: {
updatedAt: 'desc' updatedAt: 'desc'
@ -106,7 +111,8 @@ export class AdminService {
} }
}, },
createdAt: true, createdAt: true,
id: true id: true,
Subscription: true
}, },
take: 30, take: 30,
where: { where: {
@ -115,5 +121,30 @@ export class AdminService {
} }
} }
}); });
return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.subscriptionService.getSubscription(Subscription)
: undefined;
return {
alias,
createdAt,
engagement,
id,
subscription,
accountCount: _count.Account || 0,
lastActivity: Analytics.updatedAt,
transactionCount: _count.Order || 0
};
}
);
} }
} }

View File

@ -1,12 +1,12 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Controller } from '@nestjs/common'; import { Controller } from '@nestjs/common';
import { PrismaService } from '../services/prisma.service';
import { RedisCacheService } from './redis-cache/redis-cache.service'; import { RedisCacheService } from './redis-cache/redis-cache.service';
@Controller() @Controller()
export class AppController { export class AppController {
public constructor( public constructor(
private prisma: PrismaService, private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService private readonly redisCacheService: RedisCacheService
) { ) {
this.initialize(); this.initialize();
@ -15,17 +15,12 @@ export class AppController {
private async initialize() { private async initialize() {
this.redisCacheService.reset(); this.redisCacheService.reset();
const isDataGatheringLocked = await this.prisma.property.findUnique({ const isDataGatheringInProgress =
where: { key: 'LOCKED_DATA_GATHERING' } await this.dataGatheringService.getIsInProgress();
});
if (!isDataGatheringLocked) { if (isDataGatheringInProgress) {
// Prepare for automatical data gather if not locked // Prepare for automatical data gathering, if hung up in progress state
await this.prisma.property.deleteMany({ await this.dataGatheringService.reset();
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
} }
} }
} }

View File

@ -1,35 +1,30 @@
import { join } from 'path'; import { join } from 'path';
import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module'; import { AuthDeviceModule } from '@ghostfolio/api/app/auth-device/auth-device.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CronService } from '@ghostfolio/api/services/cron.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { ScheduleModule } from '@nestjs/schedule'; import { ScheduleModule } from '@nestjs/schedule';
import { ServeStaticModule } from '@nestjs/serve-static'; import { ServeStaticModule } from '@nestjs/serve-static';
import { ConfigurationService } from '../services/configuration.service';
import { CronService } from '../services/cron.service';
import { DataGatheringService } from '../services/data-gathering.service';
import { DataProviderService } from '../services/data-provider.service';
import { AlphaVantageService } from '../services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '../services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '../services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '../services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { PrismaService } from '../services/prisma.service';
import { AccessModule } from './access/access.module'; import { AccessModule } from './access/access.module';
import { AccountModule } from './account/account.module'; import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller'; import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { CacheModule } from './cache/cache.module'; import { CacheModule } from './cache/cache.module';
import { CoreModule } from './core/core.module';
import { ExperimentalModule } from './experimental/experimental.module'; import { ExperimentalModule } from './experimental/experimental.module';
import { ExportModule } from './export/export.module'; import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module'; import { ImportModule } from './import/import.module';
import { InfoModule } from './info/info.module'; import { InfoModule } from './info/info.module';
import { OrderModule } from './order/order.module'; import { OrderModule } from './order/order.module';
import { PortfolioModule } from './portfolio/portfolio.module'; import { PortfolioModule } from './portfolio/portfolio.module';
import { RedisCacheModule } from './redis-cache/redis-cache.module';
import { SubscriptionModule } from './subscription/subscription.module'; import { SubscriptionModule } from './subscription/subscription.module';
import { SymbolModule } from './symbol/symbol.module'; import { SymbolModule } from './symbol/symbol.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
@ -43,13 +38,17 @@ import { UserModule } from './user/user.module';
AuthModule, AuthModule,
CacheModule, CacheModule,
ConfigModule.forRoot(), ConfigModule.forRoot(),
CoreModule, ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ExperimentalModule, ExperimentalModule,
ExportModule, ExportModule,
ImportModule, ImportModule,
InfoModule, InfoModule,
OrderModule, OrderModule,
PortfolioModule, PortfolioModule,
PrismaModule,
RedisCacheModule, RedisCacheModule,
ScheduleModule.forRoot(), ScheduleModule.forRoot(),
ServeStaticModule.forRoot({ ServeStaticModule.forRoot({
@ -71,17 +70,6 @@ import { UserModule } from './user/user.module';
UserModule UserModule
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [CronService]
AlphaVantageService,
ConfigurationService,
CronService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class AppModule {} export class AppModule {}

View File

@ -7,13 +7,13 @@ import { AuthDevice, Prisma } from '@prisma/client';
export class AuthDeviceService { export class AuthDeviceService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async authDevice( public async authDevice(
where: Prisma.AuthDeviceWhereUniqueInput where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice | null> { ): Promise<AuthDevice | null> {
return this.prisma.authDevice.findUnique({ return this.prismaService.authDevice.findUnique({
where where
}); });
} }
@ -26,7 +26,7 @@ export class AuthDeviceService {
orderBy?: Prisma.AuthDeviceOrderByInput; orderBy?: Prisma.AuthDeviceOrderByInput;
}): Promise<AuthDevice[]> { }): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prisma.authDevice.findMany({ return this.prismaService.authDevice.findMany({
skip, skip,
take, take,
cursor, cursor,
@ -38,7 +38,7 @@ export class AuthDeviceService {
public async createAuthDevice( public async createAuthDevice(
data: Prisma.AuthDeviceCreateInput data: Prisma.AuthDeviceCreateInput
): Promise<AuthDevice> { ): Promise<AuthDevice> {
return this.prisma.authDevice.create({ return this.prismaService.authDevice.create({
data data
}); });
} }
@ -49,7 +49,7 @@ export class AuthDeviceService {
}): Promise<AuthDevice> { }): Promise<AuthDevice> {
const { data, where } = params; const { data, where } = params;
return this.prisma.authDevice.update({ return this.prismaService.authDevice.update({
data, data,
where where
}); });
@ -58,7 +58,7 @@ export class AuthDeviceService {
public async deleteAuthDevice( public async deleteAuthDevice(
where: Prisma.AuthDeviceWhereUniqueInput where: Prisma.AuthDeviceWhereUniqueInput
): Promise<AuthDevice> { ): Promise<AuthDevice> {
return this.prisma.authDevice.delete({ return this.prismaService.authDevice.delete({
where where
}); });
} }

View File

@ -1,11 +1,12 @@
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service'; import { WebAuthService } from '@ghostfolio/api/app/auth/web-auth.service';
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { UserService } from '../user/user.service';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { GoogleStrategy } from './google.strategy'; import { GoogleStrategy } from './google.strategy';
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '180 days' } signOptions: { expiresIn: '180 days' }
}) }),
SubscriptionModule
], ],
providers: [ providers: [
AuthDeviceService, AuthDeviceService,

View File

@ -1,8 +1,8 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { Injectable, InternalServerErrorException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { UserService } from '../user/user.service';
import { ValidateOAuthLoginParams } from './interfaces/interfaces'; import { ValidateOAuthLoginParams } from './interfaces/interfaces';
@Injectable() @Injectable()

View File

@ -1,16 +1,15 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable, UnauthorizedException } from '@nestjs/common'; import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { UserService } from '../user/user.service';
@Injectable() @Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
public constructor( public constructor(
readonly configurationService: ConfigurationService, readonly configurationService: ConfigurationService,
private prisma: PrismaService, private readonly prismaService: PrismaService,
private readonly userService: UserService private readonly userService: UserService
) { ) {
super({ super({
@ -24,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
const user = await this.userService.user({ id }); const user = await this.userService.user({ id });
if (user) { if (user) {
await this.prisma.analytics.upsert({ await this.prismaService.analytics.upsert({
create: { User: { connect: { id: user.id } } }, create: { User: { connect: { id: user.id } } },
update: { activityCount: { increment: 1 }, updatedAt: new Date() }, update: { activityCount: { increment: 1 }, updatedAt: new Date() },
where: { userId: user.id } where: { userId: user.id }

View File

@ -1,5 +1,6 @@
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto'; import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
@ -22,7 +23,6 @@ import {
verifyAttestationResponse verifyAttestationResponse
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { UserService } from '../user/user.service';
import { import {
AssertionCredentialJSON, AssertionCredentialJSON,
AttestationCredentialJSON AttestationCredentialJSON

View File

@ -1,11 +1,10 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
import { CacheService } from './cache.service';
@Controller('cache') @Controller('cache')
export class CacheController { export class CacheController {
public constructor( public constructor(
@ -21,6 +20,6 @@ export class CacheController {
public async flushCache(): Promise<void> { public async flushCache(): Promise<void> {
this.redisCacheService.reset(); this.redisCacheService.reset();
return this.cacheService.flush(this.request.user.id); return this.cacheService.flush();
} }
} }

View File

@ -1,13 +1,30 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { CacheController } from './cache.controller'; import { CacheController } from './cache.controller';
import { CacheService } from './cache.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [RedisCacheModule],
controllers: [CacheController], controllers: [CacheController],
providers: [CacheService, PrismaService] providers: [
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class CacheModule {} export class CacheModule {}

View File

@ -1,16 +1,14 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class CacheService { export class CacheService {
public constructor(private prisma: PrismaService) {} public constructor(
private readonly dataGaterhingService: DataGatheringService
) {}
public async flush(aUserId: string): Promise<void> { public async flush(): Promise<void> {
await this.prisma.property.deleteMany({ await this.dataGaterhingService.reset();
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
return; return;
} }

View File

@ -1,30 +0,0 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
@Module({
imports: [],
controllers: [],
providers: [
AlphaVantageService,
ConfigurationService,
CurrentRateService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
MarketDataService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
})
export class CoreModule {}

View File

@ -1,6 +0,0 @@
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
export interface TransactionPoint {
date: string;
items: TransactionPointSymbol[];
}

View File

@ -1,34 +1,23 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExperimentalController } from './experimental.controller'; import { ExperimentalController } from './experimental.controller';
import { ExperimentalService } from './experimental.service'; import { ExperimentalService } from './experimental.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
RedisCacheModule,
PrismaModule
],
controllers: [ExperimentalController], controllers: [ExperimentalController],
providers: [ providers: [AccountService, ExperimentalService]
AccountService,
AlphaVantageService,
ConfigurationService,
DataProviderService,
ExchangeRateDataService,
ExperimentalService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
RulesService,
YahooFinanceService
]
}) })
export class ExperimentalModule {} export class ExperimentalModule {}

View File

@ -1,8 +1,7 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
@ -11,12 +10,11 @@ export class ExperimentalService {
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private prisma: PrismaService, private readonly prismaService: PrismaService
private readonly rulesService: RulesService
) {} ) {}
public async getBenchmark(aSymbol: string) { public async getBenchmark(aSymbol: string) {
return this.prisma.marketData.findMany({ return this.prismaService.marketData.findMany({
orderBy: { date: 'asc' }, orderBy: { date: 'asc' },
select: { date: true, marketPrice: true }, select: { date: true, marketPrice: true },
where: { symbol: aSymbol } where: { symbol: aSymbol }

View File

@ -1,32 +1,23 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ExportController } from './export.controller'; import { ExportController } from './export.controller';
import { ExportService } from './export.service'; import { ExportService } from './export.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
PrismaModule,
RedisCacheModule
],
controllers: [ExportController], controllers: [ExportController],
providers: [ providers: [CacheService, ExportService]
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExportService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class ExportModule {} export class ExportModule {}

View File

@ -5,10 +5,10 @@ import { Injectable } from '@nestjs/common';
@Injectable() @Injectable()
export class ExportService { export class ExportService {
public constructor(private prisma: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async export({ userId }: { userId: string }): Promise<Export> { public async export({ userId }: { userId: string }): Promise<Export> {
const orders = await this.prisma.order.findMany({ const orders = await this.prismaService.order.findMany({
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
select: { select: {
currency: true, currency: true,

View File

@ -1,34 +1,24 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ImportController } from './import.controller'; import { ImportController } from './import.controller';
import { ImportService } from './import.service'; import { ImportService } from './import.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
PrismaModule,
RedisCacheModule
],
controllers: [ImportController], controllers: [ImportController],
providers: [ providers: [CacheService, ImportService, OrderService]
AlphaVantageService,
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImportService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class ImportModule {} export class ImportModule {}

View File

@ -24,20 +24,17 @@ export class ImportService {
type, type,
unitPrice unitPrice
} of orders) { } of orders) {
await this.orderService.createOrder( await this.orderService.createOrder({
{ currency,
currency, dataSource,
dataSource, fee,
fee, quantity,
quantity, symbol,
symbol, type,
type, unitPrice,
unitPrice, date: parseISO(<string>(<unknown>date)),
date: parseISO(<string>(<unknown>date)), User: { connect: { id: userId } }
User: { connect: { id: userId } } });
},
userId
);
} }
} }
} }

View File

@ -1,4 +1,10 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
@ -14,6 +20,16 @@ import { InfoService } from './info.service';
}) })
], ],
controllers: [InfoController], controllers: [InfoController],
providers: [ConfigurationService, InfoService, PrismaService] providers: [
AlphaVantageService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
InfoService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class InfoModule {} export class InfoModule {}

View File

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -15,13 +16,14 @@ export class InfoService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private jwtService: JwtService, private readonly dataGatheringService: DataGatheringService,
private prisma: PrismaService private readonly jwtService: JwtService,
private readonly prismaService: PrismaService
) {} ) {}
public async get(): Promise<InfoItem> { public async get(): Promise<InfoItem> {
const info: Partial<InfoItem> = {}; const info: Partial<InfoItem> = {};
const platforms = await this.prisma.platform.findMany({ const platforms = await this.prismaService.platform.findMany({
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
select: { id: true, name: true } select: { id: true, name: true }
}); });
@ -63,7 +65,7 @@ export class InfoService {
} }
private async countActiveUsers(aDays: number) { private async countActiveUsers(aDays: number) {
return await this.prisma.user.count({ return await this.prismaService.user.count({
orderBy: { orderBy: {
Analytics: { Analytics: {
updatedAt: 'desc' updatedAt: 'desc'
@ -116,11 +118,10 @@ export class InfoService {
} }
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = await this.prisma.property.findUnique({ const lastDataGathering =
where: { key: 'LAST_DATA_GATHERING' } await this.dataGatheringService.getLastDataGathering();
});
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null; return lastDataGathering ?? null;
} }
private async getStatistics() { private async getStatistics() {
@ -144,7 +145,7 @@ export class InfoService {
return undefined; return undefined;
} }
const stripeConfig = await this.prisma.property.findUnique({ const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' } where: { key: 'STRIPE_CONFIG' }
}); });

View File

@ -1,3 +1,4 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper'; import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import {
@ -34,7 +35,8 @@ export class OrderController {
public constructor( public constructor(
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService, private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {} ) {}
@Delete(':id') @Delete(':id')
@ -52,15 +54,12 @@ export class OrderController {
); );
} }
return this.orderService.deleteOrder( return this.orderService.deleteOrder({
{ id_userId: {
id_userId: { id,
id, userId: this.request.user.id
userId: this.request.user.id }
} });
},
this.request.user.id
);
} }
@Get() @Get()
@ -80,6 +79,11 @@ export class OrderController {
include: { include: {
Platform: true Platform: true
} }
},
SymbolProfile: {
select: {
name: true
}
} }
}, },
orderBy: { date: 'desc' }, orderBy: { date: 'desc' },
@ -87,11 +91,8 @@ export class OrderController {
}); });
if ( if (
impersonationUserId && impersonationUserId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']); orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
} }
@ -130,33 +131,30 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
delete data.accountId; delete data.accountId;
return this.orderService.createOrder( return this.orderService.createOrder({
{ ...data,
...data, Account: {
Account: { connect: {
connect: { id_userId: { id: accountId, userId: this.request.user.id }
id_userId: { id: accountId, userId: this.request.user.id } }
} },
}, date,
date, SymbolProfile: {
SymbolProfile: { connectOrCreate: {
connectOrCreate: { where: {
where: { dataSource_symbol: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
},
create: {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
} }
}, }
User: { connect: { id: this.request.user.id } }
}, },
this.request.user.id User: { connect: { id: this.request.user.id } }
); });
} }
@Put(':id') @Put(':id')
@ -193,26 +191,23 @@ export class OrderController {
const accountId = data.accountId; const accountId = data.accountId;
delete data.accountId; delete data.accountId;
return this.orderService.updateOrder( return this.orderService.updateOrder({
{ data: {
data: { ...data,
...data, date,
date, Account: {
Account: { connect: {
connect: { id_userId: { id: accountId, userId: this.request.user.id }
id_userId: { id: accountId, userId: this.request.user.id }
}
},
User: { connect: { id: this.request.user.id } }
},
where: {
id_userId: {
id,
userId: this.request.user.id
} }
} },
User: { connect: { id: this.request.user.id } }
}, },
this.request.user.id where: {
); id_userId: {
id,
userId: this.request.user.id
}
}
});
} }
} }

View File

@ -1,34 +1,28 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
import { OrderController } from './order.controller'; import { OrderController } from './order.controller';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ImpersonationModule,
PrismaModule,
RedisCacheModule,
UserModule
],
controllers: [OrderController], controllers: [OrderController],
providers: [ providers: [CacheService, OrderService],
AlphaVantageService, exports: [OrderService]
CacheService,
ConfigurationService,
DataGatheringService,
DataProviderService,
GhostfolioScraperApiService,
ImpersonationService,
OrderService,
PrismaService,
RakutenRapidApiService,
YahooFinanceService
]
}) })
export class OrderModule {} export class OrderModule {}

View File

@ -1,3 +1,4 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
@ -5,22 +6,18 @@ import { Injectable } from '@nestjs/common';
import { DataSource, Order, Prisma } from '@prisma/client'; import { DataSource, Order, Prisma } from '@prisma/client';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { CacheService } from '../cache/cache.service';
import { RedisCacheService } from '../redis-cache/redis-cache.service';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly redisCacheService: RedisCacheService, private readonly prismaService: PrismaService
private prisma: PrismaService
) {} ) {}
public async order( public async order(
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
): Promise<Order | null> { ): Promise<Order | null> {
return this.prisma.order.findUnique({ return this.prismaService.order.findUnique({
where: orderWhereUniqueInput where: orderWhereUniqueInput
}); });
} }
@ -35,7 +32,7 @@ export class OrderService {
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
return this.prisma.order.findMany({ return this.prismaService.order.findMany({
cursor, cursor,
include, include,
orderBy, orderBy,
@ -45,13 +42,10 @@ export class OrderService {
}); });
} }
public async createOrder( public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
data: Prisma.OrderCreateInput, const isDraft = isAfter(data.date as Date, endOfToday());
aUserId: string
): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`);
if (!isAfter(data.date as Date, endOfToday())) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
@ -64,48 +58,75 @@ export class OrderService {
this.dataGatheringService.gatherProfileData([data.symbol]); this.dataGatheringService.gatherProfileData([data.symbol]);
await this.cacheService.flush(aUserId); await this.cacheService.flush();
return this.prisma.order.create({ return this.prismaService.order.create({
data data: {
...data,
isDraft
}
}); });
} }
public async deleteOrder( public async deleteOrder(
where: Prisma.OrderWhereUniqueInput, where: Prisma.OrderWhereUniqueInput
aUserId: string
): Promise<Order> { ): Promise<Order> {
this.redisCacheService.remove(`${aUserId}.portfolio`); return this.prismaService.order.delete({
return this.prisma.order.delete({
where where
}); });
} }
public async updateOrder( public getOrders({
params: { includeDrafts = false,
where: Prisma.OrderWhereUniqueInput; userId
data: Prisma.OrderUpdateInput; }: {
}, includeDrafts?: boolean;
aUserId: string userId: string;
): Promise<Order> { }) {
const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) {
where.isDraft = false;
}
return this.orders({
where,
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' }
});
}
public async updateOrder(params: {
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
}): Promise<Order> {
const { data, where } = params; const { data, where } = params;
this.redisCacheService.remove(`${aUserId}.portfolio`); const isDraft = isAfter(data.date as Date, endOfToday());
// Gather symbol data of order in the background if (!isDraft) {
this.dataGatheringService.gatherSymbols([ // Gather symbol data of order in the background, if not draft
{ this.dataGatheringService.gatherSymbols([
dataSource: <DataSource>data.dataSource, {
date: <Date>data.date, dataSource: <DataSource>data.dataSource,
symbol: <string>data.symbol date: <Date>data.date,
} symbol: <string>data.symbol
]); }
]);
}
await this.cacheService.flush(aUserId); await this.cacheService.flush();
return this.prisma.order.update({ return this.prismaService.order.update({
data, data: {
...data,
isDraft
},
where where
}); });
} }

View File

@ -1,8 +1,8 @@
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Currency, MarketData } from '@prisma/client'; import { Currency, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service'; import { MarketDataService } from './market-data.service';
jest.mock('./market-data.service', () => { jest.mock('./market-data.service', () => {

View File

@ -1,13 +1,13 @@
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GetValueParams } from '@ghostfolio/api/app/core/interfaces/get-value-params.interface';
import { GetValuesParams } from '@ghostfolio/api/app/core/interfaces/get-values-params.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isBefore, isToday } from 'date-fns'; import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
import { MarketDataService } from './market-data.service'; import { MarketDataService } from './market-data.service';
@Injectable() @Injectable()

View File

@ -1,6 +1,7 @@
import { DateQuery } from '@ghostfolio/api/app/core/interfaces/date-query.interface';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { DateQuery } from './date-query.interface';
export interface GetValuesParams { export interface GetValuesParams {
currencies: { [symbol: string]: Currency }; currencies: { [symbol: string]: Currency };
dateQuery: DateQuery; dateQuery: DateQuery;

View File

@ -5,7 +5,6 @@ export interface TransactionPointSymbol {
currency: Currency; currency: Currency;
firstBuyDate: string; firstBuyDate: string;
investment: Big; investment: Big;
name: string;
quantity: Big; quantity: Big;
symbol: string; symbol: string;
transactionCount: number; transactionCount: number;

View File

@ -0,0 +1,6 @@
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
export interface TransactionPoint {
date: string;
items: TransactionPointSymbol[];
}

View File

@ -7,7 +7,7 @@ import { DateQuery } from './interfaces/date-query.interface';
@Injectable() @Injectable()
export class MarketDataService { export class MarketDataService {
public constructor(private prisma: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async get({ public async get({
date, date,
@ -16,7 +16,7 @@ export class MarketDataService {
date: Date; date: Date;
symbol: string; symbol: string;
}): Promise<MarketData> { }): Promise<MarketData> {
return await this.prisma.marketData.findFirst({ return await this.prismaService.marketData.findFirst({
where: { where: {
symbol, symbol,
date: resetHours(date) date: resetHours(date)
@ -31,7 +31,7 @@ export class MarketDataService {
dateQuery: DateQuery; dateQuery: DateQuery;
symbols: string[]; symbols: string[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
return await this.prisma.marketData.findMany({ return await this.prismaService.marketData.findMany({
orderBy: [ orderBy: [
{ {
date: 'asc' date: 'asc'

View File

@ -1,11 +1,3 @@
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { GetValueParams } from '@ghostfolio/api/app/core/interfaces/get-value-params.interface';
import { GetValuesParams } from '@ghostfolio/api/app/core/interfaces/get-values-params.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelinePeriod } from '@ghostfolio/api/app/core/interfaces/timeline-period.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
import { OrderType } from '@ghostfolio/api/models/order-type'; import { OrderType } from '@ghostfolio/api/models/order-type';
import { parseDate, resetHours } from '@ghostfolio/common/helper'; import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
@ -18,6 +10,15 @@ import {
isSameDay isSameDay
} from 'date-fns'; } from 'date-fns';
import { CurrentRateService } from './current-rate.service';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import { TimelineSpecification } from './interfaces/timeline-specification.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
import { PortfolioCalculator } from './portfolio-calculator';
function mockGetValue(symbol: string, date: Date) { function mockGetValue(symbol: string, date: Date) {
switch (symbol) { switch (symbol) {
case 'AMZN': case 'AMZN':
@ -67,7 +68,7 @@ function mockGetValue(symbol: string, date: Date) {
} }
} }
jest.mock('@ghostfolio/api/app/core/current-rate.service', () => { jest.mock('./current-rate.service', () => {
return { return {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
@ -170,7 +171,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2019-02-01', firstBuyDate: '2019-02-01',
investment: new Big('1443.8'), investment: new Big('1443.8'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
transactionCount: 1 transactionCount: 1
@ -184,7 +184,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2019-02-01', firstBuyDate: '2019-02-01',
investment: new Big('2923.7'), investment: new Big('2923.7'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'), quantity: new Big('20'),
symbol: 'VTI', symbol: 'VTI',
transactionCount: 2 transactionCount: 2
@ -198,7 +197,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2019-02-01', firstBuyDate: '2019-02-01',
investment: new Big('652.55'), investment: new Big('652.55'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'VTI', symbol: 'VTI',
transactionCount: 3 transactionCount: 3
@ -212,7 +210,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2019-02-01', firstBuyDate: '2019-02-01',
investment: new Big('6627.05'), investment: new Big('6627.05'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('35'), quantity: new Big('35'),
symbol: 'VTI', symbol: 'VTI',
transactionCount: 5 transactionCount: 5
@ -226,7 +223,6 @@ describe('PortfolioCalculator', () => {
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2019-02-01', firstBuyDate: '2019-02-01',
investment: new Big('8403.95'), investment: new Big('8403.95'),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('45'), quantity: new Big('45'),
symbol: 'VTI', symbol: 'VTI',
transactionCount: 6 transactionCount: 6
@ -262,7 +258,6 @@ describe('PortfolioCalculator', () => {
date: '2019-02-01', date: '2019-02-01',
items: [ items: [
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('1443.8'), investment: new Big('1443.8'),
@ -276,7 +271,6 @@ describe('PortfolioCalculator', () => {
date: '2019-08-03', date: '2019-08-03',
items: [ items: [
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'), quantity: new Big('20'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('2923.7'), investment: new Big('2923.7'),
@ -290,7 +284,6 @@ describe('PortfolioCalculator', () => {
date: '2019-09-01', date: '2019-09-01',
items: [ items: [
{ {
name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
investment: new Big('10109.95'), investment: new Big('10109.95'),
@ -299,7 +292,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'), quantity: new Big('20'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('2923.7'), investment: new Big('2923.7'),
@ -313,7 +305,6 @@ describe('PortfolioCalculator', () => {
date: '2020-02-02', date: '2020-02-02',
items: [ items: [
{ {
name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
investment: new Big('10109.95'), investment: new Big('10109.95'),
@ -322,7 +313,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('652.55'), investment: new Big('652.55'),
@ -336,7 +326,6 @@ describe('PortfolioCalculator', () => {
date: '2021-02-01', date: '2021-02-01',
items: [ items: [
{ {
name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
investment: new Big('10109.95'), investment: new Big('10109.95'),
@ -345,7 +334,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'), quantity: new Big('15'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('2684.05'), investment: new Big('2684.05'),
@ -359,7 +347,6 @@ describe('PortfolioCalculator', () => {
date: '2021-08-01', date: '2021-08-01',
items: [ items: [
{ {
name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
investment: new Big('10109.95'), investment: new Big('10109.95'),
@ -368,7 +355,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'), quantity: new Big('25'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('4460.95'), investment: new Big('4460.95'),
@ -411,124 +397,9 @@ describe('PortfolioCalculator', () => {
const portfolioItemsAtTransactionPoints = const portfolioItemsAtTransactionPoints =
portfolioCalculator.getTransactionPoints(); portfolioCalculator.getTransactionPoints();
expect(portfolioItemsAtTransactionPoints).toEqual([ expect(portfolioItemsAtTransactionPoints).toEqual(
{ transactionPointsBuyAndSell
date: '2019-02-01', );
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'),
symbol: 'VTI',
investment: new Big('1443.8'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 1
}
]
},
{
date: '2019-08-03',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2019-09-01',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2020-02-02',
items: [
{
name: 'Amazon.com, Inc.',
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2020-08-02',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2021-02-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'),
symbol: 'VTI',
investment: new Big('2684.05'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 4
}
]
},
{
date: '2021-08-01',
items: [
{
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'),
symbol: 'VTI',
investment: new Big('4460.95'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 5
}
]
}
]);
}); });
it('with mixed symbols', () => { it('with mixed symbols', () => {
@ -545,7 +416,6 @@ describe('PortfolioCalculator', () => {
date: '2017-01-03', date: '2017-01-03',
items: [ items: [
{ {
name: 'Tesla, Inc.',
quantity: new Big('50'), quantity: new Big('50'),
symbol: 'TSLA', symbol: 'TSLA',
investment: new Big('2148.5'), investment: new Big('2148.5'),
@ -559,7 +429,6 @@ describe('PortfolioCalculator', () => {
date: '2017-07-01', date: '2017-07-01',
items: [ items: [
{ {
name: 'Bitcoin USD',
quantity: new Big('0.5614682'), quantity: new Big('0.5614682'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
investment: new Big('1999.9999999999998659756'), investment: new Big('1999.9999999999998659756'),
@ -568,7 +437,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Tesla, Inc.',
quantity: new Big('50'), quantity: new Big('50'),
symbol: 'TSLA', symbol: 'TSLA',
investment: new Big('2148.5'), investment: new Big('2148.5'),
@ -582,7 +450,6 @@ describe('PortfolioCalculator', () => {
date: '2018-09-01', date: '2018-09-01',
items: [ items: [
{ {
name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
investment: new Big('10109.95'), investment: new Big('10109.95'),
@ -591,7 +458,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Bitcoin USD',
quantity: new Big('0.5614682'), quantity: new Big('0.5614682'),
symbol: 'BTCUSD', symbol: 'BTCUSD',
investment: new Big('1999.9999999999998659756'), investment: new Big('1999.9999999999998659756'),
@ -600,7 +466,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Tesla, Inc.',
quantity: new Big('50'), quantity: new Big('50'),
symbol: 'TSLA', symbol: 'TSLA',
investment: new Big('2148.5'), investment: new Big('2148.5'),
@ -645,7 +510,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673 grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
investment: new Big('719.46'), investment: new Big('719.46'),
marketPrice: 657.62, marketPrice: 657.62,
name: 'Tesla, Inc.',
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'TSLA', symbol: 'TSLA',
transactionCount: 1 transactionCount: 1
@ -684,7 +548,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673 grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
investment: new Big('719.46'), investment: new Big('719.46'),
marketPrice: 657.62, marketPrice: 657.62,
name: 'Tesla, Inc.',
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'TSLA', symbol: 'TSLA',
transactionCount: 1 transactionCount: 1
@ -723,7 +586,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big('-0.01356013560135601356'), // 657.62/666.66-1=-0.013560136 grossPerformancePercentage: new Big('-0.01356013560135601356'), // 657.62/666.66-1=-0.013560136
investment: new Big('719.46'), investment: new Big('719.46'),
marketPrice: 657.62, marketPrice: 657.62,
name: 'Tesla, Inc.',
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'TSLA', symbol: 'TSLA',
transactionCount: 1 transactionCount: 1
@ -765,7 +627,6 @@ describe('PortfolioCalculator', () => {
), ),
investment: new Big('4460.95'), investment: new Big('4460.95'),
marketPrice: 194.86, marketPrice: 194.86,
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('25'), quantity: new Big('25'),
symbol: 'VTI', symbol: 'VTI',
transactionCount: 5 transactionCount: 5
@ -774,6 +635,138 @@ describe('PortfolioCalculator', () => {
}); });
}); });
it('with buy and sell', async () => {
const portfolioCalculator = new PortfolioCalculator(
currentRateService,
Currency.USD
);
portfolioCalculator.setTransactionPoints(transactionPointsBuyAndSell);
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date(Date.UTC(2020, 9, 24)).getTime()); // 2020-10-24
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2019-01-01')
);
spy.mockRestore();
expect(currentPositions).toEqual({
hasErrors: false,
currentValue: new Big('4871.5'),
grossPerformance: new Big('240.4'),
grossPerformancePercentage: new Big('0.01104605615757711361'),
totalInvestment: new Big('4460.95'),
positions: [
{
averagePrice: new Big('0'),
currency: 'USD',
firstBuyDate: '2019-09-01',
grossPerformance: new Big('0'),
grossPerformancePercentage: new Big('0'),
investment: new Big('0'),
marketPrice: 2021.99,
quantity: new Big('0'),
symbol: 'AMZN',
transactionCount: 2
},
{
averagePrice: new Big('178.438'),
currency: 'USD',
firstBuyDate: '2019-02-01',
grossPerformance: new Big('240.4'),
grossPerformancePercentage: new Big(
'0.08839407904876477101219019935616297754969945667391763908415656216989674494965785538864363782688167989866968512455219637257546280462751601552'
),
investment: new Big('4460.95'),
marketPrice: 194.86,
quantity: new Big('25'),
symbol: 'VTI',
transactionCount: 5
}
]
});
});
it('with buy, sell, buy', async () => {
const portfolioCalculator = new PortfolioCalculator(
currentRateService,
Currency.USD
);
portfolioCalculator.setTransactionPoints([
{
date: '2019-09-01',
items: [
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('805.9'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
}
]
},
{
date: '2020-08-02',
items: [
{
quantity: new Big('0'),
symbol: 'VTI',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
}
]
},
{
date: '2021-02-01',
items: [
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('1013.9'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 3
}
]
}
]);
const spy = jest
.spyOn(Date, 'now')
.mockImplementation(() => new Date(Date.UTC(2021, 7, 1)).getTime()); // 2021-08-01
const currentPositions = await portfolioCalculator.getCurrentPositions(
parseDate('2019-02-01')
);
spy.mockRestore();
expect(currentPositions).toEqual({
hasErrors: false,
currentValue: new Big('1086.7'),
grossPerformance: new Big('207.6'),
grossPerformancePercentage: new Big('0.2516103956224511062'),
totalInvestment: new Big('1013.9'),
positions: [
{
averagePrice: new Big('202.78'),
currency: 'USD',
firstBuyDate: '2019-09-01',
grossPerformance: new Big('207.6'),
grossPerformancePercentage: new Big(
'0.2516103956224511061954915466429950404846'
),
investment: new Big('1013.9'),
marketPrice: 217.34,
quantity: new Big('5'),
symbol: 'VTI',
transactionCount: 3
}
]
});
});
it('with performance since Jan 1st, 2020', async () => { it('with performance since Jan 1st, 2020', async () => {
const portfolioCalculator = new PortfolioCalculator( const portfolioCalculator = new PortfolioCalculator(
currentRateService, currentRateService,
@ -847,7 +840,6 @@ describe('PortfolioCalculator', () => {
grossPerformancePercentage: new Big( grossPerformancePercentage: new Big(
'0.2753783814827239834392742298083677500037' '0.2753783814827239834392742298083677500037'
), ),
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
currency: 'USD' currency: 'USD'
} }
] ]
@ -867,9 +859,8 @@ describe('PortfolioCalculator', () => {
date: '2010-12-31', date: '2010-12-31',
items: [ items: [
{ {
name: 'Mutual Fund A',
quantity: new Big('1000000'), // 1 million quantity: new Big('1000000'), // 1 million
symbol: 'MFA', symbol: 'MFA', // Mutual Fund A
investment: new Big('1000000'), // 1 million investment: new Big('1000000'), // 1 million
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2010-12-31', firstBuyDate: '2010-12-31',
@ -881,9 +872,8 @@ describe('PortfolioCalculator', () => {
date: '2011-08-15', date: '2011-08-15',
items: [ items: [
{ {
name: 'Mutual Fund A',
quantity: new Big('1086022.689344541'), // 1,000,000 + 100,000 / 1.162484 quantity: new Big('1086022.689344541'), // 1,000,000 + 100,000 / 1.162484
symbol: 'MFA', symbol: 'MFA', // Mutual Fund A
investment: new Big('1100000'), // 1,000,000 + 100,000 investment: new Big('1100000'), // 1,000,000 + 100,000
currency: Currency.USD, currency: Currency.USD,
firstBuyDate: '2010-12-31', firstBuyDate: '2010-12-31',
@ -919,7 +909,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 2, transactionCount: 2,
grossPerformance: new Big('92327.999656600898394721'), // 1'192'328 - 1'100'000 = 92'328 grossPerformance: new Big('92327.999656600898394721'), // 1'192'328 - 1'100'000 = 92'328
grossPerformancePercentage: new Big('0.09788498099999947808927632'), // 9.79 % grossPerformancePercentage: new Big('0.09788498099999947808927632'), // 9.79 %
name: 'Mutual Fund A',
currency: 'USD' currency: 'USD'
} }
] ]
@ -939,18 +928,16 @@ describe('PortfolioCalculator', () => {
date: '2012-12-31', date: '2012-12-31',
items: [ items: [
{ {
name: 'Sub Portfolio A',
quantity: new Big('200'), quantity: new Big('200'),
symbol: 'SPA', symbol: 'SPA', // Sub Portfolio A
investment: new Big('200'), investment: new Big('200'),
currency: Currency.CHF, currency: Currency.CHF,
firstBuyDate: '2012-12-31', firstBuyDate: '2012-12-31',
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Sub Portfolio B',
quantity: new Big('300'), quantity: new Big('300'),
symbol: 'SPB', symbol: 'SPB', // Sub Portfolio B
investment: new Big('300'), investment: new Big('300'),
currency: Currency.CHF, currency: Currency.CHF,
firstBuyDate: '2012-12-31', firstBuyDate: '2012-12-31',
@ -962,18 +949,16 @@ describe('PortfolioCalculator', () => {
date: '2013-12-31', date: '2013-12-31',
items: [ items: [
{ {
name: 'Sub Portfolio A',
quantity: new Big('200'), quantity: new Big('200'),
symbol: 'SPA', symbol: 'SPA', // Sub Portfolio A
investment: new Big('200'), investment: new Big('200'),
currency: Currency.CHF, currency: Currency.CHF,
firstBuyDate: '2012-12-31', firstBuyDate: '2012-12-31',
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Sub Portfolio B',
quantity: new Big('300'), quantity: new Big('300'),
symbol: 'SPB', symbol: 'SPB', // Sub Portfolio B
investment: new Big('300'), investment: new Big('300'),
currency: Currency.CHF, currency: Currency.CHF,
firstBuyDate: '2012-12-31', firstBuyDate: '2012-12-31',
@ -1009,7 +994,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1, transactionCount: 1,
grossPerformance: new Big('5'), // 205 - 200 grossPerformance: new Big('5'), // 205 - 200
grossPerformancePercentage: new Big('0.025'), grossPerformancePercentage: new Big('0.025'),
name: 'Sub Portfolio A',
currency: 'CHF' currency: 'CHF'
}, },
{ {
@ -1022,7 +1006,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1, transactionCount: 1,
grossPerformance: new Big('12'), // 312 - 300 grossPerformance: new Big('12'), // 312 - 300
grossPerformancePercentage: new Big('0.04'), grossPerformancePercentage: new Big('0.04'),
name: 'Sub Portfolio B',
currency: 'CHF' currency: 'CHF'
} }
] ]
@ -1611,7 +1594,6 @@ describe('PortfolioCalculator', () => {
date: '2019-02-01', date: '2019-02-01',
items: [ items: [
{ {
name: 'Amazon.com, Inc.',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'AMZN', symbol: 'AMZN',
investment: new Big('10109.95'), investment: new Big('10109.95'),
@ -1620,7 +1602,6 @@ describe('PortfolioCalculator', () => {
transactionCount: 1 transactionCount: 1
}, },
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('1443.8'), investment: new Big('1443.8'),
@ -1744,7 +1725,6 @@ const orderTslaTransactionPoint: TransactionPoint[] = [
date: '2021-01-01', date: '2021-01-01',
items: [ items: [
{ {
name: 'Tesla, Inc.',
quantity: new Big('1'), quantity: new Big('1'),
symbol: 'TSLA', symbol: 'TSLA',
investment: new Big('719.46'), investment: new Big('719.46'),
@ -1761,7 +1741,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2019-02-01', date: '2019-02-01',
items: [ items: [
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('10'), quantity: new Big('10'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('1443.8'), investment: new Big('1443.8'),
@ -1775,7 +1754,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2019-08-03', date: '2019-08-03',
items: [ items: [
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('20'), quantity: new Big('20'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('2923.7'), investment: new Big('2923.7'),
@ -1789,7 +1767,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2020-02-02', date: '2020-02-02',
items: [ items: [
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('5'), quantity: new Big('5'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('652.55'), investment: new Big('652.55'),
@ -1803,7 +1780,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2021-02-01', date: '2021-02-01',
items: [ items: [
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
quantity: new Big('15'), quantity: new Big('15'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('2684.05'), investment: new Big('2684.05'),
@ -1817,7 +1793,140 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
date: '2021-08-01', date: '2021-08-01',
items: [ items: [
{ {
name: 'Vanguard Total Stock Market Index Fund ETF Shares', quantity: new Big('25'),
symbol: 'VTI',
investment: new Big('4460.95'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 5
}
]
}
];
const transactionPointsBuyAndSell = [
{
date: '2019-02-01',
items: [
{
quantity: new Big('10'),
symbol: 'VTI',
investment: new Big('1443.8'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 1
}
]
},
{
date: '2019-08-03',
items: [
{
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2019-09-01',
items: [
{
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
quantity: new Big('20'),
symbol: 'VTI',
investment: new Big('2923.7'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 2
}
]
},
{
date: '2020-02-02',
items: [
{
quantity: new Big('5'),
symbol: 'AMZN',
investment: new Big('10109.95'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 1
},
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2020-08-02',
items: [
{
quantity: new Big('0'),
symbol: 'AMZN',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
},
{
quantity: new Big('5'),
symbol: 'VTI',
investment: new Big('652.55'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 3
}
]
},
{
date: '2021-02-01',
items: [
{
quantity: new Big('0'),
symbol: 'AMZN',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
},
{
quantity: new Big('15'),
symbol: 'VTI',
investment: new Big('2684.05'),
currency: Currency.USD,
firstBuyDate: '2019-02-01',
transactionCount: 4
}
]
},
{
date: '2021-08-01',
items: [
{
quantity: new Big('0'),
symbol: 'AMZN',
investment: new Big('0'),
currency: Currency.USD,
firstBuyDate: '2019-09-01',
transactionCount: 2
},
{
quantity: new Big('25'), quantity: new Big('25'),
symbol: 'VTI', symbol: 'VTI',
investment: new Big('4460.95'), investment: new Big('4460.95'),

View File

@ -1,14 +1,3 @@
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface';
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelinePeriod } from '@ghostfolio/api/app/core/interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
import { OrderType } from '@ghostfolio/api/models/order-type'; import { OrderType } from '@ghostfolio/api/models/order-type';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/interfaces';
@ -23,11 +12,22 @@ import {
isAfter, isAfter,
isBefore, isBefore,
max, max,
min, min
subDays
} from 'date-fns'; } from 'date-fns';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import { CurrentRateService } from './current-rate.service';
import { CurrentPositions } from './interfaces/current-positions.interface';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface';
import {
Accuracy,
TimelineSpecification
} from './interfaces/timeline-specification.interface';
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
import { TransactionPoint } from './interfaces/transaction-point.interface';
export class PortfolioCalculator { export class PortfolioCalculator {
private transactionPoints: TransactionPoint[]; private transactionPoints: TransactionPoint[];
@ -53,17 +53,19 @@ export class PortfolioCalculator {
const factor = this.getFactor(order.type); const factor = this.getFactor(order.type);
const unitPrice = new Big(order.unitPrice); const unitPrice = new Big(order.unitPrice);
if (oldAccumulatedSymbol) { if (oldAccumulatedSymbol) {
const newQuantity = order.quantity
.mul(factor)
.plus(oldAccumulatedSymbol.quantity);
currentTransactionPointItem = { currentTransactionPointItem = {
currency: order.currency, currency: order.currency,
firstBuyDate: oldAccumulatedSymbol.firstBuyDate, firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
investment: unitPrice investment: newQuantity.eq(0)
.mul(order.quantity) ? new Big(0)
.mul(factor) : unitPrice
.add(oldAccumulatedSymbol.investment), .mul(order.quantity)
name: order.name, .mul(factor)
quantity: order.quantity .add(oldAccumulatedSymbol.investment),
.mul(factor) quantity: newQuantity,
.plus(oldAccumulatedSymbol.quantity),
symbol: order.symbol, symbol: order.symbol,
transactionCount: oldAccumulatedSymbol.transactionCount + 1 transactionCount: oldAccumulatedSymbol.transactionCount + 1
}; };
@ -72,7 +74,6 @@ export class PortfolioCalculator {
currency: order.currency, currency: order.currency,
firstBuyDate: order.date, firstBuyDate: order.date,
investment: unitPrice.mul(order.quantity).mul(factor), investment: unitPrice.mul(order.quantity).mul(factor),
name: order.name,
quantity: order.quantity.mul(factor), quantity: order.quantity.mul(factor),
symbol: order.symbol, symbol: order.symbol,
transactionCount: 1 transactionCount: 1
@ -85,11 +86,7 @@ export class PortfolioCalculator {
const newItems = items.filter( const newItems = items.filter(
(transactionPointItem) => transactionPointItem.symbol !== order.symbol (transactionPointItem) => transactionPointItem.symbol !== order.symbol
); );
if (!currentTransactionPointItem.quantity.eq(0)) { newItems.push(currentTransactionPointItem);
newItems.push(currentTransactionPointItem);
} else {
delete symbols[order.symbol];
}
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol)); newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
if (lastDate !== currentDate || lastTransactionPoint === null) { if (lastDate !== currentDate || lastTransactionPoint === null) {
lastTransactionPoint = { lastTransactionPoint = {
@ -234,42 +231,46 @@ export class PortfolioCalculator {
if (i === firstIndex || !initialValues[item.symbol]) { if (i === firstIndex || !initialValues[item.symbol]) {
initialValues[item.symbol] = initialValue; initialValues[item.symbol] = initialValue;
} }
if (!initialValue) { if (!item.quantity.eq(0)) {
invalidSymbols.push(item.symbol); if (!initialValue) {
hasErrors = true; invalidSymbols.push(item.symbol);
console.error( hasErrors = true;
`Missing value for symbol ${item.symbol} at ${currentDate}` console.error(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
}
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
); );
continue;
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] =
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] =
oldGrossPerformance.plus(currentPerformance);
} }
const cashFlow = lastInvestment;
const endValue = marketSymbolMap[nextDate][item.symbol].mul(
item.quantity
);
const holdingPeriodReturn = endValue.div(initialValue.plus(cashFlow));
holdingPeriodReturns[item.symbol] =
oldHoldingPeriodReturn.mul(holdingPeriodReturn);
let oldGrossPerformance = grossPerformance[item.symbol];
if (!oldGrossPerformance) {
oldGrossPerformance = new Big(0);
}
const currentPerformance = endValue.minus(investedValue);
grossPerformance[item.symbol] =
oldGrossPerformance.plus(currentPerformance);
lastInvestments[item.symbol] = item.investment; lastInvestments[item.symbol] = item.investment;
lastQuantities[item.symbol] = item.quantity; lastQuantities[item.symbol] = item.quantity;
} }
} }
const positions: TimelinePosition[] = []; const positions: TimelinePosition[] = [];
for (const item of lastTransactionPoint.items) { for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol]; const marketValue = marketSymbolMap[todayString]?.[item.symbol];
const isValid = invalidSymbols.indexOf(item.symbol) === -1; const isValid = invalidSymbols.indexOf(item.symbol) === -1;
positions.push({ positions.push({
averagePrice: item.investment.div(item.quantity), averagePrice: item.quantity.eq(0)
? new Big(0)
: item.investment.div(item.quantity),
currency: item.currency, currency: item.currency,
firstBuyDate: item.firstBuyDate, firstBuyDate: item.firstBuyDate,
grossPerformance: isValid grossPerformance: isValid
@ -281,7 +282,6 @@ export class PortfolioCalculator {
: null, : null,
investment: item.investment, investment: item.investment,
marketPrice: marketValue?.toNumber() ?? null, marketPrice: marketValue?.toNumber() ?? null,
name: item.name,
quantity: item.quantity, quantity: item.quantity,
symbol: item.symbol, symbol: item.symbol,
transactionCount: item.transactionCount transactionCount: item.transactionCount
@ -401,7 +401,7 @@ export class PortfolioCalculator {
grossPerformance = grossPerformance.plus( grossPerformance = grossPerformance.plus(
currentPosition.grossPerformance currentPosition.grossPerformance
); );
} else { } else if (!currentPosition.quantity.eq(0)) {
hasErrors = true; hasErrors = true;
} }
@ -414,20 +414,25 @@ export class PortfolioCalculator {
grossPerformancePercentage = grossPerformancePercentage.plus( grossPerformancePercentage = grossPerformancePercentage.plus(
currentPosition.grossPerformancePercentage.mul(currentInitialValue) currentPosition.grossPerformancePercentage.mul(currentInitialValue)
); );
} else { } else if (!currentPosition.quantity.eq(0)) {
console.error( console.error(
`Initial value is missing for symbol ${currentPosition.symbol}` `Initial value is missing for symbol ${currentPosition.symbol}`
); );
hasErrors = true; hasErrors = true;
} }
} }
if (!completeInitialValue.eq(0)) {
grossPerformancePercentage =
grossPerformancePercentage.div(completeInitialValue);
}
return { return {
currentValue, currentValue,
grossPerformance, grossPerformance,
grossPerformancePercentage,
hasErrors, hasErrors,
totalInvestment, totalInvestment
grossPerformancePercentage:
grossPerformancePercentage.div(completeInitialValue)
}; };
} }

View File

@ -1,22 +1,16 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
hasNotDefinedValuesInObject, hasNotDefinedValuesInObject,
nullifyValuesInObject nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper'; } from '@ghostfolio/api/helper/object.helper';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { import {
PortfolioItem, PortfolioDetails,
PortfolioOverview,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioReport,
PortfolioReport PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import {
getPermissions,
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
@ -45,12 +39,12 @@ import { PortfolioService } from './portfolio.service';
export class PortfolioController { export class PortfolioController {
public constructor( public constructor(
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly impersonationService: ImpersonationService, private readonly portfolioService: PortfolioService,
private portfolioService: PortfolioService, @Inject(REQUEST) private readonly request: RequestWithUser,
@Inject(REQUEST) private readonly request: RequestWithUser private readonly userService: UserService
) {} ) {}
@Get('/investments') @Get('investments')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async findAll( public async findAll(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
@ -60,11 +54,8 @@ export class PortfolioController {
); );
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
const maxInvestment = investments.reduce( const maxInvestment = investments.reduce(
(investment, item) => Math.max(investment, item.investment), (investment, item) => Math.max(investment, item.investment),
@ -105,11 +96,8 @@ export class PortfolioController {
} }
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
let maxValue = 0; let maxValue = 0;
@ -136,44 +124,25 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId, @Headers('impersonation-id') impersonationId,
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<{ [symbol: string]: PortfolioPosition }> { ): Promise<PortfolioDetails> {
let details: { [symbol: string]: PortfolioPosition } = {}; const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails(impersonationId, range);
const impersonationUserId = if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
try {
details = await this.portfolioService.getDetails(
impersonationUserId,
range
);
} catch (error) {
console.error(error);
res.status(StatusCodes.ACCEPTED);
}
if (hasNotDefinedValuesInObject(details)) {
res.status(StatusCodes.ACCEPTED); res.status(StatusCodes.ACCEPTED);
} }
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
const totalInvestment = Object.values(details) const totalInvestment = Object.values(holdings)
.map((portfolioPosition) => { .map((portfolioPosition) => {
return portfolioPosition.investment; return portfolioPosition.investment;
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
const totalValue = Object.values(details) const totalValue = Object.values(holdings)
.map((portfolioPosition) => { .map((portfolioPosition) => {
return this.exchangeRateDataService.toCurrency( return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice, portfolioPosition.quantity * portfolioPosition.marketPrice,
@ -183,50 +152,21 @@ export class PortfolioController {
}) })
.reduce((a, b) => a + b, 0); .reduce((a, b) => a + b, 0);
for (const [symbol, portfolioPosition] of Object.entries(details)) { for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPosition.grossPerformance = null; portfolioPosition.grossPerformance = null;
portfolioPosition.investment = portfolioPosition.investment =
portfolioPosition.investment / totalInvestment; portfolioPosition.investment / totalInvestment;
for (const [account, { current, original }] of Object.entries(
portfolioPosition.accounts
)) {
portfolioPosition.accounts[account].current = current / totalValue;
portfolioPosition.accounts[account].original =
original / totalInvestment;
}
portfolioPosition.quantity = null; portfolioPosition.quantity = null;
} }
for (const [name, { current, original }] of Object.entries(accounts)) {
accounts[name].current = current / totalValue;
accounts[name].original = original / totalInvestment;
}
} }
return <any>res.json(details); return <any>res.json({ accounts, holdings });
}
@Get('overview')
@UseGuards(AuthGuard('jwt'))
public async getOverview(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioOverview> {
let overview = await this.portfolioService.getOverview(impersonationId);
if (
impersonationId &&
!hasPermission(
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) {
overview = nullifyValuesInObject(overview, [
'cash',
'committedFunds',
'fees',
'totalBuy',
'totalSell'
]);
}
return overview;
} }
@Get('performance') @Get('performance')
@ -247,15 +187,11 @@ export class PortfolioController {
let performance = performanceInformation.performance; let performance = performanceInformation.performance;
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
performance = nullifyValuesInObject(performance, [ performance = nullifyValuesInObject(performance, [
'currentGrossPerformance', 'currentGrossPerformance',
'currentNetPerformance',
'currentValue' 'currentValue'
]); ]);
} }
@ -279,9 +215,48 @@ export class PortfolioController {
res.status(StatusCodes.ACCEPTED); res.status(StatusCodes.ACCEPTED);
} }
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
result.positions = result.positions.map((position) => {
return nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'quantity'
]);
});
}
return <any>res.json(result); return <any>res.json(result);
} }
@Get('summary')
@UseGuards(AuthGuard('jwt'))
public async getSummary(
@Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId);
if (
impersonationId ||
this.userService.isRestrictedView(this.request.user)
) {
summary = nullifyValuesInObject(summary, [
'cash',
'committedFunds',
'currentGrossPerformance',
'currentValue',
'fees',
'netWorth',
'totalBuy',
'totalSell'
]);
}
return summary;
}
@Get('position/:symbol') @Get('position/:symbol')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getPosition( public async getPosition(
@ -295,13 +270,14 @@ export class PortfolioController {
if (position) { if (position) {
if ( if (
impersonationId && impersonationId ||
!hasPermission( this.userService.isRestrictedView(this.request.user)
getPermissions(this.request.user.role),
permissions.readForeignPortfolio
)
) { ) {
position = nullifyValuesInObject(position, ['grossPerformance']); position = nullifyValuesInObject(position, [
'grossPerformance',
'investment',
'quantity'
]);
} }
return position; return position;

View File

@ -1,50 +1,40 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service'; import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { MarketDataService } from '@ghostfolio/api/app/core/market-data.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service';
import { MarketDataService } from './market-data.service';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@Module({ @Module({
imports: [RedisCacheModule], imports: [
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
OrderModule,
PrismaModule,
UserModule
],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [ providers: [
AccountService, AccountService,
AlphaVantageService,
CacheService,
CurrentRateService, CurrentRateService,
ConfigurationService,
DataGatheringService,
DataProviderService,
ExchangeRateDataService,
GhostfolioScraperApiService,
ImpersonationService,
MarketDataService, MarketDataService,
OrderService,
PortfolioService, PortfolioService,
PrismaService,
RakutenRapidApiService,
RulesService, RulesService,
SymbolProfileService, SymbolProfileService
UserService,
YahooFinanceService
] ]
}) })
export class PortfolioModule {} export class PortfolioModule {}

View File

@ -1,10 +1,11 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service'; import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service'; import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { PortfolioOrder } from '@ghostfolio/api/app/core/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/core/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/core/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/core/portfolio-calculator';
import { OrderService } from '@ghostfolio/api/app/order/order.service'; import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service';
import { PortfolioOrder } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-order.interface';
import { TimelineSpecification } from '@ghostfolio/api/app/portfolio/interfaces/timeline-specification.interface';
import { TransactionPoint } from '@ghostfolio/api/app/portfolio/interfaces/transaction-point.interface';
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/portfolio-calculator';
import { OrderType } from '@ghostfolio/api/models/order-type'; import { OrderType } from '@ghostfolio/api/models/order-type';
import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment'; import { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-investment';
import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment'; import { AccountClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/initial-investment';
@ -14,19 +15,20 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment'; import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment'; import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment'; import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service'; import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface'; import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { RulesService } from '@ghostfolio/api/services/rules.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY, ghostfolioCashSymbol } from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper'; import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import { import {
PortfolioOverview, PortfolioDetails,
PortfolioPerformance, PortfolioPerformance,
PortfolioPosition, PortfolioPosition,
PortfolioReport, PortfolioReport,
PortfolioSummary,
Position, Position,
TimelinePosition TimelinePosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
@ -38,7 +40,12 @@ import {
} from '@ghostfolio/common/types'; } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { Currency, DataSource, Type as TypeOfOrder } from '@prisma/client'; import {
AssetClass,
Currency,
DataSource,
Type as TypeOfOrder
} from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
endOfToday, endOfToday,
@ -58,6 +65,7 @@ import {
HistoricalDataItem, HistoricalDataItem,
PortfolioPositionDetail PortfolioPositionDetail
} from './interfaces/portfolio-position-detail.interface'; } from './interfaces/portfolio-position-detail.interface';
import { RulesService } from './rules.service';
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
@ -83,7 +91,10 @@ export class PortfolioService {
this.request.user.Settings.currency this.request.user.Settings.currency
); );
const { transactionPoints } = await this.getTransactionPoints(userId); const { transactionPoints } = await this.getTransactionPoints({
userId,
includeDrafts: true
});
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return [];
@ -108,7 +119,7 @@ export class PortfolioService {
this.request.user.Settings.currency this.request.user.Settings.currency
); );
const { transactionPoints } = await this.getTransactionPoints(userId); const { transactionPoints } = await this.getTransactionPoints({ userId });
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
if (transactionPoints.length === 0) { if (transactionPoints.length === 0) {
return []; return [];
@ -141,35 +152,10 @@ export class PortfolioService {
})); }));
} }
public async getOverview(
aImpersonationId: string
): Promise<PortfolioOverview> {
const userId = await this.getUserId(aImpersonationId);
const currency = this.request.user.Settings.currency;
const { balance } = await this.accountService.getCashDetails(
userId,
currency
);
const orders = await this.getOrders(userId);
const fees = this.getFees(orders);
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
return {
committedFunds: totalBuy - totalSell,
fees,
cash: balance,
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
};
}
public async getDetails( public async getDetails(
aImpersonationId: string, aImpersonationId: string,
aDateRange: DateRange = 'max' aDateRange: DateRange = 'max'
): Promise<{ [symbol: string]: PortfolioPosition }> { ): Promise<PortfolioDetails & { hasErrors: boolean }> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId);
const userCurrency = this.request.user.Settings.currency; const userCurrency = this.request.user.Settings.currency;
@ -178,12 +164,12 @@ export class PortfolioService {
userCurrency userCurrency
); );
const { transactionPoints, orders } = await this.getTransactionPoints( const { orders, transactionPoints } = await this.getTransactionPoints({
userId userId
); });
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return {}; return { accounts: {}, holdings: {}, hasErrors: false };
} }
portfolioCalculator.setTransactionPoints(transactionPoints); portfolioCalculator.setTransactionPoints(transactionPoints);
@ -194,12 +180,16 @@ export class PortfolioService {
startDate startDate
); );
if (currentPositions.hasErrors) { const cashDetails = await this.accountService.getCashDetails(
throw new Error('Missing information'); userId,
} userCurrency
);
const result: { [symbol: string]: PortfolioPosition } = {}; const holdings: PortfolioDetails['holdings'] = {};
const totalValue = currentPositions.currentValue; const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance
);
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const symbols = currentPositions.positions.map( const symbols = currentPositions.positions.map(
(position) => position.symbol (position) => position.symbol
@ -219,37 +209,49 @@ export class PortfolioService {
for (const position of currentPositions.positions) { for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
for (const item of currentPositions.positions) { for (const item of currentPositions.positions) {
const value = item.quantity.mul(item.marketPrice); const value = item.quantity.mul(item.marketPrice);
const symbolProfile = symbolProfileMap[item.symbol]; const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol]; const dataProviderResponse = dataProviderResponses[item.symbol];
result[item.symbol] = { holdings[item.symbol] = {
accounts,
allocationCurrent: value.div(totalValue).toNumber(), allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment allocationInvestment: item.investment.div(totalInvestment).toNumber(),
.div(currentPositions.totalInvestment) assetClass: symbolProfile.assetClass,
.toNumber(), assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
exchange: dataProviderResponse.exchange, exchange: dataProviderResponse.exchange,
grossPerformance: item.grossPerformance.toNumber(), grossPerformance: item.grossPerformance?.toNumber() ?? 0,
grossPerformancePercent: item.grossPerformancePercentage.toNumber(), grossPerformancePercent:
item.grossPerformancePercentage?.toNumber() ?? 0,
investment: item.investment.toNumber(), investment: item.investment.toNumber(),
marketPrice: item.marketPrice, marketPrice: item.marketPrice,
marketState: dataProviderResponse.marketState, marketState: dataProviderResponse.marketState,
name: item.name, name: symbolProfile.name,
quantity: item.quantity.toNumber(), quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors, sectors: symbolProfile.sectors,
symbol: item.symbol, symbol: item.symbol,
transactionCount: item.transactionCount, transactionCount: item.transactionCount,
type: dataProviderResponse.type,
value: value.toNumber() value: value.toNumber()
}; };
} }
return result; // TODO: Add a cash position for each currency
holdings[ghostfolioCashSymbol] = await this.getCashPosition({
cashDetails,
investment: totalInvestment,
value: totalValue
});
const accounts = await this.getAccounts(
orders,
portfolioItemsNow,
userCurrency,
userId
);
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
} }
public async getPosition( public async getPosition(
@ -258,14 +260,11 @@ export class PortfolioService {
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userId = await this.getUserId(aImpersonationId); const userId = await this.getUserId(aImpersonationId);
const portfolioCalculator = new PortfolioCalculator( const orders = (await this.orderService.getOrders({ userId })).filter(
this.currentRateService, (order) => order.symbol === aSymbol
this.request.user.Settings.currency
); );
const { transactionPoints } = await this.getTransactionPoints(userId); if (orders.length <= 0) {
if (transactionPoints?.length <= 0) {
return { return {
averagePrice: undefined, averagePrice: undefined,
currency: undefined, currency: undefined,
@ -283,7 +282,24 @@ export class PortfolioService {
}; };
} }
portfolioCalculator.setTransactionPoints(transactionPoints); const positionCurrency = orders[0].currency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
date: format(order.date, DATE_FORMAT),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
type: <OrderType>order.type,
unitPrice: new Big(order.unitPrice)
}));
const portfolioCalculator = new PortfolioCalculator(
this.currentRateService,
positionCurrency
);
portfolioCalculator.computeTransactionPoints(portfolioOrders);
const transactionPoints = portfolioCalculator.getTransactionPoints();
const portfolioStart = parseDate(transactionPoints[0].date); const portfolioStart = parseDate(transactionPoints[0].date);
const currentPositions = await portfolioCalculator.getCurrentPositions( const currentPositions = await portfolioCalculator.getCurrentPositions(
@ -299,12 +315,24 @@ export class PortfolioService {
averagePrice, averagePrice,
currency, currency,
firstBuyDate, firstBuyDate,
investment,
marketPrice, marketPrice,
quantity, quantity,
transactionCount transactionCount
} = position; } = position;
// Convert investment and gross performance to currency of user
const userCurrency = this.request.user.Settings.currency;
const investment = this.exchangeRateDataService.toCurrency(
position.investment.toNumber(),
currency,
userCurrency
);
const grossPerformance = this.exchangeRateDataService.toCurrency(
position.grossPerformance.toNumber(),
currency,
userCurrency
);
const historicalData = await this.dataProviderService.getHistorical( const historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [aSymbol],
'day', 'day',
@ -313,8 +341,17 @@ export class PortfolioService {
); );
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice; let maxPrice = Math.max(orders[0].unitPrice, marketPrice);
let minPrice = marketPrice; let minPrice = Math.min(orders[0].unitPrice, marketPrice);
if (!historicalData?.[aSymbol]?.[firstBuyDate]) {
// Add historical entry for buy date, if no historical data available
historicalDataArray.push({
averagePrice: orders[0].unitPrice,
date: firstBuyDate,
value: orders[0].unitPrice
});
}
if (historicalData[aSymbol]) { if (historicalData[aSymbol]) {
let j = -1; let j = -1;
@ -332,9 +369,9 @@ export class PortfolioService {
(item) => item.symbol === aSymbol (item) => item.symbol === aSymbol
); );
if (currentSymbol) { if (currentSymbol) {
currentAveragePrice = currentSymbol.investment currentAveragePrice = currentSymbol.quantity.eq(0)
.div(currentSymbol.quantity) ? 0
.toNumber(); : currentSymbol.investment.div(currentSymbol.quantity).toNumber();
} }
historicalDataArray.push({ historicalDataArray.push({
@ -351,20 +388,21 @@ export class PortfolioService {
return { return {
currency, currency,
firstBuyDate, firstBuyDate,
grossPerformance,
investment,
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
grossPerformance: position.grossPerformance.toNumber(),
grossPerformancePercent: position.grossPerformancePercentage.toNumber(), grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: investment.toNumber(),
quantity: quantity.toNumber(), quantity: quantity.toNumber(),
symbol: aSymbol symbol: aSymbol
}; };
} else { } else {
const currentData = await this.dataProviderService.get([aSymbol]); const currentData = await this.dataProviderService.get([aSymbol]);
const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical( let historicalData = await this.dataProviderService.getHistorical(
[aSymbol], [aSymbol],
@ -382,28 +420,33 @@ export class PortfolioService {
} }
const historicalDataArray: HistoricalDataItem[] = []; const historicalDataArray: HistoricalDataItem[] = [];
let maxPrice = marketPrice;
let minPrice = marketPrice;
for (const [date, { marketPrice }] of Object.entries( for (const [date, { marketPrice }] of Object.entries(
historicalData[aSymbol] historicalData[aSymbol]
).reverse()) { )) {
historicalDataArray.push({ historicalDataArray.push({
date, date,
value: marketPrice value: marketPrice
}); });
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
} }
return { return {
averagePrice: undefined, marketPrice,
maxPrice,
minPrice,
averagePrice: 0,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined, firstBuyDate: undefined,
grossPerformance: undefined, grossPerformance: undefined,
grossPerformancePercent: undefined, grossPerformancePercent: undefined,
historicalData: historicalDataArray, historicalData: historicalDataArray,
investment: undefined, investment: 0,
marketPrice: currentData[aSymbol]?.marketPrice, quantity: 0,
maxPrice: undefined,
minPrice: undefined,
quantity: undefined,
symbol: aSymbol, symbol: aSymbol,
transactionCount: undefined transactionCount: undefined
}; };
@ -421,7 +464,7 @@ export class PortfolioService {
this.request.user.Settings.currency this.request.user.Settings.currency
); );
const { transactionPoints } = await this.getTransactionPoints(userId); const { transactionPoints } = await this.getTransactionPoints({ userId });
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
@ -438,17 +481,34 @@ export class PortfolioService {
startDate startDate
); );
const positions = currentPositions.positions.filter(
(item) => !item.quantity.eq(0)
);
const symbols = positions.map((position) => position.symbol);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.get(symbols),
this.symbolProfileService.getSymbolProfiles(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
for (const symbolProfile of symbolProfiles) {
symbolProfileMap[symbolProfile.symbol] = symbolProfile;
}
return { return {
hasErrors: currentPositions.hasErrors, hasErrors: currentPositions.hasErrors,
positions: currentPositions.positions.map((position) => { positions: positions.map((position) => {
return { return {
...position, ...position,
assetClass: symbolProfileMap[position.symbol].assetClass,
averagePrice: new Big(position.averagePrice).toNumber(), averagePrice: new Big(position.averagePrice).toNumber(),
grossPerformance: position.grossPerformance?.toNumber() ?? null, grossPerformance: position.grossPerformance?.toNumber() ?? null,
grossPerformancePercentage: grossPerformancePercentage:
position.grossPerformancePercentage?.toNumber() ?? null, position.grossPerformancePercentage?.toNumber() ?? null,
investment: new Big(position.investment).toNumber(), investment: new Big(position.investment).toNumber(),
name: position.name, marketState: dataProviderResponses[position.symbol].marketState,
name: symbolProfileMap[position.symbol].name,
quantity: new Big(position.quantity).toNumber() quantity: new Big(position.quantity).toNumber()
}; };
}) })
@ -466,7 +526,7 @@ export class PortfolioService {
this.request.user.Settings.currency this.request.user.Settings.currency
); );
const { transactionPoints } = await this.getTransactionPoints(userId); const { transactionPoints } = await this.getTransactionPoints({ userId });
if (transactionPoints?.length <= 0) { if (transactionPoints?.length <= 0) {
return { return {
@ -474,8 +534,6 @@ export class PortfolioService {
performance: { performance: {
currentGrossPerformance: 0, currentGrossPerformance: 0,
currentGrossPerformancePercent: 0, currentGrossPerformancePercent: 0,
currentNetPerformance: 0,
currentNetPerformancePercent: 0,
currentValue: 0 currentValue: 0
} }
}; };
@ -500,9 +558,6 @@ export class PortfolioService {
performance: { performance: {
currentGrossPerformance, currentGrossPerformance,
currentGrossPerformancePercent, currentGrossPerformancePercent,
// TODO: the next two should include fees
currentNetPerformance: currentGrossPerformance,
currentNetPerformancePercent: currentGrossPerformancePercent,
currentValue: currentValue currentValue: currentValue
} }
}; };
@ -528,9 +583,9 @@ export class PortfolioService {
const userId = await this.getUserId(impersonationId); const userId = await this.getUserId(impersonationId);
const baseCurrency = this.request.user.Settings.currency; const baseCurrency = this.request.user.Settings.currency;
const { transactionPoints, orders } = await this.getTransactionPoints( const { orders, transactionPoints } = await this.getTransactionPoints({
userId userId
); });
if (isEmpty(orders)) { if (isEmpty(orders)) {
return { return {
@ -553,7 +608,12 @@ export class PortfolioService {
for (const position of currentPositions.positions) { for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position; portfolioItemsNow[position.symbol] = position;
} }
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency); const accounts = await this.getAccounts(
orders,
portfolioItemsNow,
baseCurrency,
userId
);
return { return {
rules: { rules: {
accountClusterRisk: await this.rulesService.evaluate( accountClusterRisk: await this.rulesService.evaluate(
@ -608,6 +668,73 @@ export class PortfolioService {
}; };
} }
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
const currency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId);
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
userId,
currency
);
const orders = await this.orderService.getOrders({ userId });
const fees = this.getFees(orders);
const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
const committedFunds = new Big(totalBuy).sub(totalSell);
const netWorth = new Big(balance)
.plus(performanceInformation.performance.currentValue)
.toNumber();
return {
...performanceInformation.performance,
fees,
firstOrderDate,
netWorth,
cash: balance,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.length,
totalBuy: totalBuy,
totalSell: totalSell
};
}
private async getCashPosition({
cashDetails,
investment,
value
}: {
cashDetails: CashDetails;
investment: Big;
value: Big;
}) {
const cashValue = new Big(cashDetails.balance);
return {
allocationCurrent: cashValue.div(value).toNumber(),
allocationInvestment: cashValue.div(investment).toNumber(),
assetClass: AssetClass.CASH,
countries: [],
currency: Currency.CHF,
grossPerformance: 0,
grossPerformancePercent: 0,
investment: cashValue.toNumber(),
marketPrice: 0,
marketState: MarketState.open,
name: 'Cash',
quantity: 0,
sectors: [],
symbol: ghostfolioCashSymbol,
transactionCount: 0,
value: cashValue.toNumber()
};
}
private getStartDate(aDateRange: DateRange, portfolioStart: Date) { private getStartDate(aDateRange: DateRange, portfolioStart: Date) {
switch (aDateRange) { switch (aDateRange) {
case '1d': case '1d':
@ -626,11 +753,17 @@ export class PortfolioService {
return portfolioStart; return portfolioStart;
} }
private async getTransactionPoints(userId: string): Promise<{ private async getTransactionPoints({
includeDrafts = false,
userId
}: {
includeDrafts?: boolean;
userId: string;
}): Promise<{
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
}> { }> {
const orders = await this.getOrders(userId); const orders = await this.orderService.getOrders({ includeDrafts, userId });
if (orders.length <= 0) { if (orders.length <= 0) {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [] };
@ -664,55 +797,68 @@ export class PortfolioService {
}; };
} }
private getAccounts( private async getAccounts(
orders: OrderWithAccount[], orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition }, portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency userCurrency: Currency,
userId: string
) { ) {
const accounts: PortfolioPosition['accounts'] = {}; const accounts: PortfolioDetails['accounts'] = {};
for (const order of orders) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * portfolioItemsNow[order.symbol].marketPrice,
order.currency,
userCurrency
);
let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
userCurrency
);
if (order.type === 'SELL') { const currentAccounts = await this.accountService.getAccounts(userId);
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1; for (const account of currentAccounts) {
const ordersByAccount = orders.filter(({ accountId }) => {
return accountId === account.id;
});
if (ordersByAccount.length <= 0) {
// Add account without orders
const balance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.name] = {
current: balance,
original: balance
};
continue;
} }
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) { for (const order of ordersByAccount) {
accounts[order.Account?.name || UNKNOWN_KEY].current += let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(
currentValueOfSymbol; order.quantity * portfolioItemsNow[order.symbol].marketPrice,
accounts[order.Account?.name || UNKNOWN_KEY].original += order.currency,
originalValueOfSymbol; userCurrency
} else { );
accounts[order.Account?.name || UNKNOWN_KEY] = { let originalValueOfSymbol = this.exchangeRateDataService.toCurrency(
current: currentValueOfSymbol, order.quantity * order.unitPrice,
original: originalValueOfSymbol order.currency,
}; userCurrency
);
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
}
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current +=
currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original +=
originalValueOfSymbol;
} else {
accounts[order.Account?.name || UNKNOWN_KEY] = {
current: currentValueOfSymbol,
original: originalValueOfSymbol
};
}
} }
} }
return accounts;
}
private getOrders(aUserId: string) { return accounts;
return this.orderService.orders({
include: {
// eslint-disable-next-line @typescript-eslint/naming-convention
Account: true,
// eslint-disable-next-line @typescript-eslint/naming-convention
SymbolProfile: true
},
orderBy: { date: 'asc' },
where: { userId: aUserId }
});
} }
private async getUserId(aImpersonationId: string) { private async getUserId(aImpersonationId: string) {

View File

@ -1,9 +1,8 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { Rule } from '@ghostfolio/api/models/rule';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { Rule } from '../models/rule';
@Injectable() @Injectable()
export class RulesService { export class RulesService {
public constructor() {} public constructor() {}

View File

@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
@Module({ @Module({
imports: [], imports: [],
controllers: [SubscriptionController], controllers: [SubscriptionController],
providers: [ConfigurationService, PrismaService, SubscriptionService] providers: [ConfigurationService, PrismaService, SubscriptionService],
exports: [SubscriptionService]
}) })
export class SubscriptionModule {} export class SubscriptionModule {}

View File

@ -1,7 +1,9 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { addDays } from 'date-fns'; import { Subscription } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe'; import Stripe from 'stripe';
@Injectable() @Injectable()
@ -10,7 +12,7 @@ export class SubscriptionService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly prismaService: PrismaService
) { ) {
this.stripe = new Stripe( this.stripe = new Stripe(
this.configurationService.get('STRIPE_SECRET_KEY'), this.configurationService.get('STRIPE_SECRET_KEY'),
@ -68,7 +70,7 @@ export class SubscriptionService {
aCheckoutSessionId aCheckoutSessionId
); );
await this.prisma.subscription.create({ await this.prismaService.subscription.create({
data: { data: {
expiresAt: addDays(new Date(), 365), expiresAt: addDays(new Date(), 365),
User: { User: {
@ -86,4 +88,23 @@ export class SubscriptionService {
console.error(error); console.error(error);
} }
} }
public getSubscription(aSubscriptions: Subscription[]) {
if (aSubscriptions.length > 0) {
const latestSubscription = aSubscriptions.reduce((a, b) => {
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
});
return {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
return {
type: SubscriptionType.Basic
};
}
}
} }

View File

@ -1,6 +1,7 @@
import { DataSource } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
export interface LookupItem { export interface LookupItem {
currency: Currency;
dataSource: DataSource; dataSource: DataSource;
name: string; name: string;
symbol: string; symbol: string;

View File

@ -1,27 +1,14 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { SymbolController } from './symbol.controller'; import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Module({ @Module({
imports: [], imports: [ConfigurationModule, DataProviderModule, PrismaModule],
controllers: [SymbolController], controllers: [SymbolController],
providers: [ providers: [SymbolService]
AlphaVantageService,
ConfigurationService,
DataProviderService,
GhostfolioScraperApiService,
PrismaService,
RakutenRapidApiService,
SymbolService,
YahooFinanceService
]
}) })
export class SymbolModule {} export class SymbolModule {}

View File

@ -1,6 +1,5 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, DataSource } from '@prisma/client'; import { Currency, DataSource } from '@prisma/client';
@ -11,7 +10,7 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService private readonly prismaService: PrismaService
) {} ) {}
public async get(aSymbol: string): Promise<SymbolItem> { public async get(aSymbol: string): Promise<SymbolItem> {
@ -37,16 +36,29 @@ export class SymbolService {
results.items = items; results.items = items;
// Add custom symbols // Add custom symbols
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations(); const ghostfolioSymbolProfiles =
scraperConfigurations.forEach((scraperConfiguration) => { await this.prismaService.symbolProfile.findMany({
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) { select: {
results.items.push({ currency: true,
dataSource: DataSource.GHOSTFOLIO, dataSource: true,
name: scraperConfiguration.name, name: true,
symbol: scraperConfiguration.symbol symbol: true
}); },
} where: {
}); AND: [
{
dataSource: DataSource.GHOSTFOLIO,
name: {
startsWith: aQuery
}
}
]
}
});
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
results.items.push(ghostfolioSymbolProfile);
}
return results; return results;
} catch (error) { } catch (error) {

View File

@ -0,0 +1,3 @@
export interface UserSettings {
isRestrictedView?: boolean;
}

View File

@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateUserSettingDto {
@IsBoolean()
isRestrictedView?: boolean;
}

View File

@ -26,6 +26,8 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { UserItem } from './interfaces/user-item.interface'; import { UserItem } from './interfaces/user-item.interface';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
import { UpdateUserSettingDto } from './update-user-setting.dto';
import { UpdateUserSettingsDto } from './update-user-settings.dto'; import { UpdateUserSettingsDto } from './update-user-settings.dto';
import { UserService } from './user.service'; import { UserService } from './user.service';
@ -78,6 +80,32 @@ export class UserController {
}; };
} }
@Put('setting')
@UseGuards(AuthGuard('jwt'))
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.updateUserSettings
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
const userSettings: UserSettings = {
...(<UserSettings>this.request.user.Settings.settings),
...data
};
return await this.userService.updateUserSetting({
userSettings,
userId: this.request.user.id
});
}
@Put('settings') @Put('settings')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) { public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {

View File

@ -1,3 +1,4 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -11,9 +12,11 @@ import { UserService } from './user.service';
JwtModule.register({ JwtModule.register({
secret: process.env.JWT_SECRET_KEY, secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' } signOptions: { expiresIn: '30 days' }
}) }),
SubscriptionModule
], ],
controllers: [UserController], controllers: [UserController],
providers: [ConfigurationService, PrismaService, UserService] providers: [ConfigurationService, PrismaService, UserService],
exports: [UserService]
}) })
export class UserModule {} export class UserModule {}

View File

@ -1,3 +1,4 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config'; import { locale } from '@ghostfolio/common/config';
@ -6,9 +7,9 @@ import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface';
const crypto = require('crypto'); const crypto = require('crypto');
@ -18,7 +19,8 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private prisma: PrismaService private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async getUser({ public async getUser({
@ -29,7 +31,7 @@ export class UserService {
Settings, Settings,
subscription subscription
}: UserWithSettings): Promise<IUser> { }: UserWithSettings): Promise<IUser> {
const access = await this.prisma.access.findMany({ const access = await this.prismaService.access.findMany({
include: { include: {
User: true User: true
}, },
@ -50,6 +52,7 @@ export class UserService {
}), }),
accounts: Account, accounts: Account,
settings: { settings: {
...(<UserSettings>Settings.settings),
locale, locale,
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY, baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
viewMode: Settings?.viewMode ?? ViewMode.DEFAULT viewMode: Settings?.viewMode ?? ViewMode.DEFAULT
@ -57,10 +60,14 @@ export class UserService {
}; };
} }
public isRestrictedView(aUser: UserWithSettings) {
return (aUser.Settings.settings as UserSettings)?.isRestrictedView ?? false;
}
public async user( public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> { ): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prisma.user.findUnique({ const userFromDatabase = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true }, include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput where: userWhereUniqueInput
}); });
@ -84,6 +91,7 @@ export class UserService {
// Set default settings if needed // Set default settings if needed
userFromDatabase.Settings = { userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY, currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(), updatedAt: new Date(),
userId: userFromDatabase?.id, userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT viewMode: ViewMode.DEFAULT
@ -91,24 +99,9 @@ export class UserService {
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) { user.subscription = this.subscriptionService.getSubscription(
const latestSubscription = userFromDatabase.Subscription.reduce( userFromDatabase?.Subscription
(a, b) => { );
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
if (user.subscription.type === SubscriptionType.Basic) { if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => { user.permissions = user.permissions.filter((permission) => {
@ -129,7 +122,7 @@ export class UserService {
orderBy?: Prisma.UserOrderByInput; orderBy?: Prisma.UserOrderByInput;
}): Promise<User[]> { }): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prisma.user.findMany({ return this.prismaService.user.findMany({
skip, skip,
take, take,
cursor, cursor,
@ -146,7 +139,7 @@ export class UserService {
} }
public async createUser(data?: Prisma.UserCreateInput): Promise<User> { public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
let user = await this.prisma.user.create({ let user = await this.prismaService.user.create({
data: { data: {
...data, ...data,
Account: { Account: {
@ -169,7 +162,7 @@ export class UserService {
process.env.ACCESS_TOKEN_SALT process.env.ACCESS_TOKEN_SALT
); );
user = await this.prisma.user.update({ user = await this.prismaService.user.update({
data: { accessToken: hashedAccessToken }, data: { accessToken: hashedAccessToken },
where: { id: user.id } where: { id: user.id }
}); });
@ -185,46 +178,75 @@ export class UserService {
data: Prisma.UserUpdateInput; data: Prisma.UserUpdateInput;
}): Promise<User> { }): Promise<User> {
const { where, data } = params; const { where, data } = params;
return this.prisma.user.update({ return this.prismaService.user.update({
data, data,
where where
}); });
} }
public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> { public async deleteUser(where: Prisma.UserWhereUniqueInput): Promise<User> {
await this.prisma.access.deleteMany({ await this.prismaService.access.deleteMany({
where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] } where: { OR: [{ granteeUserId: where.id }, { userId: where.id }] }
}); });
await this.prisma.account.deleteMany({ await this.prismaService.account.deleteMany({
where: { userId: where.id } where: { userId: where.id }
}); });
await this.prisma.analytics.delete({ await this.prismaService.analytics.delete({
where: { userId: where.id } where: { userId: where.id }
}); });
await this.prisma.order.deleteMany({ await this.prismaService.order.deleteMany({
where: { userId: where.id } where: { userId: where.id }
}); });
try { try {
await this.prisma.settings.delete({ await this.prismaService.settings.delete({
where: { userId: where.id } where: { userId: where.id }
}); });
} catch {} } catch {}
return this.prisma.user.delete({ return this.prismaService.user.delete({
where where
}); });
} }
public async updateUserSetting({
userId,
userSettings
}: {
userId: string;
userSettings: UserSettings;
}) {
const settings = userSettings as Prisma.JsonObject;
await this.prismaService.settings.upsert({
create: {
settings,
User: {
connect: {
id: userId
}
}
},
update: {
settings
},
where: {
userId: userId
}
});
return;
}
public async updateUserSettings({ public async updateUserSettings({
currency, currency,
userId, userId,
viewMode viewMode
}: UserSettingsParams) { }: UserSettingsParams) {
await this.prisma.settings.upsert({ await this.prismaService.settings.upsert({
create: { create: {
currency, currency,
User: { User: {

View File

@ -1,5 +1,4 @@
import { Account, Currency, SymbolProfile } from '@prisma/client'; import { Account, Currency, SymbolProfile } from '@prisma/client';
import { endOfToday, isAfter, parseISO } from 'date-fns';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { IOrder } from '../services/interfaces/interfaces'; import { IOrder } from '../services/interfaces/interfaces';
@ -11,6 +10,7 @@ export class Order {
private fee: number; private fee: number;
private date: string; private date: string;
private id: string; private id: string;
private isDraft: boolean;
private quantity: number; private quantity: number;
private symbol: string; private symbol: string;
private symbolProfile: SymbolProfile; private symbolProfile: SymbolProfile;
@ -24,6 +24,7 @@ export class Order {
this.fee = data.fee; this.fee = data.fee;
this.date = data.date; this.date = data.date;
this.id = data.id || uuidv4(); this.id = data.id || uuidv4();
this.isDraft = data.isDraft;
this.quantity = data.quantity; this.quantity = data.quantity;
this.symbol = data.symbol; this.symbol = data.symbol;
this.symbolProfile = data.symbolProfile; this.symbolProfile = data.symbolProfile;
@ -54,7 +55,7 @@ export class Order {
} }
public getIsDraft() { public getIsDraft() {
return isAfter(parseISO(this.date), endOfToday()); return this.isDraft;
} }
public getQuantity() { public getQuantity() {

View File

@ -1,10 +1,10 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { groupBy } from '@ghostfolio/common/helper'; import { groupBy } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces'; import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
import { EvaluationResult } from './interfaces/evaluation-result.interface'; import { EvaluationResult } from './interfaces/evaluation-result.interface';
import { RuleInterface } from './interfaces/rule.interface'; import { RuleInterface } from './interfaces/rule.interface';

View File

@ -1,16 +1,17 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import {
PortfolioDetails,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> { export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private accounts: { private accounts: PortfolioDetails['accounts']
[account: string]: { current: number; original: number };
}
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Current Investment' name: 'Current Investment'

View File

@ -1,6 +1,9 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { PortfolioPosition } from '@ghostfolio/common/interfaces'; import {
PortfolioDetails,
PortfolioPosition
} from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
@ -8,9 +11,7 @@ import { Rule } from '../../rule';
export class AccountClusterRiskInitialInvestment extends Rule<Settings> { export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private accounts: { private accounts: PortfolioDetails['accounts']
[account: string]: { current: number; original: number };
}
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Initial Investment' name: 'Initial Investment'

View File

@ -1,5 +1,6 @@
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { PortfolioDetails } from '@ghostfolio/common/interfaces';
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service'; import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
import { Rule } from '../../rule'; import { Rule } from '../../rule';
@ -7,9 +8,7 @@ import { Rule } from '../../rule';
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> { export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
public constructor( public constructor(
protected exchangeRateDataService: ExchangeRateDataService, protected exchangeRateDataService: ExchangeRateDataService,
private accounts: { private accounts: PortfolioDetails['accounts']
[account: string]: { current: number; original: number };
}
) { ) {
super(exchangeRateDataService, { super(exchangeRateDataService, {
name: 'Single Account' name: 'Single Account'

View File

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

View File

@ -1,4 +1,4 @@
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';

View File

@ -1,4 +1,4 @@
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';

View File

@ -1,4 +1,4 @@
import { CurrentPositions } from '@ghostfolio/api/app/core/interfaces/current-positions.interface'; import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/current-positions.interface';
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface'; import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface'; import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';

View File

@ -0,0 +1,8 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Module } from '@nestjs/common';
@Module({
providers: [ConfigurationService],
exports: [ConfigurationService]
})
export class ConfigurationModule {}

View File

@ -0,0 +1,12 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
@Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
providers: [DataGatheringService],
exports: [DataGatheringService]
})
export class DataGatheringModule {}

View File

@ -3,7 +3,6 @@ import {
DATE_FORMAT, DATE_FORMAT,
getUtc, getUtc,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
isRakutenRapidApiSymbol,
resetHours resetHours
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
@ -20,7 +19,7 @@ import {
} from 'date-fns'; } from 'date-fns';
import { ConfigurationService } from './configuration.service'; import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
@ -31,7 +30,7 @@ export class DataGatheringService {
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService, private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private prisma: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async gather7Days() { public async gather7Days() {
@ -39,9 +38,9 @@ export class DataGatheringService {
if (isDataGatheringNeeded) { if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.'); console.log('7d data gathering has been started.');
console.time('7d-data-gathering'); console.time('data-gathering-7d');
await this.prisma.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: 'LOCKED_DATA_GATHERING',
value: new Date().toISOString() value: new Date().toISOString()
@ -53,7 +52,7 @@ export class DataGatheringService {
try { try {
await this.gatherSymbols(symbols); await this.gatherSymbols(symbols);
await this.prisma.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: 'LAST_DATA_GATHERING',
value: new Date().toISOString() value: new Date().toISOString()
@ -65,27 +64,27 @@ export class DataGatheringService {
console.error(error); console.error(error);
} }
await this.prisma.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: 'LOCKED_DATA_GATHERING'
} }
}); });
console.log('7d data gathering has been completed.'); console.log('7d data gathering has been completed.');
console.timeEnd('7d-data-gathering'); console.timeEnd('data-gathering-7d');
} }
} }
public async gatherMax() { public async gatherMax() {
const isDataGatheringLocked = await this.prisma.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: 'LOCKED_DATA_GATHERING' }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.'); console.log('Max data gathering has been started.');
console.time('max-data-gathering'); console.time('data-gathering-max');
await this.prisma.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: 'LOCKED_DATA_GATHERING',
value: new Date().toISOString() value: new Date().toISOString()
@ -97,7 +96,7 @@ export class DataGatheringService {
try { try {
await this.gatherSymbols(symbols); await this.gatherSymbols(symbols);
await this.prisma.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: 'LAST_DATA_GATHERING',
value: new Date().toISOString() value: new Date().toISOString()
@ -109,20 +108,20 @@ export class DataGatheringService {
console.error(error); console.error(error);
} }
await this.prisma.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: 'LOCKED_DATA_GATHERING'
} }
}); });
console.log('Max data gathering has been completed.'); console.log('Max data gathering has been completed.');
console.timeEnd('max-data-gathering'); console.timeEnd('data-gathering-max');
} }
} }
public async gatherProfileData(aSymbols?: string[]) { public async gatherProfileData(aSymbols?: string[]) {
console.log('Profile data gathering has been started.'); console.log('Profile data gathering has been started.');
console.time('profile-data-gathering'); console.time('data-gathering-profile');
let symbols = aSymbols; let symbols = aSymbols;
@ -135,18 +134,25 @@ export class DataGatheringService {
const currentData = await this.dataProviderService.get(symbols); const currentData = await this.dataProviderService.get(symbols);
for (const [symbol, { currency, dataSource, name }] of Object.entries( for (const [
currentData symbol,
)) { { assetClass, assetSubClass, countries, currency, dataSource, name }
] of Object.entries(currentData)) {
try { try {
await this.prisma.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
create: { create: {
assetClass,
assetSubClass,
countries,
currency, currency,
dataSource, dataSource,
name, name,
symbol symbol
}, },
update: { update: {
assetClass,
assetSubClass,
countries,
currency, currency,
name name
}, },
@ -163,7 +169,7 @@ export class DataGatheringService {
} }
console.log('Profile data gathering has been completed.'); console.log('Profile data gathering has been completed.');
console.timeEnd('profile-data-gathering'); console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
@ -203,7 +209,7 @@ export class DataGatheringService {
} }
try { try {
await this.prisma.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
date: currentDate, date: currentDate,
@ -248,6 +254,34 @@ export class DataGatheringService {
}); });
} }
public async getIsInProgress() {
return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' }
});
}
public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' }
});
if (lastDataGathering?.value) {
return new Date(lastDataGathering.value);
}
return undefined;
}
public async reset() {
console.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({
where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
}
});
}
private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] { private getBenchmarksToGather(startDate: Date): IDataGatheringItem[] {
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => { const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
return { return {
@ -271,7 +305,7 @@ export class DataGatheringService {
private async getSymbols7D(): Promise<IDataGatheringItem[]> { private async getSymbols7D(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }, select: { dataSource: true, symbol: true },
@ -303,9 +337,8 @@ export class DataGatheringService {
} }
); );
const customSymbolsToGather = await this.getCustomSymbolsToGather( const customSymbolsToGather =
startDate await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
);
return [ return [
...this.getBenchmarksToGather(startDate), ...this.getBenchmarksToGather(startDate),
@ -318,9 +351,8 @@ export class DataGatheringService {
private async getSymbolsMax(): Promise<IDataGatheringItem[]> { private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
const startDate = new Date(getUtc('2015-01-01')); const startDate = new Date(getUtc('2015-01-01'));
const customSymbolsToGather = await this.getCustomSymbolsToGather( const customSymbolsToGather =
startDate await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
);
const currencyPairsToGather = currencyPairs.map( const currencyPairsToGather = currencyPairs.map(
({ dataSource, symbol }) => { ({ dataSource, symbol }) => {
@ -332,7 +364,7 @@ export class DataGatheringService {
} }
); );
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ date: 'asc' }], orderBy: [{ date: 'asc' }],
select: { dataSource: true, date: true, symbol: true }, select: { dataSource: true, date: true, symbol: true },
@ -354,7 +386,7 @@ export class DataGatheringService {
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> { private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const startDate = subDays(resetHours(new Date()), 7); const startDate = subDays(resetHours(new Date()), 7);
const distinctOrders = await this.prisma.order.findMany({ const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'], distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true } select: { dataSource: true, symbol: true }
@ -371,18 +403,13 @@ export class DataGatheringService {
} }
private async isDataGatheringNeeded() { private async isDataGatheringNeeded() {
const lastDataGathering = await this.prisma.property.findUnique({ const lastDataGathering = await this.getLastDataGathering();
where: { key: 'LAST_DATA_GATHERING' }
});
const isDataGatheringLocked = await this.prisma.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: 'LOCKED_DATA_GATHERING' }
}); });
const diffInHours = differenceInHours( const diffInHours = differenceInHours(new Date(), lastDataGathering);
new Date(),
new Date(lastDataGathering?.value)
);
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked; return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
} }

View File

@ -1,11 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns'; import { isAfter, isBefore, parse } from 'date-fns';
import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,

View File

@ -0,0 +1,22 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { DataProviderService } from './data-provider.service';
@Module({
imports: [ConfigurationModule, PrismaModule],
providers: [
AlphaVantageService,
DataProviderService,
GhostfolioScraperApiService,
RakutenRapidApiService,
YahooFinanceService
],
exports: [DataProviderService, GhostfolioScraperApiService]
})
export class DataProviderModule {}

View File

@ -1,3 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
isGhostfolioScraperApiSymbol, isGhostfolioScraperApiSymbol,
@ -8,17 +16,10 @@ import { Injectable } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { ConfigurationService } from './configuration.service'; import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage.service'; import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service'; import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse
} from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -26,11 +27,11 @@ export class DataProviderService {
private readonly alphaVantageService: AlphaVantageService, private readonly alphaVantageService: AlphaVantageService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService, private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
private prisma: PrismaService, private readonly prismaService: PrismaService,
private readonly rakutenRapidApiService: RakutenRapidApiService, private readonly rakutenRapidApiService: RakutenRapidApiService,
private readonly yahooFinanceService: YahooFinanceService private readonly yahooFinanceService: YahooFinanceService
) { ) {
this.rakutenRapidApiService?.setPrisma(this.prisma); this.rakutenRapidApiService?.setPrisma(this.prismaService);
} }
public async get( public async get(
@ -112,9 +113,8 @@ export class DataProviderService {
`','` `','`
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`; )}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
const marketDataByGranularity: MarketData[] = await this.prisma.$queryRaw( const marketDataByGranularity: MarketData[] =
queryRaw await this.prismaService.$queryRaw(queryRaw);
);
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;
@ -167,10 +167,19 @@ export class DataProviderService {
return result; return result;
} }
public async search(aSymbol: string) { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return this.getDataProvider( const { items } = await this.getDataProvider(
<DataSource>this.configurationService.get('DATA_SOURCES')[0] <DataSource>this.configurationService.get('DATA_SOURCES')[0]
).search(aSymbol); ).search(aSymbol);
const filteredItems = items.filter((item) => {
// Only allow symbols with supported currency
return item.currency ? true : false;
});
return {
items: filteredItems
};
} }
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {

View File

@ -1,3 +1,5 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getYesterday, getYesterday,
@ -12,18 +14,18 @@ import { format } from 'date-fns';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { import {
IDataGatheringItem,
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { PrismaService } from '../../prisma.service';
import { ScraperConfig } from './interfaces/scraper-config.interface'; import { ScraperConfig } from './interfaces/scraper-config.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g; private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor(private prisma: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public canHandle(symbol: string) { public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol); return isGhostfolioScraperApiSymbol(symbol);
@ -41,7 +43,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol); const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
const { marketPrice } = await this.prisma.marketData.findFirst({ const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: { orderBy: {
date: 'desc' date: 'desc'
}, },
@ -55,8 +57,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
marketPrice, marketPrice,
currency: scraperConfig?.currency, currency: scraperConfig?.currency,
dataSource: DataSource.GHOSTFOLIO, dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed, marketState: MarketState.delayed
name: scraperConfig?.name
} }
}; };
} catch (error) { } catch (error) {
@ -66,6 +67,25 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return {}; return {};
} }
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
where: {
dataSource: DataSource.GHOSTFOLIO
}
});
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
}
public async getHistorical( public async getHistorical(
aSymbols: string[], aSymbols: string[],
aGranularity: Granularity = 'day', aGranularity: Granularity = 'day',
@ -111,7 +131,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
public async getScraperConfigurations(): Promise<ScraperConfig[]> { public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try { try {
const { value: scraperConfigString } = const { value: scraperConfigString } =
await this.prisma.property.findFirst({ await this.prismaService.property.findFirst({
select: { select: {
value: true value: true
}, },
@ -124,7 +144,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return []; return [];
} }
public async search(aSymbol: string) { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

View File

@ -2,7 +2,6 @@ import { Currency } from '@prisma/client';
export interface ScraperConfig { export interface ScraperConfig {
currency: Currency; currency: Currency;
name: string;
selector: string; selector: string;
symbol: string; symbol: string;
url: string; url: string;

View File

@ -1,3 +1,6 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { import {
DATE_FORMAT, DATE_FORMAT,
getToday, getToday,
@ -10,20 +13,18 @@ import { DataSource } from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns'; import { format, subMonths, subWeeks, subYears } from 'date-fns';
import { ConfigurationService } from '../../configuration.service';
import { DataProviderInterface } from '../../interfaces/data-provider.interface'; import { DataProviderInterface } from '../../interfaces/data-provider.interface';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState MarketState
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { PrismaService } from '../../prisma.service';
@Injectable() @Injectable()
export class RakutenRapidApiService implements DataProviderInterface { export class RakutenRapidApiService implements DataProviderInterface {
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index'; public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
private prisma: PrismaService; private prismaService: PrismaService;
public constructor( public constructor(
private readonly configurationService: ConfigurationService private readonly configurationService: ConfigurationService
@ -89,7 +90,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
// TODO: can be removed after all data from the last year has been gathered // TODO: can be removed after all data from the last year has been gathered
// (introduced on 27.03.2021) // (introduced on 27.03.2021)
await this.prisma.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
date: subWeeks(getToday(), 1), date: subWeeks(getToday(), 1),
@ -97,7 +98,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
} }
}); });
await this.prisma.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
date: subMonths(getToday(), 1), date: subMonths(getToday(), 1),
@ -105,7 +106,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
} }
}); });
await this.prisma.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
date: subYears(getToday(), 1), date: subYears(getToday(), 1),
@ -129,12 +130,12 @@ export class RakutenRapidApiService implements DataProviderInterface {
return {}; return {};
} }
public async search(aSymbol: string) { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }
public setPrisma(aPrismaService: PrismaService) { public setPrisma(aPrismaService: PrismaService) {
this.prisma = aPrismaService; this.prismaService = aPrismaService;
} }
private async getFearAndGreedIndex(): Promise<{ private async getFearAndGreedIndex(): Promise<{

View File

@ -25,6 +25,7 @@ export interface IYahooFinancePrice {
} }
export interface IYahooFinanceSummaryProfile { export interface IYahooFinanceSummaryProfile {
country?: string;
industry?: string; industry?: string;
sector?: string; sector?: string;
website?: string; website?: string;

View File

@ -8,8 +8,15 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
Currency,
DataSource
} from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import Big from 'big.js';
import { countries } from 'countries-list';
import { format } from 'date-fns'; import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
@ -17,11 +24,11 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse, IDataProviderResponse,
MarketState, MarketState
Type
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { import {
IYahooFinanceHistoricalResponse, IYahooFinanceHistoricalResponse,
IYahooFinancePrice,
IYahooFinanceQuoteResponse IYahooFinanceQuoteResponse
} from './interfaces/interfaces'; } from './interfaces/interfaces';
@ -60,7 +67,11 @@ export class YahooFinanceService implements DataProviderInterface {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = convertFromYahooSymbol(yahooSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
response[symbol] = { response[symbol] = {
assetClass,
assetSubClass,
currency: parseCurrency(value.price?.currency), currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
@ -69,10 +80,36 @@ export class YahooFinanceService implements DataProviderInterface {
? MarketState.open ? MarketState.open
: MarketState.closed, : MarketState.closed,
marketPrice: value.price?.regularMarketPrice || 0, marketPrice: value.price?.regularMarketPrice || 0,
name: value.price?.longName || value.price?.shortName || symbol, name: value.price?.longName || value.price?.shortName || symbol
type: this.parseType(this.getType(symbol, value))
}; };
if (value.price?.currency === 'GBp') {
// Convert GBp (pence) to GBP
response[symbol].currency = Currency.GBP;
response[symbol].marketPrice = new Big(
value.price?.regularMarketPrice ?? 0
)
.div(100)
.toNumber();
}
// Add country if stock and available
if (
assetSubClass === AssetSubClass.STOCK &&
value.summaryProfile?.country
) {
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === value.summaryProfile?.country;
});
if (code) {
response[symbol].countries = [{ code, weight: 1 }];
}
} catch {}
}
// Add url if available
const url = value.summaryProfile?.website; const url = value.summaryProfile?.website;
if (url) { if (url) {
response[symbol].url = url; response[symbol].url = url;
@ -138,7 +175,7 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items = []; let items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
@ -148,8 +185,23 @@ export class YahooFinanceService implements DataProviderInterface {
200 200
); );
const result = await get(); const searchResult = await get();
items = result.quotes
const symbols: string[] = searchResult.quotes
.filter((quote) => {
// filter out undefined symbols
return quote.symbol;
})
.filter(({ quoteType }) => {
return quoteType === 'EQUITY' || quoteType === 'ETF';
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
items = searchResult.quotes
.filter((quote) => { .filter((quote) => {
return quote.isYahooFinance; return quote.isYahooFinance;
}) })
@ -163,13 +215,14 @@ export class YahooFinanceService implements DataProviderInterface {
.filter(({ quoteType, symbol }) => { .filter(({ quoteType, symbol }) => {
if (quoteType === 'CRYPTOCURRENCY') { if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in USD // Only allow cryptocurrencies in USD
return symbol.includes('USD'); return symbol.includes(Currency.USD);
} }
return true; return true;
}) })
.map(({ longname, shortname, symbol }) => { .map(({ longname, shortname, symbol }) => {
return { return {
currency: marketData[symbol]?.currency,
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
name: longname || shortname, name: longname || shortname,
symbol: convertFromYahooSymbol(symbol) symbol: convertFromYahooSymbol(symbol)
@ -203,14 +256,29 @@ export class YahooFinanceService implements DataProviderInterface {
return aSymbol; return aSymbol;
} }
private getType(aSymbol: string, aValue: IYahooFinanceQuoteResponse): Type { private parseAssetClass(aPrice: IYahooFinancePrice): {
if (isCrypto(aSymbol)) { assetClass: AssetClass;
return Type.Cryptocurrency; assetSubClass: AssetSubClass;
} else if (aValue.price?.quoteType.toLowerCase() === 'equity') { } {
return Type.Stock; let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (aPrice?.quoteType?.toLowerCase()) {
case 'cryptocurrency':
assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break;
case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break;
} }
return aValue.price?.quoteType.toLowerCase(); return { assetClass, assetSubClass };
} }
private parseExchange(aString: string): string { private parseExchange(aString: string): string {
@ -220,18 +288,6 @@ export class YahooFinanceService implements DataProviderInterface {
return aString; return aString;
} }
private parseType(aString: string): Type {
if (aString?.toLowerCase() === 'cryptocurrency') {
return Type.Cryptocurrency;
} else if (aString?.toLowerCase() === 'etf') {
return Type.ETF;
} else if (aString?.toLowerCase() === 'stock') {
return Type.Stock;
}
return Type.Unknown;
}
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooSymbol = (aSymbol: string) => {

View File

@ -0,0 +1,10 @@
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { Module } from '@nestjs/common';
@Module({
imports: [DataProviderModule],
providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService]
})
export class ExchangeRateDataModule {}

View File

@ -1,40 +1,58 @@
import { currencyPairs } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isEmpty, isNumber } from 'lodash';
import { DataProviderService } from './data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
private currencies = {}; private currencyPairs: string[] = [];
private pairs: string[] = []; private exchangeRates: { [currencyPair: string]: number } = {};
public constructor(private dataProviderService: DataProviderService) { public constructor(private dataProviderService: DataProviderService) {
this.initialize(); this.initialize();
} }
public async initialize() { public async initialize() {
this.pairs = []; this.currencyPairs = [];
this.exchangeRates = {};
this.addPairs(Currency.CHF, Currency.EUR); for (const { currency1, currency2 } of currencyPairs) {
this.addPairs(Currency.CHF, Currency.GBP); this.addCurrencyPairs(currency1, currency2);
this.addPairs(Currency.CHF, Currency.USD); }
this.addPairs(Currency.EUR, Currency.GBP);
this.addPairs(Currency.EUR, Currency.USD);
this.addPairs(Currency.GBP, Currency.USD);
await this.loadCurrencies(); await this.loadCurrencies();
} }
public async loadCurrencies() { public async loadCurrencies() {
const result = await this.dataProviderService.getHistorical( const result = await this.dataProviderService.getHistorical(
this.pairs, this.currencyPairs,
'day', 'day',
getYesterday(), getYesterday(),
getYesterday() getYesterday()
); );
if (isEmpty(result)) {
// Load currencies directly from data provider as a fallback
// if historical data is not yet available
const historicalData = await this.dataProviderService.get(
this.currencyPairs.map((currencyPair) => {
return currencyPair;
})
);
Object.keys(historicalData).forEach((key) => {
result[key] = {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: historicalData[key].marketPrice
}
};
});
}
const resultExtended = result; const resultExtended = result;
Object.keys(result).forEach((pair) => { Object.keys(result).forEach((pair) => {
@ -49,20 +67,21 @@ export class ExchangeRateDataService {
}; };
}); });
this.pairs.forEach((pair) => { this.currencyPairs.forEach((pair) => {
const [currency1, currency2] = pair.match(/.{1,3}/g); const [currency1, currency2] = pair.match(/.{1,3}/g);
const date = format(getYesterday(), DATE_FORMAT); const date = format(getYesterday(), DATE_FORMAT);
this.currencies[pair] = resultExtended[pair]?.[date]?.marketPrice; this.exchangeRates[pair] = resultExtended[pair]?.[date]?.marketPrice;
if (!this.currencies[pair]) { if (!this.exchangeRates[pair]) {
// Not found, calculate indirectly via USD // Not found, calculate indirectly via USD
this.currencies[pair] = this.exchangeRates[pair] =
resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice * resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice *
resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice; resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice;
// Calculate the opposite direction // Calculate the opposite direction
this.currencies[`${currency2}${currency1}`] = 1 / this.currencies[pair]; this.exchangeRates[`${currency2}${currency1}`] =
1 / this.exchangeRates[pair];
} }
}); });
} }
@ -72,7 +91,7 @@ export class ExchangeRateDataService {
aFromCurrency: Currency, aFromCurrency: Currency,
aToCurrency: Currency aToCurrency: Currency
) { ) {
if (isNaN(this.currencies[`${Currency.USD}${Currency.CHF}`])) { if (isNaN(this.exchangeRates[`${Currency.USD}${Currency.CHF}`])) {
// Reinitialize if data is not loaded correctly // Reinitialize if data is not loaded correctly
this.initialize(); this.initialize();
} }
@ -80,14 +99,32 @@ export class ExchangeRateDataService {
let factor = 1; let factor = 1;
if (aFromCurrency !== aToCurrency) { if (aFromCurrency !== aToCurrency) {
factor = this.currencies[`${aFromCurrency}${aToCurrency}`]; if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else {
// Calculate indirectly via USD
const factor1 = this.exchangeRates[`${aFromCurrency}${Currency.USD}`];
const factor2 = this.exchangeRates[`${Currency.USD}${aToCurrency}`];
factor = factor1 * factor2;
this.exchangeRates[`${aFromCurrency}${aToCurrency}`] = factor;
}
} }
return factor * aValue; if (isNumber(factor)) {
return factor * aValue;
}
// Fallback with error, if currencies are not available
console.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
);
return aValue;
} }
private addPairs(aCurrency1: Currency, aCurrency2: Currency) { private addCurrencyPairs(aCurrency1: Currency, aCurrency2: Currency) {
this.pairs.push(`${aCurrency1}${aCurrency2}`); this.currencyPairs.push(`${aCurrency1}${aCurrency2}`);
this.pairs.push(`${aCurrency2}${aCurrency1}`); this.currencyPairs.push(`${aCurrency2}${aCurrency1}`);
} }
} }

View File

@ -0,0 +1,10 @@
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common';
@Module({
imports: [PrismaModule],
providers: [ImpersonationService],
exports: [ImpersonationService]
})
export class ImpersonationModule {}

View File

@ -4,10 +4,10 @@ import { PrismaService } from './prisma.service';
@Injectable() @Injectable()
export class ImpersonationService { export class ImpersonationService {
public constructor(private prisma: PrismaService) {} public constructor(private readonly prismaService: PrismaService) {}
public async validateImpersonationId(aId = '', aUserId: string) { public async validateImpersonationId(aId = '', aUserId: string) {
const accessObject = await this.prisma.access.findFirst({ const accessObject = await this.prismaService.access.findFirst({
where: { GranteeUser: { id: aUserId }, id: aId } where: { GranteeUser: { id: aUserId }, id: aId }
}); });

View File

@ -1,5 +1,11 @@
import { UNKNOWN_KEY } from '@ghostfolio/common/config'; import {
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client'; Account,
AssetClass,
AssetSubClass,
Currency,
DataSource,
SymbolProfile
} from '@prisma/client';
import { OrderType } from '../../models/order-type'; import { OrderType } from '../../models/order-type';
@ -9,20 +15,13 @@ export const MarketState = {
open: 'open' open: 'open'
}; };
export const Type = {
Cash: 'Cash',
Cryptocurrency: 'Cryptocurrency',
ETF: 'ETF',
Stock: 'Stock',
Unknown: UNKNOWN_KEY
};
export interface IOrder { export interface IOrder {
account: Account; account: Account;
currency: Currency; currency: Currency;
date: string; date: string;
fee: number; fee: number;
id?: string; id?: string;
isDraft: boolean;
quantity: number; quantity: number;
symbol: string; symbol: string;
symbolProfile: SymbolProfile; symbolProfile: SymbolProfile;
@ -36,6 +35,9 @@ export interface IDataProviderHistoricalResponse {
} }
export interface IDataProviderResponse { export interface IDataProviderResponse {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countries?: { code: string; weight: number }[];
currency: Currency; currency: Currency;
dataSource: DataSource; dataSource: DataSource;
exchange?: string; exchange?: string;
@ -43,8 +45,7 @@ export interface IDataProviderResponse {
marketChangePercent?: number; marketChangePercent?: number;
marketPrice: number; marketPrice: number;
marketState: MarketState; marketState: MarketState;
name: string; name?: string;
type?: Type;
url?: string; url?: string;
} }
@ -55,5 +56,3 @@ export interface IDataGatheringItem {
} }
export type MarketState = typeof MarketState[keyof typeof MarketState]; export type MarketState = typeof MarketState[keyof typeof MarketState];
export type Type = typeof Type[keyof typeof Type];

View File

@ -1,8 +1,15 @@
import { Country } from '@ghostfolio/common/interfaces/country.interface'; import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface'; import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Currency, DataSource } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
Currency,
DataSource
} from '@prisma/client';
export interface EnhancedSymbolProfile { export interface EnhancedSymbolProfile {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
createdAt: Date; createdAt: Date;
currency: Currency | null; currency: Currency | null;
dataSource: DataSource; dataSource: DataSource;

View File

@ -0,0 +1,8 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Module } from '@nestjs/common';
@Module({
providers: [PrismaService],
exports: [PrismaService]
})
export class PrismaModule {}

View File

@ -9,12 +9,12 @@ import { continents, countries } from 'countries-list';
@Injectable() @Injectable()
export class SymbolProfileService { export class SymbolProfileService {
constructor(private prisma: PrismaService) {} constructor(private readonly prismaService: PrismaService) {}
public async getSymbolProfiles( public async getSymbolProfiles(
symbols: string[] symbols: string[]
): Promise<EnhancedSymbolProfile[]> { ): Promise<EnhancedSymbolProfile[]> {
return this.prisma.symbolProfile return this.prismaService.symbolProfile
.findMany({ .findMany({
where: { where: {
symbol: { symbol: {

View File

@ -52,6 +52,41 @@ const routes: Routes = [
loadChildren: () => loadChildren: () =>
import('./pages/home/home-page.module').then((m) => m.HomePageModule) import('./pages/home/home-page.module').then((m) => m.HomePageModule)
}, },
{
path: 'portfolio',
loadChildren: () =>
import('./pages/portfolio/portfolio-page.module').then(
(m) => m.PortfolioPageModule
)
},
{
path: 'portfolio/allocations',
loadChildren: () =>
import('./pages/portfolio/allocations/allocations-page.module').then(
(m) => m.AllocationsPageModule
)
},
{
path: 'portfolio/analysis',
loadChildren: () =>
import('./pages/portfolio/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'portfolio/report',
loadChildren: () =>
import('./pages/portfolio/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'portfolio/transactions',
loadChildren: () =>
import('./pages/portfolio/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'pricing', path: 'pricing',
loadChildren: () => loadChildren: () =>
@ -80,32 +115,6 @@ const routes: Routes = [
(m) => m.LandingPageModule (m) => m.LandingPageModule
) )
}, },
{
path: 'tools',
loadChildren: () =>
import('./pages/tools/tools-page.module').then((m) => m.ToolsPageModule)
},
{
path: 'tools/analysis',
loadChildren: () =>
import('./pages/tools/analysis/analysis-page.module').then(
(m) => m.AnalysisPageModule
)
},
{
path: 'tools/report',
loadChildren: () =>
import('./pages/tools/report/report-page.module').then(
(m) => m.ReportPageModule
)
},
{
path: 'transactions',
loadChildren: () =>
import('./pages/transactions/transactions-page.module').then(
(m) => m.TransactionsPageModule
)
},
{ {
path: 'webauthn', path: 'webauthn',
loadChildren: () => loadChildren: () =>

View File

@ -10,7 +10,7 @@
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon> <ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
Restricted Access Restricted View
</td></ng-container </td></ng-container
> >

View File

@ -2,7 +2,13 @@
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th> <th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
{{ element.name }} <gf-symbol-icon
*ngIf="element.Platform?.url"
class="d-inline d-sm-none mr-1"
[tooltip]="element.Platform?.name"
[url]="element.Platform?.url"
></gf-symbol-icon>
<span>{{ element.name }} </span>
<span <span
*ngIf="element.isDefault" *ngIf="element.isDefault"
class="d-lg-inline-block d-none text-muted" class="d-lg-inline-block d-none text-muted"
@ -12,13 +18,20 @@
</ng-container> </ng-container>
<ng-container matColumnDef="platform"> <ng-container matColumnDef="platform">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th> <th
<td *matCellDef="let element" class="px-1" mat-cell> *matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Platform
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
<div class="d-flex"> <div class="d-flex">
<gf-symbol-icon <gf-symbol-icon
*ngIf="element.Platform?.url" *ngIf="element.Platform?.url"
class="mr-1" class="mr-1"
[tooltip]="" [tooltip]="element.Platform?.name"
[url]="element.Platform?.url" [url]="element.Platform?.url"
></gf-symbol-icon> ></gf-symbol-icon>
<span>{{ element.Platform?.name }}</span> <span>{{ element.Platform?.name }}</span>
@ -27,17 +40,20 @@
</ng-container> </ng-container>
<ng-container matColumnDef="transactions"> <ng-container matColumnDef="transactions">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell> <th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
Transactions <span class="d-block d-sm-none">#</span>
<span class="d-none d-sm-block" i18n>Transactions</span>
</th> </th>
<td *matCellDef="let element" class="text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.Order?.length }} {{ element.transactionCount }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="balance"> <ng-container matColumnDef="balance">
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th> <th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
<td *matCellDef="let element" class="text-right" mat-cell> Balance
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[currency]="element.currency" [currency]="element.currency"

View File

@ -22,27 +22,22 @@
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': 'font-weight-bold': currentRoute === 'portfolio',
currentRoute === 'analysis' || 'text-decoration-underline': currentRoute === 'portfolio'
currentRoute === 'report' ||
currentRoute === 'tools',
'text-decoration-underline':
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}" }"
[routerLink]="['/tools']" [routerLink]="['/portfolio']"
>Tools</a >Portfolio</a
> >
<a <a
*ngIf="user?.settings?.viewMode !== 'DEFAULT'"
class="d-none d-sm-block mx-1" class="d-none d-sm-block mx-1"
i18n i18n
mat-flat-button mat-flat-button
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'transactions', 'font-weight-bold': currentRoute === 'portfolio',
'text-decoration-underline': currentRoute === 'transactions' 'text-decoration-underline': currentRoute === 'portfolio'
}" }"
[routerLink]="['/transactions']" [routerLink]="['/portfolio', 'transactions']"
>Transactions</a >Transactions</a
> >
<a <a
@ -160,28 +155,36 @@
</button> </button>
<hr class="m-0" /> <hr class="m-0" />
</ng-container> </ng-container>
<a
class="d-block d-sm-none"
i18n
mat-menu-item
[ngClass]="{
'font-weight-bold': currentRoute === 'home' || currentRoute === 'zen'
}"
[routerLink]="['/']"
>Overview</a
>
<a <a
*ngIf="user?.settings?.viewMode === 'DEFAULT'" *ngIf="user?.settings?.viewMode === 'DEFAULT'"
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': 'font-weight-bold': currentRoute === 'portfolio'
currentRoute === 'analysis' ||
currentRoute === 'report' ||
currentRoute === 'tools'
}" }"
[routerLink]="['/tools']" [routerLink]="['/portfolio']"
>Tools</a >Portfolio</a
> >
<a <a
*ngIf="user?.settings?.viewMode !== 'DEFAULT'"
class="d-block d-sm-none" class="d-block d-sm-none"
i18n i18n
mat-menu-item mat-menu-item
[ngClass]="{ [ngClass]="{
'font-weight-bold': currentRoute === 'transactions' 'font-weight-bold': currentRoute === 'portfolio'
}" }"
[routerLink]="['/transactions']" [routerLink]="['/portfolio', 'transactions']"
>Transactions</a >Transactions</a
> >
<a <a

View File

@ -73,7 +73,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
const lastItem = this.investments[this.investments.length - 1]; const lastItem = this.investments[this.investments.length - 1];
this.investments.push({ this.investments.push({
...lastItem, ...lastItem,
date: addMonths(parseISO(lastItem.date), 3).toISOString() date: addMonths(new Date(), 3).toISOString()
}); });
} }
@ -154,8 +154,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
} }
} }
private isInFuture(aContext: any, aValue: any) { private isInFuture<T>(aContext: any, aValue: T) {
return isAfter(new Date(aContext?.p0?.parsed?.x), new Date()) return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
? aValue ? aValue
: undefined; : undefined;
} }

View File

@ -5,7 +5,7 @@
<a <a
class="align-items-center justify-content-center" class="align-items-center justify-content-center"
color="primary" color="primary"
[routerLink]="['/transactions']" [routerLink]="['/portfolio', 'transactions']"
mat-button mat-button
> >
<span i18n>Time to add your first transaction.</span> <span i18n>Time to add your first transaction.</span>

View File

@ -1,74 +0,0 @@
<div class="container p-0">
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Cash</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.cash"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Buy</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.totalBuy"
></gf-value>
</div>
</div>
<div class="row px-3 py-1">
<div class="d-flex flex-grow-1" i18n>Sell</div>
<div class="d-flex justify-content-end">
<span
*ngIf="overview?.totalSell || overview?.totalSell === 0"
class="mr-1"
>-</span
>
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.totalSell"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>Investment</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.committedFunds"
></gf-value>
</div>
</div>
<div class="row">
<div class="col"><hr /></div>
</div>
<div class="row px-3">
<div class="d-flex flex-grow-1" i18n>
Fees for {{ overview?.ordersCount }} {overview?.ordersCount, plural, =1
{order} other {orders}}
</div>
<div class="d-flex justify-content-end">
<gf-value
class="justify-content-end"
[currency]="baseCurrency"
[locale]="locale"
[value]="isLoading ? undefined : overview?.fees"
></gf-value>
</div>
</div>
</div>

View File

@ -1,28 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
Input,
OnChanges,
OnInit
} from '@angular/core';
import { PortfolioOverview } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
@Component({
selector: 'gf-portfolio-overview',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-overview.component.html',
styleUrls: ['./portfolio-overview.component.scss']
})
export class PortfolioOverviewComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() overview: PortfolioOverview;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {}
}

View File

@ -1,54 +0,0 @@
<div class="container p-0">
<div class="row no-gutters">
<div class="flex-grow-1"></div>
<div *ngIf="isLoading" class="align-items-center d-flex">
<ngx-skeleton-loader
animation="pulse"
class="mb-2"
[theme]="{
height: '4rem',
width: '15rem'
}"
></ngx-skeleton-loader>
</div>
<div
[hidden]="isLoading"
class="display-4 font-weight-bold m-0 text-center value-container"
>
<span #value id="value"></span>
</div>
<div class="flex-grow-1 px-1">
<ngx-skeleton-loader
*ngIf="isLoading"
animation="pulse"
[theme]="{
height: '1.3rem',
width: '2.5rem'
}"
></ngx-skeleton-loader>
<div *ngIf="!isLoading">
{{ unit }}
</div>
</div>
</div>
<div *ngIf="showDetails" class="row">
<div class="d-flex col justify-content-end">
<gf-value
[colorizeSign]="true"
[isCurrency]="true"
[locale]="locale"
[value]="isLoading ? undefined : performance?.currentNetPerformance"
></gf-value>
</div>
<div class="col">
<gf-value
[colorizeSign]="true"
[isPercent]="true"
[locale]="locale"
[value]="
isLoading ? undefined : performance?.currentNetPerformancePercent
"
></gf-value>
</div>
</div>
</div>

View File

@ -1,9 +0,0 @@
:host {
display: block;
.value-container {
#value {
font-variant-numeric: tabular-nums;
}
}
}

View File

@ -1,65 +0,0 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
Input,
OnChanges,
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import { Currency } from '@prisma/client';
import { CountUp } from 'countup.js';
import { isNumber } from 'lodash';
@Component({
selector: 'gf-portfolio-performance-summary',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './portfolio-performance-summary.component.html',
styleUrls: ['./portfolio-performance-summary.component.scss']
})
export class PortfolioPerformanceSummaryComponent implements OnChanges, OnInit {
@Input() baseCurrency: Currency;
@Input() isLoading: boolean;
@Input() locale: string;
@Input() performance: PortfolioPerformance;
@Input() showDetails: boolean;
@ViewChild('value') value: ElementRef;
public unit: string;
public constructor() {}
public ngOnInit() {}
public ngOnChanges() {
if (this.isLoading) {
if (this.value?.nativeElement) {
this.value.nativeElement.innerHTML = '';
}
} else {
if (isNumber(this.performance?.currentValue)) {
this.unit = this.baseCurrency;
new CountUp('value', this.performance?.currentValue, {
decimalPlaces: 2,
duration: 1,
separator: `'`
}).start();
} else if (this.performance?.currentValue === null) {
this.unit = '%';
new CountUp(
'value',
this.performance?.currentNetPerformancePercent * 100,
{
decimalPlaces: 2,
duration: 0.75,
separator: `'`
}
).start();
}
}
}
}

View File

@ -1,14 +0,0 @@
import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value/value.module';
import { PortfolioPerformanceSummaryComponent } from './portfolio-performance-summary.component';
@NgModule({
declarations: [PortfolioPerformanceSummaryComponent],
exports: [PortfolioPerformanceSummaryComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
providers: []
})
export class GfPortfolioPerformanceSummaryModule {}

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