Compare commits

...

15 Commits

Author SHA1 Message Date
5422df05b3 Release 1.75.0 (#469) 2021-11-13 20:50:02 +01:00
d2fabe7ce4 Feature/add value column to accounts table (#468)
* Add value column

* Update changelog
2021-11-13 20:38:29 +01:00
a42700b9fe Feature/introduce data gathering progress (#467)
* Add data gathering progress

* Update changelog
2021-11-13 11:32:28 +01:00
9df8541145 Feature/log logo on server start (#466)
* Log logo on server start

* Update changelog
2021-11-12 22:50:40 +01:00
0b2252755c Release 1.74.0 (#464) 2021-11-11 21:52:57 +01:00
239bd09cbd Feature/move market mood to tab (#463)
* Move market mood to tab

* Update changelog
2021-11-11 21:43:17 +01:00
cd76f89902 Feature/increase decimal places for cryptocurrencies (#462)
* Calculate quantity precision

* Update changelog
2021-11-11 21:21:37 +01:00
7425ba94f1 Release 1.73.0 (#461) 2021-11-10 21:16:06 +01:00
b9522307c4 Feature/various client improvements (#460)
* Various improvements
  * info messages
  * skeleton loader of portfolio holdings

* Update changelog
2021-11-10 21:03:25 +01:00
e01e039a00 Release 1.72.0 (#459) 2021-11-08 20:55:38 +01:00
6d3513c17f Update version 2021-11-07 21:27:37 +01:00
d60b444324 Release/1.71.0 (#458)
* Refactor logger

* Fix exception

* Update changelog
2021-11-07 21:25:18 +01:00
2873130259 Release 1.70.0 (#457) 2021-11-07 18:38:29 +01:00
d999a27159 Feature/move scraper configuration to symbol profile (#456)
* Move scraper configuration

* Update changelog
2021-11-07 18:36:28 +01:00
b6902e10ea Feature/improve import json file validation (#455)
* Improve import validation

* Update changelog
2021-11-07 17:16:19 +01:00
63 changed files with 515 additions and 329 deletions

3
.vscode/launch.json vendored
View File

@ -26,7 +26,8 @@
"skipFiles": [
"${workspaceFolder}/node_modules/**/*.js",
"<node_internals>/**/*.js"
]
],
"console": "integratedTerminal"
}
]
}

View File

@ -5,6 +5,58 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.75.0 - 13.11.2021
### Added
- Added a logo to the log on the server start
- Added the data gathering progress to the log and the admin control panel
- Added the value column to the accounts table
## 1.74.0 - 11.11.2021
### Changed
- Adapted the decimal places for cryptocurrencies in the position detail dialog
- Moved the _Fear & Greed Index_ (market mood) to a new tab on the home page
## 1.73.0 - 10.11.2021
### Changed
- Improved the info messages to add the first transaction
### Fixed
- Fixed the skeleton loader of the portfolio holdings
## 1.72.0 - 08.11.2021
### Changed
- Cached the statistics section on the about page
## 1.71.0 - 07.11.2021
### Changed
- Changed the logger from `console.log()` to `Logger.log()`
### Fixed
- Fixed an exception in the scraper configuration
## 1.70.0 - 07.11.2021
### Changed
- Improved the validation of `json` files in the import functionality for transactions
- Moved the scraper configuration to the symbol profile model
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.69.0 - 07.11.2021
### Added

View File

@ -1,3 +1,4 @@
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
@ -6,7 +7,10 @@ import {
hasPermission,
permissions
} from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type {
AccountWithValue,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -34,6 +38,7 @@ export class AccountController {
public constructor(
private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
@ -85,14 +90,14 @@ export class AccountController {
@UseGuards(AuthGuard('jwt'))
public async getAllAccounts(
@Headers('impersonation-id') impersonationId
): Promise<AccountModel[]> {
): Promise<AccountWithValue[]> {
const impersonationUserId =
await this.impersonationService.validateImpersonationId(
impersonationId,
this.request.user.id
);
let accounts = await this.accountService.getAccounts(
let accounts = await this.portfolioService.getAccounts(
impersonationUserId || this.request.user.id
);
@ -102,9 +107,11 @@ export class AccountController {
) {
accounts = nullifyValuesInObjects(accounts, [
'balance',
'convertedBalance',
'fee',
'quantity',
'unitPrice'
'unitPrice',
'value'
]);
}

View File

@ -1,3 +1,4 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
@ -11,16 +12,17 @@ import { AccountController } from './account.controller';
import { AccountService } from './account.service';
@Module({
controllers: [AccountController],
imports: [
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule,
RedisCacheModule,
PortfolioModule,
PrismaModule,
RedisCacheModule,
UserModule
],
controllers: [AccountController],
providers: [AccountService]
})
export class AccountModule {}

View File

@ -20,6 +20,8 @@ export class AdminService {
public async get(): Promise<AdminData> {
return {
dataGatheringProgress:
await this.dataGatheringService.getDataGatheringProgress(),
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
@ -58,7 +60,7 @@ export class AdminService {
return 'IN_PROGRESS';
}
return null;
return undefined;
}
private async getUsersWithAnalytics(): Promise<AdminData['users']> {

View File

@ -1,5 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Provider } from '@prisma/client';
import { Strategy } from 'passport-google-oauth20';
@ -41,9 +41,9 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
};
done(null, user);
} catch (err) {
console.error(err);
done(err, false);
} catch (error) {
Logger.error(error);
done(error, false);
}
}
}

View File

@ -6,7 +6,8 @@ import type { RequestWithUser } from '@ghostfolio/common/types';
import {
Inject,
Injectable,
InternalServerErrorException
InternalServerErrorException,
Logger
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt';
@ -94,7 +95,7 @@ export class WebAuthService {
};
verification = await verifyRegistrationResponse(opts);
} catch (error) {
console.error(error);
Logger.error(error);
throw new InternalServerErrorException(error.message);
}
@ -192,7 +193,7 @@ export class WebAuthService {
};
verification = verifyAuthenticationResponse(opts);
} catch (error) {
console.error(error);
Logger.error(error);
throw new InternalServerErrorException({ error: error.message });
}

View File

@ -5,6 +5,7 @@ import {
Controller,
HttpException,
Inject,
Logger,
Post,
UseGuards
} from '@nestjs/common';
@ -39,7 +40,7 @@ export class ImportController {
userId: this.request.user.id
});
} catch (error) {
console.error(error);
Logger.error(error);
throw new HttpException(
{

View File

@ -1,3 +1,4 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
@ -20,6 +21,7 @@ import { InfoService } from './info.service';
secret: process.env.JWT_SECRET_KEY,
signOptions: { expiresIn: '30 days' }
}),
RedisCacheModule,
SymbolProfileModule
],
controllers: [InfoController],

View File

@ -1,12 +1,14 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { InfoItem } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { Subscription } from '@ghostfolio/common/interfaces/subscription.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import * as bent from 'bent';
import { subDays } from 'date-fns';
@ -14,6 +16,7 @@ import { subDays } from 'date-fns';
@Injectable()
export class InfoService {
private static DEMO_USER_ID = '9b112b4d-3b7d-4bad-9bdd-3b0f7b4dac2f';
private static CACHE_KEY_STATISTICS = 'STATISTICS';
public constructor(
private readonly configurationService: ConfigurationService,
@ -21,7 +24,8 @@ export class InfoService {
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService,
private readonly jwtService: JwtService,
private readonly prismaService: PrismaService
private readonly prismaService: PrismaService,
private readonly redisCacheService: RedisCacheService
) {}
public async get(): Promise<InfoItem> {
@ -109,7 +113,7 @@ export class InfoService {
const contributors = await get();
return contributors?.length;
} catch (error) {
console.error(error);
Logger.error(error);
return undefined;
}
@ -130,7 +134,7 @@ export class InfoService {
const { stargazers_count } = await get();
return stargazers_count;
} catch (error) {
console.error(error);
Logger.error(error);
return undefined;
}
@ -176,6 +180,18 @@ export class InfoService {
return undefined;
}
let statistics: Statistics;
try {
statistics = JSON.parse(
await this.redisCacheService.get(InfoService.CACHE_KEY_STATISTICS)
);
if (statistics) {
return statistics;
}
} catch {}
const activeUsers1d = await this.countActiveUsers(1);
const activeUsers7d = await this.countActiveUsers(7);
const activeUsers30d = await this.countActiveUsers(30);
@ -183,7 +199,7 @@ export class InfoService {
const gitHubContributors = await this.countGitHubContributors();
const gitHubStargazers = await this.countGitHubStargazers();
return {
statistics = {
activeUsers1d,
activeUsers7d,
activeUsers30d,
@ -191,6 +207,13 @@ export class InfoService {
gitHubStargazers,
newUsers30d
};
await this.redisCacheService.set(
InfoService.CACHE_KEY_STATISTICS,
JSON.stringify(statistics)
);
return statistics;
}
private async getSubscriptions(): Promise<Subscription[]> {

View File

@ -1,4 +1,8 @@
import { AssetClass, AssetSubClass } from '@prisma/client';
export interface PortfolioPositionDetail {
assetClass?: AssetClass;
assetSubClass?: AssetSubClass;
averagePrice: number;
currency: string;
firstBuyDate: string;

View File

@ -2,6 +2,7 @@ import { OrderType } from '@ghostfolio/api/models/order-type';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import Big from 'big.js';
import {
addDays,
@ -236,7 +237,7 @@ export class PortfolioCalculator {
if (!marketSymbolMap[nextDate]?.[item.symbol]) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
Logger.error(
`Missing value for symbol ${item.symbol} at ${nextDate}`
);
continue;
@ -269,7 +270,7 @@ export class PortfolioCalculator {
if (!initialValue) {
invalidSymbols.push(item.symbol);
hasErrors = true;
console.error(
Logger.error(
`Missing value for symbol ${item.symbol} at ${currentDate}`
);
continue;
@ -480,7 +481,7 @@ export class PortfolioCalculator {
currentPosition.netPerformancePercentage.mul(currentInitialValue)
);
} else if (!currentPosition.quantity.eq(0)) {
console.error(
Logger.error(
`Initial value is missing for symbol ${currentPosition.symbol}`
);
hasErrors = true;
@ -546,7 +547,7 @@ export class PortfolioCalculator {
userCurrency: this.currency
});
} catch (error) {
console.error(
Logger.error(
`Failed to fetch info for date ${startDate} with exception`,
error
);

View File

@ -18,6 +18,7 @@ import { PortfolioService } from './portfolio.service';
import { RulesService } from './rules.service';
@Module({
exports: [PortfolioService],
imports: [
AccessModule,
ConfigurationModule,

View File

@ -1,5 +1,3 @@
// TODO ///////////
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CashDetails } from '@ghostfolio/api/app/account/interfaces/cash-details.interface';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
@ -39,6 +37,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import type {
AccountWithValue,
DateRange,
OrderWithAccount,
RequestWithUser
@ -81,6 +80,36 @@ export class PortfolioService {
private readonly symbolProfileService: SymbolProfileService
) {}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
this.accountService.accounts({
include: { Order: true, Platform: true },
orderBy: { name: 'asc' },
where: { userId: aUserId }
}),
this.getDetails(aUserId, aUserId)
]);
const userCurrency = this.request.user.Settings.currency;
return accounts.map((account) => {
const result = {
...account,
convertedBalance: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
transactionCount: account.Order.length,
value: details.accounts[account.name].current
};
delete result.Order;
return result;
});
}
public async getInvestments(
aImpersonationId: string
): Promise<InvestmentItem[]> {
@ -258,7 +287,7 @@ export class PortfolioService {
value: totalValue
});
const accounts = await this.getAccounts(
const accounts = await this.getValueOfAccounts(
orders,
portfolioItemsNow,
userCurrency,
@ -299,6 +328,8 @@ export class PortfolioService {
};
}
const assetClass = orders[0].SymbolProfile?.assetClass;
const assetSubClass = orders[0].SymbolProfile?.assetSubClass;
const positionCurrency = orders[0].currency;
const name = orders[0].SymbolProfile?.name ?? '';
@ -412,6 +443,8 @@ export class PortfolioService {
}
return {
assetClass,
assetSubClass,
currency,
firstBuyDate,
grossPerformance,
@ -467,6 +500,8 @@ export class PortfolioService {
}
return {
assetClass,
assetSubClass,
marketPrice,
maxPrice,
minPrice,
@ -613,7 +648,7 @@ export class PortfolioService {
currentGrossPerformancePercent,
currentNetPerformance,
currentNetPerformancePercent,
currentValue: currentValue
currentValue
}
};
}
@ -663,7 +698,7 @@ export class PortfolioService {
for (const position of currentPositions.positions) {
portfolioItemsNow[position.symbol] = position;
}
const accounts = await this.getAccounts(
const accounts = await this.getValueOfAccounts(
orders,
portfolioItemsNow,
currency,
@ -863,7 +898,7 @@ export class PortfolioService {
};
}
private async getAccounts(
private async getValueOfAccounts(
orders: OrderWithAccount[],
portfolioItemsNow: { [p: string]: TimelinePosition },
userCurrency: string,
@ -878,20 +913,15 @@ export class PortfolioService {
return accountId === account.id;
});
if (ordersByAccount.length <= 0) {
// Add account without orders
const balance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.name] = {
current: balance,
original: balance
};
continue;
}
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.name] = {
current: convertedBalance,
original: convertedBalance
};
for (const order of ordersByAccount) {
let currentValueOfSymbol = this.exchangeRateDataService.toCurrency(

View File

@ -21,9 +21,9 @@ export class RedisCacheService {
await this.cache.reset();
}
public async set(key: string, value: string) {
public async set(key: string, value: string, ttlInSeconds?: number) {
await this.cache.set(key, value, {
ttl: this.configurationService.get('CACHE_TTL')
ttl: ttlInSeconds ?? this.configurationService.get('CACHE_TTL')
});
}
}

View File

@ -6,6 +6,7 @@ import {
Get,
HttpException,
Inject,
Logger,
Post,
Req,
Res,
@ -46,7 +47,7 @@ export class SubscriptionController {
userId: this.request.user.id
});
} catch (error) {
console.error(error);
Logger.error(error);
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),

View File

@ -1,7 +1,7 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import Stripe from 'stripe';
@ -85,7 +85,7 @@ export class SubscriptionService {
description: session.client_reference_id
});
} catch (error) {
console.error(error);
Logger.error(error);
}
}

View File

@ -1,7 +1,7 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { LookupItem } from './interfaces/lookup-item.interface';
@ -67,7 +67,7 @@ export class SymbolService {
return results;
} catch (error) {
console.error(error);
Logger.error(error);
throw error;
}

View File

@ -2,6 +2,7 @@ import { Logger, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
@ -18,8 +19,23 @@ async function bootstrap() {
const port = process.env.PORT || 3333;
await app.listen(port, () => {
Logger.log(`Listening at http://localhost:${port}`);
logLogo();
Logger.log(`Listening at http://localhost:${port}`, '', false);
Logger.log('', '', false);
});
}
function logLogo() {
Logger.log(' ________ __ ____ ___', '', false);
Logger.log(' / ____/ /_ ____ _____/ /_/ __/___ / (_)___', '', false);
Logger.log(' / / __/ __ \\/ __ \\/ ___/ __/ /_/ __ \\/ / / __ \\', '', false);
Logger.log('/ /_/ / / / / /_/ (__ ) /_/ __/ /_/ / / / /_/ /', '', false);
Logger.log(
`\\____/_/ /_/\\____/____/\\__/_/ \\____/_/_/\\____/ ${environment.version}`,
'',
false
);
Logger.log('', '', false);
}
bootstrap();

View File

@ -4,7 +4,7 @@ import {
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import {
differenceInHours,
@ -18,7 +18,6 @@ import {
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { GhostfolioScraperApiService } from './data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
import { DataEnhancerInterface } from './data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from './exchange-rate-data.service';
import { IDataGatheringItem } from './interfaces/interfaces';
@ -26,13 +25,14 @@ import { PrismaService } from './prisma.service';
@Injectable()
export class DataGatheringService {
private dataGatheringProgress: number;
public constructor(
private readonly configurationService: ConfigurationService,
@Inject('DataEnhancers')
private readonly dataEnhancers: DataEnhancerInterface[],
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly ghostfolioScraperApi: GhostfolioScraperApiService,
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
@ -41,7 +41,7 @@ export class DataGatheringService {
const isDataGatheringNeeded = await this.isDataGatheringNeeded();
if (isDataGatheringNeeded) {
console.log('7d data gathering has been started.');
Logger.log('7d data gathering has been started.');
console.time('data-gathering-7d');
await this.prismaService.property.create({
@ -65,7 +65,7 @@ export class DataGatheringService {
where: { key: 'LAST_DATA_GATHERING' }
});
} catch (error) {
console.error(error);
Logger.error(error);
}
await this.prismaService.property.delete({
@ -74,7 +74,7 @@ export class DataGatheringService {
}
});
console.log('7d data gathering has been completed.');
Logger.log('7d data gathering has been completed.');
console.timeEnd('data-gathering-7d');
}
}
@ -85,7 +85,7 @@ export class DataGatheringService {
});
if (!isDataGatheringLocked) {
console.log('Max data gathering has been started.');
Logger.log('Max data gathering has been started.');
console.time('data-gathering-max');
await this.prismaService.property.create({
@ -109,7 +109,7 @@ export class DataGatheringService {
where: { key: 'LAST_DATA_GATHERING' }
});
} catch (error) {
console.error(error);
Logger.error(error);
}
await this.prismaService.property.delete({
@ -118,13 +118,13 @@ export class DataGatheringService {
}
});
console.log('Max data gathering has been completed.');
Logger.log('Max data gathering has been completed.');
console.timeEnd('data-gathering-max');
}
}
public async gatherProfileData(aDataGatheringItems?: IDataGatheringItem[]) {
console.log('Profile data gathering has been started.');
Logger.log('Profile data gathering has been started.');
console.time('data-gathering-profile');
let dataGatheringItems = aDataGatheringItems;
@ -152,7 +152,7 @@ export class DataGatheringService {
symbol: symbolMapping[dataEnhancer.getName()] ?? symbol
});
} catch (error) {
console.error(`Failed to enhance data for symbol ${symbol}`, error);
Logger.error(`Failed to enhance data for symbol ${symbol}`, error);
}
}
@ -194,18 +194,21 @@ export class DataGatheringService {
}
});
} catch (error) {
console.error(`${symbol}: ${error?.meta?.cause}`);
Logger.error(`${symbol}: ${error?.meta?.cause}`);
}
}
console.log('Profile data gathering has been completed.');
Logger.log('Profile data gathering has been completed.');
console.timeEnd('data-gathering-profile');
}
public async gatherSymbols(aSymbolsWithStartDate: IDataGatheringItem[]) {
let hasError = false;
let symbolCounter = 0;
for (const { dataSource, date, symbol } of aSymbolsWithStartDate) {
this.dataGatheringProgress = symbolCounter / aSymbolsWithStartDate.length;
try {
const historicalData = await this.dataProviderService.getHistoricalRaw(
[{ dataSource, symbol }],
@ -261,8 +264,18 @@ export class DataGatheringService {
}
} catch (error) {
hasError = true;
console.error(error);
Logger.error(error);
}
if (symbolCounter > 0 && symbolCounter % 100 === 0) {
Logger.log(
`Data gathering progress: ${(
this.dataGatheringProgress * 100
).toFixed(2)}%`
);
}
symbolCounter += 1;
}
await this.exchangeRateDataService.initialize();
@ -272,19 +285,14 @@ export class DataGatheringService {
}
}
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const scraperConfigurations =
await this.ghostfolioScraperApi.getScraperConfigurations();
public async getDataGatheringProgress() {
const isInProgress = await this.getIsInProgress();
return scraperConfigurations.map((scraperConfiguration) => {
return {
dataSource: DataSource.GHOSTFOLIO,
date: startDate,
symbol: scraperConfiguration.symbol
};
});
if (isInProgress) {
return this.dataGatheringProgress;
}
return undefined;
}
public async getIsInProgress() {
@ -306,7 +314,7 @@ export class DataGatheringService {
}
public async reset() {
console.log('Data gathering has been reset.');
Logger.log('Data gathering has been reset.');
await this.prismaService.property.deleteMany({
where: {
@ -343,6 +351,7 @@ export class DataGatheringService {
orderBy: [{ symbol: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
}
})
@ -363,12 +372,8 @@ export class DataGatheringService {
};
});
const customSymbolsToGather =
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
return [
...this.getBenchmarksToGather(startDate),
...customSymbolsToGather,
...currencyPairsToGather,
...symbolProfilesToGather
];
@ -382,9 +387,6 @@ export class DataGatheringService {
})
)?.date ?? new Date();
const customSymbolsToGather =
await this.ghostfolioScraperApi.getCustomSymbolsToGather(startDate);
const currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
@ -405,20 +407,19 @@ export class DataGatheringService {
select: { date: true },
take: 1
},
scraperConfiguration: true,
symbol: true
}
})
).map((item) => {
).map((symbolProfile) => {
return {
dataSource: item.dataSource,
date: item.Order?.[0]?.date ?? startDate,
symbol: item.symbol
...symbolProfile,
date: symbolProfile.Order?.[0]?.date ?? startDate
};
});
return [
...this.getBenchmarksToGather(startDate),
...customSymbolsToGather,
...currencyPairsToGather,
...symbolProfilesToGather
];

View File

@ -2,7 +2,7 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { isAfter, isBefore, parse } from 'date-fns';
@ -78,7 +78,7 @@ export class AlphaVantageService implements DataProviderInterface {
return response;
} catch (error) {
console.error(error, symbol);
Logger.error(error, symbol);
return {};
}

View File

@ -4,13 +4,19 @@ import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provi
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
import { DataProviderService } from './data-provider.service';
@Module({
imports: [ConfigurationModule, CryptocurrencyModule, PrismaModule],
imports: [
ConfigurationModule,
CryptocurrencyModule,
PrismaModule,
SymbolProfileModule
],
providers: [
AlphaVantageService,
DataProviderService,

View File

@ -9,7 +9,7 @@ import {
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable } from '@nestjs/common';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client';
import { format } from 'date-fns';
import { isEmpty } from 'lodash';
@ -109,7 +109,7 @@ export class DataProviderService {
return r;
}, {});
} catch (error) {
console.error(error);
Logger.error(error);
} finally {
return response;
}

View File

@ -1,31 +1,33 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
DATE_FORMAT,
getYesterday,
isGhostfolioScraperApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import * as cheerio from 'cheerio';
import { format } from 'date-fns';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
import { ScraperConfig } from './interfaces/scraper-config.interface';
@Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
public constructor(private readonly prismaService: PrismaService) {}
public constructor(
private readonly prismaService: PrismaService,
private readonly symbolProfileService: SymbolProfileService
) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
@ -39,9 +41,10 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
try {
const symbol = aSymbols[0];
const scraperConfig = await this.getScraperConfigurationBySymbol(symbol);
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const { marketPrice } = await this.prismaService.marketData.findFirst({
orderBy: {
@ -55,37 +58,18 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return {
[symbol]: {
marketPrice,
currency: scraperConfig?.currency,
currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO,
marketState: MarketState.delayed
}
};
} catch (error) {
console.error(error);
Logger.error(error);
}
return {};
}
public async getCustomSymbolsToGather(
startDate?: Date
): Promise<IDataGatheringItem[]> {
const ghostfolioSymbolProfiles =
await this.prismaService.symbolProfile.findMany({
where: {
dataSource: DataSource.GHOSTFOLIO
}
});
return ghostfolioSymbolProfiles.map(({ dataSource, symbol }) => {
return {
dataSource,
symbol,
date: startDate
};
});
}
public async getHistorical(
aSymbols: string[],
aGranularity: Granularity = 'day',
@ -99,11 +83,11 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
try {
const symbol = aSymbols[0];
const scraperConfiguration = await this.getScraperConfigurationBySymbol(
symbol
const [symbol] = aSymbols;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const scraperConfiguration = symbolProfile?.scraperConfiguration;
const get = bent(scraperConfiguration?.url, 'GET', 'string', 200, {});
@ -122,28 +106,12 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
};
} catch (error) {
console.error(error);
Logger.error(error);
}
return {};
}
public async getScraperConfigurations(): Promise<ScraperConfig[]> {
try {
const { value: scraperConfigString } =
await this.prismaService.property.findFirst({
select: {
value: true
},
where: { key: 'SCRAPER_CONFIG' }
});
return JSON.parse(scraperConfigString);
} catch {}
return [];
}
public getName(): DataSource {
return DataSource.GHOSTFOLIO;
}
@ -162,11 +130,4 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return undefined;
}
}
private async getScraperConfigurationBySymbol(aSymbol: string) {
const scraperConfigurations = await this.getScraperConfigurations();
return scraperConfigurations.find((scraperConfiguration) => {
return scraperConfiguration.symbol === aSymbol;
});
}
}

View File

@ -1,6 +0,0 @@
export interface ScraperConfig {
currency: string;
selector: string;
symbol: string;
url: string;
}

View File

@ -0,0 +1,4 @@
export interface ScraperConfiguration {
selector: string;
url: string;
}

View File

@ -9,7 +9,7 @@ import {
isRakutenRapidApiSymbol
} from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import * as bent from 'bent';
import { format, subMonths, subWeeks, subYears } from 'date-fns';
@ -61,7 +61,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
};
}
} catch (error) {
console.error(error);
Logger.error(error);
}
return {};
@ -166,7 +166,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
const { fgi } = await get();
return fgi;
} catch (error) {
console.error(error);
Logger.error(error);
return undefined;
}

View File

@ -3,7 +3,7 @@ import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/c
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import * as bent from 'bent';
import Big from 'big.js';
@ -117,7 +117,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response;
} catch (error) {
console.error(error);
Logger.error(error);
return {};
}
@ -169,7 +169,7 @@ export class YahooFinanceService implements DataProviderInterface {
return response;
} catch (error) {
console.error(error);
Logger.error(error);
return {};
}

View File

@ -1,6 +1,6 @@
import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { format } from 'date-fns';
import { isEmpty, isNumber, uniq } from 'lodash';
@ -140,7 +140,7 @@ export class ExchangeRateDataService {
}
// Fallback with error, if currencies are not available
console.error(
Logger.error(
`No exchange rate has been found for ${aFromCurrency}${aToCurrency}`
);
return aValue;

View File

@ -1,3 +1,4 @@
import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
@ -11,6 +12,7 @@ export interface EnhancedSymbolProfile {
dataSource: DataSource;
id: string;
name: string | null;
scraperConfiguration?: ScraperConfiguration | null;
sectors: Sector[];
symbol: string;
symbolMapping?: { [key: string]: string };

View File

@ -7,6 +7,8 @@ import { Injectable } from '@nestjs/common';
import { Prisma, SymbolProfile } from '@prisma/client';
import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable()
export class SymbolProfileService {
constructor(private readonly prismaService: PrismaService) {}
@ -29,6 +31,7 @@ export class SymbolProfileService {
return symbolProfiles.map((symbolProfile) => ({
...symbolProfile,
countries: this.getCountries(symbolProfile),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
}));
@ -50,6 +53,22 @@ export class SymbolProfileService {
);
}
private getScraperConfiguration(
symbolProfile: SymbolProfile
): ScraperConfiguration {
const scraperConfiguration =
symbolProfile.scraperConfiguration as Prisma.JsonObject;
if (scraperConfiguration) {
return {
selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string
};
}
return null;
}
private getSectors(symbolProfile: SymbolProfile): Sector[] {
return ((symbolProfile?.sectors as Prisma.JsonArray) ?? []).map(
(sector) => {

View File

@ -17,6 +17,20 @@
</td>
</ng-container>
<ng-container matColumnDef="currency">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1"
i18n
mat-header-cell
>
Currency
</th>
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }}
</td>
</ng-container>
<ng-container matColumnDef="platform">
<th
*matHeaderCellDef
@ -45,7 +59,9 @@
<span class="d-none d-sm-block" i18n>Transactions</span>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
{{ element.transactionCount }}
<ng-container *ngIf="element.accountType === 'SECURITIES'">{{
element.transactionCount
}}</ng-container>
</td>
</ng-container>
@ -56,9 +72,23 @@
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[currency]="element.currency"
[isCurrency]="true"
[locale]="locale"
[value]="element.balance"
[value]="element.convertedBalance"
></gf-value>
</td>
</ng-container>
<ng-container matColumnDef="value">
<th *matHeaderCellDef class="px-1 text-right" i18n mat-header-cell>
Value
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.value"
></gf-value>
</td>
</ng-container>

View File

@ -41,7 +41,14 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
public ngOnInit() {}
public ngOnChanges() {
this.displayedColumns = ['account', 'platform', 'transactions', 'balance'];
this.displayedColumns = [
'account',
'currency',
'platform',
'transactions',
'balance',
'value'
];
if (this.showActions) {
this.displayedColumns.push('actions');

View File

@ -2,6 +2,5 @@ import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.i
export interface PositionDetailDialogParams {
deviceType: string;
fearAndGreedIndex: number;
historicalDataItems: LineChartItem[];
}

View File

@ -22,13 +22,11 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
})
export class PerformanceChartDialog {
public benchmarkDataItems: LineChartItem[];
public benchmarkLabel = 'S&P 500';
public benchmarkSymbol = 'VOO';
public currency: string;
public firstBuyDate: string;
public marketPrice: number;
public historicalDataItems: LineChartItem[];
public title: string;
private unsubscribeSubject = new Subject<void>();
@ -83,8 +81,6 @@ export class PerformanceChartDialog {
this.changeDetectorRef.markForCheck();
});
this.title = `Performance vs. ${this.benchmarkLabel}`;
}
public onClose(): void {

View File

@ -1,7 +1,7 @@
<gf-dialog-header
mat-dialog-title
title="Performance"
[deviceType]="data.deviceType"
[title]="title"
(closeButtonClicked)="onClose()"
></gf-dialog-header>
@ -11,7 +11,6 @@
class="mb-4"
symbol="Performance"
[benchmarkDataItems]="benchmarkDataItems"
[benchmarkLabel]="benchmarkLabel"
[historicalDataItems]="historicalDataItems"
[showGradient]="true"
[showLegend]="true"
@ -19,13 +18,6 @@
[showYAxis]="false"
></gf-line-chart>
</div>
<div *ngIf="data.fearAndGreedIndex" class="container p-0">
<gf-fear-and-greed-index
class="d-flex flex-column justify-content-center"
[fearAndGreedIndex]="data.fearAndGreedIndex"
></gf-fear-and-greed-index>
</div>
</div>
<gf-dialog-footer

View File

@ -8,7 +8,6 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfDialogFooterModule } from '../dialog-footer/dialog-footer.module';
import { GfDialogHeaderModule } from '../dialog-header/dialog-header.module';
import { GfFearAndGreedIndexModule } from '../fear-and-greed-index/fear-and-greed-index.module';
import { PerformanceChartDialog } from './performance-chart-dialog.component';
@NgModule({
@ -18,7 +17,6 @@ import { PerformanceChartDialog } from './performance-chart-dialog.component';
CommonModule,
GfDialogFooterModule,
GfDialogHeaderModule,
GfFearAndGreedIndexModule,
GfLineChartModule,
GfValueModule,
MatButtonModule,

View File

@ -9,6 +9,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { AssetSubClass } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -23,6 +24,7 @@ import { PositionDetailDialogParams } from './interfaces/interfaces';
styleUrls: ['./position-detail-dialog.component.scss']
})
export class PositionDetailDialog implements OnDestroy {
public assetSubClass: AssetSubClass;
public averagePrice: number;
public benchmarkDataItems: LineChartItem[];
public currency: string;
@ -38,6 +40,7 @@ export class PositionDetailDialog implements OnDestroy {
public netPerformance: number;
public netPerformancePercent: number;
public quantity: number;
public quantityPrecision = 2;
public symbol: string;
public transactionCount: number;
@ -54,6 +57,7 @@ export class PositionDetailDialog implements OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
assetSubClass,
averagePrice,
currency,
firstBuyDate,
@ -71,6 +75,7 @@ export class PositionDetailDialog implements OnDestroy {
symbol,
transactionCount
}) => {
this.assetSubClass = assetSubClass;
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.currency = currency;
@ -146,6 +151,18 @@ export class PositionDetailDialog implements OnDestroy {
this.benchmarkDataItems[0].value = this.averagePrice;
}
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (assetSubClass === 'CRYPTOCURRENCY') {
if (this.quantity < 1) {
this.quantityPrecision = 7;
} else if (this.quantity < 1000) {
this.quantityPrecision = 5;
} else if (this.quantity > 10000000) {
this.quantityPrecision = 0;
}
}
this.changeDetectorRef.markForCheck();
}
);

View File

@ -82,7 +82,7 @@
label="Quantity"
size="medium"
[locale]="data.locale"
[precision]="2"
[precision]="quantityPrecision"
[value]="quantity"
></gf-value>
</div>

View File

@ -103,7 +103,9 @@
></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>
<div

View File

@ -24,7 +24,9 @@
></gf-position>
</ng-container>
<div *ngIf="!hasPositions" class="p-3 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>
</ng-container>
</div>

View File

@ -2,7 +2,9 @@
<div class="row no-gutters">
<div class="col">
<mat-card *ngIf="rules === null" class="my-2 text-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</mat-card>
<gf-rule *ngIf="rules === undefined" [isLoading]="true"></gf-rule>

View File

@ -270,3 +270,9 @@
width: '100%'
}"
></ngx-skeleton-loader>
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
<gf-no-transactions-info-indicator
[hasBorder]="false"
></gf-no-transactions-info-indicator>
</div>

View File

@ -10,6 +10,7 @@ import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfNoTransactionsInfoModule } from '@ghostfolio/ui/no-transactions-info';
import { GfValueModule } from '@ghostfolio/ui/value';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
@ -22,6 +23,7 @@ import { TransactionsTableComponent } from './transactions-table.component';
exports: [TransactionsTableComponent],
imports: [
CommonModule,
GfNoTransactionsInfoModule,
GfPositionDetailDialogModule,
GfSymbolIconModule,
GfSymbolModule,

View File

@ -22,6 +22,7 @@ import { takeUntil } from 'rxjs/operators';
})
export class AdminPageComponent implements OnDestroy, OnInit {
public dataGatheringInProgress: boolean;
public dataGatheringProgress: number;
public defaultDateFormat = DEFAULT_DATE_FORMAT;
public exchangeRates: { label1: string; label2: string; value: number }[];
public lastDataGathering: string;
@ -134,12 +135,14 @@ export class AdminPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(
({
dataGatheringProgress,
exchangeRates,
lastDataGathering,
transactionCount,
userCount,
users
}) => {
this.dataGatheringProgress = dataGatheringProgress;
this.exchangeRates = exchangeRates;
this.users = users;

View File

@ -35,14 +35,15 @@
</div>
</div>
<div class="d-flex my-3">
<div class="w-50" i18n>Last Data Gathering</div>
<div class="w-50" i18n>Data Gathering</div>
<div class="w-50">
<div>
<ng-container *ngIf="lastDataGathering"
>{{ lastDataGathering }}</ng-container
>
<ng-container *ngIf="dataGatheringInProgress" i18n
>In Progress</ng-container
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
}})</ng-container
>
</div>
<div class="mt-2 overflow-hidden">

View File

@ -7,10 +7,7 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatTabChangeEvent } from '@angular/material/tabs';
import { ActivatedRoute, Router } from '@angular/router';
import { PerformanceChartDialog } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.component';
import { ToggleOption } from '@ghostfolio/client/components/toggle/interfaces/toggle-option.type';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -61,7 +58,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
public hasImpersonationId: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true;
public isLoadingSummary = true;
@ -80,21 +76,10 @@ export class HomePageComponent implements OnDestroy, OnInit {
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private impersonationStorageService: ImpersonationStorageService,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private userService: UserService
) {
this.routeQueryParams = this.route.queryParams
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => {
if (params['performanceChartDialog']) {
this.openDialog();
}
});
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
@ -173,25 +158,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private openDialog(): void {
const dialogRef = this.dialog.open(PerformanceChartDialog, {
autoFocus: false,
data: {
deviceType: this.deviceType,
fearAndGreedIndex: this.fearAndGreedIndex,
historicalDataItems: this.historicalDataItems
},
width: '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(() => {
this.router.navigate(['.'], { relativeTo: this.route });
});
}
private update() {
if (this.currentTabIndex === 0) {
this.isLoadingPerformance = true;
@ -225,7 +191,6 @@ export class HomePageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response.positions;
this.hasPositions = this.positions?.length > 0;
this.changeDetectorRef.markForCheck();
});

View File

@ -23,12 +23,7 @@
"
>
<div class="row w-100">
<a
*ngIf="historicalDataItems?.length !== 0"
class="chart-container col"
[routerLink]="[]"
[queryParams]="{performanceChartDialog: true}"
>
<div class="chart-container col">
<gf-line-chart
class="mr-3"
symbol="Performance"
@ -38,19 +33,19 @@
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</a>
<div
*ngIf="historicalDataItems?.length === 0"
class="
align-items-center
chart-container
d-flex
justify-content-center
w-100
"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<div
*ngIf="historicalDataItems?.length === 0"
class="
align-items-center
chart-container
d-flex
justify-content-center
w-100
"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
</div>
</div>
</div>
@ -92,33 +87,25 @@
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
<ng-container *ngIf="hasPositions === true">
<mat-card class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</ng-container>
<div
*ngIf="hasPositions === false"
class="d-flex justify-content-center"
>
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
<mat-card class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</div>
</div>
@ -148,4 +135,33 @@
</div>
</div>
</mat-tab>
<mat-tab *ngIf="hasPermissionToAccessFearAndGreedIndex">
<ng-template mat-tab-label>
<ion-icon name="newspaper-outline" size="large"></ion-icon>
</ng-template>
<div
class="
align-items-center
container
d-flex
flex-grow-1
h-100
justify-content-center
w-100
"
>
<div class="row w-100">
<div class="col-xs-12 col-md-8 offset-md-2">
<mat-card class="h-100">
<mat-card-content>
<gf-fear-and-greed-index
class="d-flex justify-content-center"
[fearAndGreedIndex]="fearAndGreedIndex"
></gf-fear-and-greed-index>
</mat-card-content>
</mat-card>
</div>
</div>
</div>
</mat-tab>
</mat-tab-group>

View File

@ -4,6 +4,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfPerformanceChartDialogModule } from '@ghostfolio/client/components/performance-chart-dialog/performance-chart-dialog.module';
import { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfPortfolioSummaryModule } from '@ghostfolio/client/components/portfolio-summary/portfolio-summary.module';
@ -20,6 +21,7 @@ import { HomePageComponent } from './home-page.component';
exports: [],
imports: [
CommonModule,
GfFearAndGreedIndexModule,
GfLineChartModule,
GfNoTransactionsInfoModule,
GfPerformanceChartDialogModule,

View File

@ -12,6 +12,7 @@ import { User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DataSource, Order as OrderModel } from '@prisma/client';
import { format, parseISO } from 'date-fns';
import { isArray } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -189,6 +190,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
try {
if (file.type === 'application/json') {
const content = JSON.parse(fileContent);
if (!isArray(content.orders)) {
throw new Error();
}
try {
await this.importTransactionsService.importJson({
content: content.orders,

View File

@ -38,7 +38,6 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
public deviceType: string;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public hasPositions: boolean;
public historicalDataItems: LineChartItem[];
public isLoadingPerformance = true;
public performance: PortfolioPerformance;
@ -140,7 +139,6 @@ export class ZenPageComponent implements AfterViewInit, OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.positions = response.positions;
this.hasPositions = this.positions?.length > 0;
this.changeDetectorRef.markForCheck();
});

View File

@ -65,35 +65,25 @@
<h3 class="d-flex justify-content-center mb-3" i18n>Holdings</h3>
<div class="row">
<div class="align-items-center col">
<ng-container *ngIf="hasPositions === true">
<mat-card class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</ng-container>
<div
*ngIf="hasPositions === false"
class="d-flex justify-content-center"
>
<div>
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
<mat-card class="p-0">
<mat-card-content>
<gf-positions
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[positions]="positions"
[range]="dateRange"
></gf-positions>
</mat-card-content>
</mat-card>
<div *ngIf="hasPermissionToCreateOrder" class="text-center">
<a
class="mt-3"
i18n
mat-button
[routerLink]="['/portfolio', 'transactions']"
>Manage Transactions...</a
>
</div>
</div>
</div>

View File

@ -29,7 +29,7 @@ import {
} from '@ghostfolio/common/interfaces';
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { AccountWithValue, DateRange } from '@ghostfolio/common/types';
import {
Account as AccountModel,
DataSource,
@ -62,7 +62,7 @@ export class DataService {
}
public fetchAccounts() {
return this.http.get<AccountModel[]>('/api/account');
return this.http.get<AccountWithValue[]>('/api/account');
}
public fetchAdminData() {

View File

@ -29,5 +29,5 @@ import { environment } from './environments/environment';
.bootstrapModule(AppModule, {
providers: [{ provide: LOCALE_ID, useValue: 'de-CH' }]
})
.catch((err) => console.error(err));
.catch((error) => console.error(error));
})();

View File

@ -1,6 +1,7 @@
export interface AdminData {
dataGatheringProgress?: number;
exchangeRates: { label1: string; label2: string; value: number }[];
lastDataGathering: Date | 'IN_PROGRESS';
lastDataGathering?: Date | 'IN_PROGRESS';
transactionCount: number;
userCount: number;
users: {

View File

@ -0,0 +1,6 @@
import { Account as AccountModel } from '@prisma/client';
export type AccountWithValue = AccountModel & {
convertedBalance: number;
value: number;
};

View File

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

View File

@ -6,6 +6,7 @@
class="align-items-center justify-content-center"
color="primary"
[routerLink]="['/portfolio', 'transactions']"
[queryParams]="{ createDialog: true }"
mat-button
>
<span i18n>Time to add your first transaction.</span>

View File

@ -1,8 +1,11 @@
:host {
border: 1px solid rgba(var(--dark-dividers));
border-radius: 0.25rem;
display: block;
&.has-border {
border: 1px solid rgba(var(--dark-dividers));
border-radius: 0.25rem;
}
gf-logo {
opacity: 0.25;
}

View File

@ -1,4 +1,9 @@
import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
HostBinding,
Input
} from '@angular/core';
@Component({
selector: 'gf-no-transactions-info-indicator',
@ -6,8 +11,8 @@ import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
templateUrl: './no-transactions-info.component.html',
styleUrls: ['./no-transactions-info.component.scss']
})
export class NoTransactionsInfoComponent implements OnInit {
public constructor() {}
export class NoTransactionsInfoComponent {
@HostBinding('class.has-border') @Input() hasBorder = true;
public ngOnInit() {}
public constructor() {}
}

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.69.0",
"version": "1.75.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "scraperConfiguration" JSONB;

View File

@ -118,19 +118,20 @@ model Settings {
}
model SymbolProfile {
assetClass AssetClass?
assetSubClass AssetSubClass?
countries Json?
createdAt DateTime @default(now())
currency String?
dataSource DataSource
id String @id @default(uuid())
name String?
Order Order[]
updatedAt DateTime @updatedAt
sectors Json?
symbol String
symbolMapping Json?
assetClass AssetClass?
assetSubClass AssetSubClass?
countries Json?
createdAt DateTime @default(now())
currency String?
dataSource DataSource
id String @id @default(uuid())
name String?
Order Order[]
updatedAt DateTime @updatedAt
scraperConfiguration Json?
sectors Json?
symbol String
symbolMapping Json?
@@unique([dataSource, symbol])
}