Compare commits

..

19 Commits

Author SHA1 Message Date
ba926ffcf2 Release 1.52.0 (#366) 2021-09-11 21:36:22 +02:00
5ea455b98b Feature/upgrade simplewebauthn dependencies to version 4.1.0 (#365)
* Upgrade @simplewebauthn dependencies to version 4.1.0
  * @simplewebauthn/browser
  * @simplewebauthn/server

* Update changelog
2021-09-11 21:23:06 +02:00
39f315aba0 Feature/add annualized performance (#364)
* Add annualized performance

* Update changelog
2021-09-11 20:16:53 +02:00
df2dfc20a1 Feature/add slack channel (#363)
* Add Slack channel

* Update changelog
2021-09-11 12:06:28 +02:00
81e83d4cea Release 1.51.0 (#362) 2021-09-11 11:25:07 +02:00
5d4156ecec Feature/refactor position detail dialog (#355)
* Add name to portfolio position endpoint

* Update changelog
2021-09-11 11:23:47 +02:00
4693a8baa2 Release 1.50.0 (#361) 2021-09-11 11:21:53 +02:00
773444b1e2 Bugfix/fix home button overlap on ios (#360)
* Fix overlap

* Update changelog
2021-09-11 11:17:49 +02:00
3c46bde8d5 Bugfix/fix fear and greed index (#359)
* Fix fear and greed index
* Refactor fear and greed index symbol
   * GF.FEAR_AND_GREED_INDEX -> _GF_FEAR_AND_GREED_INDEX

* Update changelog
2021-09-11 11:14:55 +02:00
63ee33b685 Use 'import type' to import types, eliminate webpack warnings (#358) 2021-09-11 09:27:22 +02:00
bc87c0a3e1 Add slack (#357) 2021-09-10 18:11:35 +02:00
caa9fc3efa Release 1.49.0 (#354) 2021-09-08 22:19:53 +02:00
9ed82ac82b Feature/improve labels of allocation chart by symbol (#353)
* Improve labels

* Update changelog
2021-09-08 22:03:33 +02:00
9c9ca4ab1e Add labels to allocation piecharts (#337) 2021-09-08 21:31:34 +02:00
b0b0942162 Release 1.48.0 (#352)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:23:07 +02:00
9cbf789c22 Bugfix/fix values in position detail dialog (#351)
* Nullify netPerformance

* Introduce precision

* Update changelog
2021-09-07 22:11:38 +02:00
ee5ab05d8a Release 1.47.1 (#350) 2021-09-06 22:55:08 +02:00
20731c67cb Release 1.47.0 (#349) 2021-09-06 22:34:17 +02:00
bf8856ad19 Bugfix/fix search for cryptocurrencies (#348)
* Fix the search for cryptocurrency symbols

* Update changelog
2021-09-06 22:02:49 +02:00
52 changed files with 433 additions and 239 deletions

View File

@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.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
- Provided the name in the portfolio position endpoint
## 1.50.0 - 11.09.2021
### Fixed
- Fixed the _Fear & Greed Index_ (market mood)
- Fixed the overlap of the home button with tabs on iOS (_Add to Home Screen_)
## 1.49.0 - 08.09.2021
### Added
- Added labels to the allocation chart by symbol on desktop
## 1.48.0 - 07.09.2021
### Added
- Added the attribute `precision` in the value component
### Fixed
- Hid the performance in the _Presenter View_
## 1.47.1 - 06.09.2021
### Fixed
- Fixed the search functionality for cryptocurrency symbols
## 1.46.0 - 05.09.2021 ## 1.46.0 - 05.09.2021
### Added ### Added

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software made for Humans</strong>
</p> </p>
<p> <p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a> <a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
</p> </p>
<p> <p>
<a href="#contributing"> <a href="#contributing">
@ -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. 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 ## License

View File

@ -1,5 +1,5 @@
import { Access } from '@ghostfolio/common/interfaces'; import { Access } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -6,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -5,7 +5,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Delete, Delete,

View File

@ -62,10 +62,10 @@ export class AuthController {
} }
} }
@Get('webauthn/generate-attestation-options') @Get('webauthn/generate-registration-options')
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async generateAttestationOptions() { public async generateRegistrationOptions() {
return this.webAuthService.generateAttestationOptions(); return this.webAuthService.generateRegistrationOptions();
} }
@Post('webauthn/verify-attestation') @Post('webauthn/verify-attestation')

View File

@ -2,7 +2,7 @@ import { AuthDeviceDto } from '@ghostfolio/api/app/auth-device/auth-device.dto';
import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service'; import { AuthDeviceService } from '@ghostfolio/api/app/auth-device/auth-device.service';
import { UserService } from '@ghostfolio/api/app/user/user.service'; import { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Inject, Inject,
Injectable, Injectable,
@ -11,16 +11,16 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { import {
GenerateAssertionOptionsOpts, GenerateAuthenticationOptionsOpts,
GenerateAttestationOptionsOpts, GenerateRegistrationOptionsOpts,
VerifiedAssertion, VerifiedAuthenticationResponse,
VerifiedAttestation, VerifiedRegistrationResponse,
VerifyAssertionResponseOpts, VerifyAuthenticationResponseOpts,
VerifyAttestationResponseOpts, VerifyRegistrationResponseOpts,
generateAssertionOptions, generateAuthenticationOptions,
generateAttestationOptions, generateRegistrationOptions,
verifyAssertionResponse, verifyAuthenticationResponse,
verifyAttestationResponse verifyRegistrationResponse
} from '@simplewebauthn/server'; } from '@simplewebauthn/server';
import { import {
@ -46,10 +46,10 @@ export class WebAuthService {
return this.configurationService.get('ROOT_URL'); return this.configurationService.get('ROOT_URL');
} }
public async generateAttestationOptions() { public async generateRegistrationOptions() {
const user = this.request.user; const user = this.request.user;
const opts: GenerateAttestationOptionsOpts = { const opts: GenerateRegistrationOptionsOpts = {
rpName: 'Ghostfolio', rpName: 'Ghostfolio',
rpID: this.rpID, rpID: this.rpID,
userID: user.id, userID: user.id,
@ -63,7 +63,7 @@ export class WebAuthService {
} }
}; };
const options = generateAttestationOptions(opts); const options = generateRegistrationOptions(opts);
await this.userService.updateUser({ await this.userService.updateUser({
data: { data: {
@ -84,27 +84,27 @@ export class WebAuthService {
const user = this.request.user; const user = this.request.user;
const expectedChallenge = user.authChallenge; const expectedChallenge = user.authChallenge;
let verification: VerifiedAttestation; let verification: VerifiedRegistrationResponse;
try { try {
const opts: VerifyAttestationResponseOpts = { const opts: VerifyRegistrationResponseOpts = {
credential, credential,
expectedChallenge, expectedChallenge,
expectedOrigin: this.expectedOrigin, expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID expectedRPID: this.rpID
}; };
verification = await verifyAttestationResponse(opts); verification = await verifyRegistrationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new InternalServerErrorException(error.message); throw new InternalServerErrorException(error.message);
} }
const { verified, attestationInfo } = verification; const { registrationInfo, verified } = verification;
const devices = await this.deviceService.authDevices({ const devices = await this.deviceService.authDevices({
where: { userId: user.id } where: { userId: user.id }
}); });
if (verified && attestationInfo) { if (registrationInfo && verified) {
const { credentialPublicKey, credentialID, counter } = attestationInfo; const { counter, credentialID, credentialPublicKey } = registrationInfo;
let existingDevice = devices.find( let existingDevice = devices.find(
(device) => device.credentialId === credentialID (device) => device.credentialId === credentialID
@ -115,9 +115,9 @@ export class WebAuthService {
* Add the returned device to the user's list of devices * Add the returned device to the user's list of devices
*/ */
existingDevice = await this.deviceService.createAuthDevice({ existingDevice = await this.deviceService.createAuthDevice({
counter,
credentialPublicKey, credentialPublicKey,
credentialId: credentialID, credentialId: credentialID,
counter,
User: { connect: { id: user.id } } User: { connect: { id: user.id } }
}); });
} }
@ -138,20 +138,20 @@ export class WebAuthService {
throw new Error('Device not found'); throw new Error('Device not found');
} }
const opts: GenerateAssertionOptionsOpts = { const opts: GenerateAuthenticationOptionsOpts = {
timeout: 60000,
allowCredentials: [ allowCredentials: [
{ {
id: device.credentialId, 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({ await this.userService.updateUser({
data: { data: {
@ -177,29 +177,29 @@ export class WebAuthService {
const user = await this.userService.user({ id: device.userId }); const user = await this.userService.user({ id: device.userId });
let verification: VerifiedAssertion; let verification: VerifiedAuthenticationResponse;
try { try {
const opts: VerifyAssertionResponseOpts = { const opts: VerifyAuthenticationResponseOpts = {
credential, credential,
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID,
authenticator: { authenticator: {
credentialID: device.credentialId, credentialID: device.credentialId,
credentialPublicKey: device.credentialPublicKey, credentialPublicKey: device.credentialPublicKey,
counter: device.counter counter: device.counter
} },
expectedChallenge: `${user.authChallenge}`,
expectedOrigin: this.expectedOrigin,
expectedRPID: this.rpID
}; };
verification = verifyAssertionResponse(opts); verification = verifyAuthenticationResponse(opts);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
throw new InternalServerErrorException({ error: error.message }); throw new InternalServerErrorException({ error: error.message });
} }
const { verified, assertionInfo } = verification; const { verified, authenticationInfo } = verification;
if (verified) { if (verified) {
device.counter = assertionInfo.newCounter; device.counter = authenticationInfo.newCounter;
await this.deviceService.updateAuthDevice({ await this.deviceService.updateAuthDevice({
data: device, data: device,

View File

@ -1,6 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Inject, Post, UseGuards } from '@nestjs/common'; import { Controller, Inject, Post, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -1,7 +1,7 @@
import { baseCurrency, benchmarks } from '@ghostfolio/common/config'; import { baseCurrency, benchmarks } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper'; import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { isApiTokenAuthorized } from '@ghostfolio/common/permissions'; import { isApiTokenAuthorized } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,5 +1,5 @@
import { Export } from '@ghostfolio/common/interfaces'; import { Export } from '@ghostfolio/common/interfaces';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { Controller, Get, Inject, UseGuards } from '@nestjs/common'; import { Controller, Get, Inject, UseGuards } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -6,7 +6,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -11,6 +11,7 @@ export interface PortfolioPositionDetail {
marketPrice: number; marketPrice: number;
maxPrice: number; maxPrice: number;
minPrice: number; minPrice: number;
name: string;
netPerformance: number; netPerformance: number;
netPerformancePercent: number; netPerformancePercent: number;
quantity: number; quantity: number;

View File

@ -11,7 +11,7 @@ import {
PortfolioSummary PortfolioSummary
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,
@ -276,6 +276,7 @@ export class PortfolioController {
position = nullifyValuesInObject(position, [ position = nullifyValuesInObject(position, [
'grossPerformance', 'grossPerformance',
'investment', 'investment',
'netPerformance',
'quantity' 'quantity'
]); ]);
} }

View File

@ -0,0 +1,62 @@
import { PortfolioService } from './portfolio.service';
describe('PortfolioService', () => {
let portfolioService: PortfolioService;
beforeAll(async () => {
portfolioService = new PortfolioService(
null,
null,
null,
null,
null,
null,
null,
null,
null
);
});
it('Get annualized performance', async () => {
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
netPerformancePercent: 0
})
).toEqual(0);
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 0,
netPerformancePercent: 0
})
).toEqual(0);
/**
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
*/
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 65, // < 1 year
netPerformancePercent: 0.1025
})
).toBeCloseTo(0.729705);
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 365, // 1 year
netPerformancePercent: 0.05
})
).toBeCloseTo(0.05);
/**
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
*/
expect(
portfolioService.getAnnualizedPerformancePercent({
daysInMarket: 575, // > 1 year
netPerformancePercent: 0.2374
})
).toBeCloseTo(0.145);
});
});

View File

@ -32,7 +32,7 @@ import {
TimelinePosition TimelinePosition
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface'; import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { import type {
DateRange, DateRange,
OrderWithAccount, OrderWithAccount,
RequestWithUser RequestWithUser
@ -47,6 +47,7 @@ import {
} from '@prisma/client'; } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import {
differenceInDays,
endOfToday, endOfToday,
format, format,
isAfter, isAfter,
@ -58,7 +59,7 @@ import {
subDays, subDays,
subYears subYears
} from 'date-fns'; } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty, isNumber } from 'lodash';
import { import {
HistoricalDataItem, HistoricalDataItem,
@ -80,6 +81,21 @@ export class PortfolioService {
private readonly symbolProfileService: SymbolProfileService private readonly symbolProfileService: SymbolProfileService
) {} ) {}
public getAnnualizedPerformancePercent({
daysInMarket,
netPerformancePercent
}: {
daysInMarket: number;
netPerformancePercent: number;
}) {
if (isNumber(daysInMarket) && daysInMarket > 0) {
const exponent = new Big(365).div(daysInMarket).toNumber();
return Math.pow(1 + netPerformancePercent, exponent) - 1;
}
return 0;
}
public async getInvestments( public async getInvestments(
aImpersonationId: string aImpersonationId: string
): Promise<InvestmentItem[]> { ): Promise<InvestmentItem[]> {
@ -282,6 +298,7 @@ export class PortfolioService {
marketPrice: undefined, marketPrice: undefined,
maxPrice: undefined, maxPrice: undefined,
minPrice: undefined, minPrice: undefined,
name: undefined,
netPerformance: undefined, netPerformance: undefined,
netPerformancePercent: undefined, netPerformancePercent: undefined,
quantity: undefined, quantity: undefined,
@ -291,6 +308,7 @@ export class PortfolioService {
} }
const positionCurrency = orders[0].currency; const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
@ -407,6 +425,7 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
netPerformance, netPerformance,
transactionCount, transactionCount,
averagePrice: averagePrice.toNumber(), averagePrice: averagePrice.toNumber(),
@ -455,6 +474,7 @@ export class PortfolioService {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
averagePrice: 0, averagePrice: 0,
currency: currentData[aSymbol]?.currency, currency: currentData[aSymbol]?.currency,
firstBuyDate: undefined, firstBuyDate: undefined,
@ -711,6 +731,12 @@ export class PortfolioService {
const fees = this.getFees(orders); const fees = this.getFees(orders);
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const annualizedPerformancePercent = this.getAnnualizedPerformancePercent({
daysInMarket: differenceInDays(new Date(), firstOrderDate),
netPerformancePercent:
performanceInformation.performance.currentNetPerformancePercent
});
const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY); const totalBuy = this.getTotalByType(orders, currency, TypeOfOrder.BUY);
const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL); const totalSell = this.getTotalByType(orders, currency, TypeOfOrder.SELL);
@ -722,6 +748,7 @@ export class PortfolioService {
return { return {
...performanceInformation.performance, ...performanceInformation.performance,
annualizedPerformancePercent,
fees, fees,
firstOrderDate, firstOrderDate,
netWorth, netWorth,

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,4 +1,4 @@
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
Get, Get,

View File

@ -17,7 +17,7 @@ export class SymbolService {
const response = await this.dataProviderService.get([aSymbol]); const response = await this.dataProviderService.get([aSymbol]);
const { currency, dataSource, marketPrice } = response[aSymbol] ?? {}; const { currency, dataSource, marketPrice } = response[aSymbol] ?? {};
if (currency && dataSource && marketPrice) { if (dataSource && marketPrice) {
return { return {
dataSource, dataSource,
marketPrice, marketPrice,

View File

@ -4,7 +4,7 @@ import {
hasPermission, hasPermission,
permissions permissions
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body, Body,
Controller, Controller,

View File

@ -1,4 +1,8 @@
import { benchmarks, currencyPairs } from '@ghostfolio/common/config'; import {
benchmarks,
currencyPairs,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getUtc, getUtc,
@ -295,7 +299,7 @@ export class DataGatheringService {
benchmarksToGather.push({ benchmarksToGather.push({
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
date: startDate, date: startDate,
symbol: 'GF.FEAR_AND_GREED_INDEX' symbol: ghostfolioFearAndGreedIndexSymbol
}); });
} }

View File

@ -19,7 +19,10 @@ import { format } from 'date-fns';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service'; import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service'; import { GhostfolioScraperApiService } from './ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service'; import { RakutenRapidApiService } from './rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from './yahoo-finance/yahoo-finance.service'; import {
convertToYahooFinanceSymbol,
YahooFinanceService
} from './yahoo-finance/yahoo-finance.service';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -47,12 +50,16 @@ export class DataProviderService {
} }
} }
const yahooFinanceSymbols = aSymbols.filter((symbol) => { const yahooFinanceSymbols = aSymbols
return ( .filter((symbol) => {
!isGhostfolioScraperApiSymbol(symbol) && return (
!isRakutenRapidApiSymbol(symbol) !isGhostfolioScraperApiSymbol(symbol) &&
); !isRakutenRapidApiSymbol(symbol)
}); );
})
.map((symbol) => {
return convertToYahooFinanceSymbol(symbol);
});
const response = await this.yahooFinanceService.get(yahooFinanceSymbols); const response = await this.yahooFinanceService.get(yahooFinanceSymbols);

View File

@ -1,6 +1,7 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import {
DATE_FORMAT, DATE_FORMAT,
getToday, getToday,
@ -47,11 +48,11 @@ export class RakutenRapidApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
return { return {
'GF.FEAR_AND_GREED_INDEX': { [ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
@ -82,7 +83,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
try { try {
const symbol = aSymbols[0]; const symbol = aSymbols[0];
if (symbol === 'GF.FEAR_AND_GREED_INDEX') { if (symbol === ghostfolioFearAndGreedIndexSymbol) {
const fgi = await this.getFearAndGreedIndex(); const fgi = await this.getFearAndGreedIndex();
try { try {
@ -118,7 +119,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
} catch {} } catch {}
return { return {
'GF.FEAR_AND_GREED_INDEX': { [ghostfolioFearAndGreedIndexSymbol]: {
[format(getYesterday(), DATE_FORMAT)]: { [format(getYesterday(), DATE_FORMAT)]: {
marketPrice: fgi.previousClose.value marketPrice: fgi.previousClose.value
} }

View File

@ -43,16 +43,12 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public async get( public async get(
aSymbols: string[] aYahooFinanceSymbols: string[]
): Promise<{ [symbol: string]: IDataProviderResponse }> { ): Promise<{ [symbol: string]: IDataProviderResponse }> {
if (aSymbols.length <= 0) { if (aYahooFinanceSymbols.length <= 0) {
return {}; return {};
} }
const yahooSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol);
});
try { try {
const response: { [symbol: string]: IDataProviderResponse } = {}; const response: { [symbol: string]: IDataProviderResponse } = {};
@ -60,12 +56,12 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: IYahooFinanceQuoteResponse; [symbol: string]: IYahooFinanceQuoteResponse;
} = await yahooFinance.quote({ } = await yahooFinance.quote({
modules: ['price', 'summaryProfile'], modules: ['price', 'summaryProfile'],
symbols: yahooSymbols symbols: aYahooFinanceSymbols
}); });
for (const [yahooSymbol, value] of Object.entries(data)) { for (const [yahooFinanceSymbol, value] of Object.entries(data)) {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price); const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
@ -136,15 +132,15 @@ export class YahooFinanceService implements DataProviderInterface {
return {}; return {};
} }
const yahooSymbols = aSymbols.map((symbol) => { const yahooFinanceSymbols = aSymbols.map((symbol) => {
return this.convertToYahooSymbol(symbol); return convertToYahooFinanceSymbol(symbol);
}); });
try { try {
const historicalData: { const historicalData: {
[symbol: string]: IYahooFinanceHistoricalResponse[]; [symbol: string]: IYahooFinanceHistoricalResponse[];
} = await yahooFinance.historical({ } = await yahooFinance.historical({
symbols: yahooSymbols, symbols: yahooFinanceSymbols,
from: format(from, DATE_FORMAT), from: format(from, DATE_FORMAT),
to: format(to, DATE_FORMAT) to: format(to, DATE_FORMAT)
}); });
@ -153,9 +149,11 @@ export class YahooFinanceService implements DataProviderInterface {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
for (const [yahooSymbol, timeSeries] of Object.entries(historicalData)) { for (const [yahooFinanceSymbol, timeSeries] of Object.entries(
historicalData
)) {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = convertFromYahooFinanceSymbol(yahooFinanceSymbol);
response[symbol] = {}; response[symbol] = {};
timeSeries.forEach((timeSerie) => { timeSeries.forEach((timeSerie) => {
@ -175,7 +173,7 @@ export class YahooFinanceService implements DataProviderInterface {
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
@ -192,19 +190,6 @@ export class YahooFinanceService implements DataProviderInterface {
// filter out undefined symbols // filter out undefined symbols
return quote.symbol; return quote.symbol;
}) })
.filter(({ quoteType }) => {
return quoteType === 'EQUITY' || quoteType === 'ETF';
})
.map(({ symbol }) => {
return symbol;
});
const marketData = await this.get(symbols);
items = searchResult.quotes
.filter((quote) => {
return quote.isYahooFinance;
})
.filter(({ quoteType }) => { .filter(({ quoteType }) => {
return ( return (
quoteType === 'CRYPTOCURRENCY' || quoteType === 'CRYPTOCURRENCY' ||
@ -220,42 +205,25 @@ export class YahooFinanceService implements DataProviderInterface {
return true; return true;
}) })
.map(({ longname, shortname, symbol }) => { .map(({ symbol }) => {
return { return symbol;
currency: marketData[symbol]?.currency,
dataSource: DataSource.YAHOO,
name: longname || shortname,
symbol: convertFromYahooSymbol(symbol)
};
}); });
const marketData = await this.get(symbols);
for (const [symbol, value] of Object.entries(marketData)) {
items.push({
symbol,
currency: value.currency,
dataSource: DataSource.YAHOO,
name: value.name
});
}
} catch {} } catch {}
return { items }; return { items };
} }
/**
* Converts a symbol to a Yahoo symbol
*
* Currency: USDCHF=X
* Cryptocurrency: BTC-USD
*/
private convertToYahooSymbol(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;
}
private parseAssetClass(aPrice: IYahooFinancePrice): { private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass; assetClass: AssetClass;
assetSubClass: AssetSubClass; assetSubClass: AssetSubClass;
@ -290,7 +258,30 @@ export class YahooFinanceService implements DataProviderInterface {
} }
} }
export const convertFromYahooSymbol = (aSymbol: string) => { export const convertFromYahooFinanceSymbol = (aYahooFinanceSymbol: string) => {
const symbol = aSymbol.replace('-', ''); const symbol = aYahooFinanceSymbol.replace('-', '');
return symbol.replace('=X', ''); 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;
};

View File

@ -16,6 +16,7 @@ import { LinearScale } from 'chart.js';
import { ArcElement } from 'chart.js'; import { ArcElement } from 'chart.js';
import { DoughnutController } from 'chart.js'; import { DoughnutController } from 'chart.js';
import { Chart } from 'chart.js'; import { Chart } from 'chart.js';
import ChartDataLabels from 'chartjs-plugin-datalabels';
import * as Color from 'color'; import * as Color from 'color';
@Component({ @Component({
@ -32,6 +33,7 @@ export class PortfolioProportionChartComponent
@Input() keys: string[]; @Input() keys: string[];
@Input() locale: string; @Input() locale: string;
@Input() maxItems?: number; @Input() maxItems?: number;
@Input() showLabels = false;
@Input() positions: { @Input() positions: {
[symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number }; [symbol: string]: Pick<PortfolioPosition, 'type'> & { value: number };
}; };
@ -48,7 +50,13 @@ export class PortfolioProportionChartComponent
}; };
public constructor() { public constructor() {
Chart.register(ArcElement, DoughnutController, LinearScale, Tooltip); Chart.register(
ArcElement,
ChartDataLabels,
DoughnutController,
LinearScale,
Tooltip
);
} }
public ngOnInit() {} public ngOnInit() {}
@ -235,7 +243,30 @@ export class PortfolioProportionChartComponent
data, data,
options: { options: {
cutout: '70%', cutout: '70%',
layout: {
padding: this.showLabels === true ? 100 : 0
},
plugins: { plugins: {
datalabels: {
color: (context) => {
return this.getColorPalette()[
context.dataIndex % this.getColorPalette().length
];
},
display: this.showLabels === true ? 'auto' : false,
labels: {
index: {
align: 'end',
anchor: 'end',
formatter: (value, context) => {
return value > 0
? context.chart.data.labels[context.dataIndex]
: '';
},
offset: 8
}
}
},
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
callbacks: { callbacks: {

View File

@ -146,7 +146,7 @@
<div class="col"><hr /></div> <div class="col"><hr /></div>
</div> </div>
<div class="row px-3 py-1"> <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"> <div class="d-flex justify-content-end">
<gf-value <gf-value
class="justify-content-end" class="justify-content-end"
@ -156,4 +156,17 @@
></gf-value> ></gf-value>
</div> </div>
</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> </div>

View File

@ -3,5 +3,4 @@ export interface PositionDetailDialogParams {
deviceType: string; deviceType: string;
locale: string; locale: string;
symbol: string; symbol: string;
title: string;
} }

View File

@ -34,9 +34,11 @@ export class PositionDetailDialog implements OnDestroy {
public marketPrice: number; public marketPrice: number;
public maxPrice: number; public maxPrice: number;
public minPrice: number; public minPrice: number;
public name: string;
public netPerformance: number; public netPerformance: number;
public netPerformancePercent: number; public netPerformancePercent: number;
public quantity: number; public quantity: number;
public symbol: string;
public transactionCount: number; public transactionCount: number;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -62,9 +64,11 @@ export class PositionDetailDialog implements OnDestroy {
marketPrice, marketPrice,
maxPrice, maxPrice,
minPrice, minPrice,
name,
netPerformance, netPerformance,
netPerformancePercent, netPerformancePercent,
quantity, quantity,
symbol,
transactionCount transactionCount
}) => { }) => {
this.averagePrice = averagePrice; this.averagePrice = averagePrice;
@ -90,9 +94,11 @@ export class PositionDetailDialog implements OnDestroy {
this.marketPrice = marketPrice; this.marketPrice = marketPrice;
this.maxPrice = maxPrice; this.maxPrice = maxPrice;
this.minPrice = minPrice; this.minPrice = minPrice;
this.name = name;
this.netPerformance = netPerformance; this.netPerformance = netPerformance;
this.netPerformancePercent = netPerformancePercent; this.netPerformancePercent = netPerformancePercent;
this.quantity = quantity; this.quantity = quantity;
this.symbol = symbol;
this.transactionCount = transactionCount; this.transactionCount = transactionCount;
if (isToday(parseISO(this.firstBuyDate))) { if (isToday(parseISO(this.firstBuyDate))) {

View File

@ -1,7 +1,7 @@
<gf-dialog-header <gf-dialog-header
mat-dialog-title mat-dialog-title
[deviceType]="data.deviceType" [deviceType]="data.deviceType"
[title]="data.title ?? data.symbol" [title]="name ?? symbol"
(closeButtonClicked)="onClose()" (closeButtonClicked)="onClose()"
></gf-dialog-header> ></gf-dialog-header>
@ -80,7 +80,8 @@
<gf-value <gf-value
label="Quantity" label="Quantity"
size="medium" size="medium"
[isCurrency]="true" [locale]="data.locale"
[precision]="2"
[value]="quantity" [value]="quantity"
></gf-value> ></gf-value>
</div> </div>
@ -103,8 +104,6 @@
<div class="col-6 mb-3"> <div class="col-6 mb-3">
<gf-value <gf-value
size="medium" size="medium"
[isCurrency]="false"
[isInteger]="true"
[label]="transactionCount === 1 ? 'Transaction' : 'Transactions'" [label]="transactionCount === 1 ? 'Transaction' : 'Transactions'"
[locale]="data.locale" [locale]="data.locale"
[value]="transactionCount" [value]="transactionCount"

View File

@ -64,8 +64,7 @@ export class PositionComponent implements OnDestroy, OnInit {
baseCurrency: this.baseCurrency, baseCurrency: this.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.locale, locale: this.locale,
symbol: this.position?.symbol, symbol: this.position?.symbol
title: this.position?.name
}, },
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh', height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem' width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -87,7 +87,7 @@
}" }"
(click)=" (click)="
!this.ignoreAssetClasses.includes(row.assetClass) && !this.ignoreAssetClasses.includes(row.assetClass) &&
onOpenPositionDialog({ symbol: row.symbol, title: row.name }) onOpenPositionDialog({ symbol: row.symbol })
" "
></tr> ></tr>
</table> </table>

View File

@ -57,14 +57,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if ( if (params['positionDetailDialog'] && params['symbol']) {
params['positionDetailDialog'] &&
params['symbol'] &&
params['title']
) {
this.openPositionDialog({ this.openPositionDialog({
symbol: params['symbol'], symbol: params['symbol']
title: params['title']
}); });
} }
}); });
@ -96,15 +91,9 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
this.dataSource.filter = filterValue.trim().toLowerCase(); this.dataSource.filter = filterValue.trim().toLowerCase();
}*/ }*/
public onOpenPositionDialog({ public onOpenPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
this.router.navigate([], { this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title } queryParams: { positionDetailDialog: true, symbol }
}); });
} }
@ -116,18 +105,11 @@ export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
}); });
} }
public openPositionDialog({ public openPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: {
symbol, symbol,
title,
baseCurrency: this.baseCurrency, baseCurrency: this.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.locale locale: this.locale

View File

@ -255,8 +255,7 @@
mat-row mat-row
(click)=" (click)="
onOpenPositionDialog({ onOpenPositionDialog({
symbol: row.symbol, symbol: row.symbol
title: row.SymbolProfile?.name
}) })
" "
></tr> ></tr>

View File

@ -86,8 +86,7 @@ export class TransactionsTableComponent
.subscribe((params) => { .subscribe((params) => {
if (params['positionDetailDialog'] && params['symbol']) { if (params['positionDetailDialog'] && params['symbol']) {
this.openPositionDialog({ this.openPositionDialog({
symbol: params['symbol'], symbol: params['symbol']
title: params['title']
}); });
} }
}); });
@ -196,15 +195,9 @@ export class TransactionsTableComponent
this.import.emit(); this.import.emit();
} }
public onOpenPositionDialog({ public onOpenPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
this.router.navigate([], { this.router.navigate([], {
queryParams: { positionDetailDialog: true, symbol, title } queryParams: { positionDetailDialog: true, symbol }
}); });
} }
@ -216,18 +209,11 @@ export class TransactionsTableComponent
this.transactionToClone.emit(aTransaction); this.transactionToClone.emit(aTransaction);
} }
public openPositionDialog({ public openPositionDialog({ symbol }: { symbol: string }): void {
symbol,
title
}: {
symbol: string;
title: string;
}): void {
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, autoFocus: false,
data: { data: {
symbol, symbol,
title,
baseCurrency: this.baseCurrency, baseCurrency: this.baseCurrency,
deviceType: this.deviceType, deviceType: this.deviceType,
locale: this.locale locale: this.locale

View File

@ -32,7 +32,12 @@
</p> </p>
<p> <p>
If you encounter a bug or would like to suggest an improvement or a 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 <a
href="https://twitter.com/ghostfolio_" href="https://twitter.com/ghostfolio_"
title="Tweet to Ghostfolio on Twitter" title="Tweet to Ghostfolio on Twitter"
@ -65,6 +70,14 @@
> >
<ion-icon name="mail" size="large"></ion-icon> <ion-icon name="mail" size="large"></ion-icon>
</a> </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 <a
class="mx-2" class="mx-2"
href="https://github.com/ghostfolio/ghostfolio" href="https://github.com/ghostfolio/ghostfolio"

View File

@ -20,6 +20,7 @@ import {
SettingsStorageService SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service'; } from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { import {
PortfolioPerformance, PortfolioPerformance,
PortfolioSummary, PortfolioSummary,
@ -111,7 +112,7 @@ export class HomePageComponent implements OnDestroy, OnInit {
if (this.hasPermissionToAccessFearAndGreedIndex) { if (this.hasPermissionToAccessFearAndGreedIndex) {
this.dataService this.dataService
.fetchSymbolItem('GF.FEAR_AND_GREED_INDEX') .fetchSymbolItem(ghostfolioFearAndGreedIndexSymbol)
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => { .subscribe(({ marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;

View File

@ -16,6 +16,9 @@
right: 0; right: 0;
top: 0; top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep { ::ng-deep {
.mat-tab-body-wrapper { .mat-tab-body-wrapper {
height: 100%; height: 100%;

View File

@ -94,6 +94,7 @@
[keys]="['name']" [keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="symbols" [positions]="symbols"
[showLabels]="deviceType !== 'mobile'"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
</mat-card-content> </mat-card-content>
</mat-card> </mat-card>

View File

@ -1,7 +1,7 @@
:host { :host {
.allocations-by-symbol { .allocations-by-symbol {
gf-portfolio-proportion-chart { gf-portfolio-proportion-chart {
max-width: 67vh; max-width: 80vh;
} }
} }

View File

@ -2,8 +2,8 @@ import { CommonModule } from '@angular/common';
import { NgModule } from '@angular/core'; import { NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component'; import { WebauthnPageComponent } from '@ghostfolio/client/pages/webauthn/webauthn-page.component';
import { GfLogoModule } from '@ghostfolio/ui/logo';
import { WebauthnPageRoutingModule } from './webauthn-page-routing.module'; import { WebauthnPageRoutingModule } from './webauthn-page-routing.module';

View File

@ -12,6 +12,9 @@
right: 0; right: 0;
top: 0; top: 0;
margin-bottom: env(safe-area-inset-bottom);
margin-bottom: constant(safe-area-inset-bottom);
::ng-deep { ::ng-deep {
.mat-tab-body-wrapper { .mat-tab-body-wrapper {
height: 100%; height: 100%;

View File

@ -6,7 +6,10 @@ import {
PublicKeyCredentialRequestOptionsJSON PublicKeyCredentialRequestOptionsJSON
} from '@ghostfolio/api/app/auth/interfaces/simplewebauthn'; } from '@ghostfolio/api/app/auth/interfaces/simplewebauthn';
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service'; import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
import { startAssertion, startAttestation } from '@simplewebauthn/browser'; import {
startAuthentication,
startRegistration
} from '@simplewebauthn/browser';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { catchError, switchMap, tap } from 'rxjs/operators'; import { catchError, switchMap, tap } from 'rxjs/operators';
@ -32,7 +35,7 @@ export class WebAuthnService {
public register() { public register() {
return this.http return this.http
.get<PublicKeyCredentialCreationOptionsJSON>( .get<PublicKeyCredentialCreationOptionsJSON>(
`/api/auth/webauthn/generate-attestation-options`, `/api/auth/webauthn/generate-registration-options`,
{} {}
) )
.pipe( .pipe(
@ -41,7 +44,7 @@ export class WebAuthnService {
return of(null); return of(null);
}), }),
switchMap((attOps) => { switchMap((attOps) => {
return startAttestation(attOps); return startRegistration(attOps);
}), }),
switchMap((attResp) => { switchMap((attResp) => {
return this.http.post<AuthDeviceDto>( return this.http.post<AuthDeviceDto>(
@ -83,7 +86,7 @@ export class WebAuthnService {
{ deviceId } { deviceId }
) )
.pipe( .pipe(
switchMap(startAssertion), switchMap(startAuthentication),
switchMap((assertionResponse) => { switchMap((assertionResponse) => {
return this.http.post<{ authToken: string }>( return this.http.post<{ authToken: string }>(
`/api/auth/webauthn/verify-assertion`, `/api/auth/webauthn/verify-assertion`,

View File

@ -27,7 +27,10 @@
name="twitter:title" name="twitter:title"
content="Ghostfolio Open Source Wealth Management Software" content="Ghostfolio Open Source Wealth Management Software"
/> />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta
name="viewport"
content="initial-scale=1, viewport-fit=cover, width=device-width"
/>
<meta property="og:description" content="" /> <meta property="og:description" content="" />
<meta <meta
property="og:title" property="og:title"

View File

@ -28,6 +28,7 @@ export const currencyPairs: Partial<
export const ghostfolioScraperApiSymbolPrefix = '_GF_'; export const ghostfolioScraperApiSymbolPrefix = '_GF_';
export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`; export const ghostfolioCashSymbol = `${ghostfolioScraperApiSymbolPrefix}CASH`;
export const ghostfolioFearAndGreedIndexSymbol = `${ghostfolioScraperApiSymbolPrefix}FEAR_AND_GREED_INDEX`;
export const locale = 'de-CH'; export const locale = 'de-CH';

View File

@ -1,6 +1,7 @@
import { PortfolioPerformance } from './portfolio-performance.interface'; import { PortfolioPerformance } from './portfolio-performance.interface';
export interface PortfolioSummary extends PortfolioPerformance { export interface PortfolioSummary extends PortfolioPerformance {
annualizedPerformancePercent: number;
cash: number; cash: number;
committedFunds: number; committedFunds: number;
fees: number; fees: number;

View File

@ -1,10 +1,10 @@
import { AccessWithGranteeUser } from './access-with-grantee-user.type'; import type { AccessWithGranteeUser } from './access-with-grantee-user.type';
import { DateRange } from './date-range.type'; import type { DateRange } from './date-range.type';
import { Granularity } from './granularity.type'; import type { Granularity } from './granularity.type';
import { OrderWithAccount } from './order-with-account.type'; import type { OrderWithAccount } from './order-with-account.type';
import { RequestWithUser } from './request-with-user.type'; import type { RequestWithUser } from './request-with-user.type';
export { export type {
AccessWithGranteeUser, AccessWithGranteeUser,
DateRange, DateRange,
Granularity, Granularity,

View File

@ -13,7 +13,7 @@
"error", "error",
{ {
"type": "attribute", "type": "attribute",
"prefix": "ghostfolio", "prefix": "gf",
"style": "camelCase" "style": "camelCase"
} }
], ],
@ -21,7 +21,7 @@
"error", "error",
{ {
"type": "element", "type": "element",
"prefix": "ghostfolio", "prefix": "gf",
"style": "kebab-case" "style": "kebab-case"
} }
] ]

View File

@ -29,17 +29,16 @@ Currency.args = {
value: 7 value: 7
}; };
export const Integer = Template.bind({});
Integer.args = {
isInteger: true,
locale: 'en-US',
value: 7
};
export const Label = Template.bind({}); export const Label = Template.bind({});
Label.args = { Label.args = {
isInteger: true,
label: 'Label', label: 'Label',
locale: 'en-US', locale: 'en-US',
value: 7 value: 7.25
};
export const Precision = Template.bind({});
Precision.args = {
locale: 'en-US',
precision: 3,
value: 7.2534802394809285309
}; };

View File

@ -18,11 +18,11 @@ export class ValueComponent implements OnChanges {
@Input() colorizeSign = false; @Input() colorizeSign = false;
@Input() currency = ''; @Input() currency = '';
@Input() isCurrency = false; @Input() isCurrency = false;
@Input() isInteger = false;
@Input() isPercent = false; @Input() isPercent = false;
@Input() label = ''; @Input() label = '';
@Input() locale = ''; @Input() locale = '';
@Input() position = ''; @Input() position = '';
@Input() precision: number | undefined;
@Input() size = ''; @Input() size = '';
@Input() value: number | string = ''; @Input() value: number | string = '';
@ -82,13 +82,15 @@ export class ValueComponent implements OnChanges {
minimumFractionDigits: 2 minimumFractionDigits: 2
}); });
} catch {} } catch {}
} else if (this.isInteger) { } else if (this.precision || this.precision === 0) {
try { try {
this.formattedValue = this.value?.toLocaleString(this.locale, { this.formattedValue = this.value?.toLocaleString(this.locale, {
maximumFractionDigits: 0, maximumFractionDigits: this.precision,
minimumFractionDigits: 0 minimumFractionDigits: this.precision
}); });
} catch {} } catch {}
} else {
this.formattedValue = this.value?.toString();
} }
} else { } else {
try { try {

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.46.0", "version": "1.52.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -68,9 +68,9 @@
"@nestjs/serve-static": "2.1.4", "@nestjs/serve-static": "2.1.4",
"@nrwl/angular": "12.8.0", "@nrwl/angular": "12.8.0",
"@prisma/client": "2.30.2", "@prisma/client": "2.30.2",
"@simplewebauthn/browser": "3.0.0", "@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "3.0.0", "@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "3.0.0", "@simplewebauthn/typescript-types": "4.0.0",
"@stripe/stripe-js": "1.15.0", "@stripe/stripe-js": "1.15.0",
"alphavantage": "2.2.0", "alphavantage": "2.2.0",
"angular-material-css-vars": "2.1.2", "angular-material-css-vars": "2.1.2",
@ -81,6 +81,7 @@
"cache-manager-redis-store": "2.0.0", "cache-manager-redis-store": "2.0.0",
"chart.js": "3.5.0", "chart.js": "3.5.0",
"chartjs-adapter-date-fns": "2.0.0", "chartjs-adapter-date-fns": "2.0.0",
"chartjs-plugin-datalabels": "2.0.0",
"cheerio": "1.0.0-rc.6", "cheerio": "1.0.0-rc.6",
"class-transformer": "0.3.2", "class-transformer": "0.3.2",
"class-validator": "0.13.1", "class-validator": "0.13.1",

View File

@ -2519,7 +2519,7 @@
consola "^2.15.0" consola "^2.15.0"
node-fetch "^2.6.1" node-fetch "^2.6.1"
"@peculiar/asn1-android@^2.0.26": "@peculiar/asn1-android@^2.0.38":
version "2.0.38" version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.0.38.tgz#193281f5a232e323d6f2c069c7a8e8e8f4a994bd" resolved "https://registry.yarnpkg.com/@peculiar/asn1-android/-/asn1-android-2.0.38.tgz#193281f5a232e323d6f2c069c7a8e8e8f4a994bd"
integrity sha512-krWyggV6FgYf3fEPKVNjHVecLcQWlAu3/YhOyN+/L43dNKcsmqiEvuhqplh3aiXF62Ds0pqzqttWmdvoVqmSVQ== integrity sha512-krWyggV6FgYf3fEPKVNjHVecLcQWlAu3/YhOyN+/L43dNKcsmqiEvuhqplh3aiXF62Ds0pqzqttWmdvoVqmSVQ==
@ -2528,7 +2528,7 @@
asn1js "^2.1.1" asn1js "^2.1.1"
tslib "^2.3.0" tslib "^2.3.0"
"@peculiar/asn1-schema@^2.0.26", "@peculiar/asn1-schema@^2.0.38": "@peculiar/asn1-schema@^2.0.38":
version "2.0.38" version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.38.tgz#98b6f12daad275ecd6774dfe31fb62f362900412" resolved "https://registry.yarnpkg.com/@peculiar/asn1-schema/-/asn1-schema-2.0.38.tgz#98b6f12daad275ecd6774dfe31fb62f362900412"
integrity sha512-zZ64UpCTm9me15nuCpPgJghSdbEm8atcDQPCyK+bKXjZAQ1735NCZXCSCfbckbQ4MH36Rm9403n/qMq77LFDzQ== integrity sha512-zZ64UpCTm9me15nuCpPgJghSdbEm8atcDQPCyK+bKXjZAQ1735NCZXCSCfbckbQ4MH36Rm9403n/qMq77LFDzQ==
@ -2538,7 +2538,7 @@
pvtsutils "^1.2.0" pvtsutils "^1.2.0"
tslib "^2.3.0" tslib "^2.3.0"
"@peculiar/asn1-x509@^2.0.26": "@peculiar/asn1-x509@^2.0.38":
version "2.0.38" version "2.0.38"
resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.0.38.tgz#7ff3b5478d9c3784f0eb2fbe7693509da9de0a43" resolved "https://registry.yarnpkg.com/@peculiar/asn1-x509/-/asn1-x509-2.0.38.tgz#7ff3b5478d9c3784f0eb2fbe7693509da9de0a43"
integrity sha512-10aK9fSxlc1DK9nEcwh+WPFNhAheXSE9RbI5MyS7FdBhgq+Mz4Z9JqFfaBZm1Qp+5mPtUMOP6cXVo7aaYlgq7A== integrity sha512-10aK9fSxlc1DK9nEcwh+WPFNhAheXSE9RbI5MyS7FdBhgq+Mz4Z9JqFfaBZm1Qp+5mPtUMOP6cXVo7aaYlgq7A==
@ -2613,32 +2613,32 @@
"@angular-devkit/schematics" "12.1.4" "@angular-devkit/schematics" "12.1.4"
jsonc-parser "3.0.0" jsonc-parser "3.0.0"
"@simplewebauthn/browser@3.0.0": "@simplewebauthn/browser@4.1.0":
version "3.0.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-3.0.0.tgz#3d76b199c9f474408a7ed75d86004423dd6ae38a" resolved "https://registry.yarnpkg.com/@simplewebauthn/browser/-/browser-4.1.0.tgz#3e7fd66729405d6a2a2a187c93577b90a8e41786"
integrity sha512-P661gZX/QW0Rg2NRAMtW84Q3u4nhXkPef9LLU4btLJFYoXO8RBFfxcmyqwyf2QEb4B7+lFdp5EWfZV5T7FvuHw== integrity sha512-tIsEfShC1rrqrsNb44tOFuSriAFCz4tkdDnCjHfn2rYxgz+t+yqEvuIRfJHQpFrWSnZPdsjrAHtasj6lzfGI6w==
"@simplewebauthn/server@3.0.0": "@simplewebauthn/server@4.1.0":
version "3.0.0" version "4.1.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-3.0.0.tgz#eb1a5bbe2ecdda54363b178f4bb3e134f25641f0" resolved "https://registry.yarnpkg.com/@simplewebauthn/server/-/server-4.1.0.tgz#9ad2e32cffa83833ff8a633775b2ace5e6926fa0"
integrity sha512-ymGX2obBrhY9R3OxrpCYaNGAovFHmMlQrGoNdVOe2R2JUBXC1Rg5JEUl1lGyaRykN1SyZqLgz86wAjDVuRITTA== integrity sha512-52X5/U+5Fo0XYG1TuBBGgG0ap9c0ffpeq0GZfFio/DZDW4He0Arb7Q/XkHw96JK0X1sfRKNmnfC+NImplvIimA==
dependencies: dependencies:
"@peculiar/asn1-android" "^2.0.26" "@peculiar/asn1-android" "^2.0.38"
"@peculiar/asn1-schema" "^2.0.26" "@peculiar/asn1-schema" "^2.0.38"
"@peculiar/asn1-x509" "^2.0.26" "@peculiar/asn1-x509" "^2.0.38"
"@simplewebauthn/typescript-types" "^3.0.0" "@simplewebauthn/typescript-types" "^4.0.0"
base64url "^3.0.1" base64url "^3.0.1"
cbor "^5.1.0" cbor "^5.1.0"
elliptic "^6.5.3" elliptic "^6.5.3"
jsrsasign "^10.2.0" jsrsasign "^10.4.0"
jwk-to-pem "^2.0.4" jwk-to-pem "^2.0.4"
node-fetch "^2.6.0" node-fetch "^2.6.0"
node-rsa "^1.1.1" node-rsa "^1.1.1"
"@simplewebauthn/typescript-types@3.0.0", "@simplewebauthn/typescript-types@^3.0.0": "@simplewebauthn/typescript-types@4.0.0", "@simplewebauthn/typescript-types@^4.0.0":
version "3.0.0" version "4.0.0"
resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-3.0.0.tgz#6712e9619d860f54f571cd27dbe167b2d9e5ab87" resolved "https://registry.yarnpkg.com/@simplewebauthn/typescript-types/-/typescript-types-4.0.0.tgz#46ae4e69cb07305c57093a3ed99555437dfe0d49"
integrity sha512-bsk3EQWzPOZwP9C+ETVhcFDpZywY5sTqmNuGkNm3aNpc9Xh/mqZjy8nL0Sm7xwrlhY0zWAlOaIWQ3LvN5SoFhg== integrity sha512-jqQ0bCeBO96CytB397vSrQ8ipozQzAmI57izA7izyglyu35JBV90I7+75fSX+ZGNHmMwDNnA3EGYtBLOIpkJEg==
"@sinonjs/commons@^1.7.0": "@sinonjs/commons@^1.7.0":
version "1.8.3" version "1.8.3"
@ -6112,6 +6112,11 @@ chartjs-adapter-date-fns@2.0.0:
resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b" resolved "https://registry.yarnpkg.com/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-2.0.0.tgz#5e53b2f660b993698f936f509c86dddf9ed44c6b"
integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw== integrity sha512-rmZINGLe+9IiiEB0kb57vH3UugAtYw33anRiw5kS2Tu87agpetDDoouquycWc9pRsKtQo5j+vLsYHyr8etAvFw==
chartjs-plugin-datalabels@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/chartjs-plugin-datalabels/-/chartjs-plugin-datalabels-2.0.0.tgz#caacefb26803d968785071eab012dde8746c5939"
integrity sha512-WBsWihphzM0Y8fmQVm89+iy99mmgejmj5/jcsYqwxSioLRL/zqJ4Scv/eXq5ZqvG3TpojlGzZLeaOaSvDm7fwA==
check-more-types@^2.24.0: check-more-types@^2.24.0:
version "2.24.0" version "2.24.0"
resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
@ -11338,7 +11343,7 @@ jsprim@^1.2.2:
json-schema "0.2.3" json-schema "0.2.3"
verror "1.10.0" verror "1.10.0"
jsrsasign@^10.2.0: jsrsasign@^10.4.0:
version "10.4.0" version "10.4.0"
resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.4.0.tgz#362cc787079c03a363a958c03eb68d8545ba92f7" resolved "https://registry.yarnpkg.com/jsrsasign/-/jsrsasign-10.4.0.tgz#362cc787079c03a363a958c03eb68d8545ba92f7"
integrity sha512-C8qLhiAssh/b74KJpGhWuFGG9cFhJqMCVuuHXRibb3Z5vPuAW0ue0jUirpoExCdpdhv4nD3sZ1DAwJURYJTm9g== integrity sha512-C8qLhiAssh/b74KJpGhWuFGG9cFhJqMCVuuHXRibb3Z5vPuAW0ue0jUirpoExCdpdhv4nD3sZ1DAwJURYJTm9g==