Compare commits
43 Commits
Author | SHA1 | Date | |
---|---|---|---|
174c1d1a62 | |||
f308ae7a13 | |||
a7a6b0608b | |||
15a61b7a20 | |||
d1eedf9726 | |||
30a592b524 | |||
de94494aa0 | |||
d3c6788ad5 | |||
3ec4a73b35 | |||
1050bfa098 | |||
595ec1d7b4 | |||
c8389599b6 | |||
8769fe4c90 | |||
4219e1121e | |||
f558eb8de8 | |||
fe2bd6eea8 | |||
035052be99 | |||
bcdd2780b3 | |||
22d1ed7920 | |||
39d9828f9f | |||
6333aa972d | |||
554f2f861f | |||
dcee651098 | |||
508a48f4c3 | |||
8466e3d73f | |||
9ae9904389 | |||
af022ae316 | |||
5cd6edaf3a | |||
98be8745d9 | |||
861dff9210 | |||
f2364eed10 | |||
d5392de7c9 | |||
0f72673ef4 | |||
641fe4e8f4 | |||
18e06bb6e6 | |||
5b588c2000 | |||
162d19fa44 | |||
4a815d2031 | |||
d2aeeb3e88 | |||
ba926ffcf2 | |||
5ea455b98b | |||
39f315aba0 | |||
df2dfc20a1 |
127
CHANGELOG.md
127
CHANGELOG.md
@ -5,6 +5,127 @@ 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.59.0 - 11.10.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a data enhancer for symbol profile data (countries and sectors) via _Trackinsight_
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the values of the global heat map to fixed-point notation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the links of cryptocurrency assets in the positions table
|
||||
- Fixed various values in the impersonation mode which have not been nullified
|
||||
|
||||
## 1.58.1 - 03.10.2021
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the symbol conversion for _Yahoo Finance_ (for a cryptocurrency with the same code as a currency)
|
||||
|
||||
## 1.58.0 - 02.10.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the symbol conversion for _Yahoo Finance_: Support for _Solana USD_ (`SOL1-USD`)
|
||||
- Improved the tooltips of the allocations page
|
||||
- Upgraded `envalid` from version `7.1.0` to `7.2.1`
|
||||
|
||||
## 1.57.0 - 29.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a protection for endpoints (subscriptions)
|
||||
|
||||
### Changed
|
||||
|
||||
- Reformatted the exchange rates table in the admin control panel
|
||||
|
||||
## 1.56.0 - 25.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added a story for the line chart component
|
||||
- Added a story for the portfolio proportion chart component
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the navigation to always show the portfolio page
|
||||
- Migrated the data type of currencies from `enum` to `string` in the database
|
||||
- Supported unlimited currencies (instead of `CHF`, `EUR`, `GBP` and `USD`)
|
||||
- Respected the accounts' currencies in the exchange rate service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hid the actions from the accounts table in the _Presenter View_
|
||||
- Hid the actions from the transactions table in the _Presenter View_
|
||||
- Fixed the data gathering of the initial project setup (database seeding)
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.55.0 - 20.09.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Removed the default value of the data source attribute
|
||||
- Upgraded `@storybook` dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the create or edit transaction dialog
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.54.0 - 18.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the data source attribute to the symbol profile model
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the data source attribute in the data provider service
|
||||
- Respected the data source attribute in the symbol data endpoint
|
||||
- Improved the search functionality of the data management (multiple data sources)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Hid the net performance in the _Presenter View_ (portfolio holdings and summary tab on the home page)
|
||||
- Hid the sign if the performance is zero in the value component
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.53.0 - 13.09.2021
|
||||
|
||||
### Changed
|
||||
|
||||
- Optimized the annualized performance calculation
|
||||
- Changed the data gathering selection from distinct orders to symbol profiles
|
||||
|
||||
## 1.52.0 - 11.09.2021
|
||||
|
||||
### Added
|
||||
|
||||
- Added the annualized performance to the portfolio summary tab on the home page
|
||||
- Added the Ghostfolio Slack channel to the about page
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `@simplewebauthn/browser` and `@simplewebauthn/server` from version `3.0.0` to `4.1.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the sign in with fingerprint for some android devices
|
||||
|
||||
## 1.51.0 - 11.09.2021
|
||||
|
||||
### Changed
|
||||
@ -103,7 +224,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:push`)
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.41.0 - 21.08.2021
|
||||
|
||||
@ -156,7 +277,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:push`)
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.38.0 - 14.08.2021
|
||||
|
||||
@ -216,7 +337,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:push`)
|
||||
- Apply data migration (`yarn prisma migrate deploy`)
|
||||
|
||||
## 1.34.0 - 07.08.2021
|
||||
|
||||
|
@ -34,7 +34,7 @@
|
||||
|
||||
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.
|
||||
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_.
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please find the source code and further instructions here on _GitHub_ or use the [setup](https://github.com/psychowood/ghostfolio-docker) by [psychowood](https://github.com/psychowood).
|
||||
|
||||
## Why Ghostfolio?
|
||||
|
||||
@ -128,7 +128,7 @@ Run `yarn test`
|
||||
|
||||
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public - including you.
|
||||
|
||||
Not sure what to work on? We have got some ideas. Please tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
Not sure what to work on? We have got some ideas. Please join the Ghostfolio [Slack channel](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg), tweet to [@ghostfolio\_](https://twitter.com/ghostfolio_) or send an e-mail to hi@ghostfol.io. We would love to hear from you.
|
||||
|
||||
## License
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
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, Platform, Prisma } from '@prisma/client';
|
||||
import { Account, Order, Platform, Prisma } from '@prisma/client';
|
||||
|
||||
import { CashDetails } from './interfaces/cash-details.interface';
|
||||
|
||||
@ -95,7 +95,7 @@ export class AccountService {
|
||||
|
||||
public async getCashDetails(
|
||||
aUserId: string,
|
||||
aCurrency: Currency
|
||||
aCurrency: string
|
||||
): Promise<CashDetails> {
|
||||
let totalCashBalance = 0;
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AccountType, Currency } from '@prisma/client';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@ -9,7 +9,7 @@ export class CreateAccountDto {
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { AccountType, Currency } from '@prisma/client';
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@ -9,7 +9,7 @@ export class UpdateAccountDto {
|
||||
balance: number;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
id: string;
|
||||
|
@ -3,9 +3,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
|
||||
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 { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
@ -20,53 +20,22 @@ export class AdminService {
|
||||
|
||||
public async get(): Promise<AdminData> {
|
||||
return {
|
||||
exchangeRates: [
|
||||
{
|
||||
label1: Currency.EUR,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.EUR,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.GBP,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.GBP,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.CHF,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.CHF
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.EUR,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.EUR
|
||||
)
|
||||
},
|
||||
{
|
||||
label1: Currency.USD,
|
||||
label2: Currency.GBP,
|
||||
value: await this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
Currency.USD,
|
||||
Currency.GBP
|
||||
)
|
||||
}
|
||||
],
|
||||
exchangeRates: this.exchangeRateDataService
|
||||
.getCurrencies()
|
||||
.filter((currency) => {
|
||||
return currency !== baseCurrency;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
label1: baseCurrency,
|
||||
label2: currency,
|
||||
value: this.exchangeRateDataService.toCurrency(
|
||||
1,
|
||||
baseCurrency,
|
||||
currency
|
||||
)
|
||||
};
|
||||
}),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
transactionCount: await this.prismaService.order.count(),
|
||||
userCount: await this.prismaService.user.count(),
|
||||
|
@ -62,10 +62,10 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Get('webauthn/generate-attestation-options')
|
||||
@Get('webauthn/generate-registration-options')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async generateAttestationOptions() {
|
||||
return this.webAuthService.generateAttestationOptions();
|
||||
public async generateRegistrationOptions() {
|
||||
return this.webAuthService.generateRegistrationOptions();
|
||||
}
|
||||
|
||||
@Post('webauthn/verify-attestation')
|
||||
|
@ -11,16 +11,16 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
GenerateAssertionOptionsOpts,
|
||||
GenerateAttestationOptionsOpts,
|
||||
VerifiedAssertion,
|
||||
VerifiedAttestation,
|
||||
VerifyAssertionResponseOpts,
|
||||
VerifyAttestationResponseOpts,
|
||||
generateAssertionOptions,
|
||||
generateAttestationOptions,
|
||||
verifyAssertionResponse,
|
||||
verifyAttestationResponse
|
||||
GenerateAuthenticationOptionsOpts,
|
||||
GenerateRegistrationOptionsOpts,
|
||||
VerifiedAuthenticationResponse,
|
||||
VerifiedRegistrationResponse,
|
||||
VerifyAuthenticationResponseOpts,
|
||||
VerifyRegistrationResponseOpts,
|
||||
generateAuthenticationOptions,
|
||||
generateRegistrationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
verifyRegistrationResponse
|
||||
} from '@simplewebauthn/server';
|
||||
|
||||
import {
|
||||
@ -46,10 +46,10 @@ export class WebAuthService {
|
||||
return this.configurationService.get('ROOT_URL');
|
||||
}
|
||||
|
||||
public async generateAttestationOptions() {
|
||||
public async generateRegistrationOptions() {
|
||||
const user = this.request.user;
|
||||
|
||||
const opts: GenerateAttestationOptionsOpts = {
|
||||
const opts: GenerateRegistrationOptionsOpts = {
|
||||
rpName: 'Ghostfolio',
|
||||
rpID: this.rpID,
|
||||
userID: user.id,
|
||||
@ -63,7 +63,7 @@ export class WebAuthService {
|
||||
}
|
||||
};
|
||||
|
||||
const options = generateAttestationOptions(opts);
|
||||
const options = generateRegistrationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -84,27 +84,27 @@ export class WebAuthService {
|
||||
const user = this.request.user;
|
||||
const expectedChallenge = user.authChallenge;
|
||||
|
||||
let verification: VerifiedAttestation;
|
||||
let verification: VerifiedRegistrationResponse;
|
||||
try {
|
||||
const opts: VerifyAttestationResponseOpts = {
|
||||
const opts: VerifyRegistrationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = await verifyAttestationResponse(opts);
|
||||
verification = await verifyRegistrationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException(error.message);
|
||||
}
|
||||
|
||||
const { verified, attestationInfo } = verification;
|
||||
const { registrationInfo, verified } = verification;
|
||||
|
||||
const devices = await this.deviceService.authDevices({
|
||||
where: { userId: user.id }
|
||||
});
|
||||
if (verified && attestationInfo) {
|
||||
const { credentialPublicKey, credentialID, counter } = attestationInfo;
|
||||
if (registrationInfo && verified) {
|
||||
const { counter, credentialID, credentialPublicKey } = registrationInfo;
|
||||
|
||||
let existingDevice = devices.find(
|
||||
(device) => device.credentialId === credentialID
|
||||
@ -115,9 +115,9 @@ export class WebAuthService {
|
||||
* Add the returned device to the user's list of devices
|
||||
*/
|
||||
existingDevice = await this.deviceService.createAuthDevice({
|
||||
counter,
|
||||
credentialPublicKey,
|
||||
credentialId: credentialID,
|
||||
counter,
|
||||
User: { connect: { id: user.id } }
|
||||
});
|
||||
}
|
||||
@ -138,20 +138,20 @@ export class WebAuthService {
|
||||
throw new Error('Device not found');
|
||||
}
|
||||
|
||||
const opts: GenerateAssertionOptionsOpts = {
|
||||
timeout: 60000,
|
||||
const opts: GenerateAuthenticationOptionsOpts = {
|
||||
allowCredentials: [
|
||||
{
|
||||
id: device.credentialId,
|
||||
type: 'public-key',
|
||||
transports: ['internal']
|
||||
transports: ['internal'],
|
||||
type: 'public-key'
|
||||
}
|
||||
],
|
||||
userVerification: 'preferred',
|
||||
rpID: this.rpID
|
||||
rpID: this.rpID,
|
||||
timeout: 60000,
|
||||
userVerification: 'preferred'
|
||||
};
|
||||
|
||||
const options = generateAssertionOptions(opts);
|
||||
const options = generateAuthenticationOptions(opts);
|
||||
|
||||
await this.userService.updateUser({
|
||||
data: {
|
||||
@ -177,29 +177,29 @@ export class WebAuthService {
|
||||
|
||||
const user = await this.userService.user({ id: device.userId });
|
||||
|
||||
let verification: VerifiedAssertion;
|
||||
let verification: VerifiedAuthenticationResponse;
|
||||
try {
|
||||
const opts: VerifyAssertionResponseOpts = {
|
||||
const opts: VerifyAuthenticationResponseOpts = {
|
||||
credential,
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID,
|
||||
authenticator: {
|
||||
credentialID: device.credentialId,
|
||||
credentialPublicKey: device.credentialPublicKey,
|
||||
counter: device.counter
|
||||
}
|
||||
},
|
||||
expectedChallenge: `${user.authChallenge}`,
|
||||
expectedOrigin: this.expectedOrigin,
|
||||
expectedRPID: this.rpID
|
||||
};
|
||||
verification = verifyAssertionResponse(opts);
|
||||
verification = verifyAuthenticationResponse(opts);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new InternalServerErrorException({ error: error.message });
|
||||
}
|
||||
|
||||
const { verified, assertionInfo } = verification;
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (verified) {
|
||||
device.counter = assertionInfo.newCounter;
|
||||
device.counter = authenticationInfo.newCounter;
|
||||
|
||||
await this.deviceService.updateAuthDevice({
|
||||
data: device,
|
||||
|
16
apps/api/src/app/cache/cache.module.ts
vendored
16
apps/api/src/app/cache/cache.module.ts
vendored
@ -2,29 +2,21 @@ 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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CacheController } from './cache.controller';
|
||||
|
||||
@Module({
|
||||
imports: [RedisCacheModule],
|
||||
imports: [DataProviderModule, ExchangeRateDataModule, RedisCacheModule],
|
||||
controllers: [CacheController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
CacheService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
PrismaService
|
||||
]
|
||||
})
|
||||
export class CacheModule {}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Currency, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import { Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsISO8601()
|
||||
date: string;
|
||||
|
@ -1,6 +1,4 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface Data {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
value: number;
|
||||
}
|
||||
|
@ -1,4 +1,3 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
@ -18,6 +17,6 @@ import { ExportService } from './export.service';
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ExportController],
|
||||
providers: [CacheService, ExportService]
|
||||
providers: [ExportService]
|
||||
})
|
||||
export class ExportModule {}
|
||||
|
@ -1,10 +1,7 @@
|
||||
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 { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
@ -14,6 +11,8 @@ import { InfoService } from './info.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
JwtModule.register({
|
||||
secret: process.env.JWT_SECRET_KEY,
|
||||
signOptions: { expiresIn: '30 days' }
|
||||
@ -21,15 +20,10 @@ import { InfoService } from './info.service';
|
||||
],
|
||||
controllers: [InfoController],
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
ConfigurationService,
|
||||
DataGatheringService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
InfoService,
|
||||
PrismaService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
PrismaService
|
||||
]
|
||||
})
|
||||
export class InfoModule {}
|
||||
|
@ -1,12 +1,12 @@
|
||||
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 { InfoItem } from '@ghostfolio/common/interfaces';
|
||||
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
|
||||
import { permissions } from '@ghostfolio/common/permissions';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { Currency } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { subDays } from 'date-fns';
|
||||
|
||||
@ -16,6 +16,7 @@ export class InfoService {
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly jwtService: JwtService,
|
||||
private readonly prismaService: PrismaService
|
||||
@ -56,7 +57,7 @@ export class InfoService {
|
||||
...info,
|
||||
globalPermissions,
|
||||
platforms,
|
||||
currencies: Object.values(Currency),
|
||||
currencies: this.exchangeRateDataService.getCurrencies(),
|
||||
demoAuthToken: this.getDemoAuthToken(),
|
||||
lastDataGathering: await this.getLastDataGathering(),
|
||||
statistics: await this.getStatistics(),
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Currency, DataSource, Type } from '@prisma/client';
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class CreateOrderDto {
|
||||
@ -6,7 +6,7 @@ export class CreateOrderDto {
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
@ -56,7 +56,9 @@ export class OrderService {
|
||||
]);
|
||||
}
|
||||
|
||||
this.dataGatheringService.gatherProfileData([data.symbol]);
|
||||
this.dataGatheringService.gatherProfileData([
|
||||
{ dataSource: data.dataSource, symbol: data.symbol }
|
||||
]);
|
||||
|
||||
await this.cacheService.flush();
|
||||
|
||||
|
@ -1,12 +1,12 @@
|
||||
import { Currency, DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import { DataSource, Type } from '@prisma/client';
|
||||
import { IsISO8601, IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateOrderDto {
|
||||
@IsString()
|
||||
accountId: string;
|
||||
|
||||
@IsString()
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
|
||||
@IsString()
|
||||
dataSource: DataSource;
|
||||
|
@ -1,6 +1,6 @@
|
||||
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 { DataSource, MarketData } from '@prisma/client';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { MarketDataService } from './market-data.service';
|
||||
@ -14,6 +14,7 @@ jest.mock('./market-data.service', () => {
|
||||
date,
|
||||
symbol,
|
||||
createdAt: date,
|
||||
dataSource: DataSource.YAHOO,
|
||||
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
@ -30,6 +31,7 @@ jest.mock('./market-data.service', () => {
|
||||
return Promise.resolve<MarketData[]>([
|
||||
{
|
||||
createdAt: dateRangeStart,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: dateRangeStart,
|
||||
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
|
||||
marketPrice: 1841.823902,
|
||||
@ -37,6 +39,7 @@ jest.mock('./market-data.service', () => {
|
||||
},
|
||||
{
|
||||
createdAt: dateRangeEnd,
|
||||
dataSource: DataSource.YAHOO,
|
||||
date: dateRangeEnd,
|
||||
id: '082d6893-df27-4c91-8a5d-092e84315b56',
|
||||
marketPrice: 1847.839966,
|
||||
@ -72,12 +75,13 @@ describe('CurrentRateService', () => {
|
||||
dataProviderService = new DataProviderService(
|
||||
null,
|
||||
null,
|
||||
[],
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null);
|
||||
exchangeRateDataService = new ExchangeRateDataService(null, null);
|
||||
marketDataService = new MarketDataService(null);
|
||||
|
||||
await exchangeRateDataService.initialize();
|
||||
@ -92,10 +96,10 @@ describe('CurrentRateService', () => {
|
||||
it('getValue', async () => {
|
||||
expect(
|
||||
await currentRateService.getValue({
|
||||
currency: Currency.USD,
|
||||
currency: 'USD',
|
||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||
symbol: 'AMZN',
|
||||
userCurrency: Currency.CHF
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject({
|
||||
marketPrice: 1847.839966
|
||||
@ -105,13 +109,13 @@ describe('CurrentRateService', () => {
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
currencies: { AMZN: Currency.USD },
|
||||
currencies: { AMZN: 'USD' },
|
||||
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }],
|
||||
dateQuery: {
|
||||
lt: new Date(Date.UTC(2020, 0, 2, 0, 0, 0)),
|
||||
gte: new Date(Date.UTC(2020, 0, 1, 0, 0, 0))
|
||||
},
|
||||
symbols: ['AMZN'],
|
||||
userCurrency: Currency.CHF
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject([
|
||||
{
|
||||
|
@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
@ -25,7 +26,9 @@ export class CurrentRateService {
|
||||
userCurrency
|
||||
}: GetValueParams): Promise<GetValueObject> {
|
||||
if (isToday(date)) {
|
||||
const dataProviderResult = await this.dataProviderService.get([symbol]);
|
||||
const dataProviderResult = await this.dataProviderService.get([
|
||||
{ symbol, dataSource: DataSource.YAHOO }
|
||||
]);
|
||||
return {
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0,
|
||||
@ -55,8 +58,8 @@ export class CurrentRateService {
|
||||
|
||||
public async getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery,
|
||||
symbols,
|
||||
userCurrency
|
||||
}: GetValuesParams): Promise<GetValueObject[]> {
|
||||
const includeToday =
|
||||
@ -75,24 +78,31 @@ export class CurrentRateService {
|
||||
if (includeToday) {
|
||||
const today = resetHours(new Date());
|
||||
promises.push(
|
||||
this.dataProviderService.get(symbols).then((dataResultProvider) => {
|
||||
const result = [];
|
||||
for (const symbol of symbols) {
|
||||
result.push({
|
||||
symbol,
|
||||
date: today,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[symbol]?.marketPrice ?? 0,
|
||||
dataResultProvider?.[symbol]?.currency,
|
||||
userCurrency
|
||||
)
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
this.dataProviderService
|
||||
.get(dataGatheringItems)
|
||||
.then((dataResultProvider) => {
|
||||
const result = [];
|
||||
for (const dataGatheringItem of dataGatheringItems) {
|
||||
result.push({
|
||||
date: today,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
|
||||
0,
|
||||
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: dataGatheringItem.symbol
|
||||
});
|
||||
}
|
||||
return result;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
|
||||
promises.push(
|
||||
this.marketDataService
|
||||
.getRange({
|
||||
|
@ -6,6 +6,7 @@ export interface CurrentPositions {
|
||||
positions: TimelinePosition[];
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
netAnnualizedPerformance: Big;
|
||||
netPerformance: Big;
|
||||
netPerformancePercentage: Big;
|
||||
currentValue: Big;
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface GetValueParams {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
userCurrency: Currency;
|
||||
userCurrency: string;
|
||||
}
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
import { DateQuery } from './date-query.interface';
|
||||
|
||||
export interface GetValuesParams {
|
||||
currencies: { [symbol: string]: Currency };
|
||||
currencies: { [symbol: string]: string };
|
||||
dataGatheringItems: IDataGatheringItem[];
|
||||
dateQuery: DateQuery;
|
||||
symbols: string[];
|
||||
userCurrency: Currency;
|
||||
userCurrency: string;
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface PortfolioOrder {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
date: string;
|
||||
dataSource: DataSource;
|
||||
fee: Big;
|
||||
name: string;
|
||||
quantity: Big;
|
||||
|
@ -1,8 +1,6 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface PortfolioPositionDetail {
|
||||
averagePrice: number;
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
firstBuyDate: string;
|
||||
grossPerformance: number;
|
||||
grossPerformancePercent: number;
|
||||
|
@ -1,8 +1,9 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
|
||||
export interface TransactionPointSymbol {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
fee: Big;
|
||||
firstBuyDate: string;
|
||||
investment: Big;
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,12 +1,13 @@
|
||||
import { OrderType } from '@ghostfolio/api/models/order-type';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { TimelinePosition } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
addYears,
|
||||
differenceInDays,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
@ -14,7 +15,7 @@ import {
|
||||
max,
|
||||
min
|
||||
} from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
import { flatten, isNumber } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
@ -33,7 +34,7 @@ export class PortfolioCalculator {
|
||||
|
||||
public constructor(
|
||||
private currentRateService: CurrentRateService,
|
||||
private currency: Currency
|
||||
private currency: string
|
||||
) {}
|
||||
|
||||
public computeTransactionPoints(orders: PortfolioOrder[]) {
|
||||
@ -58,6 +59,7 @@ export class PortfolioCalculator {
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: newQuantity.eq(0)
|
||||
@ -73,6 +75,7 @@ export class PortfolioCalculator {
|
||||
} else {
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
@ -103,6 +106,23 @@ export class PortfolioCalculator {
|
||||
}
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercent: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public getTransactionPoints(): TransactionPoint[] {
|
||||
return this.transactionPoints;
|
||||
}
|
||||
@ -118,6 +138,7 @@ export class PortfolioCalculator {
|
||||
hasErrors: false,
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
netAnnualizedPerformance: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
positions: [],
|
||||
@ -134,12 +155,15 @@ export class PortfolioCalculator {
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = this.transactionPoints.length;
|
||||
const dates = [];
|
||||
const symbols = new Set<string>();
|
||||
const currencies: { [symbol: string]: Currency } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||
symbols.add(item.symbol);
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
}
|
||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||
@ -159,10 +183,10 @@ export class PortfolioCalculator {
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
symbols: Array.from(symbols),
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
@ -290,6 +314,7 @@ export class PortfolioCalculator {
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: isValid
|
||||
? grossPerformance[item.symbol] ?? null
|
||||
@ -410,6 +435,11 @@ export class PortfolioCalculator {
|
||||
let netPerformance = new Big(0);
|
||||
let netPerformancePercentage = new Big(0);
|
||||
let completeInitialValue = new Big(0);
|
||||
let netAnnualizedPerformance = new Big(0);
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
@ -437,6 +467,15 @@ export class PortfolioCalculator {
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
netAnnualizedPerformance = netAnnualizedPerformance.plus(
|
||||
this.getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(
|
||||
today,
|
||||
parseDate(currentPosition.firstBuyDate)
|
||||
),
|
||||
netPerformancePercent: currentPosition.netPerformancePercentage
|
||||
}).mul(currentInitialValue)
|
||||
);
|
||||
netPerformancePercentage = netPerformancePercentage.plus(
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
@ -453,6 +492,8 @@ export class PortfolioCalculator {
|
||||
grossPerformancePercentage.div(completeInitialValue);
|
||||
netPerformancePercentage =
|
||||
netPerformancePercentage.div(completeInitialValue);
|
||||
netAnnualizedPerformance =
|
||||
netAnnualizedPerformance.div(completeInitialValue);
|
||||
}
|
||||
|
||||
return {
|
||||
@ -460,6 +501,7 @@ export class PortfolioCalculator {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netAnnualizedPerformance,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
@ -478,26 +520,29 @@ export class PortfolioCalculator {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
if (j >= 0) {
|
||||
const currencies: { [name: string]: Currency } = {};
|
||||
const symbols: string[] = [];
|
||||
const currencies: { [name: string]: string } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
currencies[item.symbol] = item.currency;
|
||||
symbols.push(item.symbol);
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.add(item.investment);
|
||||
fees = fees.add(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (symbols.length > 0) {
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endOfDay(endDate)
|
||||
},
|
||||
symbols,
|
||||
currencies,
|
||||
userCurrency: this.currency
|
||||
});
|
||||
} catch (error) {
|
||||
|
@ -3,6 +3,7 @@ import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import {
|
||||
PortfolioDetails,
|
||||
@ -38,6 +39,7 @@ import { PortfolioService } from './portfolio.service';
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
@ -47,8 +49,17 @@ export class PortfolioController {
|
||||
@Get('investments')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async findAll(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Res() res: Response
|
||||
): Promise<InvestmentItem[]> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json([]);
|
||||
}
|
||||
|
||||
let investments = await this.portfolioService.getInvestments(
|
||||
impersonationId
|
||||
);
|
||||
@ -68,7 +79,7 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
|
||||
return investments;
|
||||
return <any>res.json(investments);
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@ -125,6 +136,14 @@ export class PortfolioController {
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioDetails> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({ accounts: {}, holdings: {} });
|
||||
}
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(impersonationId, range);
|
||||
|
||||
@ -156,8 +175,9 @@ export class PortfolioController {
|
||||
portfolioPosition.grossPerformance = null;
|
||||
portfolioPosition.investment =
|
||||
portfolioPosition.investment / totalInvestment;
|
||||
|
||||
portfolioPosition.netPerformance = null;
|
||||
portfolioPosition.quantity = null;
|
||||
portfolioPosition.value = portfolioPosition.value / totalValue;
|
||||
}
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(accounts)) {
|
||||
@ -223,6 +243,7 @@ export class PortfolioController {
|
||||
return nullifyValuesInObject(position, [
|
||||
'grossPerformance',
|
||||
'investment',
|
||||
'netPerformance',
|
||||
'quantity'
|
||||
]);
|
||||
});
|
||||
@ -246,6 +267,7 @@ export class PortfolioController {
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'fees',
|
||||
'netWorth',
|
||||
@ -293,8 +315,19 @@ export class PortfolioController {
|
||||
@Get('report')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getReport(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
@Headers('impersonation-id') impersonationId,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioReport> {
|
||||
return await this.portfolioService.getReport(impersonationId);
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
res.status(StatusCodes.FORBIDDEN);
|
||||
return <any>res.json({ rules: [] });
|
||||
}
|
||||
|
||||
return <any>(
|
||||
res.json(await this.portfolioService.getReport(impersonationId))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -39,12 +39,7 @@ import type {
|
||||
} from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import {
|
||||
AssetClass,
|
||||
Currency,
|
||||
DataSource,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
import { AssetClass, DataSource, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
endOfToday,
|
||||
@ -190,12 +185,18 @@ export class PortfolioService {
|
||||
);
|
||||
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
|
||||
|
||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
||||
return {
|
||||
dataSource: position.dataSource,
|
||||
symbol: position.symbol
|
||||
};
|
||||
});
|
||||
const symbols = currentPositions.positions.map(
|
||||
(position) => position.symbol
|
||||
);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.get(symbols),
|
||||
this.dataProviderService.get(dataGatheringItems),
|
||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||
]);
|
||||
|
||||
@ -296,6 +297,7 @@ export class PortfolioService {
|
||||
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
@ -325,6 +327,7 @@ export class PortfolioService {
|
||||
const {
|
||||
averagePrice,
|
||||
currency,
|
||||
dataSource,
|
||||
firstBuyDate,
|
||||
marketPrice,
|
||||
quantity,
|
||||
@ -350,7 +353,7 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
const historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
[{ dataSource, symbol: aSymbol }],
|
||||
'day',
|
||||
parseISO(firstBuyDate),
|
||||
new Date()
|
||||
@ -420,11 +423,13 @@ export class PortfolioService {
|
||||
symbol: aSymbol
|
||||
};
|
||||
} else {
|
||||
const currentData = await this.dataProviderService.get([aSymbol]);
|
||||
const currentData = await this.dataProviderService.get([
|
||||
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
|
||||
]);
|
||||
const marketPrice = currentData[aSymbol]?.marketPrice;
|
||||
|
||||
let historicalData = await this.dataProviderService.getHistorical(
|
||||
[aSymbol],
|
||||
[{ dataSource: DataSource.YAHOO, symbol: aSymbol }],
|
||||
'day',
|
||||
portfolioStart,
|
||||
new Date()
|
||||
@ -506,10 +511,16 @@ export class PortfolioService {
|
||||
const positions = currentPositions.positions.filter(
|
||||
(item) => !item.quantity.eq(0)
|
||||
);
|
||||
const dataGatheringItem = positions.map((position) => {
|
||||
return {
|
||||
dataSource: position.dataSource,
|
||||
symbol: position.symbol
|
||||
};
|
||||
});
|
||||
const symbols = positions.map((position) => position.symbol);
|
||||
|
||||
const [dataProviderResponses, symbolProfiles] = await Promise.all([
|
||||
this.dataProviderService.get(symbols),
|
||||
this.dataProviderService.get(dataGatheringItem),
|
||||
this.symbolProfileService.getSymbolProfiles(symbols)
|
||||
]);
|
||||
|
||||
@ -557,6 +568,7 @@ export class PortfolioService {
|
||||
return {
|
||||
hasErrors: false,
|
||||
performance: {
|
||||
annualizedPerformancePercent: 0,
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
@ -575,6 +587,8 @@ export class PortfolioService {
|
||||
);
|
||||
|
||||
const hasErrors = currentPositions.hasErrors;
|
||||
const annualizedPerformancePercent =
|
||||
currentPositions.netAnnualizedPerformance.toNumber();
|
||||
const currentValue = currentPositions.currentValue.toNumber();
|
||||
const currentGrossPerformance =
|
||||
currentPositions.grossPerformance.toNumber();
|
||||
@ -587,6 +601,7 @@ export class PortfolioService {
|
||||
return {
|
||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||
performance: {
|
||||
annualizedPerformancePercent,
|
||||
currentGrossPerformance,
|
||||
currentGrossPerformancePercent,
|
||||
currentNetPerformance,
|
||||
@ -754,7 +769,7 @@ export class PortfolioService {
|
||||
assetClass: AssetClass.CASH,
|
||||
assetSubClass: AssetClass.CASH,
|
||||
countries: [],
|
||||
currency: Currency.CHF,
|
||||
currency: 'CHF',
|
||||
grossPerformance: 0,
|
||||
grossPerformancePercent: 0,
|
||||
investment: cashValue.toNumber(),
|
||||
@ -808,6 +823,7 @@ export class PortfolioService {
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
@ -843,7 +859,7 @@ export class PortfolioService {
|
||||
private async getAccounts(
|
||||
orders: OrderWithAccount[],
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition },
|
||||
userCurrency: Currency,
|
||||
userCurrency: string,
|
||||
userId: string
|
||||
) {
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
@ -916,7 +932,7 @@ export class PortfolioService {
|
||||
|
||||
private getTotalByType(
|
||||
orders: OrderWithAccount[],
|
||||
currency: Currency,
|
||||
currency: string,
|
||||
type: TypeOfOrder
|
||||
) {
|
||||
return orders
|
||||
|
@ -1,7 +1,6 @@
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class RulesService {
|
||||
@ -9,7 +8,7 @@ export class RulesService {
|
||||
|
||||
public async evaluate<T extends RuleSettings>(
|
||||
aRules: Rule<T>[],
|
||||
aUserSettings: { baseCurrency: Currency }
|
||||
aUserSettings: { baseCurrency: string }
|
||||
) {
|
||||
return aRules
|
||||
.filter((rule) => {
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface LookupItem {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
name: string;
|
||||
symbol: string;
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface SymbolItem {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
marketPrice: number;
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
@ -46,10 +47,20 @@ export class SymbolController {
|
||||
/**
|
||||
* Must be after /lookup
|
||||
*/
|
||||
@Get(':symbol')
|
||||
@Get(':dataSource/:symbol')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getPosition(@Param('symbol') symbol): Promise<SymbolItem> {
|
||||
const result = await this.symbolService.get(symbol);
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.NOT_FOUND),
|
||||
StatusCodes.NOT_FOUND
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.symbolService.get({ dataSource, symbol });
|
||||
|
||||
if (!result || isEmpty(result)) {
|
||||
throw new HttpException(
|
||||
|
@ -1,7 +1,8 @@
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency, DataSource } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import { LookupItem } from './interfaces/lookup-item.interface';
|
||||
import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
@ -13,15 +14,15 @@ export class SymbolService {
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public async get(aSymbol: string): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([aSymbol]);
|
||||
const { currency, dataSource, marketPrice } = response[aSymbol] ?? {};
|
||||
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
if (dataSource && marketPrice) {
|
||||
if (dataGatheringItem.dataSource && marketPrice) {
|
||||
return {
|
||||
dataSource,
|
||||
currency,
|
||||
marketPrice,
|
||||
currency: <Currency>(<unknown>currency)
|
||||
dataSource: dataGatheringItem.dataSource
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
import { ViewMode } from '@prisma/client';
|
||||
|
||||
export interface UserSettingsParams {
|
||||
currency?: Currency;
|
||||
currency?: string;
|
||||
userId: string;
|
||||
viewMode?: ViewMode;
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import { Currency, ViewMode } from '@prisma/client';
|
||||
import { ViewMode } from '@prisma/client';
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingsDto {
|
||||
@IsString()
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
|
||||
@IsString()
|
||||
viewMode: ViewMode;
|
||||
|
@ -1,12 +1,12 @@
|
||||
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';
|
||||
import { baseCurrency, locale } from '@ghostfolio/common/config';
|
||||
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
|
||||
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 { Prisma, Provider, User, ViewMode } from '@prisma/client';
|
||||
|
||||
import { UserSettingsParams } from './interfaces/user-settings-params.interface';
|
||||
import { UserSettings } from './interfaces/user-settings.interface';
|
||||
@ -15,7 +15,7 @@ const crypto = require('crypto');
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
public static DEFAULT_CURRENCY = Currency.USD;
|
||||
public static DEFAULT_CURRENCY = 'USD';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@ -144,9 +144,15 @@ export class UserService {
|
||||
...data,
|
||||
Account: {
|
||||
create: {
|
||||
currency: baseCurrency,
|
||||
isDefault: true,
|
||||
name: 'Default Account'
|
||||
}
|
||||
},
|
||||
Settings: {
|
||||
create: {
|
||||
currency: baseCurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -1,5 +1,3 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface UserSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Account, Currency, SymbolProfile } from '@prisma/client';
|
||||
import { Account, SymbolProfile } from '@prisma/client';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { IOrder } from '../services/interfaces/interfaces';
|
||||
@ -6,7 +6,7 @@ import { OrderType } from './order-type';
|
||||
|
||||
export class Order {
|
||||
private account: Account;
|
||||
private currency: Currency;
|
||||
private currency: string;
|
||||
private fee: number;
|
||||
private date: string;
|
||||
private id: string;
|
||||
|
@ -3,7 +3,6 @@ import { UserSettings } from '@ghostfolio/api/models/interfaces/user-settings.in
|
||||
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 { EvaluationResult } from './interfaces/evaluation-result.interface';
|
||||
import { RuleInterface } from './interfaces/rule.interface';
|
||||
@ -29,7 +28,7 @@ export abstract class Rule<T extends RuleSettings> implements RuleInterface<T> {
|
||||
public groupCurrentPositionsByAttribute(
|
||||
positions: TimelinePosition[],
|
||||
attribute: keyof TimelinePosition,
|
||||
baseCurrency: Currency
|
||||
baseCurrency: string
|
||||
) {
|
||||
return Array.from(groupBy(attribute, positions).entries()).map(
|
||||
([attributeValue, objs]) => ({
|
||||
|
@ -2,8 +2,6 @@ import { CurrentPositions } from '@ghostfolio/api/app/portfolio/interfaces/curre
|
||||
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 { Currency } from '@prisma/client';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
|
||||
@ -69,5 +67,5 @@ export class CurrencyClusterRiskBaseCurrencyCurrentInvestment extends Rule<Setti
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
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';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -69,5 +68,5 @@ export class CurrencyClusterRiskBaseCurrencyInitialInvestment extends Rule<Setti
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
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';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -69,6 +68,6 @@ export class CurrencyClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
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';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -69,6 +68,6 @@ export class CurrencyClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -1,6 +1,5 @@
|
||||
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';
|
||||
import { ExchangeRateDataService } from 'apps/api/src/services/exchange-rate-data.service';
|
||||
|
||||
import { Rule } from '../../rule';
|
||||
@ -46,6 +45,6 @@ export class FeeRatioInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
interface Settings extends RuleSettings {
|
||||
baseCurrency: Currency;
|
||||
baseCurrency: string;
|
||||
threshold: number;
|
||||
}
|
||||
|
@ -4,8 +4,15 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ExchangeRateDataModule } from './exchange-rate-data.module';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigurationModule, DataProviderModule, PrismaModule],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
PrismaModule
|
||||
],
|
||||
providers: [DataGatheringService],
|
||||
exports: [DataGatheringService]
|
||||
})
|
||||
|
@ -1,19 +1,12 @@
|
||||
import {
|
||||
benchmarks,
|
||||
currencyPairs,
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getUtc,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
resetHours
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import {
|
||||
differenceInHours,
|
||||
endOfToday,
|
||||
format,
|
||||
getDate,
|
||||
getMonth,
|
||||
@ -25,6 +18,7 @@ import {
|
||||
import { ConfigurationService } from './configuration.service';
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { ExchangeRateDataService } from './exchange-rate-data.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@ -33,6 +27,7 @@ export class DataGatheringService {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -123,24 +118,29 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
public async gatherProfileData(aSymbols?: string[]) {
|
||||
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
|
||||
console.log('Profile data gathering has been started.');
|
||||
console.time('data-gathering-profile');
|
||||
|
||||
let symbols = aSymbols;
|
||||
let dataGatheringItems = aDataGatheringItems;
|
||||
|
||||
if (!symbols) {
|
||||
const dataGatheringItems = await this.getSymbolsProfileData();
|
||||
symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
if (!dataGatheringItems) {
|
||||
dataGatheringItems = await this.getSymbolsProfileData();
|
||||
}
|
||||
|
||||
const currentData = await this.dataProviderService.get(symbols);
|
||||
const currentData = await this.dataProviderService.get(dataGatheringItems);
|
||||
|
||||
for (const [
|
||||
symbol,
|
||||
{ assetClass, assetSubClass, countries, currency, dataSource, name }
|
||||
{
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
sectors
|
||||
}
|
||||
] of Object.entries(currentData)) {
|
||||
try {
|
||||
await this.prismaService.symbolProfile.upsert({
|
||||
@ -151,6 +151,7 @@ export class DataGatheringService {
|
||||
currency,
|
||||
dataSource,
|
||||
name,
|
||||
sectors,
|
||||
symbol
|
||||
},
|
||||
update: {
|
||||
@ -158,7 +159,8 @@ export class DataGatheringService {
|
||||
assetSubClass,
|
||||
countries,
|
||||
currency,
|
||||
name
|
||||
name,
|
||||
sectors
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
@ -215,6 +217,7 @@ export class DataGatheringService {
|
||||
try {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: currentDate,
|
||||
marketPrice: lastMarketPrice
|
||||
@ -238,6 +241,8 @@ export class DataGatheringService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.exchangeRateDataService.initialize();
|
||||
|
||||
if (hasError) {
|
||||
throw '';
|
||||
}
|
||||
@ -309,38 +314,31 @@ export class DataGatheringService {
|
||||
private async getSymbols7D(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = subDays(resetHours(new Date()), 7);
|
||||
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: { dataSource: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
symbol: true
|
||||
}
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
return {
|
||||
...symbolProfile,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const distinctOrdersWithDate: IDataGatheringItem[] = distinctOrders
|
||||
.filter((distinctOrder) => {
|
||||
return !isGhostfolioScraperApiSymbol(distinctOrder.symbol);
|
||||
})
|
||||
.map((distinctOrder) => {
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
...distinctOrder,
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
});
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
const customSymbolsToGather =
|
||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
||||
|
||||
@ -348,42 +346,57 @@ export class DataGatheringService {
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...customSymbolsToGather,
|
||||
...currencyPairsToGather,
|
||||
...distinctOrdersWithDate
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
}
|
||||
|
||||
private async getSymbolsMax(): Promise<IDataGatheringItem[]> {
|
||||
const startDate = new Date(getUtc('2015-01-01'));
|
||||
const startDate =
|
||||
(
|
||||
await this.prismaService.order.findFirst({
|
||||
orderBy: [{ date: 'asc' }]
|
||||
})
|
||||
)?.date ?? new Date();
|
||||
|
||||
const customSymbolsToGather =
|
||||
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
|
||||
|
||||
const currencyPairsToGather = currencyPairs.map(
|
||||
({ dataSource, symbol }) => {
|
||||
const currencyPairsToGather = this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol,
|
||||
date: startDate
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const distinctOrders = await this.prismaService.order.findMany({
|
||||
distinct: ['symbol'],
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { dataSource: true, date: true, symbol: true },
|
||||
where: {
|
||||
date: {
|
||||
lt: endOfToday() // no draft
|
||||
const symbolProfilesToGather = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
symbol: true
|
||||
}
|
||||
}
|
||||
})
|
||||
).map((item) => {
|
||||
return {
|
||||
dataSource: item.dataSource,
|
||||
date: item.Order?.[0]?.date ?? startDate,
|
||||
symbol: item.symbol
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
...this.getBenchmarksToGather(startDate),
|
||||
...customSymbolsToGather,
|
||||
...currencyPairsToGather,
|
||||
...distinctOrders
|
||||
...symbolProfilesToGather
|
||||
];
|
||||
}
|
||||
|
||||
|
@ -6,11 +6,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { isAfter, isBefore, parse } from 'date-fns';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '../../interfaces/interfaces';
|
||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||
import { IAlphaVantageHistoricalResponse } from './interfaces/interfaces';
|
||||
|
||||
@Injectable()
|
||||
|
@ -0,0 +1,73 @@
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import bent from 'bent';
|
||||
|
||||
const getJSON = bent('json');
|
||||
|
||||
export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
|
||||
private static baseUrl = 'https://data.trackinsight.com/holdings';
|
||||
private static countries = require('countries-list/dist/countries.json');
|
||||
private static sectorsMapping = {
|
||||
'Consumer Discretionary': 'Consumer Cyclical',
|
||||
'Consumer Defensive': 'Consumer Staples',
|
||||
'Health Care': 'Healthcare',
|
||||
'Information Technology': 'Technology'
|
||||
};
|
||||
|
||||
public async enhance({
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
response: IDataProviderResponse;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderResponse> {
|
||||
if (
|
||||
!(response.assetClass === 'EQUITY' && response.assetSubClass === 'ETF')
|
||||
) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const holdings = await getJSON(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
|
||||
).catch(() => {
|
||||
return getJSON(
|
||||
`${TrackinsightDataEnhancerService.baseUrl}/${
|
||||
symbol.split('.')[0]
|
||||
}.json`
|
||||
);
|
||||
});
|
||||
|
||||
if (!response.countries || response.countries.length === 0) {
|
||||
response.countries = [];
|
||||
for (const [name, value] of Object.entries<any>(holdings.countries)) {
|
||||
let countryCode: string;
|
||||
|
||||
for (const [key, country] of Object.entries<any>(
|
||||
TrackinsightDataEnhancerService.countries
|
||||
)) {
|
||||
if (country.name === name) {
|
||||
countryCode = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
response.countries.push({
|
||||
code: countryCode,
|
||||
weight: value.weight
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.sectors || response.sectors.length === 0) {
|
||||
response.sectors = [];
|
||||
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
|
||||
response.sectors.push({
|
||||
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
|
||||
weight: value.weight
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.resolve(response);
|
||||
}
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { TrackinsightDataEnhancerService } from '@ghostfolio/api/services/data-provider/data-enhancer/trackinsight/trackinsight.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';
|
||||
@ -15,7 +16,13 @@ import { DataProviderService } from './data-provider.service';
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
TrackinsightDataEnhancerService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [TrackinsightDataEnhancerService],
|
||||
provide: 'DataEnhancers',
|
||||
useFactory: (trackinsight) => [trackinsight]
|
||||
}
|
||||
],
|
||||
exports: [DataProviderService, GhostfolioScraperApiService]
|
||||
})
|
||||
|
@ -1,34 +1,31 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
isGhostfolioScraperApiSymbol,
|
||||
isRakutenRapidApiSymbol
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
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 {
|
||||
convertToYahooFinanceSymbol,
|
||||
YahooFinanceService
|
||||
} from './yahoo-finance/yahoo-finance.service';
|
||||
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService {
|
||||
public constructor(
|
||||
private readonly alphaVantageService: AlphaVantageService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@Inject('DataEnhancers')
|
||||
private readonly dataEnhancers: DataEnhancerInterface[],
|
||||
private readonly ghostfolioScraperApiService: GhostfolioScraperApiService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly rakutenRapidApiService: RakutenRapidApiService,
|
||||
@ -37,61 +34,48 @@ export class DataProviderService {
|
||||
this.rakutenRapidApiService?.setPrisma(this.prismaService);
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length === 1) {
|
||||
const symbol = aSymbols[0];
|
||||
public async get(items: IDataGatheringItem[]): Promise<{
|
||||
[symbol: string]: IDataProviderResponse;
|
||||
}> {
|
||||
const response: {
|
||||
[symbol: string]: IDataProviderResponse;
|
||||
} = {};
|
||||
|
||||
if (isGhostfolioScraperApiSymbol(symbol)) {
|
||||
return this.ghostfolioScraperApiService.get(aSymbols);
|
||||
} else if (isRakutenRapidApiSymbol(symbol)) {
|
||||
return this.rakutenRapidApiService.get(aSymbols);
|
||||
}
|
||||
for (const item of items) {
|
||||
const dataProvider = this.getDataProvider(item.dataSource);
|
||||
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
||||
item.symbol
|
||||
];
|
||||
}
|
||||
|
||||
const yahooFinanceSymbols = aSymbols
|
||||
.filter((symbol) => {
|
||||
return (
|
||||
!isGhostfolioScraperApiSymbol(symbol) &&
|
||||
!isRakutenRapidApiSymbol(symbol)
|
||||
const promises = [];
|
||||
for (const symbol of Object.keys(response)) {
|
||||
let promise = Promise.resolve(response[symbol]);
|
||||
for (const dataEnhancer of this.dataEnhancers) {
|
||||
promise = promise.then((currentResponse) =>
|
||||
dataEnhancer
|
||||
.enhance({ symbol, response: currentResponse })
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to enhance data for symbol ${symbol}`,
|
||||
error
|
||||
);
|
||||
return currentResponse;
|
||||
})
|
||||
);
|
||||
})
|
||||
.map((symbol) => {
|
||||
return convertToYahooFinanceSymbol(symbol);
|
||||
});
|
||||
|
||||
const response = await this.yahooFinanceService.get(yahooFinanceSymbols);
|
||||
|
||||
const ghostfolioScraperApiSymbols = aSymbols.filter((symbol) => {
|
||||
return isGhostfolioScraperApiSymbol(symbol);
|
||||
});
|
||||
|
||||
for (const symbol of ghostfolioScraperApiSymbols) {
|
||||
if (symbol) {
|
||||
const ghostfolioScraperApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = ghostfolioScraperApiResult[symbol];
|
||||
}
|
||||
promises.push(
|
||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||
);
|
||||
}
|
||||
|
||||
const rakutenRapidApiSymbols = aSymbols.filter((symbol) => {
|
||||
return isRakutenRapidApiSymbol(symbol);
|
||||
});
|
||||
|
||||
for (const symbol of rakutenRapidApiSymbols) {
|
||||
if (symbol) {
|
||||
const rakutenRapidApiResult =
|
||||
await this.ghostfolioScraperApiService.get([symbol]);
|
||||
response[symbol] = rakutenRapidApiResult[symbol];
|
||||
}
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbols: string[],
|
||||
aItems: IDataGatheringItem[],
|
||||
aGranularity: Granularity = 'month',
|
||||
from: Date,
|
||||
to: Date
|
||||
@ -102,6 +86,10 @@ export class DataProviderService {
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
} = {};
|
||||
|
||||
if (isEmpty(aItems)) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const granularityQuery =
|
||||
aGranularity === 'month'
|
||||
? `AND (date_part('day', date) = 1 OR date >= TIMESTAMP 'yesterday')`
|
||||
@ -115,10 +103,21 @@ export class DataProviderService {
|
||||
)}'`
|
||||
: '';
|
||||
|
||||
const dataSources = aItems.map((item) => {
|
||||
return item.dataSource;
|
||||
});
|
||||
const symbols = aItems.map((item) => {
|
||||
return item.symbol;
|
||||
});
|
||||
|
||||
try {
|
||||
const queryRaw = `SELECT * FROM "MarketData" WHERE "symbol" IN ('${aSymbols.join(
|
||||
`','`
|
||||
)}') ${granularityQuery} ${rangeQuery} ORDER BY date;`;
|
||||
const queryRaw = `SELECT *
|
||||
FROM "MarketData"
|
||||
WHERE "dataSource" IN ('${dataSources.join(`','`)}')
|
||||
AND "symbol" IN ('${symbols.join(
|
||||
`','`
|
||||
)}') ${granularityQuery} ${rangeQuery}
|
||||
ORDER BY date;`;
|
||||
|
||||
const marketDataByGranularity: MarketData[] =
|
||||
await this.prismaService.$queryRaw(queryRaw);
|
||||
@ -175,13 +174,24 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
const { items } = await this.getDataProvider(
|
||||
<DataSource>this.configurationService.get('DATA_SOURCES')[0]
|
||||
).search(aSymbol);
|
||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||
let lookupItems: LookupItem[] = [];
|
||||
|
||||
const filteredItems = items.filter((item) => {
|
||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||
promises.push(
|
||||
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
||||
);
|
||||
}
|
||||
|
||||
const searchResults = await Promise.all(promises);
|
||||
|
||||
searchResults.forEach((searchResult) => {
|
||||
lookupItems = lookupItems.concat(searchResult.items);
|
||||
});
|
||||
|
||||
const filteredItems = lookupItems.filter((lookupItem) => {
|
||||
// Only allow symbols with supported currency
|
||||
return item.currency ? true : false;
|
||||
return lookupItem.currency ? true : false;
|
||||
});
|
||||
|
||||
return {
|
||||
|
@ -12,13 +12,13 @@ import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataGatheringItem,
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||
import { ScraperConfig } from './interfaces/scraper-config.interface';
|
||||
|
||||
@Injectable()
|
||||
|
@ -1,7 +1,5 @@
|
||||
import { Currency } from '@prisma/client';
|
||||
|
||||
export interface ScraperConfig {
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
selector: string;
|
||||
symbol: string;
|
||||
url: string;
|
||||
|
@ -0,0 +1,11 @@
|
||||
import { IDataProviderResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
|
||||
export interface DataEnhancerInterface {
|
||||
enhance({
|
||||
response,
|
||||
symbol
|
||||
}: {
|
||||
response: IDataProviderResponse;
|
||||
symbol: string;
|
||||
}): Promise<IDataProviderResponse>;
|
||||
}
|
@ -4,7 +4,7 @@ import { Granularity } from '@ghostfolio/common/types';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from './interfaces';
|
||||
} from '../../interfaces/interfaces';
|
||||
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
@ -14,12 +14,12 @@ import { DataSource } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class RakutenRapidApiService implements DataProviderInterface {
|
||||
@ -94,6 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: subWeeks(getToday(), 1),
|
||||
marketPrice: fgi.oneWeekAgo.value
|
||||
}
|
||||
@ -102,6 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: subMonths(getToday(), 1),
|
||||
marketPrice: fgi.oneMonthAgo.value
|
||||
}
|
||||
@ -110,6 +112,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
date: subYears(getToday(), 1),
|
||||
marketPrice: fgi.oneYearAgo.value
|
||||
}
|
||||
|
@ -1,31 +1,21 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
isCrypto,
|
||||
isCurrency,
|
||||
parseCurrency
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, isCrypto, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource
|
||||
} from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, 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';
|
||||
|
||||
import { DataProviderInterface } from '../../interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||
import {
|
||||
IYahooFinanceHistoricalResponse,
|
||||
IYahooFinancePrice,
|
||||
@ -43,11 +33,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public async get(
|
||||
aYahooFinanceSymbols: string[]
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aYahooFinanceSymbols.length <= 0) {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
const yahooFinanceSymbols = aSymbols.map((symbol) =>
|
||||
this.convertToYahooFinanceSymbol(symbol)
|
||||
);
|
||||
|
||||
try {
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
@ -56,19 +49,19 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
[symbol: string]: IYahooFinanceQuoteResponse;
|
||||
} = await yahooFinance.quote({
|
||||
modules: ['price', 'summaryProfile'],
|
||||
symbols: aYahooFinanceSymbols
|
||||
symbols: yahooFinanceSymbols
|
||||
});
|
||||
|
||||
for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
|
||||
// Convert symbols back
|
||||
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
|
||||
|
||||
response[symbol] = {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency: parseCurrency(value.price?.currency),
|
||||
currency: value.price?.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
exchange: this.parseExchange(value.price?.exchangeName),
|
||||
marketState:
|
||||
@ -81,7 +74,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
if (value.price?.currency === 'GBp') {
|
||||
// Convert GBp (pence) to GBP
|
||||
response[symbol].currency = Currency.GBP;
|
||||
response[symbol].currency = 'GBP';
|
||||
response[symbol].marketPrice = new Big(
|
||||
value.price?.regularMarketPrice ?? 0
|
||||
)
|
||||
@ -103,6 +96,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
response[symbol].countries = [{ code, weight: 1 }];
|
||||
}
|
||||
} catch {}
|
||||
|
||||
if (value.summaryProfile?.sector) {
|
||||
response[symbol].sectors = [
|
||||
{ name: value.summaryProfile?.sector, weight: 1 }
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
// Add url if available
|
||||
@ -133,7 +132,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
const yahooFinanceSymbols = aSymbols.map((symbol) => {
|
||||
return convertToYahooFinanceSymbol(symbol);
|
||||
return this.convertToYahooFinanceSymbol(symbol);
|
||||
});
|
||||
|
||||
try {
|
||||
@ -153,7 +152,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
historicalData
|
||||
)) {
|
||||
// Convert symbols back
|
||||
const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||
const symbol = this.convertFromYahooFinanceSymbol(yahooFinanceSymbol);
|
||||
response[symbol] = {};
|
||||
|
||||
timeSeries.forEach((timeSerie) => {
|
||||
@ -200,7 +199,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.filter(({ quoteType, symbol }) => {
|
||||
if (quoteType === 'CRYPTOCURRENCY') {
|
||||
// Only allow cryptocurrencies in USD
|
||||
return symbol.includes(Currency.USD);
|
||||
return symbol.includes('USD');
|
||||
}
|
||||
|
||||
return true;
|
||||
@ -224,6 +223,40 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return { items };
|
||||
}
|
||||
|
||||
private convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
|
||||
const symbol = aYahooFinanceSymbol.replace('-', '');
|
||||
return symbol.replace('=X', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a symbol to a Yahoo Finance symbol
|
||||
*
|
||||
* Currency: USDCHF -> USDCHF=X
|
||||
* Cryptocurrency: BTCUSD -> BTC-USD
|
||||
* DOGEUSD -> DOGE-USD
|
||||
* SOL1USD -> SOL1-USD
|
||||
*/
|
||||
private convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (
|
||||
(aSymbol.includes('CHF') ||
|
||||
aSymbol.includes('EUR') ||
|
||||
aSymbol.includes('USD')) &&
|
||||
aSymbol.length >= 6
|
||||
) {
|
||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (isCrypto(aSymbol) || isCrypto(aSymbol.replace('1', ''))) {
|
||||
// Add a dash before the last three characters
|
||||
// BTCUSD -> BTC-USD
|
||||
// DOGEUSD -> DOGE-USD
|
||||
// SOL1USD -> SOL1-USD
|
||||
return aSymbol.replace('USD', '-USD');
|
||||
}
|
||||
}
|
||||
|
||||
return aSymbol;
|
||||
}
|
||||
|
||||
private parseAssetClass(aPrice: IYahooFinancePrice): {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
@ -257,31 +290,3 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return aString;
|
||||
}
|
||||
}
|
||||
|
||||
export const convertFromYahooFinanceSymbol = (aYahooFinanceSymbol: string) => {
|
||||
const symbol = aYahooFinanceSymbol.replace('-', '');
|
||||
return symbol.replace('=X', '');
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a symbol to a Yahoo Finance symbol
|
||||
*
|
||||
* Currency: USDCHF=X
|
||||
* Cryptocurrency: BTC-USD
|
||||
*/
|
||||
export const convertToYahooFinanceSymbol = (aSymbol: string) => {
|
||||
if (isCurrency(aSymbol)) {
|
||||
if (isCrypto(aSymbol)) {
|
||||
// Add a dash before the last three characters
|
||||
// BTCUSD -> BTC-USD
|
||||
// DOGEUSD -> DOGE-USD
|
||||
return `${aSymbol.substring(0, aSymbol.length - 3)}-${aSymbol.substring(
|
||||
aSymbol.length - 3
|
||||
)}`;
|
||||
}
|
||||
|
||||
return `${aSymbol}=X`;
|
||||
}
|
||||
|
||||
return aSymbol;
|
||||
};
|
||||
|
@ -2,8 +2,10 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { PrismaModule } from './prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [DataProviderModule],
|
||||
imports: [DataProviderModule, PrismaModule],
|
||||
providers: [ExchangeRateDataService],
|
||||
exports: [ExchangeRateDataService]
|
||||
})
|
||||
|
@ -1,27 +1,46 @@
|
||||
import { currencyPairs } from '@ghostfolio/common/config';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { isEmpty, isNumber } from 'lodash';
|
||||
import { isEmpty, isNumber, uniq } from 'lodash';
|
||||
|
||||
import { DataProviderService } from './data-provider/data-provider.service';
|
||||
import { IDataGatheringItem } from './interfaces/interfaces';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExchangeRateDataService {
|
||||
private currencyPairs: string[] = [];
|
||||
private currencies: string[] = [];
|
||||
private currencyPairs: IDataGatheringItem[] = [];
|
||||
private exchangeRates: { [currencyPair: string]: number } = {};
|
||||
|
||||
public constructor(private dataProviderService: DataProviderService) {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
public getCurrencies() {
|
||||
return this.currencies?.length > 0 ? this.currencies : [baseCurrency];
|
||||
}
|
||||
|
||||
public getCurrencyPairs() {
|
||||
return this.currencyPairs;
|
||||
}
|
||||
|
||||
public async initialize() {
|
||||
this.currencies = await this.prepareCurrencies();
|
||||
this.currencyPairs = [];
|
||||
this.exchangeRates = {};
|
||||
|
||||
for (const { currency1, currency2 } of currencyPairs) {
|
||||
this.addCurrencyPairs(currency1, currency2);
|
||||
for (const {
|
||||
currency1,
|
||||
currency2,
|
||||
dataSource
|
||||
} of this.prepareCurrencyPairs(this.currencies)) {
|
||||
this.addCurrencyPairs({ currency1, currency2, dataSource });
|
||||
}
|
||||
|
||||
await this.loadCurrencies();
|
||||
@ -39,8 +58,8 @@ export class ExchangeRateDataService {
|
||||
// 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;
|
||||
this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
|
||||
@ -67,31 +86,35 @@ export class ExchangeRateDataService {
|
||||
};
|
||||
});
|
||||
|
||||
this.currencyPairs.forEach((pair) => {
|
||||
const [currency1, currency2] = pair.match(/.{1,3}/g);
|
||||
this.currencyPairs.forEach(({ symbol }) => {
|
||||
const [currency1, currency2] = symbol.match(/.{1,3}/g);
|
||||
const date = format(getYesterday(), DATE_FORMAT);
|
||||
|
||||
this.exchangeRates[pair] = resultExtended[pair]?.[date]?.marketPrice;
|
||||
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
|
||||
|
||||
if (!this.exchangeRates[pair]) {
|
||||
if (!this.exchangeRates[symbol]) {
|
||||
// Not found, calculate indirectly via USD
|
||||
this.exchangeRates[pair] =
|
||||
resultExtended[`${currency1}${Currency.USD}`]?.[date]?.marketPrice *
|
||||
resultExtended[`${Currency.USD}${currency2}`]?.[date]?.marketPrice;
|
||||
this.exchangeRates[symbol] =
|
||||
resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice *
|
||||
resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice;
|
||||
|
||||
// Calculate the opposite direction
|
||||
this.exchangeRates[`${currency2}${currency1}`] =
|
||||
1 / this.exchangeRates[pair];
|
||||
1 / this.exchangeRates[symbol];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toCurrency(
|
||||
aValue: number,
|
||||
aFromCurrency: Currency,
|
||||
aToCurrency: Currency
|
||||
aFromCurrency: string,
|
||||
aToCurrency: string
|
||||
) {
|
||||
if (isNaN(this.exchangeRates[`${Currency.USD}${Currency.CHF}`])) {
|
||||
const hasNaN = Object.values(this.exchangeRates).some((exchangeRate) => {
|
||||
return isNaN(exchangeRate);
|
||||
});
|
||||
|
||||
if (hasNaN) {
|
||||
// Reinitialize if data is not loaded correctly
|
||||
this.initialize();
|
||||
}
|
||||
@ -103,8 +126,8 @@ export class ExchangeRateDataService {
|
||||
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
||||
} else {
|
||||
// Calculate indirectly via USD
|
||||
const factor1 = this.exchangeRates[`${aFromCurrency}${Currency.USD}`];
|
||||
const factor2 = this.exchangeRates[`${Currency.USD}${aToCurrency}`];
|
||||
const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`];
|
||||
const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`];
|
||||
|
||||
factor = factor1 * factor2;
|
||||
|
||||
@ -112,7 +135,7 @@ export class ExchangeRateDataService {
|
||||
}
|
||||
}
|
||||
|
||||
if (isNumber(factor)) {
|
||||
if (isNumber(factor) && !isNaN(factor)) {
|
||||
return factor * aValue;
|
||||
}
|
||||
|
||||
@ -123,8 +146,73 @@ export class ExchangeRateDataService {
|
||||
return aValue;
|
||||
}
|
||||
|
||||
private addCurrencyPairs(aCurrency1: Currency, aCurrency2: Currency) {
|
||||
this.currencyPairs.push(`${aCurrency1}${aCurrency2}`);
|
||||
this.currencyPairs.push(`${aCurrency2}${aCurrency1}`);
|
||||
private addCurrencyPairs({
|
||||
currency1,
|
||||
currency2,
|
||||
dataSource
|
||||
}: {
|
||||
currency1: string;
|
||||
currency2: string;
|
||||
dataSource: DataSource;
|
||||
}) {
|
||||
this.currencyPairs.push({
|
||||
dataSource,
|
||||
symbol: `${currency1}${currency2}`
|
||||
});
|
||||
this.currencyPairs.push({
|
||||
dataSource,
|
||||
symbol: `${currency2}${currency1}`
|
||||
});
|
||||
}
|
||||
|
||||
private async prepareCurrencies(): Promise<string[]> {
|
||||
const currencies: string[] = [];
|
||||
|
||||
(
|
||||
await this.prismaService.account.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
})
|
||||
).forEach((account) => {
|
||||
currencies.push(account.currency);
|
||||
});
|
||||
|
||||
(
|
||||
await this.prismaService.settings.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
})
|
||||
).forEach((userSettings) => {
|
||||
currencies.push(userSettings.currency);
|
||||
});
|
||||
|
||||
(
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
distinct: ['currency'],
|
||||
orderBy: [{ currency: 'asc' }],
|
||||
select: { currency: true }
|
||||
})
|
||||
).forEach((symbolProfile) => {
|
||||
currencies.push(symbolProfile.currency);
|
||||
});
|
||||
|
||||
return uniq(currencies).sort();
|
||||
}
|
||||
|
||||
private prepareCurrencyPairs(aCurrencies: string[]) {
|
||||
return aCurrencies
|
||||
.filter((currency) => {
|
||||
return currency !== baseCurrency;
|
||||
})
|
||||
.map((currency) => {
|
||||
return {
|
||||
currency1: baseCurrency,
|
||||
currency2: currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
symbol: `${baseCurrency}${currency}`
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -2,7 +2,6 @@ import {
|
||||
Account,
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource,
|
||||
SymbolProfile
|
||||
} from '@prisma/client';
|
||||
@ -17,7 +16,7 @@ export const MarketState = {
|
||||
|
||||
export interface IOrder {
|
||||
account: Account;
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
date: string;
|
||||
fee: number;
|
||||
id?: string;
|
||||
@ -38,7 +37,7 @@ export interface IDataProviderResponse {
|
||||
assetClass?: AssetClass;
|
||||
assetSubClass?: AssetSubClass;
|
||||
countries?: { code: string; weight: number }[];
|
||||
currency: Currency;
|
||||
currency: string;
|
||||
dataSource: DataSource;
|
||||
exchange?: string;
|
||||
marketChange?: number;
|
||||
@ -46,6 +45,7 @@ export interface IDataProviderResponse {
|
||||
marketPrice: number;
|
||||
marketState: MarketState;
|
||||
name?: string;
|
||||
sectors?: { name: string; weight: number }[];
|
||||
url?: string;
|
||||
}
|
||||
|
||||
|
@ -1,17 +1,12 @@
|
||||
import { Country } from '@ghostfolio/common/interfaces/country.interface';
|
||||
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
|
||||
import {
|
||||
AssetClass,
|
||||
AssetSubClass,
|
||||
Currency,
|
||||
DataSource
|
||||
} from '@prisma/client';
|
||||
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
|
||||
|
||||
export interface EnhancedSymbolProfile {
|
||||
assetClass: AssetClass;
|
||||
assetSubClass: AssetSubClass;
|
||||
createdAt: Date;
|
||||
currency: Currency | null;
|
||||
currency: string | null;
|
||||
dataSource: DataSource;
|
||||
id: string;
|
||||
name: string | null;
|
||||
|
@ -51,7 +51,7 @@
|
||||
|
||||
<ng-container matColumnDef="balance">
|
||||
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
|
||||
Balance
|
||||
Cash Balance
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
|
||||
<gf-value
|
||||
|
@ -17,7 +17,6 @@
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
mat-flat-button
|
||||
@ -28,18 +27,6 @@
|
||||
[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 === 'portfolio',
|
||||
'text-decoration-underline': currentRoute === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Transactions</a
|
||||
>
|
||||
<a
|
||||
class="d-none d-sm-block mx-1"
|
||||
i18n
|
||||
@ -166,7 +153,6 @@
|
||||
>Overview</a
|
||||
>
|
||||
<a
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
mat-menu-item
|
||||
@ -176,17 +162,6 @@
|
||||
[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 === 'portfolio'
|
||||
}"
|
||||
[routerLink]="['/portfolio', 'transactions']"
|
||||
>Transactions</a
|
||||
>
|
||||
<a
|
||||
class="d-block d-sm-none"
|
||||
i18n
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
|
||||
export interface PositionDetailDialogParams {
|
||||
deviceType: string;
|
||||
|
@ -7,11 +7,11 @@ import {
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { isToday, parse } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { LineChartItem } from '../line-chart/interfaces/line-chart.interface';
|
||||
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
|
@ -13,6 +13,7 @@
|
||||
[benchmarkDataItems]="benchmarkDataItems"
|
||||
[benchmarkLabel]="benchmarkLabel"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showGradient]="true"
|
||||
[showLegend]="true"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="false"
|
||||
|
@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
|
@ -8,7 +8,6 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { CountUp } from 'countup.js';
|
||||
import { isNumber } from 'lodash';
|
||||
|
||||
@ -19,7 +18,7 @@ import { isNumber } from 'lodash';
|
||||
styleUrls: ['./portfolio-performance.component.scss']
|
||||
})
|
||||
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
|
||||
@Input() baseCurrency: Currency;
|
||||
@Input() baseCurrency: string;
|
||||
@Input() isLoading: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() performance: PortfolioPerformance;
|
||||
|
@ -146,7 +146,7 @@
|
||||
<div class="col"><hr /></div>
|
||||
</div>
|
||||
<div class="row px-3 py-1">
|
||||
<div class="d-flex flex-grow-1" i18n>Net Worth</div>
|
||||
<div class="d-flex flex-grow-1 font-weight-bold" i18n>Net Worth</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
@ -156,4 +156,17 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row px-3 py-1">
|
||||
<div class="d-flex flex-grow-1 ml-3" i18n>Annualized Performance</div>
|
||||
<div class="d-flex flex-column flex-wrap justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : summary?.annualizedPerformancePercent"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -6,7 +6,6 @@ import {
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { PortfolioSummary } from '@ghostfolio/common/interfaces';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
@Component({
|
||||
@ -16,7 +15,7 @@ import { formatDistanceToNow } from 'date-fns';
|
||||
styleUrls: ['./portfolio-summary.component.scss']
|
||||
})
|
||||
export class PortfolioSummaryComponent implements OnChanges, OnInit {
|
||||
@Input() baseCurrency: Currency;
|
||||
@Input() baseCurrency: string;
|
||||
@Input() isLoading: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() summary: PortfolioSummary;
|
||||
|
@ -8,11 +8,11 @@ import {
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { LineChartItem } from '../../line-chart/interfaces/line-chart.interface';
|
||||
import { PositionDetailDialogParams } from './interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
|
@ -12,6 +12,7 @@
|
||||
benchmarkLabel="Buy Price"
|
||||
[benchmarkDataItems]="benchmarkDataItems"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showGradient]="true"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
[symbol]="data.symbol"
|
||||
|
@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
|
@ -83,10 +83,10 @@
|
||||
*matRowDef="let row; columns: displayedColumns"
|
||||
mat-row
|
||||
[ngClass]="{
|
||||
'cursor-pointer': !this.ignoreAssetClasses.includes(row.assetClass)
|
||||
'cursor-pointer': !ignoreAssetSubClasses.includes(row.assetSubClass)
|
||||
}"
|
||||
(click)="
|
||||
!this.ignoreAssetClasses.includes(row.assetClass) &&
|
||||
!ignoreAssetSubClasses.includes(row.assetSubClass) &&
|
||||
onOpenPositionDialog({ symbol: row.symbol })
|
||||
"
|
||||
></tr>
|
||||
|
@ -42,7 +42,7 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
public dataSource: MatTableDataSource<PortfolioPosition> =
|
||||
new MatTableDataSource();
|
||||
public displayedColumns = [];
|
||||
public ignoreAssetClasses = [AssetClass.CASH.toString()];
|
||||
public ignoreAssetSubClasses = [AssetClass.CASH.toString()];
|
||||
public isLoading = true;
|
||||
public pageSize = 7;
|
||||
public routeQueryParams: Subscription;
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
OnDestroy,
|
||||
OnInit
|
||||
} from '@angular/core';
|
||||
import { Currency } from '@prisma/client';
|
||||
import svgMap from 'svgmap';
|
||||
|
||||
@Component({
|
||||
@ -17,8 +16,9 @@ import svgMap from 'svgmap';
|
||||
styleUrls: ['./world-map-chart.component.scss']
|
||||
})
|
||||
export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Input() baseCurrency: Currency;
|
||||
@Input() baseCurrency: string;
|
||||
@Input() countries: { [code: string]: { name: string; value: number } };
|
||||
@Input() isInPercent = false;
|
||||
|
||||
public isLoading = true;
|
||||
public svgMapElement;
|
||||
@ -42,6 +42,27 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
if (this.isInPercent) {
|
||||
// Convert value of countries to percentage
|
||||
let sum = 0;
|
||||
Object.keys(this.countries).map((country) => {
|
||||
sum += this.countries[country].value;
|
||||
});
|
||||
|
||||
Object.keys(this.countries).map((country) => {
|
||||
this.countries[country].value = Number(
|
||||
((this.countries[country].value * 100) / sum).toFixed(2)
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Convert value to fixed-point notation
|
||||
Object.keys(this.countries).map((country) => {
|
||||
this.countries[country].value = Number(
|
||||
this.countries[country].value.toFixed(2)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
this.svgMapElement = new svgMap({
|
||||
colorMax: '#22bdb9',
|
||||
colorMin: '#c3f1f0',
|
||||
@ -50,7 +71,7 @@ export class WorldMapChartComponent implements OnChanges, OnDestroy, OnInit {
|
||||
applyData: 'value',
|
||||
data: {
|
||||
value: {
|
||||
format: `{0} ${this.baseCurrency}`
|
||||
format: this.isInPercent ? `{0}%` : `{0} ${this.baseCurrency}`
|
||||
}
|
||||
},
|
||||
values: this.countries
|
||||
|
@ -61,7 +61,23 @@ export class HttpResponseInterceptor implements HttpInterceptor {
|
||||
return event;
|
||||
}),
|
||||
catchError((error: HttpErrorResponse) => {
|
||||
if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
|
||||
if (error.status === StatusCodes.FORBIDDEN) {
|
||||
if (!this.snackBarRef) {
|
||||
this.snackBarRef = this.snackBar.open(
|
||||
'This feature requires a subscription.',
|
||||
'Upgrade Plan',
|
||||
{ duration: 6000 }
|
||||
);
|
||||
|
||||
this.snackBarRef.afterDismissed().subscribe(() => {
|
||||
this.snackBarRef = undefined;
|
||||
});
|
||||
|
||||
this.snackBarRef.onAction().subscribe(() => {
|
||||
this.router.navigate(['/pricing']);
|
||||
});
|
||||
}
|
||||
} else if (error.status === StatusCodes.INTERNAL_SERVER_ERROR) {
|
||||
if (!this.snackBarRef) {
|
||||
this.snackBarRef = this.snackBar.open(
|
||||
'Oops! Something went wrong. Please try again later.',
|
||||
|
@ -32,7 +32,12 @@
|
||||
</p>
|
||||
<p>
|
||||
If you encounter a bug or would like to suggest an improvement or a
|
||||
new feature, please tweet to
|
||||
new feature, please join the Ghostfolio
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
title="Join the Ghostfolio Slack channel"
|
||||
>Slack channel</a
|
||||
>, tweet to
|
||||
<a
|
||||
href="https://twitter.com/ghostfolio_"
|
||||
title="Tweet to Ghostfolio on Twitter"
|
||||
@ -65,6 +70,14 @@
|
||||
>
|
||||
<ion-icon name="mail" size="large"></ion-icon>
|
||||
</a>
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
mat-icon-button
|
||||
title="Join the Ghostfolio Slack channel"
|
||||
>
|
||||
<ion-icon name="logo-slack" size="large"></ion-icon>
|
||||
</a>
|
||||
<a
|
||||
class="mx-2"
|
||||
href="https://github.com/ghostfolio/ghostfolio"
|
||||
|
@ -15,7 +15,6 @@ import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
|
||||
import { DEFAULT_DATE_FORMAT, baseCurrency } from '@ghostfolio/common/config';
|
||||
import { Access, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { StripeService } from 'ngx-stripe';
|
||||
import { EMPTY, Subject } from 'rxjs';
|
||||
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
|
||||
@ -33,7 +32,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public baseCurrency = baseCurrency;
|
||||
public coupon: number;
|
||||
public couponId: string;
|
||||
public currencies: Currency[] = [];
|
||||
public currencies: string[] = [];
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToUpdateViewMode: boolean;
|
||||
|
@ -7,7 +7,7 @@
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount"
|
||||
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
|
||||
(accountDeleted)="onDeleteAccount($event)"
|
||||
(accountToUpdate)="onUpdateAccount($event)"
|
||||
></gf-accounts-table>
|
||||
@ -15,7 +15,7 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngIf="!hasImpersonationId && hasPermissionToCreateAccount"
|
||||
*ngIf="!hasImpersonationId && hasPermissionToCreateAccount && !user.settings.isRestrictedView"
|
||||
class="fab-container"
|
||||
>
|
||||
<a
|
||||
|
@ -1,4 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.fab-container {
|
||||
position: fixed;
|
||||
right: 2rem;
|
||||
|
@ -1,12 +1,10 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
import { DataService } from '../../../services/data.service';
|
||||
@ -20,13 +18,12 @@ import { CreateOrUpdateAccountDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'create-or-update-account-dialog.html'
|
||||
})
|
||||
export class CreateOrUpdateAccountDialog implements OnDestroy {
|
||||
public currencies: Currency[] = [];
|
||||
public currencies: string[] = [];
|
||||
public platforms: { id: string; name: string }[];
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
public dialogRef: MatDialogRef<CreateOrUpdateAccountDialog>,
|
||||
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateAccountDialogParams
|
||||
|
@ -6,13 +6,32 @@
|
||||
</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="d-flex my-3">
|
||||
<div
|
||||
*ngIf="exchangeRates?.length > 0"
|
||||
class="align-items-start d-flex my-3"
|
||||
>
|
||||
<div class="w-50" i18n>Exchange Rates</div>
|
||||
<div class="w-50">
|
||||
<div *ngFor="let exchangeRate of exchangeRates" class="mb-1">
|
||||
1 {{ exchangeRate.label1 }} = {{ exchangeRate.value | number :
|
||||
'1.5-5' }} {{ exchangeRate.label2 }}
|
||||
</div>
|
||||
<table>
|
||||
<tr *ngFor="let exchangeRate of exchangeRates">
|
||||
<td class="d-flex">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="1"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
||||
<td class="px-1">=</td>
|
||||
<td class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="4"
|
||||
[value]="exchangeRate.value"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
|
@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { AdminPageRoutingModule } from './admin-page-routing.module';
|
||||
import { AdminPageComponent } from './admin-page.component';
|
||||
@ -14,6 +15,7 @@ import { AdminPageComponent } from './admin-page.component';
|
||||
imports: [
|
||||
AdminPageRoutingModule,
|
||||
CommonModule,
|
||||
GfValueModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
MatMenuModule
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatTabChangeEvent } from '@angular/material/tabs';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
||||
import { PerformanceChartDialog } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.component';
|
||||
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
@ -29,6 +28,8 @@ import {
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -112,7 +113,10 @@ export class HomePageComponent implements OnDestroy, OnInit {
|
||||
|
||||
if (this.hasPermissionToAccessFearAndGreedIndex) {
|
||||
this.dataService
|
||||
.fetchSymbolItem(ghostfolioFearAndGreedIndexSymbol)
|
||||
.fetchSymbolItem({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketPrice }) => {
|
||||
this.fearAndGreedIndex = marketPrice;
|
||||
|
@ -33,6 +33,7 @@
|
||||
class="mr-3"
|
||||
symbol="Performance"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showGradient]="true"
|
||||
[showLoader]="false"
|
||||
[showXAxis]="false"
|
||||
[showYAxis]="false"
|
||||
|
@ -4,12 +4,12 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatTabsModule } from '@angular/material/tabs';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
||||
import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module';
|
||||
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
|
||||
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';
|
||||
import { GfPositionsModule } from '@ghostfolio/client/components/positions/positions.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
|
||||
|
||||
import { HomePageRoutingModule } from './home-page-routing.module';
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { LineChartItem } from '@ghostfolio/client/components/line-chart/interfaces/line-chart.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { TokenStorageService } from '@ghostfolio/client/services/token-storage.service';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { format } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
|
@ -50,6 +50,7 @@
|
||||
class="position-absolute"
|
||||
symbol="Performance"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[showGradient]="true"
|
||||
[showLoader]="false"
|
||||
[showXAxis]="false"
|
||||
[showYAxis]="false"
|
||||
|
@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfLineChartModule } from '@ghostfolio/client/components/line-chart/line-chart.module';
|
||||
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
|
||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||
|
||||
import { LandingPageRoutingModule } from './landing-page-routing.module';
|
||||
|
@ -37,13 +37,23 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
{ label: 'Current', value: 'current' }
|
||||
];
|
||||
public portfolioDetails: PortfolioDetails;
|
||||
public positions: { [symbol: string]: any };
|
||||
public positions: {
|
||||
[symbol: string]: Pick<
|
||||
PortfolioPosition,
|
||||
| 'assetClass'
|
||||
| 'assetSubClass'
|
||||
| 'currency'
|
||||
| 'exchange'
|
||||
| 'name'
|
||||
| 'value'
|
||||
>;
|
||||
};
|
||||
public positionsArray: PortfolioPosition[];
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public symbols: {
|
||||
[name: string]: { name: string; value: number };
|
||||
[name: string]: { name: string; symbol: string; value: number };
|
||||
};
|
||||
|
||||
public user: User;
|
||||
@ -121,6 +131,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
this.symbols = {
|
||||
[UNKNOWN_KEY]: {
|
||||
name: UNKNOWN_KEY,
|
||||
symbol: UNKNOWN_KEY,
|
||||
value: 0
|
||||
}
|
||||
};
|
||||
@ -137,15 +148,29 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
for (const [symbol, position] of Object.entries(
|
||||
this.portfolioDetails.holdings
|
||||
)) {
|
||||
let value = 0;
|
||||
|
||||
if (aPeriod === 'original') {
|
||||
if (this.hasImpersonationId) {
|
||||
value = position.allocationInvestment;
|
||||
} else {
|
||||
value = position.investment;
|
||||
}
|
||||
} else {
|
||||
if (this.hasImpersonationId) {
|
||||
value = position.allocationCurrent;
|
||||
} else {
|
||||
value = position.value;
|
||||
}
|
||||
}
|
||||
|
||||
this.positions[symbol] = {
|
||||
value,
|
||||
assetClass: position.assetClass,
|
||||
assetSubClass: position.assetSubClass,
|
||||
currency: position.currency,
|
||||
exchange: position.exchange,
|
||||
value:
|
||||
aPeriod === 'original'
|
||||
? position.allocationInvestment
|
||||
: position.allocationCurrent
|
||||
name: position.name
|
||||
};
|
||||
this.positionsArray.push(position);
|
||||
|
||||
@ -221,7 +246,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
if (position.assetClass === AssetClass.EQUITY) {
|
||||
this.symbols[symbol] = {
|
||||
name: symbol,
|
||||
symbol,
|
||||
name: position.name,
|
||||
value: aPeriod === 'original' ? position.investment : position.value
|
||||
};
|
||||
}
|
||||
|
@ -19,7 +19,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="hasImpersonationId"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="accounts"
|
||||
@ -43,7 +43,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['assetClass', 'assetSubClass']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
@ -67,7 +67,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="true"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['currency']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
@ -90,8 +90,8 @@
|
||||
<gf-portfolio-proportion-chart
|
||||
class="mx-auto"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[keys]="['name']"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['symbol']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="symbols"
|
||||
[showLabels]="deviceType !== 'mobile'"
|
||||
@ -113,7 +113,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[maxItems]="10"
|
||||
@ -138,7 +138,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="continents"
|
||||
@ -161,7 +161,7 @@
|
||||
<gf-portfolio-proportion-chart
|
||||
[keys]="['name']"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[isInPercent]="false"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[locale]="user?.settings?.locale"
|
||||
[maxItems]="10"
|
||||
[positions]="countries"
|
||||
@ -186,6 +186,7 @@
|
||||
<gf-world-map-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[countries]="countries"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
></gf-world-map-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
|
@ -1,10 +1,10 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/client/components/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
import { GfPositionsTableModule } from '@ghostfolio/client/components/positions-table/positions-table.module';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||
import { GfPortfolioProportionChartModule } from '@ghostfolio/ui/portfolio-proportion-chart/portfolio-proportion-chart.module';
|
||||
|
||||
import { AllocationsPageRoutingModule } from './allocations-page-routing.module';
|
||||
import { AllocationsPageComponent } from './allocations-page.component';
|
||||
|
@ -1,4 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.allocations-by-symbol {
|
||||
gf-portfolio-proportion-chart {
|
||||
max-width: 80vh;
|
||||
|
@ -1,4 +1,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.investment-chart {
|
||||
.mat-card {
|
||||
.mat-card-content {
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -10,6 +12,7 @@ import { takeUntil } from 'rxjs/operators';
|
||||
styleUrls: ['./portfolio-page.scss']
|
||||
})
|
||||
export class PortfolioPageComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -19,8 +22,16 @@ export class PortfolioPageComponent implements OnDestroy, OnInit {
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
) {
|
||||
const { globalPermissions } = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the controller
|
||||
|
@ -17,18 +17,23 @@
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="col-xs-12 col-md-6"
|
||||
>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<h4 i18n>Allocations</h4>
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Allocations</span>
|
||||
<ion-icon
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p class="mb-0">Check the allocations of your portfolio.</p>
|
||||
<p class="text-right">
|
||||
<a
|
||||
color="primary"
|
||||
i18n
|
||||
mat-button
|
||||
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
|
||||
[routerLink]="['/portfolio', 'allocations']"
|
||||
>
|
||||
Open Allocations →
|
||||
@ -38,18 +43,23 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="col-xs-12 col-md-6"
|
||||
>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<h4 i18n>Analysis</h4>
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>Analysis</span>
|
||||
<ion-icon
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p class="mb-0">Ghostfolio Analysis visualizes your portfolio.</p>
|
||||
<p class="text-right">
|
||||
<a
|
||||
color="primary"
|
||||
i18n
|
||||
mat-button
|
||||
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
|
||||
[routerLink]="['/portfolio', 'analysis']"
|
||||
>
|
||||
Open Analysis →
|
||||
@ -57,12 +67,16 @@
|
||||
</p>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="user?.settings?.viewMode === 'DEFAULT'"
|
||||
class="col-xs-12 col-md-6"
|
||||
>
|
||||
<div class="col-xs-12 col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<h4 i18n>X-ray</h4>
|
||||
<h4 class="align-items-center d-flex">
|
||||
<span i18n>X-ray</span>
|
||||
<ion-icon
|
||||
*ngIf="hasPermissionForSubscription"
|
||||
class="ml-1 text-muted"
|
||||
name="diamond-outline"
|
||||
></ion-icon>
|
||||
</h4>
|
||||
<p class="mb-0">
|
||||
Ghostfolio X-ray uses static analysis to identify potential issues and
|
||||
risks in your portfolio.
|
||||
@ -72,6 +86,7 @@
|
||||
color="primary"
|
||||
i18n
|
||||
mat-button
|
||||
[disabled]="hasPermissionForSubscription && user?.settings?.viewMode !== 'DEFAULT'"
|
||||
[routerLink]="['/portfolio', 'report']"
|
||||
>
|
||||
Open X-ray →
|
||||
|
@ -1,6 +1,12 @@
|
||||
:host {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
.mat-card {
|
||||
.mat-button-disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
|
@ -3,14 +3,15 @@ import {
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
Inject,
|
||||
OnDestroy
|
||||
OnDestroy,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormControl, Validators } from '@angular/forms';
|
||||
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Currency } from '@prisma/client';
|
||||
import { isString } from 'lodash';
|
||||
import { EMPTY, Observable, Subject } from 'rxjs';
|
||||
import {
|
||||
catchError,
|
||||
@ -31,13 +32,19 @@ import { CreateOrUpdateTransactionDialogParams } from './interfaces/interfaces';
|
||||
templateUrl: 'create-or-update-transaction-dialog.html'
|
||||
})
|
||||
export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
public currencies: Currency[] = [];
|
||||
@ViewChild('autocomplete') autocomplete;
|
||||
|
||||
public currencies: string[] = [];
|
||||
public currentMarketPrice = null;
|
||||
public filteredLookupItems: Observable<LookupItem[]>;
|
||||
public filteredLookupItems: LookupItem[];
|
||||
public filteredLookupItemsObservable: Observable<LookupItem[]>;
|
||||
public isLoading = false;
|
||||
public platforms: { id: string; name: string }[];
|
||||
public searchSymbolCtrl = new FormControl(
|
||||
this.data.transaction.symbol,
|
||||
{
|
||||
dataSource: this.data.transaction.dataSource,
|
||||
symbol: this.data.transaction.symbol
|
||||
},
|
||||
Validators.required
|
||||
);
|
||||
|
||||
@ -56,22 +63,33 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
this.currencies = currencies;
|
||||
this.platforms = platforms;
|
||||
|
||||
this.filteredLookupItems = this.searchSymbolCtrl.valueChanges.pipe(
|
||||
startWith(''),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
switchMap((aQuery: string) => {
|
||||
if (aQuery) {
|
||||
return this.dataService.fetchSymbols(aQuery);
|
||||
}
|
||||
this.filteredLookupItemsObservable =
|
||||
this.searchSymbolCtrl.valueChanges.pipe(
|
||||
startWith(''),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged(),
|
||||
switchMap((query: string) => {
|
||||
if (isString(query)) {
|
||||
const filteredLookupItemsObservable =
|
||||
this.dataService.fetchSymbols(query);
|
||||
|
||||
return [];
|
||||
})
|
||||
);
|
||||
filteredLookupItemsObservable.subscribe((filteredLookupItems) => {
|
||||
this.filteredLookupItems = filteredLookupItems;
|
||||
});
|
||||
|
||||
return filteredLookupItemsObservable;
|
||||
}
|
||||
|
||||
return [];
|
||||
})
|
||||
);
|
||||
|
||||
if (this.data.transaction.symbol) {
|
||||
this.dataService
|
||||
.fetchSymbolItem(this.data.transaction.symbol)
|
||||
.fetchSymbolItem({
|
||||
dataSource: this.data.transaction.dataSource,
|
||||
symbol: this.data.transaction.symbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ marketPrice }) => {
|
||||
this.currentMarketPrice = marketPrice;
|
||||
@ -85,9 +103,26 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
this.data.transaction.unitPrice = this.currentMarketPrice;
|
||||
}
|
||||
|
||||
public displayFn(aLookupItem: LookupItem) {
|
||||
return aLookupItem?.symbol ?? '';
|
||||
}
|
||||
|
||||
public onBlurSymbol() {
|
||||
const symbol = this.searchSymbolCtrl.value;
|
||||
this.updateSymbol(symbol);
|
||||
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
|
||||
return lookupItem.symbol === this.data.transaction.symbol;
|
||||
});
|
||||
|
||||
if (currentLookupItem) {
|
||||
this.updateSymbol(currentLookupItem.symbol);
|
||||
} else {
|
||||
this.searchSymbolCtrl.setErrors({ incorrect: true });
|
||||
|
||||
this.data.transaction.currency = null;
|
||||
this.data.transaction.dataSource = null;
|
||||
this.data.transaction.symbol = null;
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
||||
public onCancel(): void {
|
||||
@ -95,7 +130,8 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
}
|
||||
|
||||
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
|
||||
this.updateSymbol(event.option.value);
|
||||
this.data.transaction.dataSource = event.option.value.dataSource;
|
||||
this.updateSymbol(event.option.value.symbol);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
@ -106,10 +142,15 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
|
||||
private updateSymbol(symbol: string) {
|
||||
this.isLoading = true;
|
||||
|
||||
this.searchSymbolCtrl.setErrors(null);
|
||||
|
||||
this.data.transaction.symbol = symbol;
|
||||
|
||||
this.dataService
|
||||
.fetchSymbolItem(this.data.transaction.symbol)
|
||||
.fetchSymbolItem({
|
||||
dataSource: this.data.transaction.dataSource,
|
||||
symbol: this.data.transaction.symbol
|
||||
})
|
||||
.pipe(
|
||||
catchError(() => {
|
||||
this.data.transaction.currency = null;
|
||||
|
@ -28,18 +28,19 @@
|
||||
matInput
|
||||
required
|
||||
[formControl]="searchSymbolCtrl"
|
||||
[matAutocomplete]="auto"
|
||||
[matAutocomplete]="autocomplete"
|
||||
(blur)="onBlurSymbol()"
|
||||
/>
|
||||
<mat-autocomplete
|
||||
#auto="matAutocomplete"
|
||||
#autocomplete="matAutocomplete"
|
||||
[displayWith]="displayFn"
|
||||
(optionSelected)="onUpdateSymbol($event)"
|
||||
>
|
||||
<ng-container>
|
||||
<mat-option
|
||||
*ngFor="let lookupItem of filteredLookupItems | async"
|
||||
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
|
||||
class="autocomplete"
|
||||
[value]="lookupItem.symbol"
|
||||
[value]="lookupItem"
|
||||
>
|
||||
<span class="mr-2 symbol">{{ lookupItem.symbol | gfSymbol }}</span
|
||||
><span><b>{{ lookupItem.name }}</b></span>
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user