Compare commits

...

7 Commits

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

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

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

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

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

* Sort imports
2021-08-22 10:25:34 +02:00
25 changed files with 354 additions and 94 deletions

View File

@ -5,6 +5,24 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.43.0 - 24.08.2021
### Added
- Extended the data management of symbol profile data by countries (automated for stocks)
- Added a fallback for initially loading currencies if historical data is not yet available
## 1.42.0 - 22.08.2021
### Added
- Added the subscription type to the users table of the admin control panel
- Introduced the sub classification of assets
### Todo
- Apply data migration (`yarn database:push`)
## 1.41.0 - 21.08.2021 ## 1.41.0 - 21.08.2021
### Added ### Added

View File

@ -1,3 +1,4 @@
import { SubscriptionModule } from '@ghostfolio/api/app/subscription/subscription.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
@ -14,9 +15,11 @@ import { AdminService } from './admin.service';
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule, ExchangeRateDataModule,
PrismaModule PrismaModule,
SubscriptionModule
], ],
controllers: [AdminController], controllers: [AdminController],
providers: [AdminService] providers: [AdminService],
exports: [AdminService]
}) })
export class AdminModule {} export class AdminModule {}

View File

@ -1,3 +1,5 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
@ -9,9 +11,11 @@ import { differenceInDays } from 'date-fns';
@Injectable() @Injectable()
export class AdminService { export class AdminService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async get(): Promise<AdminData> { public async get(): Promise<AdminData> {
@ -107,7 +111,8 @@ export class AdminService {
} }
}, },
createdAt: true, createdAt: true,
id: true id: true,
Subscription: true
}, },
take: 30, take: 30,
where: { where: {
@ -118,16 +123,23 @@ export class AdminService {
}); });
return usersWithAnalytics.map( return usersWithAnalytics.map(
({ _count, alias, Analytics, createdAt, id }) => { ({ _count, alias, Analytics, createdAt, id, Subscription }) => {
const daysSinceRegistration = const daysSinceRegistration =
differenceInDays(new Date(), createdAt) + 1; differenceInDays(new Date(), createdAt) + 1;
const engagement = Analytics.activityCount / daysSinceRegistration; const engagement = Analytics.activityCount / daysSinceRegistration;
const subscription = this.configurationService.get(
'ENABLE_FEATURE_SUBSCRIPTION'
)
? this.subscriptionService.getSubscription(Subscription)
: undefined;
return { return {
alias, alias,
createdAt, createdAt,
engagement, engagement,
id, id,
subscription,
accountCount: _count.Account || 0, accountCount: _count.Account || 0,
lastActivity: Analytics.updatedAt, lastActivity: Analytics.updatedAt,
transactionCount: _count.Order || 0 transactionCount: _count.Order || 0

View File

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

View File

@ -218,6 +218,7 @@ export class PortfolioService {
allocationCurrent: value.div(totalValue).toNumber(), allocationCurrent: value.div(totalValue).toNumber(),
allocationInvestment: item.investment.div(totalInvestment).toNumber(), allocationInvestment: item.investment.div(totalInvestment).toNumber(),
assetClass: symbolProfile.assetClass, assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
countries: symbolProfile.countries, countries: symbolProfile.countries,
currency: item.currency, currency: item.currency,
exchange: dataProviderResponse.exchange, exchange: dataProviderResponse.exchange,

View File

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

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { locale } from '@ghostfolio/common/config'; import { locale } from '@ghostfolio/common/config';
@ -6,7 +7,6 @@ import { getPermissions, permissions } from '@ghostfolio/common/permissions';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type'; import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client'; import { Currency, Prisma, Provider, User, ViewMode } from '@prisma/client';
import { isBefore } from 'date-fns';
import { UserSettingsParams } from './interfaces/user-settings-params.interface'; import { UserSettingsParams } from './interfaces/user-settings-params.interface';
import { UserSettings } from './interfaces/user-settings.interface'; import { UserSettings } from './interfaces/user-settings.interface';
@ -19,7 +19,8 @@ export class UserService {
public constructor( public constructor(
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly subscriptionService: SubscriptionService
) {} ) {}
public async getUser({ public async getUser({
@ -98,24 +99,9 @@ export class UserService {
} }
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) { if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
if (userFromDatabase?.Subscription?.length > 0) { user.subscription = this.subscriptionService.getSubscription(
const latestSubscription = userFromDatabase.Subscription.reduce( userFromDatabase?.Subscription
(a, b) => { );
return new Date(a.expiresAt) > new Date(b.expiresAt) ? a : b;
}
);
user.subscription = {
expiresAt: latestSubscription.expiresAt,
type: isBefore(new Date(), latestSubscription.expiresAt)
? SubscriptionType.Premium
: SubscriptionType.Basic
};
} else {
user.subscription = {
type: SubscriptionType.Basic
};
}
if (user.subscription.type === SubscriptionType.Basic) { if (user.subscription.type === SubscriptionType.Basic) {
user.permissions = user.permissions.filter((permission) => { user.permissions = user.permissions.filter((permission) => {

View File

@ -38,7 +38,7 @@ export class DataGatheringService {
if (isDataGatheringNeeded) { if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.'); console.log('7d data gathering has been started.');
console.time('7d-data-gathering'); console.time('data-gathering-7d');
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
@ -71,7 +71,7 @@ export class DataGatheringService {
}); });
console.log('7d data gathering has been completed.'); console.log('7d data gathering has been completed.');
console.timeEnd('7d-data-gathering'); console.timeEnd('data-gathering-7d');
} }
} }
@ -82,7 +82,7 @@ export class DataGatheringService {
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.'); console.log('Max data gathering has been started.');
console.time('max-data-gathering'); console.time('data-gathering-max');
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
@ -115,13 +115,13 @@ export class DataGatheringService {
}); });
console.log('Max data gathering has been completed.'); console.log('Max data gathering has been completed.');
console.timeEnd('max-data-gathering'); console.timeEnd('data-gathering-max');
} }
} }
public async gatherProfileData(aSymbols?: string[]) { public async gatherProfileData(aSymbols?: string[]) {
console.log('Profile data gathering has been started.'); console.log('Profile data gathering has been started.');
console.time('profile-data-gathering'); console.time('data-gathering-profile');
let symbols = aSymbols; let symbols = aSymbols;
@ -136,12 +136,14 @@ export class DataGatheringService {
for (const [ for (const [
symbol, symbol,
{ assetClass, currency, dataSource, name } { assetClass, assetSubClass, countries, currency, dataSource, name }
] of Object.entries(currentData)) { ] of Object.entries(currentData)) {
try { try {
await this.prismaService.symbolProfile.upsert({ await this.prismaService.symbolProfile.upsert({
create: { create: {
assetClass, assetClass,
assetSubClass,
countries,
currency, currency,
dataSource, dataSource,
name, name,
@ -149,6 +151,8 @@ export class DataGatheringService {
}, },
update: { update: {
assetClass, assetClass,
assetSubClass,
countries,
currency, currency,
name name
}, },
@ -165,7 +169,7 @@ export class DataGatheringService {
} }
console.log('Profile data gathering has been completed.'); console.log('Profile data gathering has been completed.');
console.timeEnd('profile-data-gathering'); console.timeEnd('data-gathering-profile');
} }
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) { public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {

View File

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

View File

@ -8,9 +8,15 @@ import {
} from '@ghostfolio/common/helper'; } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AssetClass, Currency, DataSource } from '@prisma/client'; import {
AssetClass,
AssetSubClass,
Currency,
DataSource
} from '@prisma/client';
import * as bent from 'bent'; import * as bent from 'bent';
import Big from 'big.js'; import Big from 'big.js';
import { countries } from 'countries-list';
import { format } from 'date-fns'; import { format } from 'date-fns';
import * as yahooFinance from 'yahoo-finance'; import * as yahooFinance from 'yahoo-finance';
@ -22,6 +28,7 @@ import {
} from '../../interfaces/interfaces'; } from '../../interfaces/interfaces';
import { import {
IYahooFinanceHistoricalResponse, IYahooFinanceHistoricalResponse,
IYahooFinancePrice,
IYahooFinanceQuoteResponse IYahooFinanceQuoteResponse
} from './interfaces/interfaces'; } from './interfaces/interfaces';
@ -60,8 +67,11 @@ export class YahooFinanceService implements DataProviderInterface {
// Convert symbols back // Convert symbols back
const symbol = convertFromYahooSymbol(yahooSymbol); const symbol = convertFromYahooSymbol(yahooSymbol);
const { assetClass, assetSubClass } = this.parseAssetClass(value.price);
response[symbol] = { response[symbol] = {
assetClass: this.parseAssetClass(value.price?.quoteType), assetClass,
assetSubClass,
currency: parseCurrency(value.price?.currency), currency: parseCurrency(value.price?.currency),
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
@ -83,6 +93,23 @@ export class YahooFinanceService implements DataProviderInterface {
.toNumber(); .toNumber();
} }
// Add country if stock and available
if (
assetSubClass === AssetSubClass.STOCK &&
value.summaryProfile?.country
) {
try {
const [code] = Object.entries(countries).find(([, country]) => {
return country.name === value.summaryProfile?.country;
});
if (code) {
response[symbol].countries = [{ code, weight: 1 }];
}
} catch {}
}
// Add url if available
const url = value.summaryProfile?.website; const url = value.summaryProfile?.website;
if (url) { if (url) {
response[symbol].url = url; response[symbol].url = url;
@ -229,20 +256,29 @@ export class YahooFinanceService implements DataProviderInterface {
return aSymbol; return aSymbol;
} }
private parseAssetClass(aString: string): AssetClass { private parseAssetClass(aPrice: IYahooFinancePrice): {
assetClass: AssetClass;
assetSubClass: AssetSubClass;
} {
let assetClass: AssetClass; let assetClass: AssetClass;
let assetSubClass: AssetSubClass;
switch (aString?.toLowerCase()) { switch (aPrice?.quoteType?.toLowerCase()) {
case 'cryptocurrency': case 'cryptocurrency':
assetClass = AssetClass.CASH; assetClass = AssetClass.CASH;
assetSubClass = AssetSubClass.CRYPTOCURRENCY;
break; break;
case 'equity': case 'equity':
assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.STOCK;
break;
case 'etf': case 'etf':
assetClass = AssetClass.EQUITY; assetClass = AssetClass.EQUITY;
assetSubClass = AssetSubClass.ETF;
break; break;
} }
return assetClass; return { assetClass, assetSubClass };
} }
private parseExchange(aString: string): string { private parseExchange(aString: string): string {

View File

@ -3,7 +3,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Currency } from '@prisma/client'; import { Currency } from '@prisma/client';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { isNumber } from 'lodash'; import { isEmpty, isNumber } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
@ -35,6 +35,24 @@ export class ExchangeRateDataService {
getYesterday() getYesterday()
); );
if (isEmpty(result)) {
// Load currencies directly from data provider as a fallback
// if historical data is not yet available
const historicalData = await this.dataProviderService.get(
this.currencyPairs.map((currencyPair) => {
return currencyPair;
})
);
Object.keys(historicalData).forEach((key) => {
result[key] = {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: historicalData[key].marketPrice
}
};
});
}
const resultExtended = result; const resultExtended = result;
Object.keys(result).forEach((pair) => { Object.keys(result).forEach((pair) => {

View File

@ -1,6 +1,7 @@
import { import {
Account, Account,
AssetClass, AssetClass,
AssetSubClass,
Currency, Currency,
DataSource, DataSource,
SymbolProfile SymbolProfile
@ -35,6 +36,8 @@ export interface IDataProviderHistoricalResponse {
export interface IDataProviderResponse { export interface IDataProviderResponse {
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countries?: { code: string; weight: number }[];
currency: Currency; currency: Currency;
dataSource: DataSource; dataSource: DataSource;
exchange?: string; exchange?: string;

View File

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

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 * as Color from 'color';
@Component({ @Component({
selector: 'gf-portfolio-proportion-chart', selector: 'gf-portfolio-proportion-chart',
@ -28,7 +29,7 @@ export class PortfolioProportionChartComponent
{ {
@Input() baseCurrency: Currency; @Input() baseCurrency: Currency;
@Input() isInPercent: boolean; @Input() isInPercent: boolean;
@Input() key: string; @Input() keys: string[];
@Input() locale: string; @Input() locale: string;
@Input() maxItems?: number; @Input() maxItems?: number;
@Input() positions: { @Input() positions: {
@ -65,24 +66,54 @@ export class PortfolioProportionChartComponent
private initialize() { private initialize() {
this.isLoading = true; this.isLoading = true;
const chartData: { const chartData: {
[symbol: string]: { color?: string; value: number }; [symbol: string]: {
color?: string;
subCategory: { [symbol: string]: { value: number } };
value: number;
};
} = {}; } = {};
Object.keys(this.positions).forEach((symbol) => { Object.keys(this.positions).forEach((symbol) => {
if (this.positions[symbol][this.key]) { if (this.positions[symbol][this.keys[0]]) {
if (chartData[this.positions[symbol][this.key]]) { if (chartData[this.positions[symbol][this.keys[0]]]) {
chartData[this.positions[symbol][this.key]].value += chartData[this.positions[symbol][this.keys[0]]].value +=
this.positions[symbol].value; this.positions[symbol].value;
if (
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]]
]
) {
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]]
].value += this.positions[symbol].value;
} else {
chartData[this.positions[symbol][this.keys[0]]].subCategory[
this.positions[symbol][this.keys[1]] ?? UNKNOWN_KEY
] = { value: this.positions[symbol].value };
}
} else { } else {
chartData[this.positions[symbol][this.key]] = { chartData[this.positions[symbol][this.keys[0]]] = {
subCategory: {},
value: this.positions[symbol].value value: this.positions[symbol].value
}; };
if (this.positions[symbol][this.keys[1]]) {
chartData[this.positions[symbol][this.keys[0]]].subCategory = {
[this.positions[symbol][this.keys[1]]]: {
value: this.positions[symbol].value
}
};
}
} }
} else { } else {
if (chartData[UNKNOWN_KEY]) { if (chartData[UNKNOWN_KEY]) {
chartData[UNKNOWN_KEY].value += this.positions[symbol].value; chartData[UNKNOWN_KEY].value += this.positions[symbol].value;
} else { } else {
chartData[UNKNOWN_KEY] = { chartData[UNKNOWN_KEY] = {
subCategory: this.keys[1]
? { [this.keys[1]]: { value: 0 } }
: undefined,
value: this.positions[symbol].value value: this.positions[symbol].value
}; };
} }
@ -107,13 +138,17 @@ export class PortfolioProportionChartComponent
}); });
if (!unknownItem) { if (!unknownItem) {
const index = chartDataSorted.push([UNKNOWN_KEY, { value: 0 }]); const index = chartDataSorted.push([
UNKNOWN_KEY,
{ subCategory: {}, value: 0 }
]);
unknownItem = chartDataSorted[index]; unknownItem = chartDataSorted[index];
} }
rest.forEach((restItem) => { rest.forEach((restItem) => {
if (unknownItem?.[1]) { if (unknownItem?.[1]) {
unknownItem[1] = { unknownItem[1] = {
subCategory: {},
value: unknownItem[1].value + restItem[1].value value: unknownItem[1].value + restItem[1].value
}; };
} }
@ -141,21 +176,53 @@ export class PortfolioProportionChartComponent
} }
}); });
const backgroundColorSubCategory: string[] = [];
const dataSubCategory: number[] = [];
const labelSubCategory: string[] = [];
chartDataSorted.forEach(([, item]) => {
let lightnessRatio = 0.2;
Object.keys(item.subCategory).forEach((subCategory) => {
backgroundColorSubCategory.push(
Color(item.color).lighten(lightnessRatio).hex()
);
dataSubCategory.push(item.subCategory[subCategory].value);
labelSubCategory.push(subCategory);
lightnessRatio += 0.1;
});
});
const datasets = [
{
backgroundColor: chartDataSorted.map(([, item]) => {
return item.color;
}),
borderWidth: 0,
data: chartDataSorted.map(([, item]) => {
return item.value;
})
}
];
let labels = chartDataSorted.map(([label]) => {
return label;
});
if (this.keys[1]) {
datasets.unshift({
backgroundColor: backgroundColorSubCategory,
borderWidth: 0,
data: dataSubCategory
});
labels = labelSubCategory.concat(labels);
}
const data = { const data = {
datasets: [ datasets,
{ labels
backgroundColor: chartDataSorted.map(([, item]) => {
return item.color;
}),
borderWidth: 0,
data: chartDataSorted.map(([, item]) => {
return item.value;
})
}
],
labels: chartDataSorted.map(([label]) => {
return label;
})
}; };
if (this.chartCanvas) { if (this.chartCanvas) {
@ -166,13 +233,16 @@ export class PortfolioProportionChartComponent
this.chart = new Chart(this.chartCanvas.nativeElement, { this.chart = new Chart(this.chartCanvas.nativeElement, {
data, data,
options: { options: {
cutout: '70%',
plugins: { plugins: {
legend: { display: false }, legend: { display: false },
tooltip: { tooltip: {
callbacks: { callbacks: {
label: (context) => { label: (context) => {
const label = const labelIndex =
context.label === UNKNOWN_KEY ? 'Other' : context.label; (data.datasets[context.datasetIndex - 1]?.data?.length ??
0) + context.dataIndex;
const label = context.chart.data.labels[labelIndex];
if (this.isInPercent) { if (this.isInPercent) {
const value = 100 * <number>context.raw; const value = 100 * <number>context.raw;

View File

@ -35,12 +35,7 @@ export class AboutPageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef, private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService, private dataService: DataService,
private userService: UserService private userService: UserService
) {} ) {
/**
* Initializes the controller
*/
public ngOnInit() {
const { globalPermissions, statistics } = this.dataService.fetchInfo(); const { globalPermissions, statistics } = this.dataService.fetchInfo();
this.hasPermissionForBlog = hasPermission( this.hasPermissionForBlog = hasPermission(
@ -59,7 +54,12 @@ export class AboutPageComponent implements OnDestroy, OnInit {
); );
this.statistics = statistics; this.statistics = statistics;
}
/**
* Initializes the controller
*/
public ngOnInit() {
this.userService.stateChanged this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {

View File

@ -116,7 +116,20 @@
<tr *ngFor="let userItem of users; let i = index" class="mat-row"> <tr *ngFor="let userItem of users; let i = index" class="mat-row">
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td> <td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ userItem.alias || userItem.id }} <div class="d-flex align-items-center">
<span class="d-none d-sm-inline-block"
>{{ userItem.alias || userItem.id }}</span
>
<span class="d-inline-block d-sm-none"
>{{ userItem.alias || (userItem.id | slice:0:5) +
'...' }}</span
>
<ion-icon
*ngIf="userItem?.subscription?.type === 'Premium'"
class="ml-1 text-muted"
name="diamond-outline"
></ion-icon>
</div>
</td> </td>
<td class="mat-cell px-1 py-2 text-right"> <td class="mat-cell px-1 py-2 text-right">
{{ formatDistanceToNow(userItem.createdAt) }} {{ formatDistanceToNow(userItem.createdAt) }}

View File

@ -3,7 +3,7 @@ import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/to
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioCashSymbol, UNKNOWN_KEY } from '@ghostfolio/common/config'; import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { import {
PortfolioDetails, PortfolioDetails,
PortfolioPosition, PortfolioPosition,
@ -129,6 +129,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
)) { )) {
this.positions[symbol] = { this.positions[symbol] = {
assetClass: position.assetClass, assetClass: position.assetClass,
assetSubClass: position.assetSubClass,
currency: position.currency, currency: position.currency,
exchange: position.exchange, exchange: position.exchange,
value: value:

View File

@ -18,9 +18,9 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="hasImpersonationId" [isInPercent]="hasImpersonationId"
[keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="accounts" [positions]="accounts"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
@ -40,9 +40,9 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="assetClass"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
[keys]="['assetClass', 'assetSubClass']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [positions]="positions"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
@ -62,9 +62,9 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="currency"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="true" [isInPercent]="true"
[keys]="['currency']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [positions]="positions"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
@ -84,9 +84,9 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false" [isInPercent]="false"
[keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[maxItems]="10" [maxItems]="10"
[positions]="sectors" [positions]="sectors"
@ -107,9 +107,9 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="name"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false" [isInPercent]="false"
[keys]="['name']"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="continents" [positions]="continents"
></gf-portfolio-proportion-chart> ></gf-portfolio-proportion-chart>
@ -129,7 +129,7 @@
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
<gf-portfolio-proportion-chart <gf-portfolio-proportion-chart
key="name" [keys]="['name']"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[isInPercent]="false" [isInPercent]="false"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"

View File

@ -1,5 +1,5 @@
import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces'; import { MarketState } from '@ghostfolio/api/services/interfaces/interfaces';
import { AssetClass, Currency } from '@prisma/client'; import { AssetClass, AssetSubClass, Currency } from '@prisma/client';
import { Country } from './country.interface'; import { Country } from './country.interface';
import { Sector } from './sector.interface'; import { Sector } from './sector.interface';
@ -8,6 +8,7 @@ export interface PortfolioPosition {
allocationCurrent: number; allocationCurrent: number;
allocationInvestment: number; allocationInvestment: number;
assetClass?: AssetClass; assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
countries: Country[]; countries: Country[];
currency: Currency; currency: Currency;
exchange?: string; exchange?: string;

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.41.0", "version": "1.43.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -82,6 +82,7 @@
"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",
"color": "4.0.1",
"countries-list": "2.6.1", "countries-list": "2.6.1",
"countup.js": "2.0.7", "countup.js": "2.0.7",
"cryptocurrencies": "7.0.0", "cryptocurrencies": "7.0.0",
@ -126,6 +127,7 @@
"@nrwl/workspace": "12.5.4", "@nrwl/workspace": "12.5.4",
"@types/big.js": "6.1.1", "@types/big.js": "6.1.1",
"@types/cache-manager": "3.4.0", "@types/cache-manager": "3.4.0",
"@types/color": "3.0.2",
"@types/jest": "26.0.20", "@types/jest": "26.0.20",
"@types/lodash": "4.14.168", "@types/lodash": "4.14.168",
"@types/node": "14.14.33", "@types/node": "14.14.33",

View File

@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "AssetSubClass" AS ENUM ('CRYPTOCURRENCY', 'ETF', 'STOCK');
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "assetSubClass" "AssetSubClass";

View File

@ -117,17 +117,18 @@ model Settings {
} }
model SymbolProfile { model SymbolProfile {
assetClass AssetClass? assetClass AssetClass?
countries Json? assetSubClass AssetSubClass?
createdAt DateTime @default(now()) countries Json?
currency Currency? createdAt DateTime @default(now())
dataSource DataSource currency Currency?
id String @id @default(uuid()) dataSource DataSource
name String? id String @id @default(uuid())
Order Order[] name String?
updatedAt DateTime @updatedAt Order Order[]
sectors Json? updatedAt DateTime @updatedAt
symbol String sectors Json?
symbol String
@@unique([dataSource, symbol]) @@unique([dataSource, symbol])
} }
@ -174,6 +175,12 @@ enum AssetClass {
EQUITY EQUITY
} }
enum AssetSubClass {
CRYPTOCURRENCY
ETF
STOCK
}
enum Currency { enum Currency {
CHF CHF
EUR EUR

View File

@ -2567,6 +2567,25 @@
resolved "https://registry.yarnpkg.com/@types/cache-manager/-/cache-manager-3.4.0.tgz#414136ea3807a8cd071b8f20370c5df5dbffd382" resolved "https://registry.yarnpkg.com/@types/cache-manager/-/cache-manager-3.4.0.tgz#414136ea3807a8cd071b8f20370c5df5dbffd382"
integrity sha512-XVbn2HS+O+Mk2SKRCjr01/8oD5p2Tv1fxxdBqJ0+Cl+UBNiz0WVY5rusHpMGx+qF6Vc2pnRwPVwSKbGaDApCpw== integrity sha512-XVbn2HS+O+Mk2SKRCjr01/8oD5p2Tv1fxxdBqJ0+Cl+UBNiz0WVY5rusHpMGx+qF6Vc2pnRwPVwSKbGaDApCpw==
"@types/color-convert@*":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@types/color-convert/-/color-convert-2.0.0.tgz#8f5ee6b9e863dcbee5703f5a517ffb13d3ea4e22"
integrity sha512-m7GG7IKKGuJUXvkZ1qqG3ChccdIM/qBBo913z+Xft0nKCX4hAU/IxKwZBU4cpRZ7GS5kV4vOblUkILtSShCPXQ==
dependencies:
"@types/color-name" "*"
"@types/color-name@*":
version "1.1.1"
resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0"
integrity sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==
"@types/color@3.0.2":
version "3.0.2"
resolved "https://registry.yarnpkg.com/@types/color/-/color-3.0.2.tgz#3779043e782f562aa9157b5fc6bd07e14fd8e7f3"
integrity sha512-INiJl6sfNn8iyC5paxVzqiVUEj2boIlFki02uRTAkKwAj++7aAF+ZfEv/XrIeBa0XI/fTZuDHW8rEEcEVnON+Q==
dependencies:
"@types/color-convert" "*"
"@types/connect@*": "@types/connect@*":
version "3.4.34" version "3.4.34"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz#170a40223a6d666006d93ca128af2beb1d9b1901" resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.34.tgz#170a40223a6d666006d93ca128af2beb1d9b1901"
@ -4804,11 +4823,27 @@ color-name@1.1.3:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU= integrity sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=
color-name@~1.1.4: color-name@^1.0.0, color-name@~1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
color@4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/color/-/color-4.0.1.tgz#21df44cd10245a91b1ccf5ba031609b0e10e7d67"
integrity sha512-rpZjOKN5O7naJxkH2Rx1sZzzBgaiWECc6BYXjeCE6kF0kcASJYbUq02u7JqIHwCb/j3NhV+QhRL2683aICeGZA==
dependencies:
color-convert "^2.0.1"
color-string "^1.6.0"
colord@^2.0.1: colord@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/colord/-/colord-2.0.1.tgz#1e7fb1f9fa1cf74f42c58cb9c20320bab8435aa0" resolved "https://registry.yarnpkg.com/colord/-/colord-2.0.1.tgz#1e7fb1f9fa1cf74f42c58cb9c20320bab8435aa0"
@ -7780,6 +7815,11 @@ is-arrayish@^0.2.1:
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d"
integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0= integrity sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=
is-arrayish@^0.3.1:
version "0.3.2"
resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03"
integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==
is-bigint@^1.0.1: is-bigint@^1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.2.tgz#ffb381442503235ad245ea89e45b3dbff040ee5a" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.2.tgz#ffb381442503235ad245ea89e45b3dbff040ee5a"
@ -12349,6 +12389,13 @@ signal-exit@^3.0.0, signal-exit@^3.0.2, signal-exit@^3.0.3:
resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.3.tgz#a1410c2edd8f077b08b4e253c8eacfcaf057461c"
integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA== integrity sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==
simple-swizzle@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a"
integrity sha1-pNprY1/8zMoz9w0Xy5JZLeleVXo=
dependencies:
is-arrayish "^0.3.1"
sisteransi@^1.0.5: sisteransi@^1.0.5:
version "1.0.5" version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"