Browse Source

Merge branch 'ghostfolio:main' into main

pull/985/head
Leon Stoldt 4 years ago
committed by GitHub
parent
commit
1a8667ee0b
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 20
      CHANGELOG.md
  2. 2
      apps/api/src/app/access/access.service.ts
  3. 2
      apps/api/src/app/account/account.service.ts
  4. 27
      apps/api/src/app/admin/admin.controller.ts
  5. 2
      apps/api/src/app/admin/admin.module.ts
  6. 16
      apps/api/src/app/admin/admin.service.ts
  7. 2
      apps/api/src/app/auth-device/auth-device.service.ts
  8. 3
      apps/api/src/app/info/info.service.ts
  9. 14
      apps/api/src/app/order/order.service.ts
  10. 2
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  11. 2
      apps/api/src/app/symbol/interfaces/symbol-item.interface.ts
  12. 11
      apps/api/src/app/symbol/symbol.controller.ts
  13. 8
      apps/api/src/app/symbol/symbol.module.ts
  14. 31
      apps/api/src/app/symbol/symbol.service.ts
  15. 2
      apps/api/src/app/user/user.service.ts
  16. 3
      apps/api/src/services/configuration.service.ts
  17. 45
      apps/api/src/services/data-gathering.service.ts
  18. 6
      apps/api/src/services/data-provider/data-provider.service.ts
  19. 3
      apps/api/src/services/exchange-rate-data.module.ts
  20. 16
      apps/api/src/services/exchange-rate-data.service.ts
  21. 2
      apps/api/src/services/market-data.service.ts
  22. 6
      apps/api/src/services/property/property.dto.ts
  23. 11
      apps/api/src/services/property/property.module.ts
  24. 43
      apps/api/src/services/property/property.service.ts
  25. 7
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html
  26. 5
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss
  27. 9
      apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts
  28. 42
      apps/client/src/app/components/admin-overview/admin-overview.component.ts
  29. 94
      apps/client/src/app/components/admin-overview/admin-overview.html
  30. 6
      apps/client/src/app/components/admin-overview/admin-overview.scss
  31. 6
      apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html
  32. 13
      apps/client/src/app/components/home-market/home-market.component.ts
  33. 29
      apps/client/src/app/components/home-market/home-market.html
  34. 4
      apps/client/src/app/components/home-market/home-market.module.ts
  35. 4
      apps/client/src/app/components/home-market/home-market.scss
  36. 12
      apps/client/src/app/services/data.service.ts
  37. 5
      libs/common/src/lib/config.ts
  38. 3
      libs/common/src/lib/interfaces/admin-data.interface.ts
  39. 17
      libs/ui/src/lib/line-chart/line-chart.component.ts
  40. 6
      package.json
  41. 36
      yarn.lock

20
CHANGELOG.md

@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased ## Unreleased
### Added
- Supported the management of additional currencies in the admin control panel
### Changed
- Upgraded `prisma` from version `2.30.2` to `3.6.0`
## 1.86.0 - 04.12.2021
### Added
- Added the historical data chart of the _Fear & Greed Index_ (market mood)
### Changed
- Improved the historical data view in the admin control panel (hide invalid and future dates)
- Enabled the import functionality for transactions by default
- Converted the symbols to uppercase to avoid case-sensitive duplicates in the symbol profile model
### Fixed ### Fixed
- Improved the allocations by currency in combination with cash balances - Improved the allocations by currency in combination with cash balances

2
apps/api/src/app/access/access.service.ts

@ -24,7 +24,7 @@ export class AccessService {
take?: number; take?: number;
cursor?: Prisma.AccessWhereUniqueInput; cursor?: Prisma.AccessWhereUniqueInput;
where?: Prisma.AccessWhereInput; where?: Prisma.AccessWhereInput;
orderBy?: Prisma.AccessOrderByInput; orderBy?: Prisma.AccessOrderByWithRelationInput;
}): Promise<AccessWithGranteeUser[]> { }): Promise<AccessWithGranteeUser[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;

2
apps/api/src/app/account/account.service.ts

@ -40,7 +40,7 @@ export class AccountService {
take?: number; take?: number;
cursor?: Prisma.AccountWhereUniqueInput; cursor?: Prisma.AccountWhereUniqueInput;
where?: Prisma.AccountWhereInput; where?: Prisma.AccountWhereInput;
orderBy?: Prisma.AccountOrderByInput; orderBy?: Prisma.AccountOrderByWithRelationInput;
}): Promise< }): Promise<
(Account & { (Account & {
Order?: Order[]; Order?: Order[];

27
apps/api/src/app/admin/admin.controller.ts

@ -1,4 +1,7 @@
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service'; import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -11,12 +14,14 @@ import {
} from '@ghostfolio/common/permissions'; } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Body,
Controller, Controller,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
Post, Post,
Put,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
@ -31,6 +36,7 @@ export class AdminController {
public constructor( public constructor(
private readonly adminService: AdminService, private readonly adminService: AdminService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly propertyService: PropertyService,
@Inject(REQUEST) private readonly request: RequestWithUser @Inject(REQUEST) private readonly request: RequestWithUser
) {} ) {}
@ -153,4 +159,25 @@ export class AdminController {
return this.adminService.getMarketDataBySymbol(symbol); return this.adminService.getMarketDataBySymbol(symbol);
} }
@Put('settings/:key')
@UseGuards(AuthGuard('jwt'))
public async updateProperty(
@Param('key') key: string,
@Body() data: PropertyDto
) {
if (
!hasPermission(
getPermissions(this.request.user.role),
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return await this.adminService.putSetting(key, data.value);
}
} }

2
apps/api/src/app/admin/admin.module.ts

@ -5,6 +5,7 @@ import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module'; import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AdminController } from './admin.controller'; import { AdminController } from './admin.controller';
@ -18,6 +19,7 @@ import { AdminService } from './admin.service';
ExchangeRateDataModule, ExchangeRateDataModule,
MarketDataModule, MarketDataModule,
PrismaModule, PrismaModule,
PropertyModule,
SubscriptionModule SubscriptionModule
], ],
controllers: [AdminController], controllers: [AdminController],

