Compare commits
63 Commits
Author | SHA1 | Date | |
---|---|---|---|
695c378b48 | |||
fe975945d1 | |||
d8782b0d4c | |||
e14f08a8fb | |||
72c065a59d | |||
98dac4052a | |||
2083d28d02 | |||
addd5c36d9 | |||
aad8f77093 | |||
a904208d06 | |||
2733b78044 | |||
b43b515df1 | |||
70e14b4d3c | |||
0f7d1b7d59 | |||
c2ab6a6c44 | |||
c71a4c078e | |||
e17b217032 | |||
408e08d43c | |||
2fa324702f | |||
05b0efef82 | |||
7c91727eb1 | |||
0ee2258af8 | |||
308b218487 | |||
26694adb8e | |||
77936e3bf3 | |||
13090bf6b5 | |||
b898c0678d | |||
3330ae70b6 | |||
96a615dc5d | |||
98f44323da | |||
8adacd9760 | |||
908aba170d | |||
d599797a65 | |||
8ac1272a9d | |||
0a85a56c67 | |||
4ad5590838 | |||
5b8af68e71 | |||
80d043729d | |||
178166d86b | |||
37358fb480 | |||
616d601cf6 | |||
e88b889fdd | |||
f6cdc4ff47 | |||
818c40fc61 | |||
3589e72aea | |||
e68aa1fa68 | |||
bb76ace95d | |||
2bd9309827 | |||
f743c03e17 | |||
dfcf826b4f | |||
218efbb5bd | |||
ba3b4564cd | |||
3723a5c725 | |||
bf256ae50c | |||
c4b6273886 | |||
7f047362cc | |||
66900ffa24 | |||
33e05e949a | |||
94d2310217 | |||
b7d950f3f9 | |||
b7943889da | |||
84aa542560 | |||
4bd41ffa41 |
166
CHANGELOG.md
166
CHANGELOG.md
@ -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/),
|
||||
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
|
||||
|
||||
### Fixed
|
||||
@ -19,8 +180,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Refactored the core engine for the portfolio calculations
|
||||
- Improved the performance of the portfolio calculations
|
||||
- Rewritten the core engine for 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
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
</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>
|
||||
<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.
|
||||
|
||||
<div align="center">
|
||||
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
|
||||
</div>
|
||||
|
||||
## 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.
|
||||
|
@ -5,7 +5,7 @@ import { Prisma } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class AccessService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async accesses(params: {
|
||||
include?: Prisma.AccessInclude;
|
||||
@ -17,7 +17,7 @@ export class AccessService {
|
||||
}): Promise<AccessWithGranteeUser[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prisma.access.findMany({
|
||||
return this.prismaService.access.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import {
|
||||
@ -33,7 +34,8 @@ export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -84,25 +86,22 @@ export class AccountController {
|
||||
public async getAllAccounts(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<AccountModel[]> {
|
||||
const impersonationUserId = await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
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 (
|
||||
impersonationUserId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
accounts = nullifyValuesInObjects(accounts, [
|
||||
'balance',
|
||||
'fee',
|
||||
'quantity',
|
||||
'unitPrice'
|
||||
|
@ -1,32 +1,26 @@
|
||||
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { AccountController } from './account.controller';
|
||||
import { AccountService } from './account.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
RedisCacheModule,
|
||||
PrismaModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [AccountController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [AccountService]
|
||||
})
|
||||
export class AccountModule {}
|
||||
|
@ -1,23 +1,21 @@
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class AccountService {
|
||||
public constructor(
|
||||
private exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private prisma: PrismaService
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async account(
|
||||
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
|
||||
): Promise<Account | null> {
|
||||
return this.prisma.account.findUnique({
|
||||
return this.prismaService.account.findUnique({
|
||||
where: accountWhereUniqueInput
|
||||
});
|
||||
}
|
||||
@ -30,7 +28,7 @@ export class AccountService {
|
||||
Order?: Order[];
|
||||
}
|
||||
> {
|
||||
return this.prisma.account.findUnique({
|
||||
return this.prismaService.account.findUnique({
|
||||
include: accountInclude,
|
||||
where: accountWhereUniqueInput
|
||||
});
|
||||
@ -43,10 +41,15 @@ export class AccountService {
|
||||
cursor?: Prisma.AccountWhereUniqueInput;
|
||||
where?: Prisma.AccountWhereInput;
|
||||
orderBy?: Prisma.AccountOrderByInput;
|
||||
}): Promise<Account[]> {
|
||||
}): Promise<
|
||||
(Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
})[]
|
||||
> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prisma.account.findMany({
|
||||
return this.prismaService.account.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
@ -60,7 +63,7 @@ export class AccountService {
|
||||
data: Prisma.AccountCreateInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
return this.prisma.account.create({
|
||||
return this.prismaService.account.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
@ -69,13 +72,27 @@ export class AccountService {
|
||||
where: Prisma.AccountWhereUniqueInput,
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
|
||||
return this.prisma.account.delete({
|
||||
return this.prismaService.account.delete({
|
||||
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(
|
||||
aUserId: string,
|
||||
aCurrency: Currency
|
||||
@ -105,7 +122,7 @@ export class AccountService {
|
||||
aUserId: string
|
||||
): Promise<Account> {
|
||||
const { data, where } = params;
|
||||
return this.prisma.account.update({
|
||||
return this.prismaService.account.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
|
@ -1,31 +1,25 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
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 { AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule,
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [AdminController],
|
||||
providers: [
|
||||
AdminService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [AdminService],
|
||||
exports: [AdminService]
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
@ -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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
public constructor(
|
||||
private exchangeRateDataService: ExchangeRateDataService,
|
||||
private prisma: PrismaService
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
@ -61,24 +68,22 @@ export class AdminService {
|
||||
}
|
||||
],
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
transactionCount: await this.prisma.order.count(),
|
||||
userCount: await this.prisma.user.count(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
users: await this.getUsersWithAnalytics()
|
||||
};
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
const lastDataGathering =
|
||||
await this.dataGatheringService.getLastDataGathering();
|
||||
|
||||
if (lastDataGathering?.value) {
|
||||
return new Date(lastDataGathering.value);
|
||||
if (lastDataGathering) {
|
||||
return lastDataGathering;
|
||||
}
|
||||
|
||||
const dataGatheringInProgress = await this.prisma.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
const dataGatheringInProgress =
|
||||
await this.dataGatheringService.getIsInProgress();
|
||||
|
||||
if (dataGatheringInProgress) {
|
||||
return 'IN_PROGRESS';
|
||||
@ -87,8 +92,8 @@ export class AdminService {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async getUsersWithAnalytics() {
|
||||
return await this.prisma.user.findMany({
|
||||
private async getUsersWithAnalytics(): Promise<AdminData['users']> {
|
||||
const usersWithAnalytics = await this.prismaService.user.findMany({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
@ -106,7 +111,8 @@ export class AdminService {
|
||||
}
|
||||
},
|
||||
createdAt: true,
|
||||
id: true
|
||||
id: true,
|
||||
Subscription: true
|
||||
},
|
||||
take: 30,
|
||||
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
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { Controller } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../services/prisma.service';
|
||||
import { RedisCacheService } from './redis-cache/redis-cache.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
public constructor(
|
||||
private prisma: PrismaService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly redisCacheService: RedisCacheService
|
||||
) {
|
||||
this.initialize();
|
||||
@ -15,17 +15,12 @@ export class AppController {
|
||||
private async initialize() {
|
||||
this.redisCacheService.reset();
|
||||
|
||||
const isDataGatheringLocked = await this.prisma.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
const isDataGatheringInProgress =
|
||||
await this.dataGatheringService.getIsInProgress();
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
// Prepare for automatical data gather if not locked
|
||||
await this.prisma.property.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||
}
|
||||
});
|
||||
if (isDataGatheringInProgress) {
|
||||
// Prepare for automatical data gathering, if hung up in progress state
|
||||
await this.dataGatheringService.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,35 +1,30 @@
|
||||
import { join } from 'path';
|
||||
|
||||
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 { ConfigModule } from '@nestjs/config';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
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 { AccountModule } from './account/account.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AppController } from './app.controller';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CacheModule } from './cache/cache.module';
|
||||
import { CoreModule } from './core/core.module';
|
||||
import { ExperimentalModule } from './experimental/experimental.module';
|
||||
import { ExportModule } from './export/export.module';
|
||||
import { ImportModule } from './import/import.module';
|
||||
import { InfoModule } from './info/info.module';
|
||||
import { OrderModule } from './order/order.module';
|
||||
import { PortfolioModule } from './portfolio/portfolio.module';
|
||||
import { RedisCacheModule } from './redis-cache/redis-cache.module';
|
||||
import { SubscriptionModule } from './subscription/subscription.module';
|
||||
import { SymbolModule } from './symbol/symbol.module';
|
||||
import { UserModule } from './user/user.module';
|
||||
@ -43,13 +38,17 @@ import { UserModule } from './user/user.module';
|
||||
AuthModule,
|
||||
CacheModule,
|
||||
ConfigModule.forRoot(),
|
||||
CoreModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ExperimentalModule,
|
||||
ExportModule,
|
||||
ImportModule,
|
||||
InfoModule,
|
||||
OrderModule,
|
||||
PortfolioModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
ScheduleModule.forRoot(),
|
||||
ServeStaticModule.forRoot({
|
||||
@ -71,17 +70,6 @@ import { UserModule } from './user/user.module';
|
||||
UserModule
|
||||
],
|
||||
controllers: [AppController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
CronService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CronService]
|
||||
})
|
||||
export class AppModule {}
|
||||
|
@ -7,13 +7,13 @@ import { AuthDevice, Prisma } from '@prisma/client';
|
||||
export class AuthDeviceService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async authDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice | null> {
|
||||
return this.prisma.authDevice.findUnique({
|
||||
return this.prismaService.authDevice.findUnique({
|
||||
where
|
||||
});
|
||||
}
|
||||
@ -26,7 +26,7 @@ export class AuthDeviceService {
|
||||
orderBy?: Prisma.AuthDeviceOrderByInput;
|
||||
}): Promise<AuthDevice[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prisma.authDevice.findMany({
|
||||
return this.prismaService.authDevice.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
@ -38,7 +38,7 @@ export class AuthDeviceService {
|
||||
public async createAuthDevice(
|
||||
data: Prisma.AuthDeviceCreateInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prisma.authDevice.create({
|
||||
return this.prismaService.authDevice.create({
|
||||
data
|
||||
});
|
||||
}
|
||||
@ -49,7 +49,7 @@ export class AuthDeviceService {
|
||||
}): Promise<AuthDevice> {
|
||||
const { data, where } = params;
|
||||
|
||||
return this.prisma.authDevice.update({
|
||||
return this.prismaService.authDevice.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
@ -58,7 +58,7 @@ export class AuthDeviceService {
|
||||
public async deleteAuthDevice(
|
||||
where: Prisma.AuthDeviceWhereUniqueInput
|
||||
): Promise<AuthDevice> {
|
||||
return this.prisma.authDevice.delete({
|
||||
return this.prismaService.authDevice.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
@ -1,11 +1,12 @@
|
||||
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { GoogleStrategy } from './google.strategy';
|
||||
@ -17,7 +18,8 @@ import { JwtStrategy } from './jwt.strategy';
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '180 days' }
|
||||
})
|
||||
}),
|
||||
SubscriptionModule
|
||||
],
|
||||
providers: [
|
||||
AuthDeviceService,
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { Injectable, InternalServerErrorException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import { ValidateOAuthLoginParams } from './interfaces/interfaces';
|
||||
|
||||
@Injectable()
|
||||
|
@ -1,16 +1,15 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
public constructor(
|
||||
readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly userService: UserService
|
||||
) {
|
||||
super({
|
||||
@ -24,7 +23,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
const user = await this.userService.user({ id });
|
||||
|
||||
if (user) {
|
||||
await this.prisma.analytics.upsert({
|
||||
await this.prismaService.analytics.upsert({
|
||||
create: { User: { connect: { id: user.id } } },
|
||||
update: { activityCount: { increment: 1 }, updatedAt: new Date() },
|
||||
where: { userId: user.id }
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
|
||||
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 { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -22,7 +23,6 @@ import {
|
||||
verifyAttestationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import { UserService } from '../user/user.service';
|
||||
import {
|
||||
AssertionCredentialJSON,
|
||||
AttestationCredentialJSON
|
||||
|
7
apps/api/src/app/cache/cache.controller.ts
vendored
7
apps/api/src/app/cache/cache.controller.ts
vendored
@ -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 { Controller, Inject, Post, UseGuards } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Controller('cache')
|
||||
export class CacheController {
|
||||
public constructor(
|
||||
@ -21,6 +20,6 @@ export class CacheController {
|
||||
public async flushCache(): Promise<void> {
|
||||
this.redisCacheService.reset();
|
||||
|
||||
return this.cacheService.flush(this.request.user.id);
|
||||
return this.cacheService.flush();
|
||||
}
|
||||
}
|
||||
|
23
apps/api/src/app/cache/cache.module.ts
vendored
23
apps/api/src/app/cache/cache.module.ts
vendored
@ -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 { Module } from '@nestjs/common';
|
||||
|
||||
import { RedisCacheModule } from '../redis-cache/redis-cache.module';
|
||||
import { CacheController } from './cache.controller';
|
||||
import { CacheService } from './cache.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
controllers: [CacheController],
|
||||
providers: [CacheService, PrismaService]
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
14
apps/api/src/app/cache/cache.service.ts
vendored
14
apps/api/src/app/cache/cache.service.ts
vendored
@ -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';
|
||||
|
||||
@Injectable()
|
||||
export class CacheService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
public constructor(
|
||||
private readonly dataGaterhingService: DataGatheringService
|
||||
) {}
|
||||
|
||||
public async flush(aUserId: string): Promise<void> {
|
||||
await this.prisma.property.deleteMany({
|
||||
where: {
|
||||
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }]
|
||||
}
|
||||
});
|
||||
public async flush(): Promise<void> {
|
||||
await this.dataGaterhingService.reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
@ -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 {}
|
@ -1,6 +0,0 @@
|
||||
import { TransactionPointSymbol } from '@ghostfolio/api/app/core/interfaces/transaction-point-symbol.interface';
|
||||
|
||||
export interface TransactionPoint {
|
||||
date: string;
|
||||
items: TransactionPointSymbol[];
|
||||
}
|
@ -1,34 +1,23 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
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 { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.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 { ExperimentalController } from './experimental.controller';
|
||||
import { ExperimentalService } from './experimental.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
RedisCacheModule,
|
||||
PrismaModule
|
||||
],
|
||||
controllers: [ExperimentalController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
ExperimentalService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [AccountService, ExperimentalService]
|
||||
})
|
||||
export class ExperimentalModule {}
|
||||
|
@ -1,8 +1,7 @@
|
||||
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
@ -11,12 +10,11 @@ export class ExperimentalService {
|
||||
private readonly accountService: AccountService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private prisma: PrismaService,
|
||||
private readonly rulesService: RulesService
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async getBenchmark(aSymbol: string) {
|
||||
return this.prisma.marketData.findMany({
|
||||
return this.prismaService.marketData.findMany({
|
||||
orderBy: { date: 'asc' },
|
||||
select: { date: true, marketPrice: true },
|
||||
where: { symbol: aSymbol }
|
||||
|
@ -1,32 +1,23 @@
|
||||
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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExportController } from './export.controller';
|
||||
import { ExportService } from './export.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ExportController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExportService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CacheService, ExportService]
|
||||
})
|
||||
export class ExportModule {}
|
||||
|
@ -5,10 +5,10 @@ import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ExportService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
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' },
|
||||
select: {
|
||||
currency: true,
|
||||
|
@ -1,34 +1,24 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.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 { 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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ImportController } from './import.controller';
|
||||
import { ImportService } from './import.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ImportController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImportService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CacheService, ImportService, OrderService]
|
||||
})
|
||||
export class ImportModule {}
|
||||
|
@ -24,20 +24,17 @@ export class ImportService {
|
||||
type,
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder(
|
||||
{
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
User: { connect: { id: userId } }
|
||||
},
|
||||
userId
|
||||
);
|
||||
await this.orderService.createOrder({
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
quantity,
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,10 @@
|
||||
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 { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@ -14,6 +20,16 @@ import { InfoService } from './info.service';
|
||||
})
|
||||
],
|
||||
controllers: [InfoController],
|
||||
providers: [ConfigurationService, InfoService, PrismaService]
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
InfoService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
})
|
||||
export class InfoModule {}
|
||||
|
@ -1,4 +1,5 @@
|
||||
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 { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
@ -15,13 +16,14 @@ export class InfoService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private jwtService: JwtService,
|
||||
private prisma: PrismaService
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async get(): Promise<InfoItem> {
|
||||
const info: Partial<InfoItem> = {};
|
||||
const platforms = await this.prisma.platform.findMany({
|
||||
const platforms = await this.prismaService.platform.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
});
|
||||
@ -63,7 +65,7 @@ export class InfoService {
|
||||
}
|
||||
|
||||
private async countActiveUsers(aDays: number) {
|
||||
return await this.prisma.user.count({
|
||||
return await this.prismaService.user.count({
|
||||
orderBy: {
|
||||
Analytics: {
|
||||
updatedAt: 'desc'
|
||||
@ -116,11 +118,10 @@ export class InfoService {
|
||||
}
|
||||
|
||||
private async getLastDataGathering() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
const lastDataGathering =
|
||||
await this.dataGatheringService.getLastDataGathering();
|
||||
|
||||
return lastDataGathering?.value ? new Date(lastDataGathering.value) : null;
|
||||
return lastDataGathering ?? null;
|
||||
}
|
||||
|
||||
private async getStatistics() {
|
||||
@ -144,7 +145,7 @@ export class InfoService {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const stripeConfig = await this.prisma.property.findUnique({
|
||||
const stripeConfig = await this.prismaService.property.findUnique({
|
||||
where: { key: 'STRIPE_CONFIG' }
|
||||
});
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import {
|
||||
@ -34,7 +35,8 @@ export class OrderController {
|
||||
public constructor(
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Delete(':id')
|
||||
@ -52,15 +54,12 @@ export class OrderController {
|
||||
);
|
||||
}
|
||||
|
||||
return this.orderService.deleteOrder(
|
||||
{
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
},
|
||||
this.request.user.id
|
||||
);
|
||||
return this.orderService.deleteOrder({
|
||||
id_userId: {
|
||||
id,
|
||||
userId: this.request.user.id
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Get()
|
||||
@ -80,6 +79,11 @@ export class OrderController {
|
||||
include: {
|
||||
Platform: true
|
||||
}
|
||||
},
|
||||
SymbolProfile: {
|
||||
select: {
|
||||
name: true
|
||||
}
|
||||
}
|
||||
},
|
||||
orderBy: { date: 'desc' },
|
||||
@ -87,11 +91,8 @@ export class OrderController {
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, ['fee', 'quantity', 'unitPrice']);
|
||||
}
|
||||
@ -130,33 +131,30 @@ export class OrderController {
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
return this.orderService.createOrder(
|
||||
{
|
||||
...data,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
create: {
|
||||
return this.orderService.createOrder({
|
||||
...data,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
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')
|
||||
@ -193,26 +191,23 @@ export class OrderController {
|
||||
const accountId = data.accountId;
|
||||
delete data.accountId;
|
||||
|
||||
return this.orderService.updateOrder(
|
||||
{
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
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
|
||||
return this.orderService.updateOrder({
|
||||
data: {
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, 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
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -1,34 +1,28 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
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 { OrderService } from './order.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [OrderController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
OrderService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [CacheService, OrderService],
|
||||
exports: [OrderService]
|
||||
})
|
||||
export class OrderModule {}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
@ -5,22 +6,18 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, Order, Prisma } from '@prisma/client';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
|
||||
import { CacheService } from '../cache/cache.service';
|
||||
import { RedisCacheService } from '../redis-cache/redis-cache.service';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private prisma: PrismaService
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async order(
|
||||
orderWhereUniqueInput: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order | null> {
|
||||
return this.prisma.order.findUnique({
|
||||
return this.prismaService.order.findUnique({
|
||||
where: orderWhereUniqueInput
|
||||
});
|
||||
}
|
||||
@ -35,7 +32,7 @@ export class OrderService {
|
||||
}): Promise<OrderWithAccount[]> {
|
||||
const { include, skip, take, cursor, where, orderBy } = params;
|
||||
|
||||
return this.prisma.order.findMany({
|
||||
return this.prismaService.order.findMany({
|
||||
cursor,
|
||||
include,
|
||||
orderBy,
|
||||
@ -45,13 +42,10 @@ export class OrderService {
|
||||
});
|
||||
}
|
||||
|
||||
public async createOrder(
|
||||
data: Prisma.OrderCreateInput,
|
||||
aUserId: string
|
||||
): Promise<Order> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
if (!isAfter(data.date as Date, endOfToday())) {
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
@ -64,48 +58,75 @@ export class OrderService {
|
||||
|
||||
this.dataGatheringService.gatherProfileData([data.symbol]);
|
||||
|
||||
await this.cacheService.flush(aUserId);
|
||||
await this.cacheService.flush();
|
||||
|
||||
return this.prisma.order.create({
|
||||
data
|
||||
return this.prismaService.order.create({
|
||||
data: {
|
||||
...data,
|
||||
isDraft
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public async deleteOrder(
|
||||
where: Prisma.OrderWhereUniqueInput,
|
||||
aUserId: string
|
||||
where: Prisma.OrderWhereUniqueInput
|
||||
): Promise<Order> {
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
|
||||
return this.prisma.order.delete({
|
||||
return this.prismaService.order.delete({
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
public async updateOrder(
|
||||
params: {
|
||||
where: Prisma.OrderWhereUniqueInput;
|
||||
data: Prisma.OrderUpdateInput;
|
||||
},
|
||||
aUserId: string
|
||||
): Promise<Order> {
|
||||
public getOrders({
|
||||
includeDrafts = false,
|
||||
userId
|
||||
}: {
|
||||
includeDrafts?: boolean;
|
||||
userId: string;
|
||||
}) {
|
||||
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;
|
||||
|
||||
this.redisCacheService.remove(`${aUserId}.portfolio`);
|
||||
const isDraft = isAfter(data.date as Date, endOfToday());
|
||||
|
||||
// Gather symbol data of order in the background
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: <DataSource>data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: <string>data.symbol
|
||||
}
|
||||
]);
|
||||
if (!isDraft) {
|
||||
// Gather symbol data of order in the background, if not draft
|
||||
this.dataGatheringService.gatherSymbols([
|
||||
{
|
||||
dataSource: <DataSource>data.dataSource,
|
||||
date: <Date>data.date,
|
||||
symbol: <string>data.symbol
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
await this.cacheService.flush(aUserId);
|
||||
await this.cacheService.flush();
|
||||
|
||||
return this.prisma.order.update({
|
||||
data,
|
||||
return this.prismaService.order.update({
|
||||
data: {
|
||||
...data,
|
||||
isDraft
|
||||
},
|
||||
where
|
||||
});
|
||||
}
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.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 { Currency, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
|
||||
jest.mock('./market-data.service', () => {
|
@ -1,13 +1,13 @@
|
||||
import { GetValueObject } from '@ghostfolio/api/app/core/interfaces/get-value-object.interface';
|
||||
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 { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
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';
|
||||
|
||||
@Injectable()
|
@ -1,6 +1,7 @@
|
||||
import { DateQuery } from '@ghostfolio/api/app/core/interfaces/date-query.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { DateQuery } from './date-query.interface';
|
||||
|
||||
export interface GetValuesParams {
|
||||
currencies: { [symbol: string]: Currency };
|
||||
dateQuery: DateQuery;
|
@ -5,7 +5,6 @@ export interface TransactionPointSymbol {
|
||||
currency: Currency;
|
||||
firstBuyDate: string;
|
||||
investment: Big;
|
||||
name: string;
|
||||
quantity: Big;
|
||||
symbol: string;
|
||||
transactionCount: number;
|
@ -0,0 +1,6 @@
|
||||
import { TransactionPointSymbol } from './transaction-point-symbol.interface';
|
||||
|
||||
export interface TransactionPoint {
|
||||
date: string;
|
||||
items: TransactionPointSymbol[];
|
||||
}
|
@ -7,7 +7,7 @@ import { DateQuery } from './interfaces/date-query.interface';
|
||||
|
||||
@Injectable()
|
||||
export class MarketDataService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async get({
|
||||
date,
|
||||
@ -16,7 +16,7 @@ export class MarketDataService {
|
||||
date: Date;
|
||||
symbol: string;
|
||||
}): Promise<MarketData> {
|
||||
return await this.prisma.marketData.findFirst({
|
||||
return await this.prismaService.marketData.findFirst({
|
||||
where: {
|
||||
symbol,
|
||||
date: resetHours(date)
|
||||
@ -31,7 +31,7 @@ export class MarketDataService {
|
||||
dateQuery: DateQuery;
|
||||
symbols: string[];
|
||||
}): Promise<MarketData[]> {
|
||||
return await this.prisma.marketData.findMany({
|
||||
return await this.prismaService.marketData.findMany({
|
||||
orderBy: [
|
||||
{
|
||||
date: 'asc'
|
@ -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 { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { Currency } from '@prisma/client';
|
||||
@ -18,6 +10,15 @@ import {
|
||||
isSameDay
|
||||
} 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) {
|
||||
switch (symbol) {
|
||||
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 {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
@ -170,7 +171,6 @@ describe('PortfolioCalculator', () => {
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2019-02-01',
|
||||
investment: new Big('1443.8'),
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
transactionCount: 1
|
||||
@ -184,7 +184,6 @@ describe('PortfolioCalculator', () => {
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2019-02-01',
|
||||
investment: new Big('2923.7'),
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('20'),
|
||||
symbol: 'VTI',
|
||||
transactionCount: 2
|
||||
@ -198,7 +197,6 @@ describe('PortfolioCalculator', () => {
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2019-02-01',
|
||||
investment: new Big('652.55'),
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'VTI',
|
||||
transactionCount: 3
|
||||
@ -212,7 +210,6 @@ describe('PortfolioCalculator', () => {
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2019-02-01',
|
||||
investment: new Big('6627.05'),
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('35'),
|
||||
symbol: 'VTI',
|
||||
transactionCount: 5
|
||||
@ -226,7 +223,6 @@ describe('PortfolioCalculator', () => {
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2019-02-01',
|
||||
investment: new Big('8403.95'),
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('45'),
|
||||
symbol: 'VTI',
|
||||
transactionCount: 6
|
||||
@ -262,7 +258,6 @@ describe('PortfolioCalculator', () => {
|
||||
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'),
|
||||
@ -276,7 +271,6 @@ describe('PortfolioCalculator', () => {
|
||||
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'),
|
||||
@ -290,7 +284,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2019-09-01',
|
||||
items: [
|
||||
{
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
investment: new Big('10109.95'),
|
||||
@ -299,7 +292,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('20'),
|
||||
symbol: 'VTI',
|
||||
investment: new Big('2923.7'),
|
||||
@ -313,7 +305,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2020-02-02',
|
||||
items: [
|
||||
{
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
investment: new Big('10109.95'),
|
||||
@ -322,7 +313,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'VTI',
|
||||
investment: new Big('652.55'),
|
||||
@ -336,7 +326,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2021-02-01',
|
||||
items: [
|
||||
{
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
investment: new Big('10109.95'),
|
||||
@ -345,7 +334,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('15'),
|
||||
symbol: 'VTI',
|
||||
investment: new Big('2684.05'),
|
||||
@ -359,7 +347,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2021-08-01',
|
||||
items: [
|
||||
{
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
investment: new Big('10109.95'),
|
||||
@ -368,7 +355,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('25'),
|
||||
symbol: 'VTI',
|
||||
investment: new Big('4460.95'),
|
||||
@ -411,124 +397,9 @@ describe('PortfolioCalculator', () => {
|
||||
const portfolioItemsAtTransactionPoints =
|
||||
portfolioCalculator.getTransactionPoints();
|
||||
|
||||
expect(portfolioItemsAtTransactionPoints).toEqual([
|
||||
{
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
]);
|
||||
expect(portfolioItemsAtTransactionPoints).toEqual(
|
||||
transactionPointsBuyAndSell
|
||||
);
|
||||
});
|
||||
|
||||
it('with mixed symbols', () => {
|
||||
@ -545,7 +416,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2017-01-03',
|
||||
items: [
|
||||
{
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('50'),
|
||||
symbol: 'TSLA',
|
||||
investment: new Big('2148.5'),
|
||||
@ -559,7 +429,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2017-07-01',
|
||||
items: [
|
||||
{
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big('0.5614682'),
|
||||
symbol: 'BTCUSD',
|
||||
investment: new Big('1999.9999999999998659756'),
|
||||
@ -568,7 +437,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('50'),
|
||||
symbol: 'TSLA',
|
||||
investment: new Big('2148.5'),
|
||||
@ -582,7 +450,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2018-09-01',
|
||||
items: [
|
||||
{
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
investment: new Big('10109.95'),
|
||||
@ -591,7 +458,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Bitcoin USD',
|
||||
quantity: new Big('0.5614682'),
|
||||
symbol: 'BTCUSD',
|
||||
investment: new Big('1999.9999999999998659756'),
|
||||
@ -600,7 +466,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('50'),
|
||||
symbol: 'TSLA',
|
||||
investment: new Big('2148.5'),
|
||||
@ -645,7 +510,6 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
|
||||
investment: new Big('719.46'),
|
||||
marketPrice: 657.62,
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('1'),
|
||||
symbol: 'TSLA',
|
||||
transactionCount: 1
|
||||
@ -684,7 +548,6 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformancePercentage: new Big('-0.08595335390431712673'), // (657.62-719.46)/719.46=-0.08595335390431712673
|
||||
investment: new Big('719.46'),
|
||||
marketPrice: 657.62,
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('1'),
|
||||
symbol: 'TSLA',
|
||||
transactionCount: 1
|
||||
@ -723,7 +586,6 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformancePercentage: new Big('-0.01356013560135601356'), // 657.62/666.66-1=-0.013560136
|
||||
investment: new Big('719.46'),
|
||||
marketPrice: 657.62,
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('1'),
|
||||
symbol: 'TSLA',
|
||||
transactionCount: 1
|
||||
@ -765,7 +627,6 @@ describe('PortfolioCalculator', () => {
|
||||
),
|
||||
investment: new Big('4460.95'),
|
||||
marketPrice: 194.86,
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('25'),
|
||||
symbol: 'VTI',
|
||||
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 () => {
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
currentRateService,
|
||||
@ -847,7 +840,6 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformancePercentage: new Big(
|
||||
'0.2753783814827239834392742298083677500037'
|
||||
),
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
currency: 'USD'
|
||||
}
|
||||
]
|
||||
@ -867,9 +859,8 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2010-12-31',
|
||||
items: [
|
||||
{
|
||||
name: 'Mutual Fund A',
|
||||
quantity: new Big('1000000'), // 1 million
|
||||
symbol: 'MFA',
|
||||
symbol: 'MFA', // Mutual Fund A
|
||||
investment: new Big('1000000'), // 1 million
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2010-12-31',
|
||||
@ -881,9 +872,8 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2011-08-15',
|
||||
items: [
|
||||
{
|
||||
name: 'Mutual Fund A',
|
||||
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
|
||||
currency: Currency.USD,
|
||||
firstBuyDate: '2010-12-31',
|
||||
@ -919,7 +909,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 2,
|
||||
grossPerformance: new Big('92327.999656600898394721'), // 1'192'328 - 1'100'000 = 92'328
|
||||
grossPerformancePercentage: new Big('0.09788498099999947808927632'), // 9.79 %
|
||||
name: 'Mutual Fund A',
|
||||
currency: 'USD'
|
||||
}
|
||||
]
|
||||
@ -939,18 +928,16 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2012-12-31',
|
||||
items: [
|
||||
{
|
||||
name: 'Sub Portfolio A',
|
||||
quantity: new Big('200'),
|
||||
symbol: 'SPA',
|
||||
symbol: 'SPA', // Sub Portfolio A
|
||||
investment: new Big('200'),
|
||||
currency: Currency.CHF,
|
||||
firstBuyDate: '2012-12-31',
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Sub Portfolio B',
|
||||
quantity: new Big('300'),
|
||||
symbol: 'SPB',
|
||||
symbol: 'SPB', // Sub Portfolio B
|
||||
investment: new Big('300'),
|
||||
currency: Currency.CHF,
|
||||
firstBuyDate: '2012-12-31',
|
||||
@ -962,18 +949,16 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2013-12-31',
|
||||
items: [
|
||||
{
|
||||
name: 'Sub Portfolio A',
|
||||
quantity: new Big('200'),
|
||||
symbol: 'SPA',
|
||||
symbol: 'SPA', // Sub Portfolio A
|
||||
investment: new Big('200'),
|
||||
currency: Currency.CHF,
|
||||
firstBuyDate: '2012-12-31',
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Sub Portfolio B',
|
||||
quantity: new Big('300'),
|
||||
symbol: 'SPB',
|
||||
symbol: 'SPB', // Sub Portfolio B
|
||||
investment: new Big('300'),
|
||||
currency: Currency.CHF,
|
||||
firstBuyDate: '2012-12-31',
|
||||
@ -1009,7 +994,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1,
|
||||
grossPerformance: new Big('5'), // 205 - 200
|
||||
grossPerformancePercentage: new Big('0.025'),
|
||||
name: 'Sub Portfolio A',
|
||||
currency: 'CHF'
|
||||
},
|
||||
{
|
||||
@ -1022,7 +1006,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1,
|
||||
grossPerformance: new Big('12'), // 312 - 300
|
||||
grossPerformancePercentage: new Big('0.04'),
|
||||
name: 'Sub Portfolio B',
|
||||
currency: 'CHF'
|
||||
}
|
||||
]
|
||||
@ -1611,7 +1594,6 @@ describe('PortfolioCalculator', () => {
|
||||
date: '2019-02-01',
|
||||
items: [
|
||||
{
|
||||
name: 'Amazon.com, Inc.',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'AMZN',
|
||||
investment: new Big('10109.95'),
|
||||
@ -1620,7 +1602,6 @@ describe('PortfolioCalculator', () => {
|
||||
transactionCount: 1
|
||||
},
|
||||
{
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('10'),
|
||||
symbol: 'VTI',
|
||||
investment: new Big('1443.8'),
|
||||
@ -1744,7 +1725,6 @@ const orderTslaTransactionPoint: TransactionPoint[] = [
|
||||
date: '2021-01-01',
|
||||
items: [
|
||||
{
|
||||
name: 'Tesla, Inc.',
|
||||
quantity: new Big('1'),
|
||||
symbol: 'TSLA',
|
||||
investment: new Big('719.46'),
|
||||
@ -1761,7 +1741,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
|
||||
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'),
|
||||
@ -1775,7 +1754,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
|
||||
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'),
|
||||
@ -1789,7 +1767,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
|
||||
date: '2020-02-02',
|
||||
items: [
|
||||
{
|
||||
name: 'Vanguard Total Stock Market Index Fund ETF Shares',
|
||||
quantity: new Big('5'),
|
||||
symbol: 'VTI',
|
||||
investment: new Big('652.55'),
|
||||
@ -1803,7 +1780,6 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
|
||||
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'),
|
||||
@ -1817,7 +1793,140 @@ const ordersVTITransactionPoints: TransactionPoint[] = [
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
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'),
|
||||
symbol: 'VTI',
|
||||
investment: new Big('4460.95'),
|
@ -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 { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
@ -23,11 +12,22 @@ import {
|
||||
isAfter,
|
||||
isBefore,
|
||||
max,
|
||||
min,
|
||||
subDays
|
||||
min
|
||||
} from 'date-fns';
|
||||
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 {
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
@ -53,17 +53,19 @@ export class PortfolioCalculator {
|
||||
const factor = this.getFactor(order.type);
|
||||
const unitPrice = new Big(order.unitPrice);
|
||||
if (oldAccumulatedSymbol) {
|
||||
const newQuantity = order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
name: order.name,
|
||||
quantity: order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity),
|
||||
investment: newQuantity.eq(0)
|
||||
? new Big(0)
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
};
|
||||
@ -72,7 +74,6 @@ export class PortfolioCalculator {
|
||||
currency: order.currency,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
name: order.name,
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
transactionCount: 1
|
||||
@ -85,11 +86,7 @@ export class PortfolioCalculator {
|
||||
const newItems = items.filter(
|
||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||
);
|
||||
if (!currentTransactionPointItem.quantity.eq(0)) {
|
||||
newItems.push(currentTransactionPointItem);
|
||||
} else {
|
||||
delete symbols[order.symbol];
|
||||
}
|
||||
newItems.push(currentTransactionPointItem);
|
||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||
lastTransactionPoint = {
|
||||
@ -234,42 +231,46 @@ export class PortfolioCalculator {
|
||||
if (i === firstIndex || !initialValues[item.symbol]) {
|
||||
initialValues[item.symbol] = initialValue;
|
||||
}
|
||||
if (!initialValue) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
console.error(
|
||||
`Missing value for symbol ${item.symbol} at ${currentDate}`
|
||||
if (!item.quantity.eq(0)) {
|
||||
if (!initialValue) {
|
||||
invalidSymbols.push(item.symbol);
|
||||
hasErrors = true;
|
||||
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;
|
||||
lastQuantities[item.symbol] = item.quantity;
|
||||
}
|
||||
}
|
||||
|
||||
const positions: TimelinePosition[] = [];
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||
const isValid = invalidSymbols.indexOf(item.symbol) === -1;
|
||||
positions.push({
|
||||
averagePrice: item.investment.div(item.quantity),
|
||||
averagePrice: item.quantity.eq(0)
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: isValid
|
||||
@ -281,7 +282,6 @@ export class PortfolioCalculator {
|
||||
: null,
|
||||
investment: item.investment,
|
||||
marketPrice: marketValue?.toNumber() ?? null,
|
||||
name: item.name,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount
|
||||
@ -401,7 +401,7 @@ export class PortfolioCalculator {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
} else {
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
@ -414,20 +414,25 @@ export class PortfolioCalculator {
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else {
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
console.error(
|
||||
`Initial value is missing for symbol ${currentPosition.symbol}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!completeInitialValue.eq(0)) {
|
||||
grossPerformancePercentage =
|
||||
grossPerformancePercentage.div(completeInitialValue);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValue,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
totalInvestment,
|
||||
grossPerformancePercentage:
|
||||
grossPerformancePercentage.div(completeInitialValue)
|
||||
totalInvestment
|
||||
};
|
||||
}
|
||||
|
@ -1,22 +1,16 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import {
|
||||
PortfolioItem,
|
||||
PortfolioOverview,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import {
|
||||
getPermissions,
|
||||
hasPermission,
|
||||
permissions
|
||||
} from '@ghostfolio/common/permissions';
|
||||
import { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
@ -45,12 +39,12 @@ import { PortfolioService } from './portfolio.service';
|
||||
export class PortfolioController {
|
||||
public constructor(
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
|
||||
@Get('/investments')
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async findAll(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@ -60,11 +54,8 @@ export class PortfolioController {
|
||||
);
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const maxInvestment = investments.reduce(
|
||||
(investment, item) => Math.max(investment, item.investment),
|
||||
@ -105,11 +96,8 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
let maxValue = 0;
|
||||
|
||||
@ -136,44 +124,25 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
let details: { [symbol: string]: PortfolioPosition } = {};
|
||||
): Promise<PortfolioDetails> {
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(impersonationId, range);
|
||||
|
||||
const impersonationUserId =
|
||||
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)) {
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
res.status(StatusCodes.ACCEPTED);
|
||||
}
|
||||
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
const totalInvestment = Object.values(details)
|
||||
const totalInvestment = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return portfolioPosition.investment;
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
||||
const totalValue = Object.values(details)
|
||||
const totalValue = Object.values(holdings)
|
||||
.map((portfolioPosition) => {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
@ -183,50 +152,21 @@ export class PortfolioController {
|
||||
})
|
||||
.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.investment =
|
||||
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;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
}
|
||||
}
|
||||
|
||||
return <any>res.json(details);
|
||||
}
|
||||
|
||||
@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;
|
||||
return <any>res.json({ accounts, holdings });
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@ -247,15 +187,11 @@ export class PortfolioController {
|
||||
|
||||
let performance = performanceInformation.performance;
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performance = nullifyValuesInObject(performance, [
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue'
|
||||
]);
|
||||
}
|
||||
@ -279,9 +215,48 @@ export class PortfolioController {
|
||||
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);
|
||||
}
|
||||
|
||||
@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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(
|
||||
@ -295,13 +270,14 @@ export class PortfolioController {
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
impersonationId &&
|
||||
!hasPermission(
|
||||
getPermissions(this.request.user.role),
|
||||
permissions.readForeignPortfolio
|
||||
)
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
position = nullifyValuesInObject(position, ['grossPerformance']);
|
||||
position = nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'quantity'
|
||||
]);
|
||||
}
|
||||
|
||||
return position;
|
||||
|
@ -1,50 +1,40 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/app/core/market-data.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { RulesService } from '@ghostfolio/api/services/rules.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
UserModule
|
||||
],
|
||||
controllers: [PortfolioController],
|
||||
providers: [
|
||||
AccountService,
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
CurrentRateService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
ExchangeRateDataService,
|
||||
GhostfolioScraperApiService,
|
||||
ImpersonationService,
|
||||
MarketDataService,
|
||||
OrderService,
|
||||
PortfolioService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
RulesService,
|
||||
SymbolProfileService,
|
||||
UserService,
|
||||
YahooFinanceService
|
||||
SymbolProfileService
|
||||
]
|
||||
})
|
||||
export class PortfolioModule {}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CurrentRateService } from '@ghostfolio/api/app/core/current-rate.service';
|
||||
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 { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
|
||||
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 { AccountClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/account-cluster-risk/current-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 { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/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 { 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 { RulesService } from '@ghostfolio/api/services/rules.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 {
|
||||
PortfolioOverview,
|
||||
PortfolioDetails,
|
||||
PortfolioPerformance,
|
||||
PortfolioPosition,
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
Position,
|
||||
TimelinePosition
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -38,7 +40,12 @@ import {
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 {
|
||||
endOfToday,
|
||||
@ -58,6 +65,7 @@ import {
|
||||
HistoricalDataItem,
|
||||
PortfolioPositionDetail
|
||||
} from './interfaces/portfolio-position-detail.interface';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
@ -83,7 +91,10 @@ export class PortfolioService {
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
const { transactionPoints } = await this.getTransactionPoints({
|
||||
userId,
|
||||
includeDrafts: true
|
||||
});
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return [];
|
||||
@ -108,7 +119,7 @@ export class PortfolioService {
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
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(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<{ [symbol: string]: PortfolioPosition }> {
|
||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
@ -178,12 +164,12 @@ export class PortfolioService {
|
||||
userCurrency
|
||||
);
|
||||
|
||||
const { transactionPoints, orders } = await this.getTransactionPoints(
|
||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||
userId
|
||||
);
|
||||
});
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {};
|
||||
return { accounts: {}, holdings: {}, hasErrors: false };
|
||||
}
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
@ -194,12 +180,16 @@ export class PortfolioService {
|
||||
startDate
|
||||
);
|
||||
|
||||
if (currentPositions.hasErrors) {
|
||||
throw new Error('Missing information');
|
||||
}
|
||||
const cashDetails = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
userCurrency
|
||||
);
|
||||
|
||||
const result: { [symbol: string]: PortfolioPosition } = {};
|
||||
const totalValue = currentPositions.currentValue;
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||
cashDetails.balance
|
||||
);
|
||||
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
|
||||
|
||||
const symbols = currentPositions.positions.map(
|
||||
(position) => position.symbol
|
||||
@ -219,37 +209,49 @@ export class PortfolioService {
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, userCurrency);
|
||||
|
||||
for (const item of currentPositions.positions) {
|
||||
const value = item.quantity.mul(item.marketPrice);
|
||||
const symbolProfile = symbolProfileMap[item.symbol];
|
||||
const dataProviderResponse = dataProviderResponses[item.symbol];
|
||||
result[item.symbol] = {
|
||||
accounts,
|
||||
holdings[item.symbol] = {
|
||||
allocationCurrent: value.div(totalValue).toNumber(),
|
||||
allocationInvestment: item.investment
|
||||
.div(currentPositions.totalInvestment)
|
||||
.toNumber(),
|
||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
currency: item.currency,
|
||||
exchange: dataProviderResponse.exchange,
|
||||
grossPerformance: item.grossPerformance.toNumber(),
|
||||
grossPerformancePercent: item.grossPerformancePercentage.toNumber(),
|
||||
grossPerformance: item.grossPerformance?.toNumber() ?? 0,
|
||||
grossPerformancePercent:
|
||||
item.grossPerformancePercentage?.toNumber() ?? 0,
|
||||
investment: item.investment.toNumber(),
|
||||
marketPrice: item.marketPrice,
|
||||
marketState: dataProviderResponse.marketState,
|
||||
name: item.name,
|
||||
name: symbolProfile.name,
|
||||
quantity: item.quantity.toNumber(),
|
||||
sectors: symbolProfile.sectors,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount,
|
||||
type: dataProviderResponse.type,
|
||||
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(
|
||||
@ -258,14 +260,11 @@ export class PortfolioService {
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userId = await this.getUserId(aImpersonationId);
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator(
|
||||
this.currentRateService,
|
||||
this.request.user.Settings.currency
|
||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||
(order) => order.symbol === aSymbol
|
||||
);
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
averagePrice: 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 currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
@ -299,12 +315,24 @@ export class PortfolioService {
|
||||
averagePrice,
|
||||
currency,
|
||||
firstBuyDate,
|
||||
investment,
|
||||
marketPrice,
|
||||
quantity,
|
||||
transactionCount
|
||||
} = 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(
|
||||
[aSymbol],
|
||||
'day',
|
||||
@ -313,8 +341,17 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
let maxPrice = marketPrice;
|
||||
let minPrice = marketPrice;
|
||||
let maxPrice = Math.max(orders[0].unitPrice, 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]) {
|
||||
let j = -1;
|
||||
@ -332,9 +369,9 @@ export class PortfolioService {
|
||||
(item) => item.symbol === aSymbol
|
||||
);
|
||||
if (currentSymbol) {
|
||||
currentAveragePrice = currentSymbol.investment
|
||||
.div(currentSymbol.quantity)
|
||||
.toNumber();
|
||||
currentAveragePrice = currentSymbol.quantity.eq(0)
|
||||
? 0
|
||||
: currentSymbol.investment.div(currentSymbol.quantity).toNumber();
|
||||
}
|
||||
|
||||
historicalDataArray.push({
|
||||
@ -351,20 +388,21 @@ export class PortfolioService {
|
||||
return {
|
||||
currency,
|
||||
firstBuyDate,
|
||||
grossPerformance,
|
||||
investment,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
transactionCount,
|
||||
averagePrice: averagePrice.toNumber(),
|
||||
grossPerformance: position.grossPerformance.toNumber(),
|
||||
grossPerformancePercent: position.grossPerformancePercentage.toNumber(),
|
||||
historicalData: historicalDataArray,
|
||||
investment: investment.toNumber(),
|
||||
quantity: quantity.toNumber(),
|
||||
symbol: aSymbol
|
||||
};
|
||||
} else {
|
||||
const currentData = await this.dataProviderService.get([aSymbol]);
|
||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||
|
||||
let historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
@ -382,28 +420,33 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const historicalDataArray: HistoricalDataItem[] = [];
|
||||
let maxPrice = marketPrice;
|
||||
let minPrice = marketPrice;
|
||||
|
||||
for (const [date, { marketPrice }] of Object.entries(
|
||||
historicalData[aSymbol]
|
||||
).reverse()) {
|
||||
)) {
|
||||
historicalDataArray.push({
|
||||
date,
|
||||
value: marketPrice
|
||||
});
|
||||
|
||||
maxPrice = Math.max(marketPrice ?? 0, maxPrice);
|
||||
minPrice = Math.min(marketPrice ?? Number.MAX_SAFE_INTEGER, minPrice);
|
||||
}
|
||||
|
||||
return {
|
||||
averagePrice: undefined,
|
||||
marketPrice,
|
||||
maxPrice,
|
||||
minPrice,
|
||||
averagePrice: 0,
|
||||
currency: currentData[aSymbol]?.currency,
|
||||
firstBuyDate: undefined,
|
||||
grossPerformance: undefined,
|
||||
grossPerformancePercent: undefined,
|
||||
historicalData: historicalDataArray,
|
||||
investment: undefined,
|
||||
marketPrice: currentData[aSymbol]?.marketPrice,
|
||||
maxPrice: undefined,
|
||||
minPrice: undefined,
|
||||
quantity: undefined,
|
||||
investment: 0,
|
||||
quantity: 0,
|
||||
symbol: aSymbol,
|
||||
transactionCount: undefined
|
||||
};
|
||||
@ -421,7 +464,7 @@ export class PortfolioService {
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
@ -438,17 +481,34 @@ export class PortfolioService {
|
||||
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 {
|
||||
hasErrors: currentPositions.hasErrors,
|
||||
positions: currentPositions.positions.map((position) => {
|
||||
positions: positions.map((position) => {
|
||||
return {
|
||||
...position,
|
||||
assetClass: symbolProfileMap[position.symbol].assetClass,
|
||||
averagePrice: new Big(position.averagePrice).toNumber(),
|
||||
grossPerformance: position.grossPerformance?.toNumber() ?? null,
|
||||
grossPerformancePercentage:
|
||||
position.grossPerformancePercentage?.toNumber() ?? null,
|
||||
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()
|
||||
};
|
||||
})
|
||||
@ -466,7 +526,7 @@ export class PortfolioService {
|
||||
this.request.user.Settings.currency
|
||||
);
|
||||
|
||||
const { transactionPoints } = await this.getTransactionPoints(userId);
|
||||
const { transactionPoints } = await this.getTransactionPoints({ userId });
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
@ -474,8 +534,6 @@ export class PortfolioService {
|
||||
performance: {
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0
|
||||
}
|
||||
};
|
||||
@ -500,9 +558,6 @@ export class PortfolioService {
|
||||
performance: {
|
||||
currentGrossPerformance,
|
||||
currentGrossPerformancePercent,
|
||||
// TODO: the next two should include fees
|
||||
currentNetPerformance: currentGrossPerformance,
|
||||
currentNetPerformancePercent: currentGrossPerformancePercent,
|
||||
currentValue: currentValue
|
||||
}
|
||||
};
|
||||
@ -528,9 +583,9 @@ export class PortfolioService {
|
||||
const userId = await this.getUserId(impersonationId);
|
||||
const baseCurrency = this.request.user.Settings.currency;
|
||||
|
||||
const { transactionPoints, orders } = await this.getTransactionPoints(
|
||||
const { orders, transactionPoints } = await this.getTransactionPoints({
|
||||
userId
|
||||
);
|
||||
});
|
||||
|
||||
if (isEmpty(orders)) {
|
||||
return {
|
||||
@ -553,7 +608,12 @@ export class PortfolioService {
|
||||
for (const position of currentPositions.positions) {
|
||||
portfolioItemsNow[position.symbol] = position;
|
||||
}
|
||||
const accounts = this.getAccounts(orders, portfolioItemsNow, baseCurrency);
|
||||
const accounts = await this.getAccounts(
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
baseCurrency,
|
||||
userId
|
||||
);
|
||||
return {
|
||||
rules: {
|
||||
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) {
|
||||
switch (aDateRange) {
|
||||
case '1d':
|
||||
@ -626,11 +753,17 @@ export class PortfolioService {
|
||||
return portfolioStart;
|
||||
}
|
||||
|
||||
private async getTransactionPoints(userId: string): Promise<{
|
||||
private async getTransactionPoints({
|
||||
includeDrafts = false,
|
||||
userId
|
||||
}: {
|
||||
includeDrafts?: boolean;
|
||||
userId: string;
|
||||
}): Promise<{
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
}> {
|
||||
const orders = await this.getOrders(userId);
|
||||
const orders = await this.orderService.getOrders({ includeDrafts, userId });
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
@ -664,55 +797,68 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
private getAccounts(
|
||||
private async getAccounts(
|
||||
orders: OrderWithAccount[],
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||
userCurrency
|
||||
userCurrency: Currency,
|
||||
userId: string
|
||||
) {
|
||||
const accounts: PortfolioPosition['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
|
||||
);
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
|
||||
if (order.type === 'SELL') {
|
||||
currentValueOfSymbol *= -1;
|
||||
originalValueOfSymbol *= -1;
|
||||
const currentAccounts = await this.accountService.getAccounts(userId);
|
||||
|
||||
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) {
|
||||
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
|
||||
};
|
||||
for (const order of ordersByAccount) {
|
||||
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') {
|
||||
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 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 }
|
||||
});
|
||||
return accounts;
|
||||
}
|
||||
|
||||
private async getUserId(aImpersonationId: string) {
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { Rule } from '@ghostfolio/api/models/rule';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { Rule } from '../models/rule';
|
||||
|
||||
@Injectable()
|
||||
export class RulesService {
|
||||
public constructor() {}
|
@ -8,6 +8,7 @@ import { SubscriptionService } from './subscription.service';
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [SubscriptionController],
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService]
|
||||
providers: [ConfigurationService, PrismaService, SubscriptionService],
|
||||
exports: [SubscriptionService]
|
||||
})
|
||||
export class SubscriptionModule {}
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
@ -10,7 +12,7 @@ export class SubscriptionService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService
|
||||
private readonly prismaService: PrismaService
|
||||
) {
|
||||
this.stripe = new Stripe(
|
||||
this.configurationService.get('STRIPE_SECRET_KEY'),
|
||||
@ -68,7 +70,7 @@ export class SubscriptionService {
|
||||
aCheckoutSessionId
|
||||
);
|
||||
|
||||
await this.prisma.subscription.create({
|
||||
await this.prismaService.subscription.create({
|
||||
data: {
|
||||
expiresAt: addDays(new Date(), 365),
|
||||
User: {
|
||||
@ -86,4 +88,23 @@ export class SubscriptionService {
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
symbol: string;
|
||||
|
@ -1,27 +1,14 @@
|
||||
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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { SymbolController } from './symbol.controller';
|
||||
import { SymbolService } from './symbol.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
|
||||
controllers: [SymbolController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
SymbolService,
|
||||
YahooFinanceService
|
||||
]
|
||||
providers: [SymbolService]
|
||||
})
|
||||
export class SymbolModule {}
|
||||
|
@ -1,6 +1,5 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { convertFromYahooSymbol } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
|
||||
@ -11,7 +10,7 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
export class SymbolService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async get(aSymbol: string): Promise<SymbolItem> {
|
||||
@ -37,16 +36,29 @@ export class SymbolService {
|
||||
results.items = items;
|
||||
|
||||
// Add custom symbols
|
||||
const scraperConfigurations = await this.ghostfolioScraperApiService.getScraperConfigurations();
|
||||
scraperConfigurations.forEach((scraperConfiguration) => {
|
||||
if (scraperConfiguration.name.toLowerCase().startsWith(aQuery)) {
|
||||
results.items.push({
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: scraperConfiguration.name,
|
||||
symbol: scraperConfiguration.symbol
|
||||
});
|
||||
}
|
||||
});
|
||||
const ghostfolioSymbolProfiles =
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: {
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
||||
results.items.push(ghostfolioSymbolProfile);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
|
@ -0,0 +1,3 @@
|
||||
export interface UserSettings {
|
||||
isRestrictedView?: boolean;
|
||||
}
|
6
apps/api/src/app/user/update-user-setting.dto.ts
Normal file
6
apps/api/src/app/user/update-user-setting.dto.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsBoolean()
|
||||
isRestrictedView?: boolean;
|
||||
}
|
@ -26,6 +26,8 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { UserItem } from './interfaces/user-item.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 { 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')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async updateUserSettings(@Body() data: UpdateUserSettingsDto) {
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -11,9 +12,11 @@ import { UserService } from './user.service';
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
})
|
||||
}),
|
||||
SubscriptionModule
|
||||
],
|
||||
controllers: [UserController],
|
||||
providers: [ConfigurationService, PrismaService, UserService]
|
||||
providers: [ConfigurationService, PrismaService, UserService],
|
||||
exports: [UserService]
|
||||
})
|
||||
export class UserModule {}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
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 { Injectable } from '@nestjs/common';
|
||||
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
import { isBefore } from 'date-fns';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
@ -18,7 +19,8 @@ export class UserService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private prisma: PrismaService
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly subscriptionService: SubscriptionService
|
||||
) {}
|
||||
|
||||
public async getUser({
|
||||
@ -29,7 +31,7 @@ export class UserService {
|
||||
Settings,
|
||||
subscription
|
||||
}: UserWithSettings): Promise<IUser> {
|
||||
const access = await this.prisma.access.findMany({
|
||||
const access = await this.prismaService.access.findMany({
|
||||
include: {
|
||||
User: true
|
||||
},
|
||||
@ -50,6 +52,7 @@ export class UserService {
|
||||
}),
|
||||
accounts: Account,
|
||||
settings: {
|
||||
...(<UserSettings>Settings.settings),
|
||||
locale,
|
||||
baseCurrency: Settings?.currency ?? UserService.DEFAULT_CURRENCY,
|
||||
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(
|
||||
userWhereUniqueInput: Prisma.UserWhereUniqueInput
|
||||
): Promise<UserWithSettings | null> {
|
||||
const userFromDatabase = await this.prisma.user.findUnique({
|
||||
const userFromDatabase = await this.prismaService.user.findUnique({
|
||||
include: { Account: true, Settings: true, Subscription: true },
|
||||
where: userWhereUniqueInput
|
||||
});
|
||||
@ -84,6 +91,7 @@ export class UserService {
|
||||
// Set default settings if needed
|
||||
userFromDatabase.Settings = {
|
||||
currency: UserService.DEFAULT_CURRENCY,
|
||||
settings: null,
|
||||
updatedAt: new Date(),
|
||||
userId: userFromDatabase?.id,
|
||||
viewMode: ViewMode.DEFAULT
|
||||
@ -91,24 +99,9 @@ export class UserService {
|
||||
}
|
||||
|
||||
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
|
||||
if (userFromDatabase?.Subscription?.length > 0) {
|
||||
const latestSubscription = userFromDatabase.Subscription.reduce(
|
||||
(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
|
||||
};
|
||||
}
|
||||
user.subscription = this.subscriptionService.getSubscription(
|
||||
userFromDatabase?.Subscription
|
||||
);
|
||||
|
||||
if (user.subscription.type === SubscriptionType.Basic) {
|
||||
user.permissions = user.permissions.filter((permission) => {
|
||||
@ -129,7 +122,7 @@ export class UserService {
|
||||
orderBy?: Prisma.UserOrderByInput;
|
||||
}): Promise<User[]> {
|
||||
const { skip, take, cursor, where, orderBy } = params;
|
||||
return this.prisma.user.findMany({
|
||||
return this.prismaService.user.findMany({
|
||||
skip,
|
||||
take,
|
||||
cursor,
|
||||
@ -146,7 +139,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
public async createUser(data?: Prisma.UserCreateInput): Promise<User> {
|
||||
let user = await this.prisma.user.create({
|
||||
let user = await this.prismaService.user.create({
|
||||
data: {
|
||||
...data,
|
||||
Account: {
|
||||
@ -169,7 +162,7 @@ export class UserService {
|
||||
process.env.ACCESS_TOKEN_SALT
|
||||
);
|
||||
|
||||
user = await this.prisma.user.update({
|
||||
user = await this.prismaService.user.update({
|
||||
data: { accessToken: hashedAccessToken },
|
||||
where: { id: user.id }
|
||||
});
|
||||
@ -185,46 +178,75 @@ export class UserService {
|
||||
data: Prisma.UserUpdateInput;
|
||||
}): Promise<User> {
|
||||
const { where, data } = params;
|
||||
return this.prisma.user.update({
|
||||
return this.prismaService.user.update({
|
||||
data,
|
||||
where
|
||||
});
|
||||
}
|
||||
|
||||
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 }] }
|
||||
});
|
||||
|
||||
await this.prisma.account.deleteMany({
|
||||
await this.prismaService.account.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
|
||||
await this.prisma.analytics.delete({
|
||||
await this.prismaService.analytics.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
|
||||
await this.prisma.order.deleteMany({
|
||||
await this.prismaService.order.deleteMany({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
|
||||
try {
|
||||
await this.prisma.settings.delete({
|
||||
await this.prismaService.settings.delete({
|
||||
where: { userId: where.id }
|
||||
});
|
||||
} catch {}
|
||||
|
||||
return this.prisma.user.delete({
|
||||
return this.prismaService.user.delete({
|
||||
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({
|
||||
currency,
|
||||
userId,
|
||||
viewMode
|
||||
}: UserSettingsParams) {
|
||||
await this.prisma.settings.upsert({
|
||||
await this.prismaService.settings.upsert({
|
||||
create: {
|
||||
currency,
|
||||
User: {
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { Account, Currency, SymbolProfile } from '@prisma/client';
|
||||
import { endOfToday, isAfter, parseISO } from 'date-fns';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
@ -11,6 +10,7 @@ export class Order {
|
||||
private fee: number;
|
||||
private date: string;
|
||||
private id: string;
|
||||
private isDraft: boolean;
|
||||
private quantity: number;
|
||||
private symbol: string;
|
||||
private symbolProfile: SymbolProfile;
|
||||
@ -24,6 +24,7 @@ export class Order {
|
||||
this.fee = data.fee;
|
||||
this.date = data.date;
|
||||
this.id = data.id || uuidv4();
|
||||
this.isDraft = data.isDraft;
|
||||
this.quantity = data.quantity;
|
||||
this.symbol = data.symbol;
|
||||
this.symbolProfile = data.symbolProfile;
|
||||
@ -54,7 +55,7 @@ export class Order {
|
||||
}
|
||||
|
||||
public getIsDraft() {
|
||||
return isAfter(parseISO(this.date), endOfToday());
|
||||
return this.isDraft;
|
||||
}
|
||||
|
||||
public getQuantity() {
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-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 { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
import { ExchangeRateDataService } from '../services/exchange-rate-data.service';
|
||||
import { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
|
||||
|
@ -1,16 +1,17 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-settings.interface';
|
||||
import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
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';
|
||||
|
||||
export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Current Investment'
|
||||
|
@ -1,6 +1,9 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-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 { Rule } from '../../rule';
|
||||
@ -8,9 +11,7 @@ import { Rule } from '../../rule';
|
||||
export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Initial Investment'
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { RuleSettings } from '@ghostfolio/api/models/interfaces/rule-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 { Rule } from '../../rule';
|
||||
@ -7,9 +8,7 @@ import { Rule } from '../../rule';
|
||||
export class AccountClusterRiskSingleAccount extends Rule<RuleSettings> {
|
||||
public constructor(
|
||||
protected exchangeRateDataService: ExchangeRateDataService,
|
||||
private accounts: {
|
||||
[account: string]: { current: number; original: number };
|
||||
}
|
||||
private accounts: PortfolioDetails['accounts']
|
||||
) {
|
||||
super(exchangeRateDataService, {
|
||||
name: 'Single Account'
|
||||
|
@ -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 { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
|
@ -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 { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
@ -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 { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
@ -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 { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.interface';
|
||||
import { Currency } from '@prisma/client';
|
||||
|
8
apps/api/src/services/configuration.module.ts
Normal file
8
apps/api/src/services/configuration.module.ts
Normal 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 {}
|
12
apps/api/src/services/data-gathering.module.ts
Normal file
12
apps/api/src/services/data-gathering.module.ts
Normal 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 {}
|
@ -3,7 +3,6 @@ import {
|
||||
DATE_FORMAT,
|
||||
getUtc,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@ -20,7 +19,7 @@ import {
|
||||
} from 'date-fns';
|
||||
|
||||
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 { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
@ -31,7 +30,7 @@ export class DataGatheringService {
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
||||
private prisma: PrismaService
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async gather7Days() {
|
||||
@ -39,9 +38,9 @@ export class DataGatheringService {
|
||||
|
||||
if (isDataGatheringNeeded) {
|
||||
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: {
|
||||
key: 'LOCKED_DATA_GATHERING',
|
||||
value: new Date().toISOString()
|
||||
@ -53,7 +52,7 @@ export class DataGatheringService {
|
||||
try {
|
||||
await this.gatherSymbols(symbols);
|
||||
|
||||
await this.prisma.property.upsert({
|
||||
await this.prismaService.property.upsert({
|
||||
create: {
|
||||
key: 'LAST_DATA_GATHERING',
|
||||
value: new Date().toISOString()
|
||||
@ -65,27 +64,27 @@ export class DataGatheringService {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await this.prisma.property.delete({
|
||||
await this.prismaService.property.delete({
|
||||
where: {
|
||||
key: 'LOCKED_DATA_GATHERING'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('7d data gathering has been completed.');
|
||||
console.timeEnd('7d-data-gathering');
|
||||
console.timeEnd('data-gathering-7d');
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherMax() {
|
||||
const isDataGatheringLocked = await this.prisma.property.findUnique({
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
if (!isDataGatheringLocked) {
|
||||
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: {
|
||||
key: 'LOCKED_DATA_GATHERING',
|
||||
value: new Date().toISOString()
|
||||
@ -97,7 +96,7 @@ export class DataGatheringService {
|
||||
try {
|
||||
await this.gatherSymbols(symbols);
|
||||
|
||||
await this.prisma.property.upsert({
|
||||
await this.prismaService.property.upsert({
|
||||
create: {
|
||||
key: 'LAST_DATA_GATHERING',
|
||||
value: new Date().toISOString()
|
||||
@ -109,20 +108,20 @@ export class DataGatheringService {
|
||||
console.error(error);
|
||||
}
|
||||
|
||||
await this.prisma.property.delete({
|
||||
await this.prismaService.property.delete({
|
||||
where: {
|
||||
key: 'LOCKED_DATA_GATHERING'
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Max data gathering has been completed.');
|
||||
console.timeEnd('max-data-gathering');
|
||||
console.timeEnd('data-gathering-max');
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aSymbols?: string[]) {
|
||||
console.log('Profile data gathering has been started.');
|
||||
console.time('profile-data-gathering');
|
||||
console.time('data-gathering-profile');
|
||||
|
||||
let symbols = aSymbols;
|
||||
|
||||
@ -135,18 +134,25 @@ export class DataGatheringService {
|
||||
|
||||
const currentData = await this.dataProviderService.get(symbols);
|
||||
|
||||
for (const [symbol, { currency, dataSource, name }] of Object.entries(
|
||||
currentData
|
||||
)) {
|
||||
for (const [
|
||||
symbol,
|
||||
{ assetClass, assetSubClass, countries, currency, dataSource, name }
|
||||
] of Object.entries(currentData)) {
|
||||
try {
|
||||
await this.prisma.symbolProfile.upsert({
|
||||
await this.prismaService.symbolProfile.upsert({
|
||||
create: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
symbol
|
||||
},
|
||||
update: {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
name
|
||||
},
|
||||
@ -163,7 +169,7 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
console.log('Profile data gathering has been completed.');
|
||||
console.timeEnd('profile-data-gathering');
|
||||
console.timeEnd('data-gathering-profile');
|
||||
}
|
||||
|
||||
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
|
||||
@ -203,7 +209,7 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
try {
|
||||
await this.prisma.marketData.create({
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
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[] {
|
||||
const benchmarksToGather = benchmarks.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
@ -271,7 +305,7 @@ export class DataGatheringService {
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true },
|
||||
@ -303,9 +337,8 @@ export class DataGatheringService {
|
||||
}
|
||||
);
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
const customSymbolsToGather =
|
||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
@ -318,9 +351,8 @@ export class DataGatheringService {
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = new Date(getUtc('2015-01-01'));
|
||||
|
||||
const customSymbolsToGather = await this.getCustomSymbolsToGather(
|
||||
startDate
|
||||
);
|
||||
const customSymbolsToGather =
|
||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ 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'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { dataSource: true, date: true, symbol: true },
|
||||
@ -354,7 +386,7 @@ export class DataGatheringService {
|
||||
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prisma.order.findMany({
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true }
|
||||
@ -371,18 +403,13 @@ export class DataGatheringService {
|
||||
}
|
||||
|
||||
private async isDataGatheringNeeded() {
|
||||
const lastDataGathering = await this.prisma.property.findUnique({
|
||||
where: { key: 'LAST_DATA_GATHERING' }
|
||||
});
|
||||
const lastDataGathering = await this.getLastDataGathering();
|
||||
|
||||
const isDataGatheringLocked = await this.prisma.property.findUnique({
|
||||
const isDataGatheringLocked = await this.prismaService.property.findUnique({
|
||||
where: { key: 'LOCKED_DATA_GATHERING' }
|
||||
});
|
||||
|
||||
const diffInHours = differenceInHours(
|
||||
new Date(),
|
||||
new Date(lastDataGathering?.value)
|
||||
);
|
||||
const diffInHours = differenceInHours(new Date(), lastDataGathering);
|
||||
|
||||
return (diffInHours >= 1 || !lastDataGathering) && !isDataGatheringLocked;
|
||||
}
|
||||
|
@ -1,11 +1,11 @@
|
||||
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 { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { isAfter, isBefore, parse } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from '../../configuration.service';
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
|
22
apps/api/src/services/data-provider/data-provider.module.ts
Normal file
22
apps/api/src/services/data-provider/data-provider.module.ts
Normal 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 {}
|
@ -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 {
|
||||
DATE_FORMAT,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
@ -8,17 +16,10 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { AlphaVantageService } from './data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from './data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from './data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
||||
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService {
|
||||
@ -26,11 +27,11 @@ export class DataProviderService {
|
||||
private readonly alphaVantageService: AlphaVantageService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
||||
private prisma: PrismaService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
||||
private readonly yahooFinanceService: YahooFinanceService
|
||||
) {
|
||||
this.rakutenRapidApiService?.setPrisma(this.prisma);
|
||||
this.rakutenRapidApiService?.setPrisma(this.prismaService);
|
||||
}
|
||||
|
||||
public async get(
|
||||
@ -112,9 +113,8 @@ export class DataProviderService {
|
||||
`','`
|
||||
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
|
||||
|
||||
const marketDataByGranularity: MarketData[] = await this.prisma.$queryRaw(
|
||||
queryRaw
|
||||
);
|
||||
const marketDataByGranularity: MarketData[] =
|
||||
await this.prismaService.$queryRaw(queryRaw);
|
||||
|
||||
response = marketDataByGranularity.reduce((r, marketData) => {
|
||||
const { date, marketPrice, symbol } = marketData;
|
||||
@ -167,10 +167,19 @@ export class DataProviderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
return this.getDataProvider(
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
const { items } = await this.getDataProvider(
|
||||
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
||||
).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) {
|
@ -1,3 +1,5 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getYesterday,
|
||||
@ -12,18 +14,18 @@ import { format } from 'date-fns';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { PrismaService } from '../../prisma.service';
|
||||
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
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) {
|
||||
return isGhostfolioScraperApiSymbol(symbol);
|
||||
@ -41,7 +43,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
|
||||
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
|
||||
|
||||
const { marketPrice } = await this.prisma.marketData.findFirst({
|
||||
const { marketPrice } = await this.prismaService.marketData.findFirst({
|
||||
orderBy: {
|
||||
date: 'desc'
|
||||
},
|
||||
@ -55,8 +57,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
marketPrice,
|
||||
currency: scraperConfig?.currency,
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
marketState: MarketState.delayed,
|
||||
name: scraperConfig?.name
|
||||
marketState: MarketState.delayed
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
@ -66,6 +67,25 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
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(
|
||||
aSymbols: string[],
|
||||
aGranularity: Granularity = 'day',
|
||||
@ -111,7 +131,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
|
||||
try {
|
||||
const { value: scraperConfigString } =
|
||||
await this.prisma.property.findFirst({
|
||||
await this.prismaService.property.findFirst({
|
||||
select: {
|
||||
value: true
|
||||
},
|
||||
@ -124,7 +144,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
return [];
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { Currency } from '@prisma/client';
|
||||
|
||||
export interface ScraperConfig {
|
||||
currency: Currency;
|
||||
name: string;
|
||||
selector: string;
|
||||
symbol: string;
|
||||
url: string;
|
||||
|
@ -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 {
|
||||
DATE_FORMAT,
|
||||
getToday,
|
||||
@ -10,20 +13,18 @@ import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
import { ConfigurationService } from '../../configuration.service';
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { PrismaService } from '../../prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class RakutenRapidApiService implements DataProviderInterface {
|
||||
public static FEAR_AND_GREED_INDEX_NAME = 'Fear & Greed Index';
|
||||
|
||||
private prisma: PrismaService;
|
||||
private prismaService: PrismaService;
|
||||
|
||||
public constructor(
|
||||
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
|
||||
// (introduced on 27.03.2021)
|
||||
|
||||
await this.prisma.marketData.create({
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
date: subWeeks(getToday(), 1),
|
||||
@ -97,7 +98,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
}
|
||||
});
|
||||
|
||||
await this.prisma.marketData.create({
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
date: subMonths(getToday(), 1),
|
||||
@ -105,7 +106,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
}
|
||||
});
|
||||
|
||||
await this.prisma.marketData.create({
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
date: subYears(getToday(), 1),
|
||||
@ -129,12 +130,12 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return {};
|
||||
}
|
||||
|
||||
public async search(aSymbol: string) {
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
public setPrisma(aPrismaService: PrismaService) {
|
||||
this.prisma = aPrismaService;
|
||||
this.prismaService = aPrismaService;
|
||||
}
|
||||
|
||||
private async getFearAndGreedIndex(): Promise<{
|
||||
|
@ -25,6 +25,7 @@ export interface IYahooFinancePrice {
|
||||
}
|
||||
|
||||
export interface IYahooFinanceSummaryProfile {
|
||||
country?: string;
|
||||
industry?: string;
|
||||
sector?: string;
|
||||
website?: string;
|
||||
|
@ -8,8 +8,15 @@ import {
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource
|
||||
} from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import Big from 'big.js';
|
||||
import { countries } from 'countries-list';
|
||||
import { format } from 'date-fns';
|
||||
import * as yahooFinance from 'yahoo-finance';
|
||||
|
||||
@ -17,11 +24,11 @@ import { DataProviderInterface } from '../../interfaces/data-provider.interface'
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState,
|
||||
Type
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import {
|
||||
IYahooFinanceHistoricalResponse,
|
||||
IYahooFinancePrice,
|
||||
IYahooFinanceQuoteResponse
|
||||
} from './interfaces/interfaces';
|
||||
|
||||
@ -60,7 +67,11 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
// Convert symbols back
|
||||
const symbol = convertFromYahooSymbol(yahooSymbol);
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
|
||||
|
||||
response[symbol] = {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency: parseCurrency(value.price?.currency),
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: this.parseExchange(value.price?.exchangeName),
|
||||
@ -69,10 +80,36 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
? MarketState.open
|
||||
: MarketState.closed,
|
||||
marketPrice: value.price?.regularMarketPrice || 0,
|
||||
name: value.price?.longName || value.price?.shortName || symbol,
|
||||
type: this.parseType(this.getType(symbol, value))
|
||||
name: value.price?.longName || value.price?.shortName || symbol
|
||||
};
|
||||
|
||||
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;
|
||||
if (url) {
|
||||
response[symbol].url = url;
|
||||
@ -138,7 +175,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
let items = [];
|
||||
let items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
@ -148,8 +185,23 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
200
|
||||
);
|
||||
|
||||
const result = await get();
|
||||
items = result.quotes
|
||||
const searchResult = await get();
|
||||
|
||||
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) => {
|
||||
return quote.isYahooFinance;
|
||||
})
|
||||
@ -163,13 +215,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
if (quoteType === 'CRYPTOCURRENCY') {
|
||||
// Only allow cryptocurrencies in USD
|
||||
return symbol.includes('USD');
|
||||
return symbol.includes(Currency.USD);
|
||||
}
|
||||
|
||||
return true;
|
||||
})
|
||||
.map(({ longname, shortname, symbol }) => {
|
||||
return {
|
||||
currency: marketData[symbol]?.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
name: longname || shortname,
|
||||
symbol: convertFromYahooSymbol(symbol)
|
||||
@ -203,14 +256,29 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
private getType(aSymbol: string, aValue: IYahooFinanceQuoteResponse): Type {
|
||||
if (isCrypto(aSymbol)) {
|
||||
return Type.Cryptocurrency;
|
||||
} else if (aValue.price?.quoteType.toLowerCase() === 'equity') {
|
||||
return Type.Stock;
|
||||
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
} {
|
||||
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 {
|
||||
@ -220,18 +288,6 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
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) => {
|
||||
|
10
apps/api/src/services/exchange-rate-data.module.ts
Normal file
10
apps/api/src/services/exchange-rate-data.module.ts
Normal 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 {}
|
@ -1,40 +1,58 @@
|
||||
import { currencyPairs } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
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()
|
||||
export class ExchangeRateDataService {
|
||||
private currencies = {};
|
||||
private pairs: string[] = [];
|
||||
private currencyPairs: string[] = [];
|
||||
private exchangeRates: { [currencyPair: string]: number } = {};
|
||||
|
||||
public constructor(private dataProviderService: DataProviderService) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
this.pairs = [];
|
||||
this.currencyPairs = [];
|
||||
this.exchangeRates = {};
|
||||
|
||||
this.addPairs(Currency.CHF, Currency.EUR);
|
||||
this.addPairs(Currency.CHF, Currency.GBP);
|
||||
this.addPairs(Currency.CHF, Currency.USD);
|
||||
this.addPairs(Currency.EUR, Currency.GBP);
|
||||
this.addPairs(Currency.EUR, Currency.USD);
|
||||
this.addPairs(Currency.GBP, Currency.USD);
|
||||
for (const { currency1, currency2 } of currencyPairs) {
|
||||
this.addCurrencyPairs(currency1, currency2);
|
||||
}
|
||||
|
||||
await this.loadCurrencies();
|
||||
}
|
||||
|
||||
public async loadCurrencies() {
|
||||
const result = await this.dataProviderService.getHistorical(
|
||||
this.pairs,
|
||||
this.currencyPairs,
|
||||
'day',
|
||||
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;
|
||||
|
||||
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 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
|
||||
this.currencies[pair] =
|
||||
this.exchangeRates[pair] =
|
||||
resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice *
|
||||
resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice;
|
||||
|
||||
// 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,
|
||||
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
|
||||
this.initialize();
|
||||
}
|
||||
@ -80,14 +99,32 @@ export class ExchangeRateDataService {
|
||||
let factor = 1;
|
||||
|
||||
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) {
|
||||
this.pairs.push(`${aCurrency1}${aCurrency2}`);
|
||||
this.pairs.push(`${aCurrency2}${aCurrency1}`);
|
||||
private addCurrencyPairs(aCurrency1: Currency, aCurrency2: Currency) {
|
||||
this.currencyPairs.push(`${aCurrency1}${aCurrency2}`);
|
||||
this.currencyPairs.push(`${aCurrency2}${aCurrency1}`);
|
||||
}
|
||||
}
|
||||
|
10
apps/api/src/services/impersonation.module.ts
Normal file
10
apps/api/src/services/impersonation.module.ts
Normal 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 {}
|
@ -4,10 +4,10 @@ import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ImpersonationService {
|
||||
public constructor(private prisma: PrismaService) {}
|
||||
public constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
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 }
|
||||
});
|
||||
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { Account, Currency, DataSource, SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
Account,
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
|
||||
import { OrderType } from '../../models/order-type';
|
||||
|
||||
@ -9,20 +15,13 @@ export const MarketState = {
|
||||
open: 'open'
|
||||
};
|
||||
|
||||
export const Type = {
|
||||
Cash: 'Cash',
|
||||
Cryptocurrency: 'Cryptocurrency',
|
||||
ETF: 'ETF',
|
||||
Stock: 'Stock',
|
||||
Unknown: UNKNOWN_KEY
|
||||
};
|
||||
|
||||
export interface IOrder {
|
||||
account: Account;
|
||||
currency: Currency;
|
||||
date: string;
|
||||
fee: number;
|
||||
id?: string;
|
||||
isDraft: boolean;
|
||||
quantity: number;
|
||||
symbol: string;
|
||||
symbolProfile: SymbolProfile;
|
||||
@ -36,6 +35,9 @@ export interface IDataProviderHistoricalResponse {
|
||||
}
|
||||
|
||||
export interface IDataProviderResponse {
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
countries?: { code: string; weight: number }[];
|
||||
currency: Currency;
|
||||
dataSource: DataSource;
|
||||
exchange?: string;
|
||||
@ -43,8 +45,7 @@ export interface IDataProviderResponse {
|
||||
marketChangePercent?: number;
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
name: string;
|
||||
type?: Type;
|
||||
name?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
@ -55,5 +56,3 @@ export interface IDataGatheringItem {
|
||||
}
|
||||
|
||||
export type MarketState = typeof MarketState[keyof typeof MarketState];
|
||||
|
||||
export type Type = typeof Type[keyof typeof Type];
|
||||
|
@ -1,8 +1,15 @@
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.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 {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
createdAt: Date;
|
||||
currency: Currency | null;
|
||||
dataSource: DataSource;
|
||||
|
8
apps/api/src/services/prisma.module.ts
Normal file
8
apps/api/src/services/prisma.module.ts
Normal 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 {}
|
@ -9,12 +9,12 @@ import { continents, countries } from 'countries-list';
|
||||
|
||||
@Injectable()
|
||||
export class SymbolProfileService {
|
||||
constructor(private prisma: PrismaService) {}
|
||||
constructor(private readonly prismaService: PrismaService) {}
|
||||
|
||||
public async getSymbolProfiles(
|
||||
symbols: string[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prisma.symbolProfile
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
where: {
|
||||
symbol: {
|
||||
|
@ -52,6 +52,41 @@ const routes: Routes = [
|
||||
loadChildren: () =>
|
||||
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',
|
||||
loadChildren: () =>
|
||||
@ -80,32 +115,6 @@ const routes: Routes = [
|
||||
(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',
|
||||
loadChildren: () =>
|
||||
|
@ -10,7 +10,7 @@
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Type</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<ion-icon class="mr-1" name="lock-closed-outline"></ion-icon>
|
||||
Restricted Access
|
||||
Restricted View
|
||||
</td></ng-container
|
||||
>
|
||||
|
||||
|
@ -2,7 +2,13 @@
|
||||
<ng-container matColumnDef="account">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Name</th>
|
||||
<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
|
||||
*ngIf="element.isDefault"
|
||||
class="d-lg-inline-block d-none text-muted"
|
||||
@ -12,13 +18,20 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="platform">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell>Platform</th>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<th
|
||||
*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">
|
||||
<gf-symbol-icon
|
||||
*ngIf="element.Platform?.url"
|
||||
class="mr-1"
|
||||
[tooltip]=""
|
||||
[tooltip]="element.Platform?.name"
|
||||
[url]="element.Platform?.url"
|
||||
></gf-symbol-icon>
|
||||
<span>{{ element.Platform?.name }}</span>
|
||||
@ -27,17 +40,20 @@
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="transactions">
|
||||
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>
|
||||
Transactions
|
||||
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
|
||||
<span class="d-block d-sm-none">#</span>
|
||||
<span class="d-none d-sm-block" i18n>Transactions</span>
|
||||
</th>
|
||||
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||
{{ element.Order?.length }}
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
{{ element.transactionCount }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="balance">
|
||||
<th *matHeaderCellDef class="text-right" i18n mat-header-cell>Balance</th>
|
||||
<td *matCellDef="let element" class="text-right" mat-cell>
|
||||
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
|
||||
Balance
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
<gf-value
|
||||
class="d-inline-block justify-content-end"
|
||||
[currency]="element.currency"
|
||||
|
@ -22,27 +22,22 @@
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools',
|
||||
'text-decoration-underline':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools'
|
||||
'font-weight-bold': currentRoute === 'portfolio',
|
||||
'text-decoration-underline': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/tools']"
|
||||
>Tools</a
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
*ngIf="user?.settings?.viewMode !== 'DEFAULT'"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'transactions',
|
||||
'text-decoration-underline': currentRoute === 'transactions'
|
||||
'font-weight-bold': currentRoute === 'portfolio',
|
||||
'text-decoration-underline': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/transactions']"
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Transactions</a
|
||||
>
|
||||
<a
|
||||
@ -160,28 +155,36 @@
|
||||
</button>
|
||||
<hr class="m-0" />
|
||||
</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
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold':
|
||||
currentRoute === 'analysis' ||
|
||||
currentRoute === 'report' ||
|
||||
currentRoute === 'tools'
|
||||
'font-weight-bold': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/tools']"
|
||||
>Tools</a
|
||||
[routerLink]="['/portfolio']"
|
||||
>Portfolio</a
|
||||
>
|
||||
<a
|
||||
*ngIf="user?.settings?.viewMode !== 'DEFAULT'"
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
[ngClass]="{
|
||||
'font-weight-bold': currentRoute === 'transactions'
|
||||
'font-weight-bold': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/transactions']"
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Transactions</a
|
||||
>
|
||||
<a
|
||||
|
@ -73,7 +73,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
const lastItem = this.investments[this.investments.length - 1];
|
||||
this.investments.push({
|
||||
...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) {
|
||||
return isAfter(new Date(aContext?.p0?.parsed?.x), new Date())
|
||||
private isInFuture<T>(aContext: any, aValue: T) {
|
||||
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
|
||||
? aValue
|
||||
: undefined;
|
||||
}
|
||||
|
@ -5,7 +5,7 @@
|
||||
<a
|
||||
class="align-items-center justify-content-center"
|
||||
color="primary"
|
||||
[routerLink]="['/transactions']"
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
mat-button
|
||||
>
|
||||
<span i18n>Time to add your first transaction.</span>
|
||||
|
@ -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>
|
@ -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() {}
|
||||
}
|
@ -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>
|
@ -1,9 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.value-container {
|
||||
#value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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
Reference in New Issue
Block a user