Compare commits

...

9 Commits

Author SHA1 Message Date
718b0de0a7 Release 1.124.0 (#744) 2022-03-06 13:48:17 +01:00
99655604d9 Feature/add support for coupon duration (#743)
* Add support for coupon duration

* Update changelog
2022-03-06 12:26:04 +01:00
b602e7690b Feature/upgrade prisma to version 3.10.0 (#742)
* Upgrade prisma to version 3.10.0

* Update changelog
2022-03-06 12:06:07 +01:00
7745dafe48 Feature/upgrade yahoo finance2 to 2.2.0 (#741)
* Upgrade yahoo-finance2 to version 2.2.0

* Update changelog
2022-03-06 12:04:38 +01:00
50184284e1 Feature/upgrade ngx skeleton loader to 5.0.0 (#740)
* Upgrade ngx-skeleton-loader to version 5.0.0

* Update changelog
2022-03-06 10:47:30 +01:00
f46533107d Release 1.123.0 (#739) 2022-03-05 11:09:07 +01:00
c216ab1d76 Eliminate data source from order model (#730)
* Eliminate currency, data source and symbol from order model

* Remove prefix for symbols with data source GHOSTFOLIO

* Update changelog
2022-03-05 11:07:27 +01:00
86acbf06f4 Feature/add data provider errors to api response (#738)
* Add data provider error details

* Update changelog
2022-03-05 11:00:02 +01:00
3de7d3f60e Bugfix/improve account calculations (#737)
* Improve account calculations

* Update changelog
2022-03-04 21:31:31 +01:00
55 changed files with 485 additions and 309 deletions

View File

@ -5,6 +5,37 @@ 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.124.0 - 06.03.2022
### Added
- Added support for setting a duration in the coupon system
### Changed
- Upgraded `ngx-skeleton-loader` from version `2.9.1` to `5.0.0`
- Upgraded `prisma` from version `3.9.1` to `3.10.0`
- Upgraded `yahoo-finance2` from version `2.1.9` to `2.2.0`
## 1.123.0 - 05.03.2022
### Added
- Included data provider errors in the API response
### Changed
- Removed the redundant attributes (`currency`, `dataSource`, `symbol`) of the activity model
- Removed the prefix for symbols with the data source `GHOSTFOLIO`
### Fixed
- Improved the account calculations
### Todo
- Apply data migration (`yarn database:migrate`)
## 1.122.0 - 01.03.2022
### Added

View File

@ -101,16 +101,18 @@ export class AccountController {
) {
accountsWithAggregations = {
...nullifyValuesInObject(accountsWithAggregations, [
'totalBalance',
'totalValue'
'totalBalanceInBaseCurrency',
'totalValueInBaseCurrency'
]),
accounts: nullifyValuesInObjects(accountsWithAggregations.accounts, [
'balance',
'balanceInBaseCurrency',
'convertedBalance',
'fee',
'quantity',
'unitPrice',
'value'
'value',
'valueInBaseCurrency'
])
};
}

View File

@ -2,6 +2,7 @@ import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { CashDetails } from './interfaces/cash-details.interface';
@ -105,21 +106,26 @@ export class AccountService {
aUserId: string,
aCurrency: string
): Promise<CashDetails> {
let totalCashBalance = 0;
let totalCashBalanceInBaseCurrency = new Big(0);
const accounts = await this.accounts({
where: { userId: aUserId }
});
accounts.forEach((account) => {
totalCashBalance += this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
for (const account of accounts) {
totalCashBalanceInBaseCurrency = totalCashBalanceInBaseCurrency.plus(
this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
aCurrency
)
);
});
}
return { accounts, balance: totalCashBalance };
return {
accounts,
balanceInBaseCurrency: totalCashBalanceInBaseCurrency.toNumber()
};
}
public async updateAccount(

View File

@ -2,5 +2,5 @@ import { Account } from '@prisma/client';
export interface CashDetails {
accounts: Account[];
balance: number;
balanceInBaseCurrency: number;
}

View File

@ -18,8 +18,6 @@ export class ExportService {
orderBy: { date: 'desc' },
select: {
accountId: true,
currency: true,
dataSource: true,
date: true,
fee: true,
id: true,
@ -42,7 +40,6 @@ export class ExportService {
orders: orders.map(
({
accountId,
currency,
date,
fee,
quantity,
@ -52,12 +49,12 @@ export class ExportService {
}) => {
return {
accountId,
currency,
date,
fee,
quantity,
type,
unitPrice,
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: type === 'ITEM' ? SymbolProfile.name : SymbolProfile.symbol
};

View File

@ -1,5 +1,4 @@
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Order } from '@prisma/client';
import { Type } from 'class-transformer';
import { IsArray, ValidateNested } from 'class-validator';
@ -7,5 +6,5 @@ export class ImportDataDto {
@IsArray()
@Type(() => CreateOrderDto)
@ValidateNested({ each: true })
orders: Order[];
orders: CreateOrderDto[];
}

View File

@ -1,9 +1,9 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { Injectable } from '@nestjs/common';
import { Order } from '@prisma/client';
import { isSameDay, parseISO } from 'date-fns';
@Injectable()
@ -19,7 +19,7 @@ export class ImportService {
orders,
userId
}: {
orders: Partial<Order>[];
orders: Partial<CreateOrderDto>[];
userId: string;
}): Promise<void> {
for (const order of orders) {
@ -52,11 +52,8 @@ export class ImportService {
unitPrice
} of orders) {
await this.orderService.createOrder({
currency,
dataSource,
fee,
quantity,
symbol,
type,
unitPrice,
userId,
@ -65,6 +62,7 @@ export class ImportService {
SymbolProfile: {
connectOrCreate: {
create: {
currency,
dataSource,
symbol
},
@ -85,7 +83,7 @@ export class ImportService {
orders,
userId
}: {
orders: Partial<Order>[];
orders: Partial<CreateOrderDto>[];
userId: string;
}) {
if (
@ -99,6 +97,7 @@ export class ImportService {
}
const existingOrders = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
where: { userId }
});
@ -109,12 +108,12 @@ export class ImportService {
] of orders.entries()) {
const duplicateOrder = existingOrders.find((order) => {
return (
order.currency === currency &&
order.dataSource === dataSource &&
order.SymbolProfile.currency === currency &&
order.SymbolProfile.dataSource === dataSource &&
isSameDay(order.date, parseISO(<string>(<unknown>date))) &&
order.fee === fee &&
order.quantity === quantity &&
order.symbol === symbol &&
order.SymbolProfile.symbol === symbol &&
order.type === type &&
order.unitPrice === unitPrice
);

View File

@ -114,6 +114,7 @@ export class OrderController {
SymbolProfile: {
connectOrCreate: {
create: {
currency: data.currency,
dataSource: data.dataSource,
symbol: data.symbol
},
@ -171,6 +172,14 @@ export class OrderController {
id_userId: { id: accountId, userId: this.request.user.id }
}
},
SymbolProfile: {
connect: {
dataSource_symbol: {
dataSource: data.dataSource,
symbol: data.symbol
}
}
},
User: { connect: { id: this.request.user.id } }
},
where: {

View File

@ -53,7 +53,13 @@ export class OrderService {
}
public async createOrder(
data: Prisma.OrderCreateInput & { accountId?: string; userId: string }
data: Prisma.OrderCreateInput & {
accountId?: string;
currency?: string;
dataSource?: DataSource;
symbol?: string;
userId: string;
}
): Promise<Order> {
const defaultAccount = (
await this.accountService.getAccounts(data.userId)
@ -71,15 +77,13 @@ export class OrderService {
};
if (data.type === 'ITEM') {
const currency = data.currency;
const currency = data.SymbolProfile.connectOrCreate.create.currency;
const dataSource: DataSource = 'MANUAL';
const id = uuidv4();
const name = data.SymbolProfile.connectOrCreate.create.symbol;
Account = undefined;
data.dataSource = dataSource;
data.id = id;
data.symbol = null;
data.SymbolProfile.connectOrCreate.create.currency = currency;
data.SymbolProfile.connectOrCreate.create.dataSource = dataSource;
data.SymbolProfile.connectOrCreate.create.name = name;
@ -95,7 +99,7 @@ export class OrderService {
await this.dataGatheringService.gatherProfileData([
{
dataSource: data.dataSource,
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
@ -106,7 +110,7 @@ export class OrderService {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
@ -116,6 +120,9 @@ export class OrderService {
await this.cacheService.flush();
delete data.accountId;
delete data.currency;
delete data.dataSource;
delete data.symbol;
delete data.userId;
const orderData: Prisma.OrderCreateInput = data;
@ -193,50 +200,60 @@ export class OrderService {
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
};
});
}
public async updateOrder(params: {
public async updateOrder({
data,
where
}: {
data: Prisma.OrderUpdateInput & {
currency?: string;
dataSource?: DataSource;
symbol?: string;
};
where: Prisma.OrderWhereUniqueInput;
data: Prisma.OrderUpdateInput;
}): Promise<Order> {
const { data, where } = params;
if (data.Account.connect.id_userId.id === null) {
delete data.Account;
}
let isDraft = false;
if (data.type === 'ITEM') {
const name = data.symbol;
const name = data.SymbolProfile.connect.dataSource_symbol.symbol;
data.symbol = null;
data.SymbolProfile = { update: { name } };
}
} else {
isDraft = isAfter(data.date as Date, endOfToday());
const isDraft = isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: <DataSource>data.dataSource,
date: <Date>data.date,
symbol: <string>data.symbol
}
]);
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.SymbolProfile.connect.dataSource_symbol.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connect.dataSource_symbol.symbol
}
]);
}
}
await this.cacheService.flush();
delete data.currency;
delete data.dataSource;
delete data.symbol;
return this.prismaService.order.update({
data: {
...data,

View File

@ -1,8 +1,7 @@
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import { ResponseError, TimelinePosition } from '@ghostfolio/common/interfaces';
import Big from 'big.js';
export interface CurrentPositions {
hasErrors: boolean;
export interface CurrentPositions extends ResponseError {
positions: TimelinePosition[];
grossPerformance: Big;
grossPerformancePercentage: Big;

View File

@ -66,6 +66,7 @@ describe('PortfolioCalculatorNew', () => {
expect(currentPositions).toEqual({
currentValue: new Big('0'),
errors: [],
grossPerformance: new Big('-12.6'),
grossPerformancePercentage: new Big('-0.0440867739678096571'),
hasErrors: false,

View File

@ -55,6 +55,7 @@ describe('PortfolioCalculatorNew', () => {
expect(currentPositions).toEqual({
currentValue: new Big('297.8'),
errors: [],
grossPerformance: new Big('24.6'),
grossPerformancePercentage: new Big('0.09004392386530014641'),
hasErrors: false,

View File

@ -1,7 +1,11 @@
import { TimelineInfoInterface } from '@ghostfolio/api/app/portfolio/interfaces/timeline-info.interface';
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
import { TimelinePosition } from '@ghostfolio/common/interfaces';
import {
ResponseError,
TimelinePosition,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Logger } from '@nestjs/common';
import { Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
@ -232,6 +236,8 @@ export class PortfolioCalculatorNew {
const positions: TimelinePosition[] = [];
let hasAnySymbolMetricsErrors = false;
const errors: ResponseError['errors'] = [];
for (const item of lastTransactionPoint.items) {
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
@ -272,12 +278,17 @@ export class PortfolioCalculatorNew {
symbol: item.symbol,
transactionCount: item.transactionCount
});
if (hasErrors) {
errors.push({ dataSource: item.dataSource, symbol: item.symbol });
}
}
const overall = this.calculateOverallPerformance(positions, initialValues);
return {
...overall,
errors,
positions,
hasErrors: hasAnySymbolMetricsErrors || overall.hasErrors
};

View File

@ -14,7 +14,7 @@ import {
PortfolioChart,
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary
@ -204,10 +204,11 @@ export class PortfolioController {
@Get('performance')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getPerformance(
@Headers('impersonation-id') impersonationId: string,
@Query('range') range
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
): Promise<PortfolioPerformanceResponse> {
const performanceInformation = await this.portfolioServiceStrategy
.get()
.getPerformance(impersonationId, range);

View File

@ -24,7 +24,7 @@ import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
PortfolioDetails,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioReport,
PortfolioSummary,
Position,
@ -100,15 +100,22 @@ export class PortfolioServiceNew {
}
}
const value = details.accounts[account.id]?.current ?? 0;
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
value,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.id]?.current ?? 0
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
account.currency,
userCurrency
)
};
delete result.Order;
@ -119,17 +126,26 @@ export class PortfolioServiceNew {
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let totalBalanceInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency
);
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
return {
accounts,
transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
};
}
public async getInvestments(
@ -293,13 +309,11 @@ export class PortfolioServiceNew {
orders: portfolioOrders
});
if (transactionPoints?.length <= 0) {
return { accounts: {}, holdings: {}, hasErrors: false };
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
@ -312,9 +326,11 @@ export class PortfolioServiceNew {
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
@ -434,7 +450,7 @@ export class PortfolioServiceNew {
};
}
const positionCurrency = orders[0].currency;
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
@ -444,13 +460,13 @@ export class PortfolioServiceNew {
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
@ -714,7 +730,7 @@ export class PortfolioServiceNew {
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const { portfolioOrders, transactionPoints } =
@ -760,6 +776,7 @@ export class PortfolioServiceNew {
currentPositions.netPerformancePercentage.toNumber();
return {
errors: currentPositions.errors,
hasErrors: currentPositions.hasErrors || hasErrors,
performance: {
currentGrossPerformance,
@ -869,7 +886,7 @@ export class PortfolioServiceNew {
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
userId,
userCurrency
);
@ -887,7 +904,7 @@ export class PortfolioServiceNew {
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balance)
const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber();
@ -917,7 +934,7 @@ export class PortfolioServiceNew {
netWorth,
totalBuy,
totalSell,
cash: balance,
cash: balanceInBaseCurrency,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
@ -1006,7 +1023,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1025,7 +1042,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1047,7 +1064,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1100,24 +1117,24 @@ export class PortfolioServiceNew {
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
order.unitPrice,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
)
@ -1153,22 +1170,18 @@ export class PortfolioServiceNew {
return accountId === account.id;
});
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.id] = {
balance: convertedBalance,
balance: account.balance,
currency: account.currency,
current: convertedBalance,
current: account.balance,
name: account.name,
original: convertedBalance
original: account.balance
};
for (const order of ordersByAccount) {
let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') {
@ -1218,7 +1231,7 @@ export class PortfolioServiceNew {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
order.SymbolProfile.currency,
currency
);
})

View File

@ -25,7 +25,7 @@ import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
PortfolioDetails,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioReport,
PortfolioSummary,
Position,
@ -99,15 +99,22 @@ export class PortfolioService {
}
}
const value = details.accounts[account.id]?.current ?? 0;
const result = {
...account,
transactionCount,
convertedBalance: this.exchangeRateDataService.toCurrency(
value,
balanceInBaseCurrency: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
value: details.accounts[account.id]?.current ?? 0
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
account.currency,
userCurrency
)
};
delete result.Order;
@ -118,17 +125,26 @@ export class PortfolioService {
public async getAccountsWithAggregations(aUserId: string): Promise<Accounts> {
const accounts = await this.getAccounts(aUserId);
let totalBalance = 0;
let totalValue = 0;
let totalBalanceInBaseCurrency = new Big(0);
let totalValueInBaseCurrency = new Big(0);
let transactionCount = 0;
for (const account of accounts) {
totalBalance += account.convertedBalance;
totalValue += account.value;
totalBalanceInBaseCurrency = totalBalanceInBaseCurrency.plus(
account.balanceInBaseCurrency
);
totalValueInBaseCurrency = totalValueInBaseCurrency.plus(
account.valueInBaseCurrency
);
transactionCount += account.transactionCount;
}
return { accounts, totalBalance, totalValue, transactionCount };
return {
accounts,
transactionCount,
totalBalanceInBaseCurrency: totalBalanceInBaseCurrency.toNumber(),
totalValueInBaseCurrency: totalValueInBaseCurrency.toNumber()
};
}
public async getInvestments(
@ -281,13 +297,11 @@ export class PortfolioService {
userId
});
if (transactionPoints?.length <= 0) {
return { accounts: {}, holdings: {}, hasErrors: false };
}
portfolioCalculator.setTransactionPoints(transactionPoints);
const portfolioStart = parseDate(transactionPoints[0].date);
const portfolioStart = parseDate(
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
);
const startDate = this.getStartDate(aDateRange, portfolioStart);
const currentPositions = await portfolioCalculator.getCurrentPositions(
startDate
@ -300,9 +314,11 @@ export class PortfolioService {
const holdings: PortfolioDetails['holdings'] = {};
const totalInvestment = currentPositions.totalInvestment.plus(
cashDetails.balance
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(
cashDetails.balanceInBaseCurrency
);
const totalValue = currentPositions.currentValue.plus(cashDetails.balance);
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
@ -422,7 +438,7 @@ export class PortfolioService {
};
}
const positionCurrency = orders[0].currency;
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
@ -432,13 +448,13 @@ export class PortfolioService {
return order.type === 'BUY' || order.type === 'SELL';
})
.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
@ -696,7 +712,7 @@ export class PortfolioService {
public async getPerformance(
aImpersonationId: string,
aDateRange: DateRange = 'max'
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
): Promise<PortfolioPerformanceResponse> {
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const portfolioCalculator = new PortfolioCalculator(
@ -848,7 +864,7 @@ export class PortfolioService {
const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails(
const { balanceInBaseCurrency } = await this.accountService.getCashDetails(
userId,
userCurrency
);
@ -866,7 +882,7 @@ export class PortfolioService {
const committedFunds = new Big(totalBuy).minus(totalSell);
const netWorth = new Big(balance)
const netWorth = new Big(balanceInBaseCurrency)
.plus(performanceInformation.performance.currentValue)
.plus(items)
.toNumber();
@ -882,7 +898,7 @@ export class PortfolioService {
totalSell,
annualizedPerformancePercent:
performanceInformation.performance.annualizedPerformancePercent,
cash: balance,
cash: balanceInBaseCurrency,
committedFunds: committedFunds.toNumber(),
ordersCount: orders.filter((order) => {
return order.type === 'BUY' || order.type === 'SELL';
@ -971,7 +987,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -990,7 +1006,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1012,7 +1028,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
new Big(order.quantity).mul(order.unitPrice).toNumber(),
order.currency,
order.SymbolProfile.currency,
this.request.user.Settings.currency
);
})
@ -1064,24 +1080,24 @@ export class PortfolioService {
}
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency,
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
currency: order.SymbolProfile.currency,
dataSource: order.SymbolProfile.dataSource,
date: format(order.date, DATE_FORMAT),
fee: new Big(
this.exchangeRateDataService.toCurrency(
order.fee,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
),
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.symbol,
symbol: order.SymbolProfile.symbol,
type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
order.unitPrice,
order.currency,
order.SymbolProfile.currency,
userCurrency
)
)
@ -1113,22 +1129,18 @@ export class PortfolioService {
return accountId === account.id;
});
const convertedBalance = this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
);
accounts[account.id] = {
balance: convertedBalance,
balance: account.balance,
currency: account.currency,
current: convertedBalance,
current: account.balance,
name: account.name,
original: convertedBalance
original: account.balance
};
for (const order of ordersByAccount) {
let currentValueOfSymbol =
order.quantity * portfolioItemsNow[order.symbol].marketPrice;
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
if (order.type === 'SELL') {
@ -1178,7 +1190,7 @@ export class PortfolioService {
.map((order) => {
return this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.currency,
order.SymbolProfile.currency,
currency
);
})

View File

@ -46,22 +46,25 @@ export class SubscriptionController {
((await this.propertyService.getByKey(PROPERTY_COUPONS)) as Coupon[]) ??
[];
const isValid = coupons.some((coupon) => {
return coupon.code === couponCode;
const coupon = coupons.find((currentCoupon) => {
return currentCoupon.code === couponCode;
});
if (!isValid) {
if (coupon === undefined) {
throw new HttpException(
getReasonPhrase(StatusCodes.BAD_REQUEST),
StatusCodes.BAD_REQUEST
);
}
await this.subscriptionService.createSubscription(this.request.user.id);
await this.subscriptionService.createSubscription({
duration: coupon.duration,
userId: this.request.user.id
});
// Destroy coupon
coupons = coupons.filter((coupon) => {
return coupon.code !== couponCode;
coupons = coupons.filter((currentCoupon) => {
return currentCoupon.code !== couponCode;
});
await this.propertyService.put({
key: PROPERTY_COUPONS,
@ -69,7 +72,7 @@ export class SubscriptionController {
});
Logger.log(
`Subscription for user '${this.request.user.id}' has been created with coupon`
`Subscription for user '${this.request.user.id}' has been created with a coupon for ${coupon.duration}`
);
return {

View File

@ -2,8 +2,9 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SubscriptionType } from '@ghostfolio/common/types/subscription.type';
import { Injectable, Logger } from '@nestjs/common';
import { Subscription, User } from '@prisma/client';
import { addDays, isBefore } from 'date-fns';
import { Subscription } from '@prisma/client';
import { addMilliseconds, isBefore } from 'date-fns';
import ms, { StringValue } from 'ms';
import Stripe from 'stripe';
@Injectable()
@ -64,13 +65,19 @@ export class SubscriptionService {
};
}
public async createSubscription(aUserId: string) {
public async createSubscription({
duration = '1 year',
userId
}: {
duration?: StringValue;
userId: string;
}) {
await this.prismaService.subscription.create({
data: {
expiresAt: addDays(new Date(), 365),
expiresAt: addMilliseconds(new Date(), ms(duration)),
User: {
connect: {
id: aUserId
id: userId
}
}
}
@ -83,7 +90,7 @@ export class SubscriptionService {
aCheckoutSessionId
);
await this.createSubscription(session.client_reference_id);
await this.createSubscription({ userId: session.client_reference_id });
await this.stripe.customers.update(session.customer as string, {
description: session.client_reference_id

View File

@ -32,7 +32,6 @@ export class TransformDataSourceInResponseInterceptor<T>
activity.SymbolProfile.dataSource = encodeDataSource(
activity.SymbolProfile.dataSource
);
activity.dataSource = encodeDataSource(activity.dataSource);
return activity;
});
}
@ -41,6 +40,14 @@ export class TransformDataSourceInResponseInterceptor<T>
data.dataSource = encodeDataSource(data.dataSource);
}
if (data.errors) {
for (const error of data.errors) {
if (error.dataSource) {
error.dataSource = encodeDataSource(error.dataSource);
}
}
}
if (data.holdings) {
for (const symbol of Object.keys(data.holdings)) {
if (data.holdings[symbol].dataSource) {
@ -58,13 +65,6 @@ export class TransformDataSourceInResponseInterceptor<T>
});
}
if (data.orders) {
data.orders.map((order) => {
order.dataSource = encodeDataSource(order.dataSource);
return order;
});
}
if (data.positions) {
data.positions.map((position) => {
position.dataSource = encodeDataSource(position.dataSource);

View File

@ -549,19 +549,24 @@ export class DataGatheringService {
}
private async getSymbolsProfileData(): Promise<IDataGatheringItem[]> {
const distinctOrders = await this.prismaService.order.findMany({
distinct: ['symbol'],
orderBy: [{ symbol: 'asc' }],
select: { dataSource: true, symbol: true }
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }]
});
return distinctOrders.filter((distinctOrder) => {
return (
distinctOrder.dataSource !== DataSource.GHOSTFOLIO &&
distinctOrder.dataSource !== DataSource.MANUAL &&
distinctOrder.dataSource !== DataSource.RAKUTEN
);
});
return symbolProfiles
.filter((symbolProfile) => {
return (
symbolProfile.dataSource !== DataSource.GHOSTFOLIO &&
symbolProfile.dataSource !== DataSource.MANUAL &&
symbolProfile.dataSource !== DataSource.RAKUTEN
);
})
.map((symbolProfile) => {
return {
dataSource: symbolProfile.dataSource,
symbol: symbolProfile.symbol
};
});
}
private async isDataGatheringNeeded() {

View File

@ -7,11 +7,7 @@ import {
} from '@ghostfolio/api/services/interfaces/interfaces';
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 { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
import { DataSource, SymbolProfile } from '@prisma/client';
@ -29,7 +25,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
) {}
public canHandle(symbol: string) {
return isGhostfolioScraperApiSymbol(symbol);
return true;
}
public async getAssetProfile(

View File

@ -191,12 +191,7 @@ export class ExchangeRateDataService {
await this.prismaService.symbolProfile.findMany({
distinct: ['currency'],
orderBy: [{ currency: 'asc' }],
select: { currency: true },
where: {
currency: {
not: null
}
}
select: { currency: true }
})
).forEach((symbolProfile) => {
currencies.push(symbolProfile.currency);

View File

@ -86,7 +86,7 @@
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="element.convertedBalance"
[value]="element.balance"
></gf-value>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
@ -94,7 +94,7 @@
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalBalance"
[value]="totalBalanceInBaseCurrency"
></gf-value>
</td>
</ng-container>
@ -116,7 +116,7 @@
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="locale"
[value]="totalValue"
[value]="totalValueInBaseCurrency"
></gf-value>
</td>
</ng-container>

View File

@ -24,8 +24,8 @@ export class AccountsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() deviceType: string;
@Input() locale: string;
@Input() showActions: boolean;
@Input() totalBalance: number;
@Input() totalValue: number;
@Input() totalBalanceInBaseCurrency: number;
@Input() totalValueInBaseCurrency: number;
@Input() transactionCount: number;
@Output() accountDeleted = new EventEmitter<string>();

View File

@ -20,6 +20,7 @@ import {
parseISO
} from 'date-fns';
import { uniq } from 'lodash';
import { StringValue } from 'ms';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -29,6 +30,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './admin-overview.html'
})
export class AdminOverviewComponent implements OnDestroy, OnInit {
public couponDuration: StringValue = '30 days';
public coupons: Coupon[];
public customCurrencies: string[];
public dataGatheringInProgress: boolean;
@ -105,7 +107,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
public onAddCoupon() {
const coupons = [...this.coupons, { code: this.generateCouponCode(16) }];
const coupons = [
...this.coupons,
{ code: this.generateCouponCode(16), duration: this.couponDuration }
];
this.putCoupons(coupons);
}
@ -118,6 +123,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
}
public onChangeCouponDuration(aCouponDuration: StringValue) {
this.couponDuration = aCouponDuration;
}
public onDeleteCoupon(aCouponCode: string) {
const confirmation = confirm('Do you really want to delete this coupon?');

View File

@ -156,11 +156,14 @@
></mat-slide-toggle>
</div>
</div>
<div *ngIf="hasPermissionForSubscription" class="d-flex my-3">
<div
*ngIf="hasPermissionForSubscription"
class="d-flex my-3 subscription"
>
<div class="w-50" i18n>Coupons</div>
<div class="w-50">
<div *ngFor="let coupon of coupons">
<span>{{ coupon.code }}</span>
<span>{{ coupon.code }} ({{ coupon.duration }})</span>
<button
class="mini-icon mx-1 no-min-width px-2"
mat-button
@ -170,10 +173,25 @@
</button>
</div>
<div class="mt-2">
<button color="primary" mat-flat-button (click)="onAddCoupon()">
<ion-icon class="mr-1" name="add-outline"></ion-icon>
<span i18n>Add Coupon</span>
</button>
<form #couponForm="ngForm">
<mat-form-field appearance="outline" class="mr-2">
<mat-select
name="duration"
[value]="couponDuration"
(selectionChange)="onChangeCouponDuration($event.value)"
>
<mat-option value="30 days">30 Days</mat-option>
<mat-option value="1 year">1 Year</mat-option>
</mat-select>
</mat-form-field>
<button
color="primary"
mat-flat-button
(click)="onAddCoupon()"
>
<span i18n>Add</span>
</button>
</form>
</div>
</div>
</div>

View File

@ -1,7 +1,9 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatCardModule } from '@angular/material/card';
import { MatSelectModule } from '@angular/material/select';
import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { GfValueModule } from '@ghostfolio/ui/value';
@ -12,11 +14,14 @@ import { AdminOverviewComponent } from './admin-overview.component';
declarations: [AdminOverviewComponent],
exports: [],
imports: [
FormsModule,
CommonModule,
GfValueModule,
MatButtonModule,
MatCardModule,
MatSlideToggleModule
MatSelectModule,
MatSlideToggleModule,
ReactiveFormsModule
],
providers: [CacheService],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -20,4 +20,10 @@
}
}
}
.subscription {
.mat-form-field {
max-width: 100%;
}
}
}

View File

@ -7,7 +7,11 @@ import {
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import {
PortfolioPerformance,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
@ -24,6 +28,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public dateRange: DateRange;
public dateRangeOptions = defaultDateRangeOptions;
public deviceType: string;
public errors: UniqueAsset[];
public hasError: boolean;
public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
@ -126,6 +131,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
.fetchPortfolioPerformance({ range: this.dateRange })
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => {
this.errors = response.errors;
this.hasError = response.hasErrors;
this.performance = response.performance;
this.isLoadingPerformance = false;

View File

@ -28,6 +28,7 @@
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[hasError]="hasError"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"

View File

@ -7,6 +7,7 @@
? 'Sorry! Our data provider partner is experiencing the hiccups.'
: ''
"
(click)="errors?.length > 0 && onShowErrors()"
>
<ion-icon
*ngIf="hasError && !isLoading"

View File

@ -7,7 +7,10 @@ import {
OnInit,
ViewChild
} from '@angular/core';
import { PortfolioPerformance } from '@ghostfolio/common/interfaces';
import {
PortfolioPerformance,
ResponseError
} from '@ghostfolio/common/interfaces';
import { CountUp } from 'countup.js';
import { isNumber } from 'lodash';
@ -20,6 +23,7 @@ import { isNumber } from 'lodash';
export class PortfolioPerformanceComponent implements OnChanges, OnInit {
@Input() baseCurrency: string;
@Input() deviceType: string;
@Input() errors: ResponseError['errors'];
@Input() hasError: boolean;
@Input() isAllTimeHigh: boolean;
@Input() isAllTimeLow: boolean;
@ -69,4 +73,12 @@ export class PortfolioPerformanceComponent implements OnChanges, OnInit {
}
}
}
public onShowErrors() {
const errorMessageParts = this.errors.map((error) => {
return `${error.symbol} (${error.dataSource})`;
});
alert(errorMessageParts.join('\n'));
}
}

View File

@ -2,6 +2,7 @@
<div class="flex-nowrap no-gutters row">
<a
class="d-flex p-3 w-100"
[ngClass]="{ 'cursor-default': isLoading }"
[routerLink]="[]"
[queryParams]="{
dataSource: position?.dataSource,

View File

@ -2,8 +2,6 @@
display: block;
.container {
cursor: pointer;
gf-trend-indicator {
padding-top: 0.15rem;
}

View File

@ -28,8 +28,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
public hasPermissionToCreateAccount: boolean;
public hasPermissionToDeleteAccount: boolean;
public routeQueryParams: Subscription;
public totalBalance = 0;
public totalValue = 0;
public totalBalanceInBaseCurrency = 0;
public totalValueInBaseCurrency = 0;
public transactionCount = 0;
public user: User;
@ -106,18 +106,25 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
this.dataService
.fetchAccounts()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ accounts, totalBalance, totalValue, transactionCount }) => {
this.accounts = accounts;
this.totalBalance = totalBalance;
this.totalValue = totalValue;
this.transactionCount = transactionCount;
.subscribe(
({
accounts,
totalBalanceInBaseCurrency,
totalValueInBaseCurrency,
transactionCount
}) => {
this.accounts = accounts;
this.totalBalanceInBaseCurrency = totalBalanceInBaseCurrency;
this.totalValueInBaseCurrency = totalValueInBaseCurrency;
this.transactionCount = transactionCount;
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
if (this.accounts?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } });
}
this.changeDetectorRef.markForCheck();
}
this.changeDetectorRef.markForCheck();
});
);
}
public onDeleteAccount(aId: string) {

View File

@ -9,8 +9,8 @@
[deviceType]="deviceType"
[locale]="user?.settings?.locale"
[showActions]="!hasImpersonationId && hasPermissionToDeleteAccount && !user.settings.isRestrictedView"
[totalBalance]="totalBalance"
[totalValue]="totalValue"
[totalBalanceInBaseCurrency]="totalBalanceInBaseCurrency"
[totalValueInBaseCurrency]="totalValueInBaseCurrency"
[transactionCount]="transactionCount"
(accountDeleted)="onDeleteAccount($event)"
(accountToUpdate)="onUpdateAccount($event)"

View File

@ -158,11 +158,11 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
this.activityForm.controls['type'].disable();
}
if (this.data.activity?.symbol) {
if (this.data.activity?.SymbolProfile?.symbol) {
this.dataService
.fetchSymbolItem({
dataSource: this.data.activity?.dataSource,
symbol: this.data.activity?.symbol
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ marketPrice }) => {
@ -196,9 +196,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
} else {
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
this.data.activity.currency = null;
this.data.activity.dataSource = null;
this.data.activity.symbol = null;
this.data.activity.SymbolProfile = null;
}
this.changeDetectorRef.markForCheck();
@ -259,9 +257,7 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
})
.pipe(
catchError(() => {
this.data.activity.currency = null;
this.data.activity.dataSource = null;
this.data.activity.unitPrice = null;
this.data.activity.SymbolProfile = null;
this.isLoading = false;

View File

@ -24,9 +24,11 @@ import {
PortfolioDetails,
PortfolioInvestments,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPublicDetails,
PortfolioReport,
PortfolioSummary,
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { permissions } from '@ghostfolio/common/permissions';
@ -188,13 +190,13 @@ export class DataService {
});
}
public fetchPortfolioPerformance(aParams: { [param: string]: any }) {
return this.http.get<{
hasErrors: boolean;
performance: PortfolioPerformance;
}>('/api/portfolio/performance', {
params: aParams
});
public fetchPortfolioPerformance(params: { [param: string]: any }) {
return this.http.get<PortfolioPerformanceResponse>(
'/api/portfolio/performance',
{
params
}
);
}
public fetchPortfolioPublic(aId: string) {

View File

@ -134,6 +134,10 @@ ngx-skeleton-loader {
}
}
.cursor-default {
cursor: default;
}
.cursor-pointer {
cursor: pointer;
}

View File

@ -102,10 +102,6 @@ export function isCurrency(aSymbol = '') {
return currencies[aSymbol];
}
export function isGhostfolioScraperApiSymbol(aSymbol = '') {
return aSymbol.startsWith(ghostfolioScraperApiSymbolPrefix);
}
export function resetHours(aDate: Date) {
const year = getYear(aDate);
const month = getMonth(aDate);

View File

@ -2,7 +2,7 @@ import { AccountWithValue } from '@ghostfolio/common/types';
export interface Accounts {
accounts: AccountWithValue[];
totalBalance: number;
totalValue: number;
totalBalanceInBaseCurrency: number;
totalValueInBaseCurrency: number;
transactionCount: number;
}

View File

@ -1,3 +1,6 @@
import { StringValue } from 'ms';
export interface Coupon {
code: string;
duration?: StringValue;
}

View File

@ -21,6 +21,8 @@ import { PortfolioReportRule } from './portfolio-report-rule.interface';
import { PortfolioReport } from './portfolio-report.interface';
import { PortfolioSummary } from './portfolio-summary.interface';
import { Position } from './position.interface';
import { ResponseError } from './responses/errors.interface';
import { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import { TimelinePosition } from './timeline-position.interface';
import { UniqueAsset } from './unique-asset.interface';
import { UserSettings } from './user-settings.interface';
@ -43,12 +45,14 @@ export {
PortfolioItem,
PortfolioOverview,
PortfolioPerformance,
PortfolioPerformanceResponse,
PortfolioPosition,
PortfolioPublicDetails,
PortfolioReport,
PortfolioReportRule,
PortfolioSummary,
Position,
ResponseError,
TimelinePosition,
UniqueAsset,
User,

View File

@ -0,0 +1,6 @@
import { UniqueAsset } from '../unique-asset.interface';
export interface ResponseError {
errors?: UniqueAsset[];
hasErrors: boolean;
}

View File

@ -0,0 +1,6 @@
import { PortfolioPerformance } from '../portfolio-performance.interface';
import { ResponseError } from './errors.interface';
export interface PortfolioPerformanceResponse extends ResponseError {
performance: PortfolioPerformance;
}

View File

@ -1,7 +1,8 @@
import { Account as AccountModel } from '@prisma/client';
export type AccountWithValue = AccountModel & {
convertedBalance: number;
balanceInBaseCurrency: number;
transactionCount: number;
value: number;
valueInBaseCurrency: number;
};

View File

@ -144,7 +144,7 @@
class="d-none d-lg-table-cell px-1"
mat-cell
>
{{ element.currency }}
{{ element.SymbolProfile.currency }}
</td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
@ -362,7 +362,7 @@
!row.isDraft &&
row.type !== 'ITEM' &&
onOpenPositionDialog({
dataSource: row.dataSource,
dataSource: row.SymbolProfile.dataSource,
symbol: row.SymbolProfile.symbol
})
"

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.122.0",
"version": "1.124.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -71,7 +71,7 @@
"@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "13.8.1",
"@prisma/client": "3.9.1",
"@prisma/client": "3.10.0",
"@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0",
@ -100,15 +100,16 @@
"http-status-codes": "2.2.0",
"ionicons": "5.5.1",
"lodash": "4.17.21",
"ms": "3.0.0-canary.1",
"ngx-device-detector": "3.0.0",
"ngx-markdown": "13.0.0",
"ngx-skeleton-loader": "2.9.1",
"ngx-skeleton-loader": "5.0.0",
"ngx-stripe": "13.0.0",
"papaparse": "5.3.1",
"passport": "0.4.1",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "3.9.1",
"prisma": "3.10.0",
"reflect-metadata": "0.1.13",
"round-to": "5.0.0",
"rxjs": "7.4.0",
@ -117,7 +118,7 @@
"tslib": "2.0.0",
"twitter-api-v2": "1.10.3",
"uuid": "8.3.2",
"yahoo-finance2": "2.1.9",
"yahoo-finance2": "2.2.0",
"zone.js": "0.11.4"
},
"devDependencies": {

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" DROP COLUMN "dataSource";

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" DROP COLUMN "currency";

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Order" DROP COLUMN "symbol";

View File

@ -0,0 +1,5 @@
-- Set default value
UPDATE "SymbolProfile" SET "currency" = 'USD' WHERE "currency" IS NULL;
-- AlterTable
ALTER TABLE "SymbolProfile" ALTER COLUMN "currency" SET NOT NULL;

View File

@ -74,14 +74,11 @@ model Order {
accountId String?
accountUserId String?
createdAt DateTime @default(now())
currency String?
dataSource DataSource?
date DateTime
fee Float
id String @default(uuid())
isDraft Boolean @default(false)
quantity Float
symbol String?
SymbolProfile SymbolProfile @relation(fields: [symbolProfileId], references: [id])
symbolProfileId String
type Type
@ -119,7 +116,7 @@ model SymbolProfile {
assetSubClass AssetSubClass?
countries Json?
createdAt DateTime @default(now())
currency String?
currency String
dataSource DataSource
id String @id @default(uuid())
name String?

View File

@ -192,14 +192,11 @@ async function main() {
{
accountId: '65cfb79d-b6c7-4591-9d46-73426bc62094',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2017, 0, 3, 0, 0, 0)),
fee: 30,
id: 'cf7c0418-8535-4089-ae3d-5dbfa0aec2e1',
quantity: 50,
symbol: 'TSLA',
symbolProfileId: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e',
symbolProfileId: 'd1ee9681-fb21-4f99-a3b7-afd4fc04df2e', // TSLA
type: Type.BUY,
unitPrice: 42.97,
userId: userDemo.id
@ -207,14 +204,11 @@ async function main() {
{
accountId: 'd804de69-0429-42dc-b6ca-b308fd7dd926',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2017, 7, 16, 0, 0, 0)),
fee: 29.9,
id: 'a1c5d73a-8631-44e5-ac44-356827a5212c',
quantity: 0.5614682,
symbol: 'BTCUSD',
symbolProfileId: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e',
symbolProfileId: 'fdc42ea6-1321-44f5-9fb0-d7f1f2cf9b1e', // BTCUSD
type: Type.BUY,
unitPrice: 3562.089535970158,
userId: userDemo.id
@ -222,14 +216,11 @@ async function main() {
{
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2018, 9, 1, 0, 0, 0)),
fee: 80.79,
id: '71c08e2a-4a86-44ae-a890-c337de5d5f9b',
quantity: 5,
symbol: 'AMZN',
symbolProfileId: '2bd26362-136e-411c-b578-334084b4cdcc',
symbolProfileId: '2bd26362-136e-411c-b578-334084b4cdcc', // AMZN
type: Type.BUY,
unitPrice: 2021.99,
userId: userDemo.id
@ -237,14 +228,11 @@ async function main() {
{
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2019, 2, 1, 0, 0, 0)),
fee: 19.9,
id: '385f2c2c-d53e-4937-b0e5-e92ef6020d4e',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', // VTI
type: Type.BUY,
unitPrice: 144.38,
userId: userDemo.id
@ -252,14 +240,11 @@ async function main() {
{
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2019, 8, 3, 0, 0, 0)),
fee: 19.9,
id: '185f2c2c-d53e-4937-b0e5-a93ef6020d4e',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', // VTI
type: Type.BUY,
unitPrice: 147.99,
userId: userDemo.id
@ -267,14 +252,11 @@ async function main() {
{
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 2, 2, 0, 0, 0)),
fee: 19.9,
id: '347b0430-a84f-4031-a0f9-390399066ad6',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', // VTI
type: Type.BUY,
unitPrice: 151.41,
userId: userDemo.id
@ -282,14 +264,11 @@ async function main() {
{
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 8, 1, 0, 0, 0)),
fee: 19.9,
id: '67ec3f47-3189-4b63-ba05-60d3a06b302f',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', // VTI
type: Type.BUY,
unitPrice: 177.69,
userId: userDemo.id
@ -297,14 +276,11 @@ async function main() {
{
accountId: '480269ce-e12a-4fd1-ac88-c4b0ff3f899c',
accountUserId: userDemo.id,
currency: 'USD',
dataSource: DataSource.YAHOO,
date: new Date(Date.UTC(2020, 2, 1, 0, 0, 0)),
fee: 19.9,
id: 'd01c6fbc-fa8d-47e6-8e80-66f882d2bfd2',
quantity: 10,
symbol: 'VTI',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796',
symbolProfileId: '7d9c8540-061e-4e7e-b019-0d0f4a84e796', // VTI
type: Type.BUY,
unitPrice: 203.15,
userId: userDemo.id

View File

@ -3470,22 +3470,22 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
"@prisma/client@3.9.1":
version "3.9.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.9.1.tgz#565c8121f1220637bcab4a1d1f106b8c1334406c"
integrity sha512-aLwfXKLvL+loQ0IuPPCXkcq8cXBg1IeoHHa5lqQu3dJHdj45wnislA/Ny4UxRQjD5FXqrfAb8sWtF+jhdmjFTg==
"@prisma/client@3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.10.0.tgz#4782fe6f1b0e43c2a11a75ad4bb1098599d1dfb1"
integrity sha512-6P4sV7WFuODSfSoSEzCH1qfmWMrCUBk1LIIuTbQf6m1LI/IOpLN4lnqGDmgiBGprEzuWobnGLfe9YsXLn0inrg==
dependencies:
"@prisma/engines-version" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
"@prisma/engines-version" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
"@prisma/engines-version@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#ea03ffa723382a526dc6625ce6eae9b6ad984400"
integrity sha512-5Dh+qTDhpPR66w6NNAnPs+/W/Qt4r1DSd+qhfPFcDThUK4uxoZKGlPb2IYQn5LL+18aIGnmteDf7BnVMmvBNSQ==
"@prisma/engines-version@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#82750856fa637dd89b8f095d2dcc6ac0631231c6"
integrity sha512-cVYs5gyQH/qyut24hUvDznCfPrWiNMKNfPb9WmEoiU6ihlkscIbCfkmuKTtspVLWRdl0LqjYEC7vfnPv17HWhw==
"@prisma/engines@3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009":
version "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009.tgz#e5c345cdedb7be83d11c1e0c5ab61d866b411256"
integrity sha512-qM+uJbkelB21bnK44gYE049YTHIjHysOuj0mj5U2gDGyNLfmiazlggzFPCgEjgme4U5YB2tYs6Z5Hq08Kl8pjA==
"@prisma/engines@3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86":
version "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86.tgz#2964113729a78b8b21e186b5592affd1fde73c16"
integrity sha512-LjRssaWu9w2SrXitofnutRIyURI7l0veQYIALz7uY4shygM9nMcK3omXcObRm7TAcw3Z+9ytfK1B+ySOsOesxQ==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1"
@ -13556,6 +13556,11 @@ ms@2.1.2:
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
ms@3.0.0-canary.1:
version "3.0.0-canary.1"
resolved "https://registry.yarnpkg.com/ms/-/ms-3.0.0-canary.1.tgz#c7b34fbce381492fd0b345d1cf56e14d67b77b80"
integrity sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==
ms@^2.0.0, ms@^2.1.1:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@ -13688,13 +13693,13 @@ ngx-markdown@13.0.0:
prismjs "^1.25.0"
tslib "^2.3.0"
ngx-skeleton-loader@2.9.1:
version "2.9.1"
resolved "https://registry.yarnpkg.com/ngx-skeleton-loader/-/ngx-skeleton-loader-2.9.1.tgz#1e419ef66696a2017afc9c8cd0bc129d2c680ffb"
integrity sha512-knFL2Ua/p60XUPH9ZrfgydiBHvvylny7jsVOXBtmACrYD7HcnuUl1uAEk/LvcN15tSjC9VnVwTIOU4i8LCSAgw==
ngx-skeleton-loader@5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/ngx-skeleton-loader/-/ngx-skeleton-loader-5.0.0.tgz#e0042de20b0159d3f97d03a696d68f39ceee383b"
integrity sha512-6cz8UAu4WcYnBp/LnU053LCIwjKNZWX8GX1v3bvqQVdDa1ubsEeJm+CZxk5B8W2jP9CcFhvWrBlmmVUyl1Yxug==
dependencies:
perf-marks "^1.13.4"
tslib "^1.10.0"
tslib "^2.0.0"
ngx-stripe@13.0.0:
version "13.0.0"
@ -15214,12 +15219,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
prisma@3.9.1:
version "3.9.1"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.9.1.tgz#7510a8bf06018a5313b9427b1127ce4750b1ce5c"
integrity sha512-IGcJAu5LzlFv+i+NNhOEh1J1xVVttsVdRBxmrMN7eIH+7mRN6L89Hz1npUAiz4jOpNlHC7n9QwaOYZGxTqlwQw==
prisma@3.10.0:
version "3.10.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.10.0.tgz#872d87afbeb1cbcaa77c3d6a63c125e0d704b04d"
integrity sha512-dAld12vtwdz9Rz01nOjmnXe+vHana5PSog8t0XGgLemKsUVsaupYpr74AHaS3s78SaTS5s2HOghnJF+jn91ZrA==
dependencies:
"@prisma/engines" "3.9.0-58.bcc2ff906db47790ee902e7bbc76d7ffb1893009"
"@prisma/engines" "3.10.0-50.73e60b76d394f8d37d8ebd1f8918c79029f0db86"
prismjs@^1.21.0, prismjs@~1.24.0:
version "1.24.1"
@ -18716,10 +18721,10 @@ y18n@^5.0.5:
resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
yahoo-finance2@2.1.9:
version "2.1.9"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.1.9.tgz#28b157e1cddc5b56e6b354f6b00b453a41bbe8a4"
integrity sha512-xLlDqcbK+4Y4oSV7Vq1KcvNcjMuODHQrk2uLyBR4SlXDNjRV7XFpTrwMrDnSLu4pErenj0gXG3ARiCWidFjqzg==
yahoo-finance2@2.2.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/yahoo-finance2/-/yahoo-finance2-2.2.0.tgz#8694b04e69f4a79996812b6d082e5b738c51cee6"
integrity sha512-ZxLCcoh+J51F7Tol1jpVBmy50IBQSoxsECWYDToBxjZwPloFNHtEVOXNqJlyzTysnzVbPA5TeCNT6G0DoaJnNQ==
dependencies:
ajv "8.10.0"
ajv-formats "2.1.1"