16
apps/api/src/app/admin/admin.service.ts

@ -4,7 +4,8 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service'; import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { baseCurrency } from '@ghostfolio/common/config'; import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
@ -21,6 +22,7 @@ export class AdminService {
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService, private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService, private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService private readonly subscriptionService: SubscriptionService
) {} ) {}
@ -45,6 +47,7 @@ export class AdminService {
}; };
}), }),
lastDataGathering: await this.getLastDataGathering(), lastDataGathering: await this.getLastDataGathering(),
settings: await this.propertyService.get(),
transactionCount: await this.prismaService.order.count(), transactionCount: await this.prismaService.order.count(),
userCount: await this.prismaService.user.count(), userCount: await this.prismaService.user.count(),
users: await this.getUsersWithAnalytics() users: await this.getUsersWithAnalytics()
@ -76,6 +79,17 @@ export class AdminService {
}; };
} }
public async putSetting(key: string, value: string) {
const response = await this.propertyService.put({ key, value });
if (key === PROPERTY_CURRENCIES) {
await this.exchangeRateDataService.initialize();
await this.dataGatheringService.reset();
}
return response;
}
private async getLastDataGathering() { private async getLastDataGathering() {
const lastDataGathering = const lastDataGathering =
await this.dataGatheringService.getLastDataGathering(); await this.dataGatheringService.getLastDataGathering();

2
apps/api/src/app/auth-device/auth-device.service.ts

@ -23,7 +23,7 @@ export class AuthDeviceService {
take?: number; take?: number;
cursor?: Prisma.AuthDeviceWhereUniqueInput; cursor?: Prisma.AuthDeviceWhereUniqueInput;
where?: Prisma.AuthDeviceWhereInput; where?: Prisma.AuthDeviceWhereInput;
orderBy?: Prisma.AuthDeviceOrderByInput; orderBy?: Prisma.AuthDeviceOrderByWithRelationInput;
}): Promise<AuthDevice[]> { }): Promise<AuthDevice[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.authDevice.findMany({ return this.prismaService.authDevice.findMany({

3
apps/api/src/app/info/info.service.ts

@ -4,6 +4,7 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.se
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PROPERTY_STRIPE_CONFIG } from '@ghostfolio/common/config';
import { InfoItem } from '@ghostfolio/common/interfaces'; import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface'; import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface'; import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
@ -222,7 +223,7 @@ export class InfoService {
} }
const stripeConfig = await this.prismaService.property.findUnique({ const stripeConfig = await this.prismaService.property.findUnique({
where: { key: 'STRIPE_CONFIG' } where: { key: PROPERTY_STRIPE_CONFIG }
}); });
if (stripeConfig) { if (stripeConfig) {

14
apps/api/src/app/order/order.service.ts

@ -28,7 +28,7 @@ export class OrderService {
take?: number; take?: number;
cursor?: Prisma.OrderWhereUniqueInput; cursor?: Prisma.OrderWhereUniqueInput;
where?: Prisma.OrderWhereInput; where?: Prisma.OrderWhereInput;
orderBy?: Prisma.OrderOrderByInput; orderBy?: Prisma.OrderOrderByWithRelationInput;
}): Promise<OrderWithAccount[]> { }): Promise<OrderWithAccount[]> {
const { include, skip, take, cursor, where, orderBy } = params; const { include, skip, take, cursor, where, orderBy } = params;
@ -45,19 +45,22 @@ export class OrderService {
public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> { public async createOrder(data: Prisma.OrderCreateInput): Promise<Order> {
const isDraft = isAfter(data.date as Date, endOfToday()); const isDraft = isAfter(data.date as Date, endOfToday());
// Convert the symbol to uppercase to avoid case-sensitive duplicates
const symbol = data.symbol.toUpperCase();
if (!isDraft) { if (!isDraft) {
// Gather symbol data of order in the background, if not draft // Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([ this.dataGatheringService.gatherSymbols([
{ {
symbol,
dataSource: data.dataSource, dataSource: data.dataSource,
date: <Date>data.date, date: <Date>data.date
symbol: data.symbol
} }
]); ]);
} }
this.dataGatheringService.gatherProfileData([ this.dataGatheringService.gatherProfileData([
{ dataSource: data.dataSource, symbol: data.symbol } { symbol, dataSource: data.dataSource }
]); ]);
await this.cacheService.flush(); await this.cacheService.flush();
@ -65,7 +68,8 @@ export class OrderService {
return this.prismaService.order.create({ return this.prismaService.order.create({
data: { data: {
...data, ...data,
isDraft isDraft,
symbol
} }
}); });
} }

2
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -73,7 +73,7 @@ describe('CurrentRateService', () => {
beforeAll(async () => { beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null); dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null); exchangeRateDataService = new ExchangeRateDataService(null, null, null);
marketDataService = new MarketDataService(null); marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize(); await exchangeRateDataService.initialize();

2
apps/api/src/app/symbol/interfaces/symbol-item.interface.ts

@ -1,7 +1,9 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
export interface SymbolItem { export interface SymbolItem {
currency: string; currency: string;
dataSource: DataSource; dataSource: DataSource;
historicalData: HistoricalDataItem[];
marketPrice: number; marketPrice: number;
} }

11
apps/api/src/app/symbol/symbol.controller.ts

@ -1,10 +1,12 @@
import type { RequestWithUser } from '@ghostfolio/common/types'; import type { RequestWithUser } from '@ghostfolio/common/types';
import { import {
Controller, Controller,
DefaultValuePipe,
Get, Get,
HttpException, HttpException,
Inject, Inject,
Param, Param,
ParseBoolPipe,
Query, Query,
UseGuards UseGuards
} from '@nestjs/common'; } from '@nestjs/common';
@ -51,7 +53,9 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getSymbolData( public async getSymbolData(
@Param('dataSource') dataSource: DataSource, @Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string @Param('symbol') symbol: string,
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
includeHistoricalData: boolean
): Promise<SymbolItem> { ): Promise<SymbolItem> {
if (!DataSource[dataSource]) { if (!DataSource[dataSource]) {
throw new HttpException( throw new HttpException(
@ -60,7 +64,10 @@ export class SymbolController {
); );
} }
const result = await this.symbolService.get({ dataSource, symbol }); const result = await this.symbolService.get({
includeHistoricalData,
dataGatheringItem: { dataSource, symbol }
});
if (!result || isEmpty(result)) { if (!result || isEmpty(result)) {
throw new HttpException( throw new HttpException(

8
apps/api/src/app/symbol/symbol.module.ts

@ -1,5 +1,6 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -7,7 +8,12 @@ import { SymbolController } from './symbol.controller';
import { SymbolService } from './symbol.service'; import { SymbolService } from './symbol.service';
@Module({ @Module({
imports: [ConfigurationModule, DataProviderModule, PrismaModule], imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PrismaModule
],
controllers: [SymbolController], controllers: [SymbolController],
providers: [SymbolService] providers: [SymbolService]
}) })

31
apps/api/src/app/symbol/symbol.service.ts

@ -1,8 +1,11 @@
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service'; import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { subDays } from 'date-fns';
import { LookupItem } from './interfaces/lookup-item.interface'; import { LookupItem } from './interfaces/lookup-item.interface';
import { SymbolItem } from './interfaces/symbol-item.interface'; import { SymbolItem } from './interfaces/symbol-item.interface';
@ -11,16 +14,42 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
export class SymbolService { export class SymbolService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
public async get(dataGatheringItem: IDataGatheringItem): Promise<SymbolItem> { public async get({
dataGatheringItem,
includeHistoricalData = false
}: {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: boolean;
}): Promise<SymbolItem> {
const response = await this.dataProviderService.get([dataGatheringItem]); const response = await this.dataProviderService.get([dataGatheringItem]);
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {}; const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice) { if (dataGatheringItem.dataSource && marketPrice) {
let historicalData: HistoricalDataItem[];
if (includeHistoricalData) {
const days = 7;
const marketData = await this.marketDataService.getRange({
dateQuery: { gte: subDays(new Date(), days) },
symbols: [dataGatheringItem.symbol]
});
historicalData = marketData.map(({ date, marketPrice }) => {
return {
date: date.toISOString(),
value: marketPrice
};
});
}
return { return {
currency, currency,
historicalData,
marketPrice, marketPrice,
dataSource: dataGatheringItem.dataSource dataSource: dataGatheringItem.dataSource
}; };

2
apps/api/src/app/user/user.service.ts

@ -119,7 +119,7 @@ export class UserService {
take?: number; take?: number;
cursor?: Prisma.UserWhereUniqueInput; cursor?: Prisma.UserWhereUniqueInput;
where?: Prisma.UserWhereInput; where?: Prisma.UserWhereInput;
orderBy?: Prisma.UserOrderByInput; orderBy?: Prisma.UserOrderByWithRelationInput;
}): Promise<User[]> { }): Promise<User[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;
return this.prismaService.user.findMany({ return this.prismaService.user.findMany({

3
apps/api/src/services/configuration.service.ts

@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { bool, cleanEnv, host, json, num, port, str } from 'envalid'; import { bool, cleanEnv, host, json, num, port, str } from 'envalid';
import { environment } from '../environments/environment';
import { Environment } from './interfaces/environment.interface'; import { Environment } from './interfaces/environment.interface';
@Injectable() @Injectable()
@ -18,7 +17,7 @@ export class ConfigurationService {
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }), ENABLE_FEATURE_FEAR_AND_GREED_INDEX: bool({ default: false }),
ENABLE_FEATURE_IMPORT: bool({ default: !environment.production }), ENABLE_FEATURE_IMPORT: bool({ default: true }),
ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }), ENABLE_FEATURE_SOCIAL_LOGIN: bool({ default: false }),
ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }),
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),

45
apps/api/src/services/data-gathering.service.ts

@ -1,5 +1,9 @@
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config'; import {
PROPERTY_LAST_DATA_GATHERING,
PROPERTY_LOCKED_DATA_GATHERING,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper'; import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -43,7 +47,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -55,11 +59,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -67,7 +71,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -78,7 +82,7 @@ export class DataGatheringService {
public async gatherMax() { public async gatherMax() {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
@ -87,7 +91,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -99,11 +103,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -111,7 +115,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -128,7 +132,7 @@ export class DataGatheringService {
symbol: string; symbol: string;
}) { }) {
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
if (!isDataGatheringLocked) { if (!isDataGatheringLocked) {
@ -137,7 +141,7 @@ export class DataGatheringService {
await this.prismaService.property.create({ await this.prismaService.property.create({
data: { data: {
key: 'LOCKED_DATA_GATHERING', key: PROPERTY_LOCKED_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
} }
}); });
@ -156,11 +160,11 @@ export class DataGatheringService {
await this.prismaService.property.upsert({ await this.prismaService.property.upsert({
create: { create: {
key: 'LAST_DATA_GATHERING', key: PROPERTY_LAST_DATA_GATHERING,
value: new Date().toISOString() value: new Date().toISOString()
}, },
update: { value: new Date().toISOString() }, update: { value: new Date().toISOString() },
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);
@ -168,7 +172,7 @@ export class DataGatheringService {
await this.prismaService.property.delete({ await this.prismaService.property.delete({
where: { where: {
key: 'LOCKED_DATA_GATHERING' key: PROPERTY_LOCKED_DATA_GATHERING
} }
}); });
@ -351,13 +355,13 @@ export class DataGatheringService {
public async getIsInProgress() { public async getIsInProgress() {
return await this.prismaService.property.findUnique({ return await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
} }
public async getLastDataGathering() { public async getLastDataGathering() {
const lastDataGathering = await this.prismaService.property.findUnique({ const lastDataGathering = await this.prismaService.property.findUnique({
where: { key: 'LAST_DATA_GATHERING' } where: { key: PROPERTY_LAST_DATA_GATHERING }
}); });
if (lastDataGathering?.value) { if (lastDataGathering?.value) {
@ -418,7 +422,10 @@ export class DataGatheringService {
await this.prismaService.property.deleteMany({ await this.prismaService.property.deleteMany({
where: { where: {
OR: [{ key: 'LAST_DATA_GATHERING' }, { key: 'LOCKED_DATA_GATHERING' }] OR: [
{ key: PROPERTY_LAST_DATA_GATHERING },
{ key: PROPERTY_LOCKED_DATA_GATHERING }
]
} }
}); });
} }
@ -496,7 +503,7 @@ export class DataGatheringService {
const lastDataGathering = await this.getLastDataGathering(); const lastDataGathering = await this.getLastDataGathering();
const isDataGatheringLocked = await this.prismaService.property.findUnique({ const isDataGatheringLocked = await this.prismaService.property.findUnique({
where: { key: 'LOCKED_DATA_GATHERING' } where: { key: PROPERTY_LOCKED_DATA_GATHERING }
}); });
const diffInHours = differenceInHours(new Date(), lastDataGathering); const diffInHours = differenceInHours(new Date(), lastDataGathering);

6
apps/api/src/services/data-provider/data-provider.service.ts

@ -11,7 +11,7 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types'; import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
@Injectable() @Injectable()
@ -62,7 +62,7 @@ export class DataProviderService {
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse }; [symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
} = {}; } = {};
if (isEmpty(aItems)) { if (isEmpty(aItems) || !isValid(from) || !isValid(to)) {
return response; return response;
} }
@ -96,7 +96,7 @@ export class DataProviderService {
ORDER BY date;`; ORDER BY date;`;
const marketDataByGranularity: MarketData[] = const marketDataByGranularity: MarketData[] =
await this.prismaService.$queryRaw(queryRaw); await this.prismaService.$queryRawUnsafe(queryRaw);
response = marketDataByGranularity.reduce((r, marketData) => { response = marketDataByGranularity.reduce((r, marketData) => {
const { date, marketPrice, symbol } = marketData; const { date, marketPrice, symbol } = marketData;

3
apps/api/src/services/exchange-rate-data.module.ts

@ -3,9 +3,10 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module'; import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({ @Module({
imports: [DataProviderModule, PrismaModule], imports: [DataProviderModule, PrismaModule, PropertyModule],
providers: [ExchangeRateDataService], providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService] exports: [ExchangeRateDataService]
}) })

16
apps/api/src/services/exchange-rate-data.service.ts

@ -1,4 +1,4 @@
import { baseCurrency } from '@ghostfolio/common/config'; import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper'; import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns'; import { format } from 'date-fns';
@ -7,6 +7,7 @@ import { isEmpty, isNumber, uniq } from 'lodash';
import { DataProviderService } from './data-provider/data-provider.service'; import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces'; import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service'; import { PrismaService } from './prisma.service';
import { PropertyService } from './property/property.service';
@Injectable() @Injectable()
export class ExchangeRateDataService { export class ExchangeRateDataService {
@ -16,7 +17,8 @@ export class ExchangeRateDataService {
public constructor( public constructor(
private readonly dataProviderService: DataProviderService, private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
) { ) {
this.initialize(); this.initialize();
} }
@ -149,7 +151,7 @@ export class ExchangeRateDataService {
} }
private async prepareCurrencies(): Promise<string[]> { private async prepareCurrencies(): Promise<string[]> {
const currencies: string[] = []; let currencies: string[] = [];
( (
await this.prismaService.account.findMany({ await this.prismaService.account.findMany({
@ -181,6 +183,14 @@ export class ExchangeRateDataService {
currencies.push(symbolProfile.currency); currencies.push(symbolProfile.currency);
}); });
const customCurrencies = (await this.propertyService.getByKey(
PROPERTY_CURRENCIES
)) as string[];
if (customCurrencies?.length > 0) {
currencies = currencies.concat(customCurrencies);
}
return uniq(currencies).sort(); return uniq(currencies).sort();
} }

2
apps/api/src/services/market-data.service.ts

@ -53,7 +53,7 @@ export class MarketDataService {
take?: number; take?: number;
cursor?: Prisma.MarketDataWhereUniqueInput; cursor?: Prisma.MarketDataWhereUniqueInput;
where?: Prisma.MarketDataWhereInput; where?: Prisma.MarketDataWhereInput;
orderBy?: Prisma.MarketDataOrderByInput; orderBy?: Prisma.MarketDataOrderByWithRelationInput;
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
const { skip, take, cursor, where, orderBy } = params; const { skip, take, cursor, where, orderBy } = params;

6
apps/api/src/services/property/property.dto.ts

@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class PropertyDto {
@IsString()
value: string;
}

11
apps/api/src/services/property/property.module.ts

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

43
apps/api/src/services/property/property.service.ts

@ -0,0 +1,43 @@
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { Injectable } from '@nestjs/common';
@Injectable()
export class PropertyService {
public constructor(private readonly prismaService: PrismaService) {}
public async get() {
const response: {
[key: string]: object | string | string[];
} = {
[PROPERTY_CURRENCIES]: []
};
const properties = await this.prismaService.property.findMany();
for (const property of properties) {
let value = property.value;
try {
value = JSON.parse(property.value);
} catch {}
response[property.key] = value;
}
return response;
}
public async getByKey(aKey: string) {
const properties = await this.get();
return properties?.[aKey];
}
public async put({ key, value }: { key: string; value: string }) {
return this.prismaService.property.upsert({
create: { key, value },
update: { value },
where: { key }
});
}
}

7
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.html

@ -6,12 +6,15 @@
*ngFor="let dayItem of days; let i = index" *ngFor="let dayItem of days; let i = index"
class="day" class="day"
[title]=" [title]="
(marketDataByMonth[itemByMonth.key][i + 1]?.date (itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
| date: defaultDateFormat) ?? '' | date: defaultDateFormat) ?? ''
" "
[ngClass]="{ [ngClass]="{
valid: isDateOfInterest(
itemByMonth.key + '-' + (i + 1 < 10 ? '0' + (i + 1) : i + 1)
),
'available cursor-pointer': 'available cursor-pointer':
marketDataByMonth[itemByMonth.key][i + 1]?.day == i + 1 marketDataByMonth[itemByMonth.key][i + 1]?.day === i + 1
}" }"
(click)=" (click)="
marketDataByMonth[itemByMonth.key][i + 1] && marketDataByMonth[itemByMonth.key][i + 1] &&

5
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.scss

@ -10,11 +10,14 @@
} }
.day { .day {
background-color: var(--danger);
height: 0.5rem; height: 0.5rem;
margin-right: 0.25rem; margin-right: 0.25rem;
width: 0.5rem; width: 0.5rem;
&.valid {
background-color: var(--danger);
}
&.available { &.available {
background-color: var(--success); background-color: var(--success);
} }

9
apps/client/src/app/components/admin-market-data-detail/admin-market-data-detail.component.ts

@ -7,8 +7,9 @@ import {
} from '@angular/core'; } from '@angular/core';
import { MatDialog } from '@angular/material/dialog'; import { MatDialog } from '@angular/material/dialog';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { MarketData } from '@prisma/client'; import { MarketData } from '@prisma/client';
import { format } from 'date-fns'; import { format, isBefore, isValid, parse } from 'date-fns';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, takeUntil } from 'rxjs'; import { Subject, takeUntil } from 'rxjs';
@ -59,6 +60,12 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
} }
} }
public isDateOfInterest(aDateString: string) {
// Date is valid and in the past
const date = parse(aDateString, DATE_FORMAT, new Date());
return isValid(date) && isBefore(date, new Date());
}
public onOpenMarketDataDetail({ date, marketPrice, symbol }: MarketData) { public onOpenMarketDataDetail({ date, marketPrice, symbol }: MarketData) {
const dialogRef = this.dialog.open(MarketDataDetailDialog, { const dialogRef = this.dialog.open(MarketDataDetailDialog, {
data: { data: {

42
apps/client/src/app/components/admin-overview/admin-overview.component.ts

@ -3,7 +3,10 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service'; import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import {
DEFAULT_DATE_FORMAT,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { import {
differenceInSeconds, differenceInSeconds,
@ -11,6 +14,7 @@ import {
isValid, isValid,
parseISO parseISO
} from 'date-fns'; } from 'date-fns';
import { uniq } from 'lodash';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -20,6 +24,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html' templateUrl: './admin-overview.html'
}) })
export class AdminOverviewComponent implements OnDestroy, OnInit { export class AdminOverviewComponent implements OnDestroy, OnInit {
public customCurrencies: string[];
public dataGatheringInProgress: boolean; public dataGatheringInProgress: boolean;
public dataGatheringProgress: number; public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
@ -57,6 +62,26 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}); });
} }
public onAddCurrency() {
const currency = prompt('Please add a currency:');
if (currency) {
const currencies = uniq([...this.customCurrencies, currency]);
this.putCurrencies(currencies);
}
}
public onDeleteCurrency(aCurrency: string) {
const confirmation = confirm('Do you really want to delete this currency?');
if (confirmation) {
const currencies = this.customCurrencies.filter((currency) => {
return currency !== aCurrency;
});
this.putCurrencies(currencies);
}
}
public onFlushCache() { public onFlushCache() {
this.cacheService this.cacheService
.flush() .flush()
@ -121,9 +146,11 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
dataGatheringProgress, dataGatheringProgress,
exchangeRates, exchangeRates,
lastDataGathering, lastDataGathering,
settings,
transactionCount, transactionCount,
userCount userCount
}) => { }) => {
this.customCurrencies = settings[PROPERTY_CURRENCIES] as string[];
this.dataGatheringProgress = dataGatheringProgress; this.dataGatheringProgress = dataGatheringProgress;
this.exchangeRates = exchangeRates; this.exchangeRates = exchangeRates;
@ -147,4 +174,17 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
} }
); );
} }
private putCurrencies(aCurrencies: string[]) {
this.dataService
.putAdminSetting(PROPERTY_CURRENCIES, {
value: JSON.stringify(aCurrencies)
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
setTimeout(() => {
window.location.reload();
}, 300);
});
}
} }

94
apps/client/src/app/components/admin-overview/admin-overview.html

@ -3,32 +3,17 @@
<div class="col"> <div class="col">
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-content> <mat-card-content>
<div <div class="d-flex my-3">
*ngIf="exchangeRates?.length > 0" <div class="w-50" i18n>User Count</div>
class="align-items-start d-flex my-3" <div class="w-50">{{ userCount }}</div>
> </div>
<div class="w-50" i18n>Exchange Rates</div> <div class="d-flex my-3">
<div class="w-50" i18n>Transaction Count</div>
<div class="w-50"> <div class="w-50">
<table> <ng-container *ngIf="transactionCount">
<tr *ngFor="let exchangeRate of exchangeRates"> {{ transactionCount }} ({{ transactionCount / userCount | number
<td class="d-flex"> : '1.2-2' }} <span i18n>per User</span>)
<gf-value </ng-container>
[locale]="user?.settings?.locale"
[value]="1"
></gf-value>
</td>
<td class="pl-1">{{ exchangeRate.label1 }}</td>
<td class="px-1">=</td>
<td class="d-flex justify-content-end">
<gf-value
[locale]="user?.settings?.locale"
[precision]="4"
[value]="exchangeRate.value"
></gf-value>
</td>
<td class="pl-1">{{ exchangeRate.label2 }}</td>
</tr>
</table>
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="d-flex my-3">
@ -46,7 +31,6 @@
<div class="mt-2 overflow-hidden"> <div class="mt-2 overflow-hidden">
<div class="mb-2"> <div class="mb-2">
<button <button
class="mw-100"
color="accent" color="accent"
mat-flat-button mat-flat-button
(click)="onFlushCache()" (click)="onFlushCache()"
@ -60,7 +44,6 @@
</div> </div>
<div class="mb-2"> <div class="mb-2">
<button <button
class="mw-100"
color="warn" color="warn"
mat-flat-button mat-flat-button
[disabled]="dataGatheringInProgress" [disabled]="dataGatheringInProgress"
@ -72,9 +55,10 @@
</div> </div>
<div> <div>
<button <button
class="mb-2 mr-2 mw-100" class="mb-2 mr-2"
color="accent" color="accent"
mat-flat-button mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onGatherProfileData()" (click)="onGatherProfileData()"
> >
<ion-icon <ion-icon
@ -87,17 +71,51 @@
</div> </div>
</div> </div>
</div> </div>
<div class="d-flex my-3"> <div class="align-items-start d-flex my-3">
<div class="w-50" i18n>User Count</div> <div class="w-50" i18n>Exchange Rates</div>
<div class="w-50">{{ userCount }}</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Transaction Count</div>
<div class="w-50"> <div class="w-50">
<ng-container *ngIf="transactionCount"> <table>
{{ transactionCount }} ({{ transactionCount / userCount | number <tr *ngFor="let exchangeRate of exchangeRates">
: '1.2-2' }} <span i18n>per User</span>) <td class="d-flex">
</ng-container> <gf-value
[locale]="user?.settings?.locale"
[value]="1"
></gf-value>
</td>
<td class="pl-1">{{ exchangeRate.label1 }}</td>
<td class="px-1">=</td>
<td class="d-flex justify-content-end">
<gf-value
[locale]="user?.settings?.locale"
[precision]="4"
[value]="exchangeRate.value"
></gf-value>
</td>
<td class="pl-1">{{ exchangeRate.label2 }}</td>
<td>
<button
*ngIf="customCurrencies.includes(exchangeRate.label2)"
class="mini-icon mx-1 no-min-width px-2"
mat-button
[disabled]="dataGatheringInProgress"
(click)="onDeleteCurrency(exchangeRate.label2)"
>
<ion-icon name="trash-outline"></ion-icon>
</button>
</td>
</tr>
</table>
<div class="mt-2">
<button
color="primary"
mat-flat-button
[disabled]="dataGatheringInProgress"
(click)="onAddCurrency()"
>
<ion-icon class="mr-1" name="add-outline"></ion-icon>
<span i18n>Add Currency</span>
</button>
</div>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>

6
apps/client/src/app/components/admin-overview/admin-overview.scss

@ -3,6 +3,12 @@
:host { :host {
display: block; display: block;
.mat-button {
&.mini-icon {
line-height: 1.5;
}
}
.mat-flat-button { .mat-flat-button {
::ng-deep { ::ng-deep {
.mat-button-wrapper { .mat-button-wrapper {

6
apps/client/src/app/components/fear-and-greed-index/fear-and-greed-index.component.html

@ -1,13 +1,13 @@
<div class="align-items-center d-flex flex-row"> <div class="align-items-center d-flex flex-row">
<div class="h3 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div> <div class="h2 mb-0 mr-2">{{ fearAndGreedIndexEmoji }}</div>
<div> <div>
<div class="h3 mb-0"> <div class="h4 mb-0">
<span class="mr-2">{{ fearAndGreedIndexText }}</span> <span class="mr-2">{{ fearAndGreedIndexText }}</span>
<small class="text-muted" <small class="text-muted"
><strong>{{ fearAndGreedIndex }}</strong ><strong>{{ fearAndGreedIndex }}</strong
>/100</small >/100</small
> >
</div> </div>
<small class="d-block" i18n>Market Mood</small> <small class="d-block" i18n>Current Market Mood</small>
</div> </div>
</div> </div>

13
apps/client/src/app/components/home-market/home-market.component.ts

@ -1,7 +1,9 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { HistoricalDataItem } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.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 { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces'; import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource } from '@prisma/client'; import { DataSource } from '@prisma/client';
@ -16,6 +18,7 @@ import { takeUntil } from 'rxjs/operators';
export class HomeMarketComponent implements OnDestroy, OnInit { export class HomeMarketComponent implements OnDestroy, OnInit {
public fearAndGreedIndex: number; public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean; public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
public isLoading = true; public isLoading = true;
public user: User; public user: User;
@ -46,11 +49,19 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchSymbolItem({ .fetchSymbolItem({
dataSource: DataSource.RAKUTEN, dataSource: DataSource.RAKUTEN,
includeHistoricalData: true,
symbol: ghostfolioFearAndGreedIndexSymbol symbol: ghostfolioFearAndGreedIndexSymbol
}) })
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => { .subscribe(({ historicalData, marketPrice }) => {
this.fearAndGreedIndex = marketPrice; this.fearAndGreedIndex = marketPrice;
this.historicalData = [
...historicalData,
{
date: resetHours(new Date()).toISOString(),
value: marketPrice
}
];
this.isLoading = false; this.isLoading = false;
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();

29
apps/client/src/app/components/home-market/home-market.html

@ -9,17 +9,26 @@
w-100 w-100
" "
> >
<div class="row w-100"> <div class="no-gutters row w-100">
<div class="col-xs-12 col-md-8 offset-md-2"> <div class="col-xs-12 col-md-8 offset-md-2">
<mat-card class="h-100"> <div class="mb-2 text-center text-muted">
<mat-card-content> <small i18n>Last 7 Days</small>
<gf-fear-and-greed-index </div>
class="d-flex justify-content-center" <gf-line-chart
[fearAndGreedIndex]="fearAndGreedIndex" class="mb-5"
[hidden]="isLoading" yMax="100"
></gf-fear-and-greed-index> yMaxLabel="Greed"
</mat-card-content> yMin="0"
</mat-card> yMinLabel="Fear"
[historicalDataItems]="historicalData"
[showXAxis]="true"
[showYAxis]="true"
></gf-line-chart>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
[hidden]="isLoading"
></gf-fear-and-greed-index>
</div> </div>
</div> </div>
</div> </div>

4
apps/client/src/app/components/home-market/home-market.module.ts

@ -1,14 +1,14 @@
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module'; import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { HomeMarketComponent } from './home-market.component'; import { HomeMarketComponent } from './home-market.component';
@NgModule({ @NgModule({
declarations: [HomeMarketComponent], declarations: [HomeMarketComponent],
exports: [], exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, MatCardModule], imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })

4
apps/client/src/app/components/home-market/home-market.scss

@ -2,4 +2,8 @@
:host { :host {
display: block; display: block;
gf-line-chart {
aspect-ratio: 16 / 9;
}
} }

12
apps/client/src/app/services/data.service.ts

@ -12,6 +12,7 @@ import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.in
import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface'; import { UserItem } from '@ghostfolio/api/app/user/interfaces/user-item.interface';
import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto'; import { UpdateUserSettingDto } from '@ghostfolio/api/app/user/update-user-setting.dto';
import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-settings.dto'; import { UpdateUserSettingsDto } from '@ghostfolio/api/app/user/update-user-settings.dto';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import { import {
Access, Access,
Accounts, Accounts,
@ -127,12 +128,16 @@ export class DataService {
public fetchSymbolItem({ public fetchSymbolItem({
dataSource, dataSource,
includeHistoricalData = false,
symbol symbol
}: { }: {
dataSource: DataSource; dataSource: DataSource;
includeHistoricalData?: boolean;
symbol: string; symbol: string;
}) { }) {
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`); return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`, {
params: { includeHistoricalData }
});
} }
public fetchPositions({ public fetchPositions({
@ -235,6 +240,11 @@ export class DataService {
return this.http.put<UserItem>(`/api/account/${aAccount.id}`, aAccount); return this.http.put<UserItem>(`/api/account/${aAccount.id}`, aAccount);
} }
public putAdminSetting(key: string, aData: PropertyDto) {
console.log(key, aData);
return this.http.put<void>(`/api/admin/settings/${key}`, aData);
}
public putOrder(aOrder: UpdateOrderDto) { public putOrder(aOrder: UpdateOrderDto) {
return this.http.put<UserItem>(`/api/order/${aOrder.id}`, aOrder); return this.http.put<UserItem>(`/api/order/${aOrder.id}`, aOrder);
} }

5
libs/common/src/lib/config.ts

@ -30,4 +30,9 @@ export const warnColorRgb = {
export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy'; export const DEFAULT_DATE_FORMAT = 'dd.MM.yyyy';
export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy'; export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_LAST_DATA_GATHERING = 'LAST_DATA_GATHERING';
export const PROPERTY_LOCKED_DATA_GATHERING = 'LOCKED_DATA_GATHERING';
export const PROPERTY_STRIPE_CONFIG = 'STRIPE_CONFIG';
export const UNKNOWN_KEY = 'UNKNOWN'; export const UNKNOWN_KEY = 'UNKNOWN';

3
libs/common/src/lib/interfaces/admin-data.interface.ts

@ -1,7 +1,10 @@
import { Property } from '@prisma/client';
export interface AdminData { export interface AdminData {
dataGatheringProgress?: number; dataGatheringProgress?: number;
exchangeRates: { label1: string; label2: string; value: number }[]; exchangeRates: { label1: string; label2: string; value: number }[];
lastDataGathering?: Date | 'IN_PROGRESS'; lastDataGathering?: Date | 'IN_PROGRESS';
settings: { [key: string]: object | string | string[] };
transactionCount: number; transactionCount: number;
userCount: number; userCount: number;
users: { users: {

17
libs/ui/src/lib/line-chart/line-chart.component.ts

@ -43,6 +43,10 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
@Input() showXAxis = false; @Input() showXAxis = false;
@Input() showYAxis = false; @Input() showYAxis = false;
@Input() symbol: string; @Input() symbol: string;
@Input() yMax: number;
@Input() yMaxLabel: string;
@Input() yMin: number;
@Input() yMinLabel: string;
@ViewChild('chartCanvas') chartCanvas; @ViewChild('chartCanvas') chartCanvas;
@ -170,11 +174,22 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
grid: { grid: {
display: false display: false
}, },
max: this.yMax,
min: this.yMin,
ticks: { ticks: {
display: this.showYAxis, display: this.showYAxis,
callback: function (tickValue, index, ticks) { callback: (tickValue, index, ticks) => {
if (index === 0 || index === ticks.length - 1) { if (index === 0 || index === ticks.length - 1) {
// Only print last and first legend entry // Only print last and first legend entry
if (index === 0 && this.yMinLabel) {
return this.yMinLabel;
}
if (index === ticks.length - 1 && this.yMaxLabel) {
return this.yMaxLabel;
}
if (typeof tickValue === 'number') { if (typeof tickValue === 'number') {
return tickValue.toFixed(2); return tickValue.toFixed(2);
} }

6
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.85.0", "version": "1.86.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -69,7 +69,7 @@
"@nestjs/schedule": "1.0.2", "@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2", "@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "13.2.2", "@nrwl/angular": "13.2.2",
"@prisma/client": "2.30.2", "@prisma/client": "3.6.0",
"@simplewebauthn/browser": "4.1.0", "@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0", "@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0", "@simplewebauthn/typescript-types": "4.0.0",
@ -105,7 +105,6 @@
"passport": "0.4.1", "passport": "0.4.1",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
"prisma": "2.30.2",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"round-to": "5.0.0", "round-to": "5.0.0",
"rxjs": "7.4.0", "rxjs": "7.4.0",
@ -162,6 +161,7 @@
"jest": "27.2.3", "jest": "27.2.3",
"jest-preset-angular": "11.0.0", "jest-preset-angular": "11.0.0",
"prettier": "2.3.2", "prettier": "2.3.2",
"prisma": "3.6.0",
"replace-in-file": "6.2.0", "replace-in-file": "6.2.0",
"rimraf": "3.0.2", "rimraf": "3.0.2",
"ts-jest": "27.0.5", "ts-jest": "27.0.5",

36
yarn.lock

@ -2904,22 +2904,22 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw== integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
"@prisma/client@2.30.2": "@prisma/client@3.6.0":
version "2.30.2" version "3.6.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-2.30.2.tgz#4e7f90f27a176d95769609e65932a4b95aa8da3f" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.6.0.tgz#68a60cd4c73a369b11f72e173e86fd6789939293"
integrity sha512-cfS/1UWBRE4sq0z7spGLYqOE0oN2YJJvKtm+63o7h/BI6ji10VWla79aXBTXj3AImtfykNtC6OHtFVawEl/49w== integrity sha512-ycSGY9EZGROtje0iCNsgC5Zqi/ttX2sO7BNMYaLsUMiTlf3F69ZPH+08pRo0hrDfkZzyimXYqeXJlaoYDH1w7A==
dependencies: dependencies:
"@prisma/engines-version" "2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20" "@prisma/engines-version" "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
"@prisma/engines-version@2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20": "@prisma/engines-version@3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727":
version "2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20" version "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20.tgz#d5ef55c92beeba56e52bba12b703af0bfd30530d" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz#25aa447776849a774885866b998732b37ec4f4f5"
integrity sha512-/iDRgaoSQC77WN2oDsOM8dn61fykm6tnZUAClY+6p+XJbOEgZ9gy4CKuKTBgrjSGDVjtQ/S2KGcYd3Ring8xaw== integrity sha512-vtoO2ys6mSfc8ONTWdcYztKN3GBU1tcKBj0aXObyjzSuGwHFcM/pEA0xF+n1W4/0TAJgfoPX2khNEit6g0jtNA==
"@prisma/engines@2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20": "@prisma/engines@3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727":
version "2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20" version "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20.tgz#2df768aa7c9f84acaa1f35c970417822233a9fb1" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727.tgz#c68ede6aeffa9ef7743a32cfa6daf9172a4e15b3"
integrity sha512-WPnA/IUrxDihrRhdP6+8KAVSwsc0zsh8ioPYsLJjOhzVhwpRbuFH2tJDRIAbc+qFh+BbTIZbeyBYt8fpNXaYQQ== integrity sha512-dRClHS7DsTVchDKzeG72OaEyeDskCv91pnZ72Fftn0mp4BkUvX2LvWup65hCNzwwQm5IDd6A88APldKDnMiEMA==
"@samverschueren/stream-to-observable@^0.3.0": "@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1" version "0.3.1"
@ -14431,12 +14431,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
prisma@2.30.2: prisma@3.6.0:
version "2.30.2" version "3.6.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-2.30.2.tgz#f65ea0a04ff642e1e393d5b42c804bdcb099a465" resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.6.0.tgz#99532abc02e045e58c6133a19771bdeb28cecdbe"
integrity sha512-AoLz3CfD7XM+vfE7IWzxQq1O2+ZZ31+6yfabWeMr32wxdLLxSsGagW80LFgKIsZgWW6Ypwleu5ujyKNvO91WHA== integrity sha512-6SqgHS/5Rq6HtHjsWsTxlj+ySamGyCLBUQfotc2lStOjPv52IQuDVpp58GieNqc9VnfuFyHUvTZw7aQB+G2fvQ==
dependencies: dependencies:
"@prisma/engines" "2.30.1-2.b8c35d44de987a9691890b3ddf3e2e7effb9bf20" "@prisma/engines" "3.6.0-24.dc520b92b1ebb2d28dc3161f9f82e875bd35d727"
prismjs@^1.21.0, prismjs@^1.23.0, prismjs@~1.24.0: prismjs@^1.21.0, prismjs@^1.23.0, prismjs@~1.24.0:
version "1.24.1" version "1.24.1"

Loading…
Cancel
Save