Compare commits
27 Commits
Author | SHA1 | Date | |
---|---|---|---|
9676f96e97 | |||
65e151151b | |||
5d3bbb8f30 | |||
b464fefc57 | |||
bcb7f5f522 | |||
f15b33e950 | |||
ca64492e77 | |||
761376d72d | |||
9c086edffe | |||
585f99e4df | |||
9d907b5eb5 | |||
ba05f5ba30 | |||
3261e3ee59 | |||
5607c6bb52 | |||
1c6050d3e3 | |||
38f2930ec6 | |||
556be61fff | |||
651b4bcff7 | |||
0a8d159f78 | |||
1a4109ebaa | |||
92e502e1c2 | |||
e344c43a5a | |||
d6b78f3457 | |||
9bbb856f66 | |||
d3707bbb87 | |||
7df53896f3 | |||
b2b3fde80e |
76
CHANGELOG.md
76
CHANGELOG.md
@ -5,6 +5,82 @@ 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.108.0 - 27.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the annualized performance in the new calculation engine
|
||||
- Increased the historical data chart of the _Fear & Greed Index_ (market mood) to 90 days
|
||||
|
||||
## 1.107.0 - 24.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a new calculation engine (experimental)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the styling in the footer row of the activities table
|
||||
|
||||
## 1.106.0 - 23.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the footer row with total fees and total value to the activities table
|
||||
|
||||
### Changed
|
||||
|
||||
- Extended the historical data view in the admin control panel
|
||||
- Upgraded _Stripe_ dependencies
|
||||
- Upgraded `prisma` from version `3.7.0` to `3.8.1`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the redirection on logout
|
||||
|
||||
## 1.105.0 - 20.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for fetching multiple symbols in the `GOOGLE_SHEETS` data provider
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the data provider with grouping by data source and thereby reducing the number of requests
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the unresolved account names in the _X-ray_ section
|
||||
- Fixed the date conversion in the `GOOGLE_SHEETS` data provider
|
||||
|
||||
## 1.104.0 - 16.01.2022
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the fallback to load currencies directly from the data provider
|
||||
- Fixed the missing symbol profile data connection in the import functionality for activities
|
||||
|
||||
## 1.103.0 - 13.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Added links to the statistics section on the about page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the currency of the value in the position detail dialog
|
||||
|
||||
## 1.102.0 - 11.01.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Start eliminating `dataSource` from activity
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the support for multiple accounts with the same name
|
||||
- Fixed the preselected default account of the create activity dialog
|
||||
|
||||
## 1.101.0 - 08.01.2022
|
||||
|
||||
### Added
|
||||
|
@ -12,7 +12,7 @@
|
||||
<strong>Open Source Wealth Management Software made for Humans</strong>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/en/blog/2021/07/hello-ghostfolio"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
|
||||
</p>
|
||||
<p>
|
||||
<a href="#contributing">
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
|
||||
import { PortfolioServiceStrategy } from '@ghostfolio/api/app/portfolio/portfolio-service.strategy';
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import {
|
||||
nullifyValuesInObject,
|
||||
@ -35,7 +35,7 @@ export class AccountController {
|
||||
public constructor(
|
||||
private readonly accountService: AccountService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
@ -91,10 +91,9 @@ export class AccountController {
|
||||
this.request.user.id
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
let accountsWithAggregations = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getAccountsWithAggregations(impersonationUserId || this.request.user.id);
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
|
@ -9,7 +9,8 @@ import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
|
||||
import {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Property } from '@prisma/client';
|
||||
@ -56,12 +57,67 @@ export class AdminService {
|
||||
}
|
||||
|
||||
public async getMarketData(): Promise<AdminMarketData> {
|
||||
return {
|
||||
marketData: await (
|
||||
await this.dataGatheringService.getSymbolsMax()
|
||||
).map((symbol) => {
|
||||
return symbol;
|
||||
const marketData = await this.prismaService.marketData.groupBy({
|
||||
_count: true,
|
||||
by: ['dataSource', 'symbol']
|
||||
});
|
||||
|
||||
const currencyPairsToGather: AdminMarketDataItem[] =
|
||||
this.exchangeRateDataService
|
||||
.getCurrencyPairs()
|
||||
.map(({ dataSource, symbol }) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === dataSource &&
|
||||
marketDataItem.symbol === symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
dataSource,
|
||||
marketDataItemCount,
|
||||
symbol
|
||||
};
|
||||
});
|
||||
|
||||
const symbolProfilesToGather: AdminMarketDataItem[] = (
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
orderBy: [{ symbol: 'asc' }],
|
||||
select: {
|
||||
_count: {
|
||||
select: { Order: true }
|
||||
},
|
||||
dataSource: true,
|
||||
Order: {
|
||||
orderBy: [{ date: 'asc' }],
|
||||
select: { date: true },
|
||||
take: 1
|
||||
},
|
||||
scraperConfiguration: true,
|
||||
symbol: true
|
||||
}
|
||||
})
|
||||
).map((symbolProfile) => {
|
||||
const marketDataItemCount =
|
||||
marketData.find((marketDataItem) => {
|
||||
return (
|
||||
marketDataItem.dataSource === symbolProfile.dataSource &&
|
||||
marketDataItem.symbol === symbolProfile.symbol
|
||||
);
|
||||
})?._count ?? 0;
|
||||
|
||||
return {
|
||||
marketDataItemCount,
|
||||
activityCount: symbolProfile._count.Order,
|
||||
dataSource: symbolProfile.dataSource,
|
||||
date: symbolProfile.Order?.[0]?.date,
|
||||
symbol: symbolProfile.symbol
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { OrderService } from '@ghostfolio/api/app/order/order.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
OrderModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule
|
||||
],
|
||||
controllers: [ImportController],
|
||||
providers: [CacheService, ImportService, OrderService]
|
||||
providers: [CacheService, ImportService]
|
||||
})
|
||||
export class ImportModule {}
|
||||
|
@ -34,11 +34,6 @@ export class ImportService {
|
||||
unitPrice
|
||||
} of orders) {
|
||||
await this.orderService.createOrder({
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
currency,
|
||||
dataSource,
|
||||
fee,
|
||||
@ -46,7 +41,26 @@ export class ImportService {
|
||||
symbol,
|
||||
type,
|
||||
unitPrice,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { userId, id: accountId }
|
||||
}
|
||||
},
|
||||
date: parseISO(<string>(<unknown>date)),
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
dataSource,
|
||||
symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource,
|
||||
symbol
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
User: { connect: { id: userId } }
|
||||
});
|
||||
}
|
||||
|
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
10
apps/api/src/app/order/interfaces/activities.interface.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
|
||||
export interface Activities {
|
||||
activities: Activity[];
|
||||
}
|
||||
|
||||
export interface Activity extends OrderWithAccount {
|
||||
feeInBaseCurrency: number;
|
||||
valueInBaseCurrency: number;
|
||||
}
|
@ -23,6 +23,7 @@ import { parseISO } from 'date-fns';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { CreateOrderDto } from './create-order.dto';
|
||||
import { Activities } from './interfaces/activities.interface';
|
||||
import { OrderService } from './order.service';
|
||||
import { UpdateOrderDto } from './update-order.dto';
|
||||
|
||||
@ -59,14 +60,16 @@ export class OrderController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<OrderModel[]> {
|
||||
): Promise<Activities> {
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
impersonationId,
|
||||
this.request.user.id
|
||||
);
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
|
||||
let orders = await this.orderService.getOrders({
|
||||
let activities = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
});
|
||||
@ -75,15 +78,17 @@ export class OrderController {
|
||||
impersonationUserId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
orders = nullifyValuesInObjects(orders, [
|
||||
activities = nullifyValuesInObjects(activities, [
|
||||
'fee',
|
||||
'feeInBaseCurrency',
|
||||
'quantity',
|
||||
'unitPrice',
|
||||
'value'
|
||||
'value',
|
||||
'valueInBaseCurrency'
|
||||
]);
|
||||
}
|
||||
|
||||
return orders;
|
||||
return { activities };
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ -116,23 +121,23 @@ export class OrderController {
|
||||
|
||||
return this.orderService.createOrder({
|
||||
...data,
|
||||
date,
|
||||
Account: {
|
||||
connect: {
|
||||
id_userId: { id: accountId, userId: this.request.user.id }
|
||||
}
|
||||
},
|
||||
date,
|
||||
SymbolProfile: {
|
||||
connectOrCreate: {
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
},
|
||||
where: {
|
||||
dataSource_symbol: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
},
|
||||
create: {
|
||||
dataSource: data.dataSource,
|
||||
symbol: data.symbol
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -4,6 +4,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data.module';
|
||||
import { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
@ -16,6 +17,7 @@ import { OrderService } from './order.service';
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
ExchangeRateDataModule,
|
||||
ImpersonationModule,
|
||||
PrismaModule,
|
||||
RedisCacheModule,
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
|
||||
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
@ -7,10 +8,13 @@ import { DataSource, Order, Prisma, Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, isAfter } from 'date-fns';
|
||||
|
||||
import { Activity } from './interfaces/activities.interface';
|
||||
|
||||
@Injectable()
|
||||
export class OrderService {
|
||||
public constructor(
|
||||
private readonly cacheService: CacheService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly dataGatheringService: DataGatheringService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
@ -86,12 +90,14 @@ export class OrderService {
|
||||
public async getOrders({
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
}: {
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
}) {
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
if (includeDrafts === false) {
|
||||
@ -124,12 +130,21 @@ export class OrderService {
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
).map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value: new Big(order.quantity)
|
||||
.mul(order.unitPrice)
|
||||
.plus(order.fee)
|
||||
.toNumber()
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -85,19 +85,6 @@ describe('CurrentRateService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('getValue', async () => {
|
||||
expect(
|
||||
await currentRateService.getValue({
|
||||
currency: 'USD',
|
||||
date: new Date(Date.UTC(2020, 0, 1, 0, 0, 0)),
|
||||
symbol: 'AMZN',
|
||||
userCurrency: 'CHF'
|
||||
})
|
||||
).toMatchObject({
|
||||
marketPrice: 1847.839966
|
||||
});
|
||||
});
|
||||
|
||||
it('getValues', async () => {
|
||||
expect(
|
||||
await currentRateService.getValues({
|
||||
|
@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
|
||||
import { flatten } from 'lodash';
|
||||
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
|
||||
@Injectable()
|
||||
@ -18,46 +17,6 @@ export class CurrentRateService {
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async getValue({
|
||||
currency,
|
||||
date,
|
||||
symbol,
|
||||
userCurrency
|
||||
}: GetValueParams): Promise<GetValueObject> {
|
||||
if (isToday(date)) {
|
||||
const dataProviderResult = await this.dataProviderService.get([
|
||||
{
|
||||
symbol,
|
||||
dataSource: this.dataProviderService.getPrimaryDataSource()
|
||||
}
|
||||
]);
|
||||
return {
|
||||
symbol,
|
||||
date: resetHours(date),
|
||||
marketPrice: dataProviderResult?.[symbol]?.marketPrice ?? 0
|
||||
};
|
||||
}
|
||||
|
||||
const marketData = await this.marketDataService.get({
|
||||
date,
|
||||
symbol
|
||||
});
|
||||
|
||||
if (marketData) {
|
||||
return {
|
||||
date: marketData.date,
|
||||
marketPrice: this.exchangeRateDataService.toCurrency(
|
||||
marketData.marketPrice,
|
||||
currency,
|
||||
userCurrency
|
||||
),
|
||||
symbol: marketData.symbol
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`Value not found for ${symbol} at ${resetHours(date)}`);
|
||||
}
|
||||
|
||||
public async getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
|
@ -6,7 +6,7 @@ export interface CurrentPositions {
|
||||
positions: TimelinePosition[];
|
||||
grossPerformance: Big;
|
||||
grossPerformancePercentage: Big;
|
||||
netAnnualizedPerformance: Big;
|
||||
netAnnualizedPerformance?: Big;
|
||||
netPerformance: Big;
|
||||
netPerformancePercentage: Big;
|
||||
currentValue: Big;
|
||||
|
@ -1,6 +0,0 @@
|
||||
export interface GetValueParams {
|
||||
currency: string;
|
||||
date: Date;
|
||||
symbol: string;
|
||||
userCurrency: string;
|
||||
}
|
@ -0,0 +1,5 @@
|
||||
import { PortfolioOrder } from './portfolio-order.interface';
|
||||
|
||||
export interface PortfolioOrderItem extends PortfolioOrder {
|
||||
itemType?: '' | 'start' | 'end';
|
||||
}
|
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
73
apps/api/src/app/portfolio/portfolio-calculator-new.spec.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import Big from 'big.js';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioCalculatorNew } from './portfolio-calculator-new';
|
||||
|
||||
describe('PortfolioCalculatorNew', () => {
|
||||
let currentRateService: CurrentRateService;
|
||||
|
||||
beforeEach(() => {
|
||||
currentRateService = new CurrentRateService(null, null, null);
|
||||
});
|
||||
|
||||
describe('annualized performance percentage', () => {
|
||||
const portfolioCalculatorNew = new PortfolioCalculatorNew({
|
||||
currentRateService,
|
||||
currency: 'USD',
|
||||
orders: []
|
||||
});
|
||||
|
||||
it('Get annualized performance', async () => {
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: NaN, // differenceInDays of date-fns returns NaN for the same day
|
||||
netPerformancePercent: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 0,
|
||||
netPerformancePercent: new Big(0)
|
||||
})
|
||||
.toNumber()
|
||||
).toEqual(0);
|
||||
|
||||
/**
|
||||
* Source: https://www.readyratios.com/reference/analysis/annualized_rate.html
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 65, // < 1 year
|
||||
netPerformancePercent: new Big(0.1025)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.729705);
|
||||
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 365, // 1 year
|
||||
netPerformancePercent: new Big(0.05)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.05);
|
||||
|
||||
/**
|
||||
* Source: https://www.investopedia.com/terms/a/annualized-total-return.asp#annualized-return-formula-and-calculation
|
||||
*/
|
||||
expect(
|
||||
portfolioCalculatorNew
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket: 575, // > 1 year
|
||||
netPerformancePercent: new Big(0.2374)
|
||||
})
|
||||
.toNumber()
|
||||
).toBeCloseTo(0.145);
|
||||
});
|
||||
});
|
||||
});
|
849
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
849
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
@ -0,0 +1,849 @@
|
||||
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 { Logger } from '@nestjs/common';
|
||||
import { Type as TypeOfOrder } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
addMilliseconds,
|
||||
addMonths,
|
||||
addYears,
|
||||
endOfDay,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
max,
|
||||
min
|
||||
} from 'date-fns';
|
||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
import { GetValueObject } from './interfaces/get-value-object.interface';
|
||||
import { PortfolioOrderItem } from './interfaces/portfolio-calculator.interface';
|
||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||
import {
|
||||
Accuracy,
|
||||
TimelineSpecification
|
||||
} from './interfaces/timeline-specification.interface';
|
||||
import { TransactionPointSymbol } from './interfaces/transaction-point-symbol.interface';
|
||||
import { TransactionPoint } from './interfaces/transaction-point.interface';
|
||||
|
||||
export class PortfolioCalculatorNew {
|
||||
private currency: string;
|
||||
private currentRateService: CurrentRateService;
|
||||
private orders: PortfolioOrder[];
|
||||
private transactionPoints: TransactionPoint[];
|
||||
|
||||
public constructor({
|
||||
currency,
|
||||
currentRateService,
|
||||
orders
|
||||
}: {
|
||||
currency: string;
|
||||
currentRateService: CurrentRateService;
|
||||
orders: PortfolioOrder[];
|
||||
}) {
|
||||
this.currency = currency;
|
||||
this.currentRateService = currentRateService;
|
||||
this.orders = orders;
|
||||
|
||||
this.orders.sort((a, b) => a.date.localeCompare(b.date));
|
||||
}
|
||||
|
||||
public computeTransactionPoints() {
|
||||
this.transactionPoints = [];
|
||||
const symbols: { [symbol: string]: TransactionPointSymbol } = {};
|
||||
|
||||
let lastDate: string = null;
|
||||
let lastTransactionPoint: TransactionPoint = null;
|
||||
for (const order of this.orders) {
|
||||
const currentDate = order.date;
|
||||
|
||||
let currentTransactionPointItem: TransactionPointSymbol;
|
||||
const oldAccumulatedSymbol = symbols[order.symbol];
|
||||
|
||||
const factor = this.getFactor(order.type);
|
||||
const unitPrice = new Big(order.unitPrice);
|
||||
if (oldAccumulatedSymbol) {
|
||||
const newQuantity = order.quantity
|
||||
.mul(factor)
|
||||
.plus(oldAccumulatedSymbol.quantity);
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee.plus(oldAccumulatedSymbol.fee),
|
||||
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
|
||||
investment: newQuantity.eq(0)
|
||||
? new Big(0)
|
||||
: unitPrice
|
||||
.mul(order.quantity)
|
||||
.mul(factor)
|
||||
.add(oldAccumulatedSymbol.investment),
|
||||
quantity: newQuantity,
|
||||
symbol: order.symbol,
|
||||
transactionCount: oldAccumulatedSymbol.transactionCount + 1
|
||||
};
|
||||
} else {
|
||||
currentTransactionPointItem = {
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
fee: order.fee,
|
||||
firstBuyDate: order.date,
|
||||
investment: unitPrice.mul(order.quantity).mul(factor),
|
||||
quantity: order.quantity.mul(factor),
|
||||
symbol: order.symbol,
|
||||
transactionCount: 1
|
||||
};
|
||||
}
|
||||
|
||||
symbols[order.symbol] = currentTransactionPointItem;
|
||||
|
||||
const items = lastTransactionPoint?.items ?? [];
|
||||
const newItems = items.filter(
|
||||
(transactionPointItem) => transactionPointItem.symbol !== order.symbol
|
||||
);
|
||||
newItems.push(currentTransactionPointItem);
|
||||
newItems.sort((a, b) => a.symbol.localeCompare(b.symbol));
|
||||
if (lastDate !== currentDate || lastTransactionPoint === null) {
|
||||
lastTransactionPoint = {
|
||||
date: currentDate,
|
||||
items: newItems
|
||||
};
|
||||
this.transactionPoints.push(lastTransactionPoint);
|
||||
} else {
|
||||
lastTransactionPoint.items = newItems;
|
||||
}
|
||||
lastDate = currentDate;
|
||||
}
|
||||
}
|
||||
|
||||
public getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent
|
||||
}: {
|
||||
daysInMarket: number;
|
||||
netPerformancePercent: Big;
|
||||
}): Big {
|
||||
if (isNumber(daysInMarket) && daysInMarket > 0) {
|
||||
const exponent = new Big(365).div(daysInMarket).toNumber();
|
||||
return new Big(
|
||||
Math.pow(netPerformancePercent.plus(1).toNumber(), exponent)
|
||||
).minus(1);
|
||||
}
|
||||
|
||||
return new Big(0);
|
||||
}
|
||||
|
||||
public getTransactionPoints(): TransactionPoint[] {
|
||||
return this.transactionPoints;
|
||||
}
|
||||
|
||||
public setTransactionPoints(transactionPoints: TransactionPoint[]) {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getCurrentPositions(start: Date): Promise<CurrentPositions> {
|
||||
if (!this.transactionPoints?.length) {
|
||||
return {
|
||||
currentValue: new Big(0),
|
||||
hasErrors: false,
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
positions: [],
|
||||
totalInvestment: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const lastTransactionPoint =
|
||||
this.transactionPoints[this.transactionPoints.length - 1];
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
|
||||
let firstTransactionPoint: TransactionPoint = null;
|
||||
let firstIndex = this.transactionPoints.length;
|
||||
const dates = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
dates.push(resetHours(start));
|
||||
for (const item of this.transactionPoints[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
}
|
||||
for (let i = 0; i < this.transactionPoints.length; i++) {
|
||||
if (
|
||||
!isBefore(parseDate(this.transactionPoints[i].date), start) &&
|
||||
firstTransactionPoint === null
|
||||
) {
|
||||
firstTransactionPoint = this.transactionPoints[i];
|
||||
firstIndex = i;
|
||||
}
|
||||
if (firstTransactionPoint !== null) {
|
||||
dates.push(resetHours(parseDate(this.transactionPoints[i].date)));
|
||||
}
|
||||
}
|
||||
|
||||
dates.push(resetHours(today));
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const todayString = format(today, DATE_FORMAT);
|
||||
|
||||
if (firstIndex > 0) {
|
||||
firstIndex--;
|
||||
}
|
||||
const initialValues: { [symbol: string]: Big } = {};
|
||||
|
||||
const positions: TimelinePosition[] = [];
|
||||
let hasErrorsInSymbolMetrics = false;
|
||||
|
||||
for (const item of lastTransactionPoint.items) {
|
||||
const marketValue = marketSymbolMap[todayString]?.[item.symbol];
|
||||
|
||||
const {
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
initialValue,
|
||||
netPerformance,
|
||||
netPerformancePercentage
|
||||
} = this.getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol: item.symbol
|
||||
});
|
||||
|
||||
hasErrorsInSymbolMetrics = hasErrorsInSymbolMetrics || hasErrors;
|
||||
|
||||
initialValues[item.symbol] = initialValue;
|
||||
|
||||
positions.push({
|
||||
averagePrice: item.quantity.eq(0)
|
||||
? new Big(0)
|
||||
: item.investment.div(item.quantity),
|
||||
currency: item.currency,
|
||||
dataSource: item.dataSource,
|
||||
firstBuyDate: item.firstBuyDate,
|
||||
grossPerformance: !hasErrors ? grossPerformance ?? null : null,
|
||||
grossPerformancePercentage: !hasErrors
|
||||
? grossPerformancePercentage ?? null
|
||||
: null,
|
||||
investment: item.investment,
|
||||
marketPrice: marketValue?.toNumber() ?? null,
|
||||
netPerformance: !hasErrors ? netPerformance ?? null : null,
|
||||
netPerformancePercentage: !hasErrors
|
||||
? netPerformancePercentage ?? null
|
||||
: null,
|
||||
quantity: item.quantity,
|
||||
symbol: item.symbol,
|
||||
transactionCount: item.transactionCount
|
||||
});
|
||||
}
|
||||
const overall = this.calculateOverallPerformance(positions, initialValues);
|
||||
|
||||
return {
|
||||
...overall,
|
||||
positions,
|
||||
hasErrors: hasErrorsInSymbolMetrics || overall.hasErrors
|
||||
};
|
||||
}
|
||||
|
||||
public getSymbolMetrics({
|
||||
marketSymbolMap,
|
||||
start,
|
||||
symbol
|
||||
}: {
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
symbol: string;
|
||||
}) {
|
||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||
return order.symbol === symbol;
|
||||
});
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
hasErrors: false,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
const dateOfFirstTransaction = new Date(first(orders).date);
|
||||
const endDate = new Date(Date.now());
|
||||
|
||||
const unitPriceAtStartDate =
|
||||
marketSymbolMap[format(start, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
const unitPriceAtEndDate =
|
||||
marketSymbolMap[format(endDate, DATE_FORMAT)]?.[symbol];
|
||||
|
||||
if (
|
||||
!unitPriceAtEndDate ||
|
||||
(!unitPriceAtStartDate && isBefore(dateOfFirstTransaction, start))
|
||||
) {
|
||||
return {
|
||||
hasErrors: true,
|
||||
initialValue: new Big(0),
|
||||
netPerformance: new Big(0),
|
||||
netPerformancePercentage: new Big(0),
|
||||
grossPerformance: new Big(0),
|
||||
grossPerformancePercentage: new Big(0)
|
||||
};
|
||||
}
|
||||
|
||||
let feesAtStartDate = new Big(0);
|
||||
let fees = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformanceAtStartDate = new Big(0);
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastValueOfInvestment = new Big(0);
|
||||
let lastNetValueOfInvestment = new Big(0);
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
let totalUnits = new Big(0);
|
||||
|
||||
// Add a synthetic order at the start and the end date
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(start, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'start',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtStartDate ?? new Big(0)
|
||||
});
|
||||
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(endDate, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
itemType: 'end',
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice: unitPriceAtEndDate ?? new Big(0)
|
||||
});
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the right
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
let sortIndex = new Date(order.date);
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
sortIndex = addMilliseconds(sortIndex, -1);
|
||||
}
|
||||
|
||||
if (order.itemType === 'end') {
|
||||
sortIndex = addMilliseconds(sortIndex, 1);
|
||||
}
|
||||
|
||||
return sortIndex.getTime();
|
||||
});
|
||||
|
||||
const indexOfStartOrder = orders.findIndex((order) => {
|
||||
return order.itemType === 'start';
|
||||
});
|
||||
|
||||
for (let i = 0; i < orders.length; i += 1) {
|
||||
const order = orders[i];
|
||||
|
||||
const transactionInvestment = order.quantity.mul(order.unitPrice);
|
||||
|
||||
if (
|
||||
!initialValue &&
|
||||
order.itemType !== 'start' &&
|
||||
order.itemType !== 'end'
|
||||
) {
|
||||
initialValue = transactionInvestment;
|
||||
}
|
||||
|
||||
fees = fees.plus(order.fee);
|
||||
|
||||
totalUnits = totalUnits.plus(
|
||||
order.quantity.mul(this.getFactor(order.type))
|
||||
);
|
||||
|
||||
const valueOfInvestment = totalUnits.mul(order.unitPrice);
|
||||
const netValueOfInvestment = totalUnits.mul(order.unitPrice).sub(fees);
|
||||
|
||||
const grossPerformanceFromSell =
|
||||
order.type === TypeOfOrder.SELL
|
||||
? order.unitPrice.minus(lastAveragePrice).mul(order.quantity)
|
||||
: new Big(0);
|
||||
|
||||
grossPerformanceFromSells = grossPerformanceFromSells.plus(
|
||||
grossPerformanceFromSell
|
||||
);
|
||||
|
||||
totalInvestment = totalInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
.plus(grossPerformanceFromSell);
|
||||
|
||||
lastAveragePrice = totalUnits.eq(0)
|
||||
? new Big(0)
|
||||
: totalInvestment.div(totalUnits);
|
||||
|
||||
const newGrossPerformance = valueOfInvestment
|
||||
.minus(totalInvestment)
|
||||
.plus(grossPerformanceFromSells);
|
||||
|
||||
if (
|
||||
i > indexOfStartOrder &&
|
||||
!lastValueOfInvestment
|
||||
.plus(transactionInvestment.mul(this.getFactor(order.type)))
|
||||
.eq(0)
|
||||
) {
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.mul(
|
||||
new Big(1).plus(
|
||||
valueOfInvestment
|
||||
.minus(
|
||||
lastValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.mul(
|
||||
new Big(1).plus(
|
||||
netValueOfInvestment
|
||||
.minus(
|
||||
lastNetValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
.div(
|
||||
lastNetValueOfInvestment.plus(
|
||||
transactionInvestment.mul(this.getFactor(order.type))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
grossPerformance = newGrossPerformance;
|
||||
lastNetValueOfInvestment = netValueOfInvestment;
|
||||
lastValueOfInvestment = valueOfInvestment;
|
||||
|
||||
if (order.itemType === 'start') {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
timeWeightedGrossPerformancePercentage.sub(1);
|
||||
|
||||
timeWeightedNetPerformancePercentage =
|
||||
timeWeightedNetPerformancePercentage.sub(1);
|
||||
|
||||
const totalGrossPerformance = grossPerformance.minus(
|
||||
grossPerformanceAtStartDate
|
||||
);
|
||||
|
||||
const totalNetPerformance = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
return {
|
||||
initialValue,
|
||||
hasErrors: !initialValue || !unitPriceAtEndDate,
|
||||
netPerformance: totalNetPerformance,
|
||||
netPerformancePercentage: timeWeightedNetPerformancePercentage,
|
||||
grossPerformance: totalGrossPerformance,
|
||||
grossPerformancePercentage: timeWeightedGrossPerformancePercentage
|
||||
};
|
||||
}
|
||||
|
||||
public getInvestments(): { date: string; investment: Big }[] {
|
||||
if (this.transactionPoints.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.transactionPoints.map((transactionPoint) => {
|
||||
return {
|
||||
date: transactionPoint.date,
|
||||
investment: transactionPoint.items.reduce(
|
||||
(investment, transactionPointSymbol) =>
|
||||
investment.add(transactionPointSymbol.investment),
|
||||
new Big(0)
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async calculateTimeline(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
endDate: string
|
||||
): Promise<TimelineInfoInterface> {
|
||||
if (timelineSpecification.length === 0) {
|
||||
return {
|
||||
maxNetPerformance: new Big(0),
|
||||
minNetPerformance: new Big(0),
|
||||
timelinePeriods: []
|
||||
};
|
||||
}
|
||||
|
||||
const startDate = timelineSpecification[0].start;
|
||||
const start = parseDate(startDate);
|
||||
const end = parseDate(endDate);
|
||||
|
||||
const timelinePeriodPromises: Promise<TimelineInfoInterface>[] = [];
|
||||
let i = 0;
|
||||
let j = -1;
|
||||
for (
|
||||
let currentDate = start;
|
||||
!isAfter(currentDate, end);
|
||||
currentDate = this.addToDate(
|
||||
currentDate,
|
||||
timelineSpecification[i].accuracy
|
||||
)
|
||||
) {
|
||||
if (this.isNextItemActive(timelineSpecification, currentDate, i)) {
|
||||
i++;
|
||||
}
|
||||
while (
|
||||
j + 1 < this.transactionPoints.length &&
|
||||
!isAfter(parseDate(this.transactionPoints[j + 1].date), currentDate)
|
||||
) {
|
||||
j++;
|
||||
}
|
||||
|
||||
let periodEndDate = currentDate;
|
||||
if (timelineSpecification[i].accuracy === 'day') {
|
||||
let nextEndDate = end;
|
||||
if (j + 1 < this.transactionPoints.length) {
|
||||
nextEndDate = parseDate(this.transactionPoints[j + 1].date);
|
||||
}
|
||||
periodEndDate = min([
|
||||
addMonths(currentDate, 3),
|
||||
max([currentDate, nextEndDate])
|
||||
]);
|
||||
}
|
||||
const timePeriodForDates = this.getTimePeriodForDate(
|
||||
j,
|
||||
currentDate,
|
||||
endOfDay(periodEndDate)
|
||||
);
|
||||
currentDate = periodEndDate;
|
||||
if (timePeriodForDates != null) {
|
||||
timelinePeriodPromises.push(timePeriodForDates);
|
||||
}
|
||||
}
|
||||
|
||||
const timelineInfoInterfaces: TimelineInfoInterface[] = await Promise.all(
|
||||
timelinePeriodPromises
|
||||
);
|
||||
const minNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.minNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((minPerformance, current) => {
|
||||
if (minPerformance.lt(current)) {
|
||||
return minPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
|
||||
const maxNetPerformance = timelineInfoInterfaces
|
||||
.map((timelineInfo) => timelineInfo.maxNetPerformance)
|
||||
.filter((performance) => performance !== null)
|
||||
.reduce((maxPerformance, current) => {
|
||||
if (maxPerformance.gt(current)) {
|
||||
return maxPerformance;
|
||||
} else {
|
||||
return current;
|
||||
}
|
||||
});
|
||||
|
||||
const timelinePeriods = timelineInfoInterfaces.map(
|
||||
(timelineInfo) => timelineInfo.timelinePeriods
|
||||
);
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: flatten(timelinePeriods)
|
||||
};
|
||||
}
|
||||
|
||||
private calculateOverallPerformance(
|
||||
positions: TimelinePosition[],
|
||||
initialValues: { [p: string]: Big }
|
||||
) {
|
||||
let hasErrors = false;
|
||||
let currentValue = new Big(0);
|
||||
let totalInvestment = new Big(0);
|
||||
let grossPerformance = new Big(0);
|
||||
let grossPerformancePercentage = new Big(0);
|
||||
let netPerformance = new Big(0);
|
||||
let netPerformancePercentage = new Big(0);
|
||||
let completeInitialValue = new Big(0);
|
||||
|
||||
for (const currentPosition of positions) {
|
||||
if (currentPosition.marketPrice) {
|
||||
currentValue = currentValue.add(
|
||||
new Big(currentPosition.marketPrice).mul(currentPosition.quantity)
|
||||
);
|
||||
} else {
|
||||
hasErrors = true;
|
||||
}
|
||||
totalInvestment = totalInvestment.add(currentPosition.investment);
|
||||
if (currentPosition.grossPerformance) {
|
||||
grossPerformance = grossPerformance.plus(
|
||||
currentPosition.grossPerformance
|
||||
);
|
||||
netPerformance = netPerformance.plus(currentPosition.netPerformance);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
hasErrors = true;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPosition.grossPerformancePercentage &&
|
||||
initialValues[currentPosition.symbol]
|
||||
) {
|
||||
const currentInitialValue = initialValues[currentPosition.symbol];
|
||||
completeInitialValue = completeInitialValue.plus(currentInitialValue);
|
||||
grossPerformancePercentage = grossPerformancePercentage.plus(
|
||||
currentPosition.grossPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
netPerformancePercentage = netPerformancePercentage.plus(
|
||||
currentPosition.netPerformancePercentage.mul(currentInitialValue)
|
||||
);
|
||||
} else if (!currentPosition.quantity.eq(0)) {
|
||||
Logger.warn(
|
||||
`Missing initial value for symbol ${currentPosition.symbol} at ${currentPosition.firstBuyDate}`
|
||||
);
|
||||
hasErrors = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!completeInitialValue.eq(0)) {
|
||||
grossPerformancePercentage =
|
||||
grossPerformancePercentage.div(completeInitialValue);
|
||||
netPerformancePercentage =
|
||||
netPerformancePercentage.div(completeInitialValue);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValue,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netPerformance,
|
||||
netPerformancePercentage,
|
||||
totalInvestment
|
||||
};
|
||||
}
|
||||
|
||||
private async getTimePeriodForDate(
|
||||
j: number,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
): Promise<TimelineInfoInterface> {
|
||||
let investment: Big = new Big(0);
|
||||
let fees: Big = new Big(0);
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
if (j >= 0) {
|
||||
const currencies: { [name: string]: string } = {};
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
currencies[item.symbol] = item.currency;
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
investment = investment.add(item.investment);
|
||||
fees = fees.add(item.fee);
|
||||
}
|
||||
|
||||
let marketSymbols: GetValueObject[] = [];
|
||||
if (dataGatheringItems.length > 0) {
|
||||
try {
|
||||
marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
gte: startDate,
|
||||
lt: endOfDay(endDate)
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to fetch info for date ${startDate} with exception`,
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const date = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[date]) {
|
||||
marketSymbolMap[date] = {};
|
||||
}
|
||||
if (marketSymbol.marketPrice) {
|
||||
marketSymbolMap[date][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPrice
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const results: TimelinePeriod[] = [];
|
||||
let maxNetPerformance: Big = null;
|
||||
let minNetPerformance: Big = null;
|
||||
for (
|
||||
let currentDate = startDate;
|
||||
isBefore(currentDate, endDate);
|
||||
currentDate = addDays(currentDate, 1)
|
||||
) {
|
||||
let value = new Big(0);
|
||||
const currentDateAsString = format(currentDate, DATE_FORMAT);
|
||||
let invalid = false;
|
||||
if (j >= 0) {
|
||||
for (const item of this.transactionPoints[j].items) {
|
||||
if (
|
||||
!marketSymbolMap[currentDateAsString]?.hasOwnProperty(item.symbol)
|
||||
) {
|
||||
invalid = true;
|
||||
break;
|
||||
}
|
||||
value = value.add(
|
||||
item.quantity.mul(marketSymbolMap[currentDateAsString][item.symbol])
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!invalid) {
|
||||
const grossPerformance = value.minus(investment);
|
||||
const netPerformance = grossPerformance.minus(fees);
|
||||
if (
|
||||
minNetPerformance === null ||
|
||||
minNetPerformance.gt(netPerformance)
|
||||
) {
|
||||
minNetPerformance = netPerformance;
|
||||
}
|
||||
if (
|
||||
maxNetPerformance === null ||
|
||||
maxNetPerformance.lt(netPerformance)
|
||||
) {
|
||||
maxNetPerformance = netPerformance;
|
||||
}
|
||||
|
||||
const result = {
|
||||
grossPerformance,
|
||||
investment,
|
||||
netPerformance,
|
||||
value,
|
||||
date: currentDateAsString
|
||||
};
|
||||
results.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
maxNetPerformance,
|
||||
minNetPerformance,
|
||||
timelinePeriods: results
|
||||
};
|
||||
}
|
||||
|
||||
private getFactor(type: TypeOfOrder) {
|
||||
let factor: number;
|
||||
|
||||
switch (type) {
|
||||
case 'BUY':
|
||||
factor = 1;
|
||||
break;
|
||||
case 'SELL':
|
||||
factor = -1;
|
||||
break;
|
||||
default:
|
||||
factor = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return factor;
|
||||
}
|
||||
|
||||
private addToDate(date: Date, accuracy: Accuracy): Date {
|
||||
switch (accuracy) {
|
||||
case 'day':
|
||||
return addDays(date, 1);
|
||||
case 'month':
|
||||
return addMonths(date, 1);
|
||||
case 'year':
|
||||
return addYears(date, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private isNextItemActive(
|
||||
timelineSpecification: TimelineSpecification[],
|
||||
currentDate: Date,
|
||||
i: number
|
||||
) {
|
||||
return (
|
||||
i + 1 < timelineSpecification.length &&
|
||||
!isBefore(currentDate, parseDate(timelineSpecification[i + 1].start))
|
||||
);
|
||||
}
|
||||
}
|
@ -1,17 +1,9 @@
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
differenceInCalendarDays,
|
||||
endOfDay,
|
||||
format,
|
||||
isBefore,
|
||||
isSameDay
|
||||
} from 'date-fns';
|
||||
import { addDays, endOfDay, format, isBefore, isSameDay } from 'date-fns';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { GetValueParams } from './interfaces/get-value-params.interface';
|
||||
import { GetValuesParams } from './interfaces/get-values-params.interface';
|
||||
import { PortfolioOrder } from './interfaces/portfolio-order.interface';
|
||||
import { TimelinePeriod } from './interfaces/timeline-period.interface';
|
||||
@ -275,9 +267,6 @@ jest.mock('./current-rate.service', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
CurrentRateService: jest.fn().mockImplementation(() => {
|
||||
return {
|
||||
getValue: ({ date, symbol }: GetValueParams) => {
|
||||
return Promise.resolve(mockGetValue(symbol, date));
|
||||
},
|
||||
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
|
||||
const result = [];
|
||||
if (dateQuery.lt) {
|
||||
|
25
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
25
apps/api/src/app/portfolio/portfolio-service.strategy.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioServiceStrategy {
|
||||
public constructor(
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceNew: PortfolioServiceNew,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
|
||||
public get() {
|
||||
if (
|
||||
this.request.user?.Settings?.settings?.['isNewCalculationEngine'] === true
|
||||
) {
|
||||
return this.portfolioServiceNew;
|
||||
}
|
||||
|
||||
return this.portfolioService;
|
||||
}
|
||||
}
|
@ -35,7 +35,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
@ -43,7 +43,7 @@ export class PortfolioController {
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
@ -55,10 +55,9 @@ export class PortfolioController {
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
const historicalDataContainer = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getChart(impersonationId, range);
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
@ -116,11 +115,9 @@ export class PortfolioController {
|
||||
let hasError = false;
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range
|
||||
);
|
||||
await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getDetails(impersonationId, this.request.user.id, range);
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
@ -178,9 +175,9 @@ export class PortfolioController {
|
||||
return <any>res.json({});
|
||||
}
|
||||
|
||||
let investments = await this.portfolioService.getInvestments(
|
||||
impersonationId
|
||||
);
|
||||
let investments = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getInvestments(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -207,10 +204,9 @@ export class PortfolioController {
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
const performanceInformation = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPerformance(impersonationId, range);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -232,10 +228,9 @@ export class PortfolioController {
|
||||
@Query('range') range,
|
||||
@Res() res: Response
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
const result = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPositions(impersonationId, range);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -274,10 +269,9 @@ export class PortfolioController {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId
|
||||
);
|
||||
const { holdings } = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getDetails(access.userId, access.userId);
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
@ -318,7 +312,9 @@ export class PortfolioController {
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioSummary> {
|
||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||
let summary = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getSummary(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
@ -347,10 +343,9 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Param('symbol') symbol
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
let position = await this.portfolioService.getPosition(
|
||||
impersonationId,
|
||||
symbol
|
||||
);
|
||||
let position = await this.portfolioServiceStrategy
|
||||
.get()
|
||||
.getPosition(impersonationId, symbol);
|
||||
|
||||
if (position) {
|
||||
if (
|
||||
@ -391,7 +386,9 @@ export class PortfolioController {
|
||||
}
|
||||
|
||||
return <any>(
|
||||
res.json(await this.portfolioService.getReport(impersonationId))
|
||||
res.json(
|
||||
await this.portfolioServiceStrategy.get().getReport(impersonationId)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -13,12 +13,14 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
|
||||
import { PortfolioController } from './portfolio.controller';
|
||||
import { PortfolioService } from './portfolio.service';
|
||||
import { PortfolioServiceNew } from './portfolio.service-new';
|
||||
import { RulesService } from './rules.service';
|
||||
|
||||
@Module({
|
||||
exports: [PortfolioService],
|
||||
exports: [PortfolioServiceStrategy],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ConfigurationModule,
|
||||
@ -37,6 +39,8 @@ import { RulesService } from './rules.service';
|
||||
AccountService,
|
||||
CurrentRateService,
|
||||
PortfolioService,
|
||||
PortfolioServiceNew,
|
||||
PortfolioServiceStrategy,
|
||||
RulesService
|
||||
]
|
||||
})
|
||||
|
1206
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
1206
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
File diff suppressed because it is too large
Load Diff
@ -107,7 +107,7 @@ export class PortfolioService {
|
||||
account.currency,
|
||||
userCurrency
|
||||
),
|
||||
value: details.accounts[account.name]?.current ?? 0
|
||||
value: details.accounts[account.id]?.current ?? 0
|
||||
};
|
||||
|
||||
delete result.Order;
|
||||
@ -388,11 +388,12 @@ export class PortfolioService {
|
||||
aImpersonationId: string,
|
||||
aSymbol: string
|
||||
): Promise<PortfolioPositionDetail> {
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const orders = (await this.orderService.getOrders({ userId })).filter(
|
||||
(order) => order.symbol === aSymbol
|
||||
);
|
||||
const orders = (
|
||||
await this.orderService.getOrders({ userCurrency, userId })
|
||||
).filter((order) => order.symbol === aSymbol);
|
||||
|
||||
if (orders.length <= 0) {
|
||||
return {
|
||||
@ -428,7 +429,7 @@ export class PortfolioService {
|
||||
})
|
||||
.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(order.fee),
|
||||
name: order.SymbolProfile?.name,
|
||||
@ -846,24 +847,25 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||
const currency = this.request.user.Settings.currency;
|
||||
const userCurrency = this.request.user.Settings.currency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
const { balance } = await this.accountService.getCashDetails(
|
||||
userId,
|
||||
currency
|
||||
userCurrency
|
||||
);
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, currency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, currency, 'SELL');
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const committedFunds = new Big(totalBuy).sub(totalSell);
|
||||
|
||||
@ -879,6 +881,8 @@ export class PortfolioService {
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
annualizedPerformancePercent:
|
||||
performanceInformation.performance.annualizedPerformancePercent,
|
||||
cash: balance,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
ordersCount: orders.filter((order) => {
|
||||
@ -895,8 +899,8 @@ export class PortfolioService {
|
||||
}: {
|
||||
cashDetails: CashDetails;
|
||||
investment: Big;
|
||||
value: Big;
|
||||
userCurrency: string;
|
||||
value: Big;
|
||||
}) {
|
||||
const cashPositions = {};
|
||||
|
||||
@ -1025,8 +1029,11 @@ export class PortfolioService {
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
}> {
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
|
||||
const orders = await this.orderService.getOrders({
|
||||
includeDrafts,
|
||||
userCurrency,
|
||||
userId,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
@ -1035,10 +1042,9 @@ export class PortfolioService {
|
||||
return { transactionPoints: [], orders: [] };
|
||||
}
|
||||
|
||||
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
|
||||
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
|
||||
currency: order.currency,
|
||||
dataSource: order.dataSource,
|
||||
dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
|
||||
date: format(order.date, DATE_FORMAT),
|
||||
fee: new Big(
|
||||
this.exchangeRateDataService.toCurrency(
|
||||
@ -1091,10 +1097,11 @@ export class PortfolioService {
|
||||
account.currency,
|
||||
userCurrency
|
||||
);
|
||||
accounts[account.name] = {
|
||||
accounts[account.id] = {
|
||||
balance: convertedBalance,
|
||||
currency: account.currency,
|
||||
current: convertedBalance,
|
||||
name: account.name,
|
||||
original: convertedBalance
|
||||
};
|
||||
|
||||
@ -1108,16 +1115,17 @@ export class PortfolioService {
|
||||
originalValueOfSymbol *= -1;
|
||||
}
|
||||
|
||||
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].current +=
|
||||
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].current +=
|
||||
currentValueOfSymbol;
|
||||
accounts[order.Account?.name || UNKNOWN_KEY].original +=
|
||||
accounts[order.Account?.id || UNKNOWN_KEY].original +=
|
||||
originalValueOfSymbol;
|
||||
} else {
|
||||
accounts[order.Account?.name || UNKNOWN_KEY] = {
|
||||
accounts[order.Account?.id || UNKNOWN_KEY] = {
|
||||
balance: 0,
|
||||
currency: order.Account?.currency,
|
||||
current: currentValueOfSymbol,
|
||||
name: account.name,
|
||||
original: originalValueOfSymbol
|
||||
};
|
||||
}
|
||||
|
@ -1,17 +1,12 @@
|
||||
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
Controller,
|
||||
DefaultValuePipe,
|
||||
Get,
|
||||
HttpException,
|
||||
Inject,
|
||||
Param,
|
||||
ParseBoolPipe,
|
||||
Query,
|
||||
UseGuards
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
@ -23,10 +18,7 @@ import { SymbolService } from './symbol.service';
|
||||
|
||||
@Controller('symbol')
|
||||
export class SymbolController {
|
||||
public constructor(
|
||||
private readonly symbolService: SymbolService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser
|
||||
) {}
|
||||
public constructor(private readonly symbolService: SymbolService) {}
|
||||
|
||||
/**
|
||||
* Must be before /:symbol
|
||||
@ -54,8 +46,7 @@ export class SymbolController {
|
||||
public async getSymbolData(
|
||||
@Param('dataSource') dataSource: DataSource,
|
||||
@Param('symbol') symbol: string,
|
||||
@Query('includeHistoricalData', new DefaultValuePipe(false), ParseBoolPipe)
|
||||
includeHistoricalData: boolean
|
||||
@Query('includeHistoricalData') includeHistoricalData?: number
|
||||
): Promise<SymbolItem> {
|
||||
if (!DataSource[dataSource]) {
|
||||
throw new HttpException(
|
||||
|
@ -5,7 +5,6 @@ import {
|
||||
IDataProviderHistoricalResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -18,25 +17,24 @@ import { SymbolItem } from './interfaces/symbol-item.interface';
|
||||
export class SymbolService {
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly prismaService: PrismaService
|
||||
private readonly marketDataService: MarketDataService
|
||||
) {}
|
||||
|
||||
public async get({
|
||||
dataGatheringItem,
|
||||
includeHistoricalData = false
|
||||
includeHistoricalData
|
||||
}: {
|
||||
dataGatheringItem: IDataGatheringItem;
|
||||
includeHistoricalData?: boolean;
|
||||
includeHistoricalData?: number;
|
||||
}): Promise<SymbolItem> {
|
||||
const response = await this.dataProviderService.get([dataGatheringItem]);
|
||||
const { currency, marketPrice } = response[dataGatheringItem.symbol] ?? {};
|
||||
|
||||
if (dataGatheringItem.dataSource && marketPrice) {
|
||||
let historicalData: HistoricalDataItem[];
|
||||
let historicalData: HistoricalDataItem[] = [];
|
||||
|
||||
if (includeHistoricalData) {
|
||||
const days = 30;
|
||||
if (includeHistoricalData > 0) {
|
||||
const days = includeHistoricalData;
|
||||
|
||||
const marketData = await this.marketDataService.getRange({
|
||||
dateQuery: { gte: subDays(new Date(), days) },
|
||||
|
@ -1,6 +1,11 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class UpdateUserSettingDto {
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isNewCalculationEngine?: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isRestrictedView?: boolean;
|
||||
}
|
||||
|
@ -23,7 +23,7 @@ import {
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { Provider, Role } from '@prisma/client';
|
||||
import { Provider } from '@prisma/client';
|
||||
import { User as UserModel } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
@ -115,6 +115,12 @@ export class UserController {
|
||||
...data
|
||||
};
|
||||
|
||||
for (const key in userSettings) {
|
||||
if (userSettings[key] === false) {
|
||||
delete userSettings[key];
|
||||
}
|
||||
}
|
||||
|
||||
return await this.userService.updateUserSetting({
|
||||
userSettings,
|
||||
userId: this.request.user.id
|
||||
|
@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
accounts[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].current
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.current
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(accounts).forEach((account) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = account;
|
||||
}
|
||||
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
|
||||
}
|
||||
|
||||
public evaluate(ruleSettings?: Settings) {
|
||||
const platforms: {
|
||||
const accounts: {
|
||||
[symbol: string]: Pick<PortfolioPosition, 'name'> & {
|
||||
investment: number;
|
||||
};
|
||||
} = {};
|
||||
|
||||
for (const account of Object.keys(this.accounts)) {
|
||||
platforms[account] = {
|
||||
name: account,
|
||||
investment: this.accounts[account].original
|
||||
for (const [accountId, account] of Object.entries(this.accounts)) {
|
||||
accounts[accountId] = {
|
||||
name: account.name,
|
||||
investment: account.original
|
||||
};
|
||||
}
|
||||
|
||||
let maxItem;
|
||||
let totalInvestment = 0;
|
||||
|
||||
Object.values(platforms).forEach((platform) => {
|
||||
for (const account of Object.values(accounts)) {
|
||||
if (!maxItem) {
|
||||
maxItem = platform;
|
||||
maxItem = account;
|
||||
}
|
||||
|
||||
// Calculate total investment
|
||||
totalInvestment += platform.investment;
|
||||
totalInvestment += account.investment;
|
||||
|
||||
// Find maximum
|
||||
if (platform.investment > maxItem?.investment) {
|
||||
maxItem = platform;
|
||||
if (account.investment > maxItem?.investment) {
|
||||
maxItem = account;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const maxInvestmentRatio = maxItem.investment / totalInvestment;
|
||||
|
||||
|
@ -12,7 +12,7 @@ import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { format, isValid } from 'date-fns';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { groupBy, isEmpty } from 'lodash';
|
||||
|
||||
@Injectable()
|
||||
export class DataProviderService {
|
||||
@ -30,18 +30,27 @@ export class DataProviderService {
|
||||
[symbol: string]: IDataProviderResponse;
|
||||
} = {};
|
||||
|
||||
for (const item of items) {
|
||||
const dataProvider = this.getDataProvider(item.dataSource);
|
||||
response[item.symbol] = (await dataProvider.get([item.symbol]))[
|
||||
item.symbol
|
||||
];
|
||||
}
|
||||
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
|
||||
|
||||
const promises = [];
|
||||
for (const symbol of Object.keys(response)) {
|
||||
const promise = Promise.resolve(response[symbol]);
|
||||
|
||||
for (const [dataSource, dataGatheringItems] of Object.entries(
|
||||
itemsGroupedByDataSource
|
||||
)) {
|
||||
const symbols = dataGatheringItems.map((dataGatheringItem) => {
|
||||
return dataGatheringItem.symbol;
|
||||
});
|
||||
|
||||
const promise = Promise.resolve(
|
||||
this.getDataProvider(DataSource[dataSource]).get(symbols)
|
||||
);
|
||||
|
||||
promises.push(
|
||||
promise.then((currentResponse) => (response[symbol] = currentResponse))
|
||||
promise.then((result) => {
|
||||
for (const [symbol, dataProviderResponse] of Object.entries(result)) {
|
||||
response[symbol] = dataProviderResponse;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -8,7 +8,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 } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
@ -35,27 +35,36 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
try {
|
||||
const [symbol] = aSymbols;
|
||||
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
|
||||
[symbol]
|
||||
const response: { [symbol: string]: IDataProviderResponse } = {};
|
||||
|
||||
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols
|
||||
);
|
||||
|
||||
const sheet = await this.getSheet({
|
||||
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),
|
||||
symbol
|
||||
symbol: 'Overview'
|
||||
});
|
||||
const marketPrice = parseFloat(
|
||||
(await sheet.getCellByA1('B1').value) as string
|
||||
);
|
||||
|
||||
return {
|
||||
[symbol]: {
|
||||
marketPrice,
|
||||
currency: symbolProfile?.currency,
|
||||
dataSource: this.getName(),
|
||||
marketState: MarketState.delayed
|
||||
const rows = await sheet.getRows();
|
||||
|
||||
for (const row of rows) {
|
||||
const marketPrice = parseFloat(row['marketPrice']);
|
||||
const symbol = row['symbol'];
|
||||
|
||||
if (aSymbols.includes(symbol)) {
|
||||
response[symbol] = {
|
||||
marketPrice,
|
||||
currency: symbolProfiles.find((symbolProfile) => {
|
||||
return symbolProfile.symbol === symbol;
|
||||
})?.currency,
|
||||
dataSource: this.getName(),
|
||||
marketState: MarketState.delayed
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
}
|
||||
@ -94,7 +103,7 @@ export class GoogleSheetsService implements DataProviderInterface {
|
||||
return index >= 1;
|
||||
})
|
||||
.forEach((row) => {
|
||||
const date = new Date(row._rawData[0]);
|
||||
const date = parseDate(row._rawData[0]);
|
||||
const close = parseFloat(row._rawData[1]);
|
||||
|
||||
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
|
||||
|
@ -58,9 +58,9 @@ export class ExchangeRateDataService {
|
||||
getYesterday()
|
||||
);
|
||||
|
||||
if (isEmpty(result)) {
|
||||
if (Object.keys(result).length !== this.currencyPairs.length) {
|
||||
// Load currencies directly from data provider as a fallback
|
||||
// if historical data is not yet available
|
||||
// if historical data is not fully available
|
||||
const historicalData = await this.dataProviderService.get(
|
||||
this.currencyPairs.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
|
@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.tokenStorageService.signOut();
|
||||
this.userService.remove();
|
||||
|
||||
this.router.navigate(['/']);
|
||||
document.location.href = '/';
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -15,7 +15,7 @@
|
||||
>(Default)</span
|
||||
>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell>Total</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell i18n>Total</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
|
@ -6,7 +6,9 @@
|
||||
<tr class="mat-header-row">
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Symbol</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Data Source</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Transaction</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>First Activity</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Activity Count</th>
|
||||
<th class="mat-header-cell px-1 py-2" i18n>Historical Data</th>
|
||||
<th class="mat-header-cell px-1 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -16,11 +18,13 @@
|
||||
class="cursor-pointer mat-row"
|
||||
(click)="setCurrentSymbol(item.symbol)"
|
||||
>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource}}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.symbol }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.dataSource }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
{{ (item.date | date: defaultDateFormat) ?? '' }}
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.activityCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">{{ item.marketDataItemCount }}</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<button
|
||||
class="mx-1 no-min-width px-2"
|
||||
|
@ -20,6 +20,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public historicalData: HistoricalDataItem[];
|
||||
public isLoading = true;
|
||||
public readonly numberOfDays = 90;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -49,7 +50,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
this.dataService
|
||||
.fetchSymbolItem({
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
includeHistoricalData: true,
|
||||
includeHistoricalData: this.numberOfDays,
|
||||
symbol: ghostfolioFearAndGreedIndexSymbol
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
|
@ -1,18 +1,10 @@
|
||||
<div
|
||||
class="
|
||||
align-items-center
|
||||
container
|
||||
d-flex
|
||||
flex-grow-1
|
||||
h-100
|
||||
justify-content-center
|
||||
w-100
|
||||
"
|
||||
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
|
||||
>
|
||||
<div class="no-gutters row w-100">
|
||||
<div class="col-xs-12 col-md-8 offset-md-2">
|
||||
<div class="mb-2 text-center text-muted">
|
||||
<small i18n>Last 30 Days</small>
|
||||
<small i18n>Last {{ numberOfDays }} Days</small>
|
||||
</div>
|
||||
<gf-line-chart
|
||||
class="mb-5"
|
||||
|
@ -13,7 +13,7 @@
|
||||
[showYAxis]="false"
|
||||
></gf-line-chart>
|
||||
<div
|
||||
*ngIf="hasPermissionToCreateOrder&& historicalDataItems?.length === 0"
|
||||
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
|
||||
class="align-items-center d-flex h-100 justify-content-center w-100"
|
||||
>
|
||||
<div class="d-flex justify-content-center">
|
||||
|
@ -12,7 +12,7 @@
|
||||
<div class="col-12 d-flex justify-content-center mb-3">
|
||||
<gf-value
|
||||
size="large"
|
||||
[currency]="currency"
|
||||
[currency]="data.baseCurrency"
|
||||
[locale]="data.locale"
|
||||
[value]="value"
|
||||
></gf-value>
|
||||
|
@ -132,16 +132,35 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.slackCommunityUsers ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Users in Slack community</div>
|
||||
<a
|
||||
class="d-block"
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>
|
||||
<h3 class="mb-0">
|
||||
{{ statistics?.slackCommunityUsers ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Users in Slack community</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.gitHubContributors ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||
<a
|
||||
class="d-block"
|
||||
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||
>
|
||||
<h3 class="mb-0">
|
||||
{{ statistics?.gitHubContributors ?? '-' }}
|
||||
</h3>
|
||||
<div class="h6 mb-0" i18n>Contributors on GitHub</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||
<a
|
||||
class="d-block"
|
||||
href="https://github.com/ghostfolio/ghostfolio/stargazers"
|
||||
>
|
||||
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
|
||||
<div class="h6 mb-0" i18n>Stars on GitHub</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
@ -150,22 +169,28 @@
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
|
||||
<a class="py-2 w-100" i18n mat-stroked-button [routerLink]="['/blog']"
|
||||
>Blog</a
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-6 col-xs-12 my-2"
|
||||
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
|
||||
>
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-stroked-button
|
||||
[routerLink]="['/about', 'changelog']"
|
||||
>Changelog & License</a
|
||||
>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForBlog" class="col-md-6 col-xs-12 my-2">
|
||||
<a
|
||||
class="py-2 w-100"
|
||||
color="primary"
|
||||
i18n
|
||||
mat-flat-button
|
||||
[routerLink]="['/blog']"
|
||||
>Blog</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,13 +2,8 @@
|
||||
color: rgb(var(--dark-primary-text));
|
||||
display: block;
|
||||
|
||||
a {
|
||||
color: rgb(var(--dark-primary-text));
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
&.about-container,
|
||||
&.changelog {
|
||||
&.about-container {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
font-weight: 500;
|
||||
@ -19,29 +14,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.changelog {
|
||||
::ng-deep {
|
||||
markdown {
|
||||
h1,
|
||||
p {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 18px;
|
||||
|
||||
&:not(:first-of-type) {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.independent-and-bootstrapped-logo {
|
||||
background-image: url('/assets/bootstrapped-dark.svg');
|
||||
background-position: center;
|
||||
@ -57,10 +29,6 @@
|
||||
:host-context(.is-dark-theme) {
|
||||
color: rgb(var(--light-primary-text));
|
||||
|
||||
a {
|
||||
color: rgb(var(--light-primary-text));
|
||||
}
|
||||
|
||||
.mat-card {
|
||||
.independent-and-bootstrapped-logo {
|
||||
background-image: url('/assets/bootstrapped-light.svg');
|
||||
|
@ -192,6 +192,24 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onNewCalculationChange(aEvent: MatSlideToggleChange) {
|
||||
this.dataService
|
||||
.putUserSetting({ isNewCalculationEngine: aEvent.checked })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public onRedeemCoupon() {
|
||||
let couponCode = prompt('Please enter your coupon code:');
|
||||
couponCode = couponCode?.trim();
|
||||
|
@ -135,6 +135,23 @@
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
*ngIf="user?.subscription"
|
||||
class="align-items-center d-flex mt-4 py-1"
|
||||
>
|
||||
<div class="pr-1 w-50">
|
||||
<div i18n>New Calculation Engine</div>
|
||||
<div class="hint-text text-muted" i18n>Experimental</div>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-slide-toggle
|
||||
color="primary"
|
||||
[checked]="user.settings.isNewCalculationEngine"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
(change)="onNewCalculationChange($event)"
|
||||
></mat-slide-toggle>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
|
@ -162,10 +162,10 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
};
|
||||
|
||||
for (const [name, { current, original }] of Object.entries(
|
||||
for (const [id, { current, name, original }] of Object.entries(
|
||||
this.portfolioDetails.accounts
|
||||
)) {
|
||||
this.accounts[name] = {
|
||||
this.accounts[id] = {
|
||||
name,
|
||||
value: aPeriod === 'original' ? original : current
|
||||
};
|
||||
|
@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
@ -28,6 +29,7 @@ import { ImportTransactionDialog } from './import-transaction-dialog/import-tran
|
||||
templateUrl: './transactions-page.html'
|
||||
})
|
||||
export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
public activities: Activity[];
|
||||
public defaultAccountId: string;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
@ -35,7 +37,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionToDeleteOrder: boolean;
|
||||
public hasPermissionToImportOrders: boolean;
|
||||
public routeQueryParams: Subscription;
|
||||
public transactions: OrderModel[];
|
||||
public user: User;
|
||||
|
||||
private primaryDataSource: DataSource;
|
||||
@ -65,8 +66,8 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
if (params['createDialog']) {
|
||||
this.openCreateTransactionDialog();
|
||||
} else if (params['editDialog']) {
|
||||
if (this.transactions) {
|
||||
const transaction = this.transactions.find(({ id }) => {
|
||||
if (this.activities) {
|
||||
const transaction = this.activities.find(({ id }) => {
|
||||
return id === params['transactionId'];
|
||||
});
|
||||
|
||||
@ -106,20 +107,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.defaultAccountId = this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
this.hasPermissionToDeleteOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.deleteOrder
|
||||
);
|
||||
this.updateUser(state.user);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
@ -132,10 +120,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
this.dataService
|
||||
.fetchOrders()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response) => {
|
||||
this.transactions = response;
|
||||
.subscribe(({ activities }) => {
|
||||
this.activities = activities;
|
||||
|
||||
if (this.hasPermissionToCreateOrder && this.transactions?.length <= 0) {
|
||||
if (this.hasPermissionToCreateOrder && this.activities?.length <= 0) {
|
||||
this.router.navigate([], { queryParams: { createDialog: true } });
|
||||
}
|
||||
|
||||
@ -352,43 +340,50 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private openCreateTransactionDialog(aTransaction?: OrderModel): void {
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||
data: {
|
||||
accounts: this.user?.accounts?.filter((account) => {
|
||||
return account.accountType === 'SECURITIES';
|
||||
}),
|
||||
transaction: {
|
||||
accountId: aTransaction?.accountId ?? this.defaultAccountId,
|
||||
currency: aTransaction?.currency ?? null,
|
||||
dataSource: aTransaction?.dataSource ?? null,
|
||||
date: new Date(),
|
||||
fee: 0,
|
||||
quantity: null,
|
||||
symbol: aTransaction?.symbol ?? null,
|
||||
type: aTransaction?.type ?? 'BUY',
|
||||
unitPrice: null
|
||||
},
|
||||
user: this.user
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
this.userService
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data: any) => {
|
||||
const transaction: CreateOrderDto = data?.transaction;
|
||||
.subscribe((user) => {
|
||||
this.updateUser(user);
|
||||
|
||||
if (transaction) {
|
||||
this.dataService.postOrder(transaction).subscribe({
|
||||
next: () => {
|
||||
this.fetchOrders();
|
||||
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
|
||||
data: {
|
||||
accounts: this.user?.accounts?.filter((account) => {
|
||||
return account.accountType === 'SECURITIES';
|
||||
}),
|
||||
transaction: {
|
||||
accountId: aTransaction?.accountId ?? this.defaultAccountId,
|
||||
currency: aTransaction?.currency ?? null,
|
||||
dataSource: aTransaction?.dataSource ?? null,
|
||||
date: new Date(),
|
||||
fee: 0,
|
||||
quantity: null,
|
||||
symbol: aTransaction?.symbol ?? null,
|
||||
type: aTransaction?.type ?? 'BUY',
|
||||
unitPrice: null
|
||||
},
|
||||
user: this.user
|
||||
},
|
||||
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
|
||||
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
|
||||
});
|
||||
|
||||
dialogRef
|
||||
.afterClosed()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((data: any) => {
|
||||
const transaction: CreateOrderDto = data?.transaction;
|
||||
|
||||
if (transaction) {
|
||||
this.dataService.postOrder(transaction).subscribe({
|
||||
next: () => {
|
||||
this.fetchOrders();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
this.router.navigate(['.'], { relativeTo: this.route });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -397,7 +392,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
.get()
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((user) => {
|
||||
this.user = user;
|
||||
this.updateUser(user);
|
||||
|
||||
const dialogRef = this.dialog.open(PositionDetailDialog, {
|
||||
autoFocus: false,
|
||||
@ -419,4 +414,21 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private updateUser(aUser: User) {
|
||||
this.user = aUser;
|
||||
|
||||
this.defaultAccountId = this.user?.accounts.find((account) => {
|
||||
return account.isDefault;
|
||||
})?.id;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
this.hasPermissionToDeleteOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.deleteOrder
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -3,7 +3,7 @@
|
||||
<div class="col">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Activities</h3>
|
||||
<gf-activities-table
|
||||
[activities]="transactions"
|
||||
[activities]="activities"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder"
|
||||
|
@ -1,9 +1,10 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable } from '@angular/core';
|
||||
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
|
||||
import { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
|
||||
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
|
||||
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
|
||||
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
|
||||
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
@ -137,15 +138,21 @@ export class DataService {
|
||||
|
||||
public fetchSymbolItem({
|
||||
dataSource,
|
||||
includeHistoricalData = false,
|
||||
includeHistoricalData,
|
||||
symbol
|
||||
}: {
|
||||
dataSource: DataSource;
|
||||
includeHistoricalData?: boolean;
|
||||
includeHistoricalData?: number;
|
||||
symbol: string;
|
||||
}) {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (includeHistoricalData) {
|
||||
params = params.append('includeHistoricalData', includeHistoricalData);
|
||||
}
|
||||
|
||||
return this.http.get<SymbolItem>(`/api/symbol/${dataSource}/${symbol}`, {
|
||||
params: { includeHistoricalData }
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
@ -169,14 +176,14 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchOrders(): Observable<OrderModel[]> {
|
||||
return this.http.get<any[]>('/api/order').pipe(
|
||||
map((data) => {
|
||||
for (const item of data) {
|
||||
item.createdAt = parseISO(item.createdAt);
|
||||
item.date = parseISO(item.date);
|
||||
public fetchOrders(): Observable<Activities> {
|
||||
return this.http.get<any>('/api/order').pipe(
|
||||
map(({ activities }) => {
|
||||
for (const activity of activities) {
|
||||
activity.createdAt = parseISO(activity.createdAt);
|
||||
activity.date = parseISO(activity.date);
|
||||
}
|
||||
return data;
|
||||
return { activities };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -1,7 +1,12 @@
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface AdminMarketData {
|
||||
marketData: AdminMarketDataItem[];
|
||||
}
|
||||
|
||||
export interface AdminMarketDataItem {
|
||||
dataSource: DataSource;
|
||||
date?: Date;
|
||||
marketDataItemCount?: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -2,7 +2,10 @@ import { Access } from './access.interface';
|
||||
import { Accounts } from './accounts.interface';
|
||||
import { AdminData } from './admin-data.interface';
|
||||
import { AdminMarketDataDetails } from './admin-market-data-details.interface';
|
||||
import { AdminMarketData } from './admin-market-data.interface';
|
||||
import {
|
||||
AdminMarketData,
|
||||
AdminMarketDataItem
|
||||
} from './admin-market-data.interface';
|
||||
import { Coupon } from './coupon.interface';
|
||||
import { Export } from './export.interface';
|
||||
import { InfoItem } from './info-item.interface';
|
||||
@ -29,6 +32,7 @@ export {
|
||||
AdminData,
|
||||
AdminMarketData,
|
||||
AdminMarketDataDetails,
|
||||
AdminMarketDataItem,
|
||||
Coupon,
|
||||
Export,
|
||||
InfoItem,
|
||||
|
@ -2,10 +2,11 @@ import { PortfolioPosition } from '@ghostfolio/common/interfaces';
|
||||
|
||||
export interface PortfolioDetails {
|
||||
accounts: {
|
||||
[name: string]: {
|
||||
[id: string]: {
|
||||
balance: number;
|
||||
currency: string;
|
||||
current: number;
|
||||
name: string;
|
||||
original: number;
|
||||
};
|
||||
};
|
||||
|
@ -1,5 +1,5 @@
|
||||
export interface PortfolioPerformance {
|
||||
annualizedPerformancePercent: number;
|
||||
annualizedPerformancePercent?: number;
|
||||
currentGrossPerformance: number;
|
||||
currentGrossPerformancePercent: number;
|
||||
currentNetPerformance: number;
|
||||
|
@ -58,6 +58,11 @@
|
||||
>
|
||||
{{ dataSource.data.length - i }}
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
<ng-container matColumnDef="date">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
@ -68,13 +73,14 @@
|
||||
{{ element.date | date: defaultDateFormat }}
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="type">
|
||||
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
|
||||
Type
|
||||
</th>
|
||||
<td mat-cell *matCellDef="let element" class="px-1">
|
||||
<td *matCellDef="let element" mat-cell class="px-1">
|
||||
<div
|
||||
class="d-inline-flex p-1 type-badge"
|
||||
[ngClass]="{
|
||||
@ -93,6 +99,7 @@
|
||||
<span class="d-none d-lg-block mx-1">{{ element.type }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="symbol">
|
||||
@ -107,6 +114,7 @@
|
||||
>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="currency">
|
||||
@ -122,6 +130,9 @@
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
{{ element.currency }}
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
{{ baseCurrency }}
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="quantity">
|
||||
@ -143,6 +154,11 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="unitPrice">
|
||||
@ -164,6 +180,11 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td
|
||||
*matFooterCellDef
|
||||
class="d-none d-lg-table-cell px-1"
|
||||
mat-footer-cell
|
||||
></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="fee">
|
||||
@ -176,7 +197,7 @@
|
||||
>
|
||||
Fee
|
||||
</th>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px1" mat-cell>
|
||||
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
@ -185,6 +206,15 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalFees"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="value">
|
||||
@ -197,7 +227,7 @@
|
||||
>
|
||||
Value
|
||||
</th>
|
||||
<td *matCellDef="let element" class="px1" mat-cell>
|
||||
<td *matCellDef="let element" class="px-1" mat-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
@ -206,6 +236,15 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
[isCurrency]="true"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : totalValue"
|
||||
></gf-value>
|
||||
</div>
|
||||
</td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="account">
|
||||
@ -223,6 +262,7 @@
|
||||
<span class="d-none d-lg-block">{{ element.Account?.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<ng-container matColumnDef="actions">
|
||||
@ -276,6 +316,7 @@
|
||||
</button>
|
||||
</mat-menu>
|
||||
</td>
|
||||
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
|
||||
</ng-container>
|
||||
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
|
||||
@ -291,6 +332,11 @@
|
||||
"
|
||||
[ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }"
|
||||
></tr>
|
||||
<tr
|
||||
*matFooterRowDef="displayedColumns"
|
||||
mat-footer-row
|
||||
[ngClass]="{ 'd-none': isLoading || dataSource.data.length === 0 }"
|
||||
></tr>
|
||||
</table>
|
||||
|
||||
<ngx-skeleton-loader
|
||||
|
@ -15,6 +15,16 @@
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
border-top: 1px solid
|
||||
rgba(
|
||||
var(--palette-foreground-divider),
|
||||
var(--palette-foreground-divider-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
th {
|
||||
::ng-deep {
|
||||
.mat-sort-header-container {
|
||||
@ -55,6 +65,15 @@
|
||||
}
|
||||
|
||||
.mat-table {
|
||||
td {
|
||||
&.mat-footer-cell {
|
||||
border-top-color: rgba(
|
||||
var(--palette-foreground-divider-dark),
|
||||
var(--palette-foreground-divider-dark-alpha)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
background-color: rgba(
|
||||
var(--palette-foreground-text-dark),
|
||||
|
@ -7,7 +7,6 @@ import {
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
@ -20,9 +19,12 @@ import { MatChipInputEvent } from '@angular/material/chips';
|
||||
import { MatSort } from '@angular/material/sort';
|
||||
import { MatTableDataSource } from '@angular/material/table';
|
||||
import { Router } from '@angular/router';
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import Big from 'big.js';
|
||||
import { endOfToday, format, isAfter } from 'date-fns';
|
||||
import { isNumber } from 'lodash';
|
||||
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -36,7 +38,7 @@ const SEARCH_STRING_SEPARATOR = ',';
|
||||
templateUrl: './activities-table.component.html'
|
||||
})
|
||||
export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
@Input() activities: OrderWithAccount[];
|
||||
@Input() activities: Activity[];
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateActivity: boolean;
|
||||
@ -57,8 +59,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
|
||||
@ViewChild(MatSort) sort: MatSort;
|
||||
|
||||
public dataSource: MatTableDataSource<OrderWithAccount> =
|
||||
new MatTableDataSource();
|
||||
public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
|
||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||
public displayedColumns = [];
|
||||
public endOfToday = endOfToday();
|
||||
@ -71,6 +72,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
public searchControl = new FormControl();
|
||||
public searchKeywords: string[] = [];
|
||||
public separatorKeysCodes: number[] = [ENTER, COMMA];
|
||||
public totalFees: number;
|
||||
public totalValue: number;
|
||||
|
||||
private allFilters: string[];
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -218,6 +221,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
);
|
||||
|
||||
this.filters$.next(this.allFilters);
|
||||
|
||||
this.totalFees = this.getTotalFees();
|
||||
this.totalValue = this.getTotalValue();
|
||||
}
|
||||
|
||||
private getSearchableFieldValues(activities: OrderWithAccount[]): string[] {
|
||||
@ -263,4 +269,36 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
|
||||
return item !== undefined;
|
||||
});
|
||||
}
|
||||
|
||||
private getTotalFees() {
|
||||
let totalFees = new Big(0);
|
||||
|
||||
for (const activity of this.dataSource.filteredData) {
|
||||
if (isNumber(activity.feeInBaseCurrency)) {
|
||||
totalFees = totalFees.plus(activity.feeInBaseCurrency);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return totalFees.toNumber();
|
||||
}
|
||||
|
||||
private getTotalValue() {
|
||||
let totalValue = new Big(0);
|
||||
|
||||
for (const activity of this.dataSource.filteredData) {
|
||||
if (isNumber(activity.valueInBaseCurrency)) {
|
||||
if (activity.type === 'BUY') {
|
||||
totalValue = totalValue.plus(activity.valueInBaseCurrency);
|
||||
} else if (activity.type === 'SELL') {
|
||||
totalValue = totalValue.minus(activity.valueInBaseCurrency);
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return totalValue.toNumber();
|
||||
}
|
||||
}
|
||||
|
10
package.json
10
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.101.0",
|
||||
"version": "1.108.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -69,11 +69,11 @@
|
||||
"@nestjs/schedule": "1.0.2",
|
||||
"@nestjs/serve-static": "2.2.2",
|
||||
"@nrwl/angular": "13.4.1",
|
||||
"@prisma/client": "3.7.0",
|
||||
"@prisma/client": "3.8.1",
|
||||
"@simplewebauthn/browser": "4.1.0",
|
||||
"@simplewebauthn/server": "4.1.0",
|
||||
"@simplewebauthn/typescript-types": "4.0.0",
|
||||
"@stripe/stripe-js": "1.15.0",
|
||||
"@stripe/stripe-js": "1.22.0",
|
||||
"@types/papaparse": "5.2.6",
|
||||
"alphavantage": "2.2.0",
|
||||
"angular-material-css-vars": "3.0.0",
|
||||
@ -106,11 +106,11 @@
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"prisma": "3.7.0",
|
||||
"prisma": "3.8.1",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "7.4.0",
|
||||
"stripe": "8.156.0",
|
||||
"stripe": "8.199.0",
|
||||
"svgmap": "2.6.0",
|
||||
"tslib": "2.0.0",
|
||||
"uuid": "8.3.2",
|
||||
|
52
yarn.lock
52
yarn.lock
@ -3349,22 +3349,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.7.0":
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.7.0.tgz#9cafc105f12635c95e9b7e7b18e8fbf52cf3f18a"
|
||||
integrity sha512-fUJMvBOX5C7JPc0e3CJD6Gbelbu4dMJB4ScYpiht8HMUnRShw20ULOipTopjNtl6ekHQJ4muI7pXlQxWS9nMbw==
|
||||
"@prisma/client@3.8.1":
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.8.1.tgz#c11eda8e84760867552ffde4de7b48fb2cf1e1c0"
|
||||
integrity sha512-NxD1Xbkx1eT1mxSwo1RwZe665mqBETs0VxohuwNfFIxMqcp0g6d4TgugPxwZ4Jb4e5wCu8mQ9quMedhNWIWcZQ==
|
||||
dependencies:
|
||||
"@prisma/engines-version" "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
"@prisma/engines-version" "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
|
||||
"@prisma/engines-version@3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f":
|
||||
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#055f36ac8b06c301332c14963cd0d6c795942c90"
|
||||
integrity sha512-+qx2b+HK7BKF4VCa0LZ/t1QCXsu6SmvhUQyJkOD2aPpmOzket4fEnSKQZSB0i5tl7rwCDsvAiSeK8o7rf+yvwg==
|
||||
"@prisma/engines-version@3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f":
|
||||
version "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f.tgz#4c8d9744b5e54650a8ba5fde0a711399d6adba24"
|
||||
integrity sha512-G2JH6yWt6ixGKmsRmVgaQYahfwMopim0u/XLIZUo2o/mZ5jdu7+BL+2V5lZr7XiG1axhyrpvlyqE/c0OgYSl3g==
|
||||
|
||||
"@prisma/engines@3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f":
|
||||
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#12f28d5b78519fbd84c89a5bdff457ff5095e7a2"
|
||||
integrity sha512-W549ub5NlgexNhR8EFstA/UwAWq3Zq0w9aNkraqsozVCt2CsX+lK4TK7IW5OZVSnxHwRjrgEAt3r9yPy8nZQRg==
|
||||
"@prisma/engines@3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f":
|
||||
version "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f.tgz#4479099b99f6a082ce5843ee7208943ccedd127f"
|
||||
integrity sha512-bHYubuItSN/DGYo36aDu7xJiJmK52JOSHs4MK+KbceAtwS20BCWadRgtpQ3iZ2EXfN/B1T0iCXlNraaNwnpU2w==
|
||||
|
||||
"@samverschueren/stream-to-observable@^0.3.0":
|
||||
version "0.3.1"
|
||||
@ -4347,10 +4347,10 @@
|
||||
resolve-from "^5.0.0"
|
||||
store2 "^2.12.0"
|
||||
|
||||
"@stripe/stripe-js@1.15.0":
|
||||
version "1.15.0"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.15.0.tgz#86178cfbe66151910b09b03595e60048ab4c698e"
|
||||
integrity sha512-KQsNPc+uVQkc8dewwz1A6uHOWeU2cWoZyNIbsx5mtmperr5TPxw4u8M20WOa22n6zmIOh/zLdzEe8DYK/0IjBw==
|
||||
"@stripe/stripe-js@1.22.0":
|
||||
version "1.22.0"
|
||||
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.22.0.tgz#9d3d2f0a1ce81f185ec477fd7cc67544b2b2a00c"
|
||||
integrity sha512-fm8TR8r4LwbXgBIYdPmeMjJJkxxFC66tvoliNnmXOpUgZSgQKoNPW3ON0ZphZIiif1oqWNhAaSrr7tOvGu+AFg==
|
||||
|
||||
"@tootallnate/once@1":
|
||||
version "1.1.2"
|
||||
@ -15025,12 +15025,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.7.0:
|
||||
version "3.7.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.7.0.tgz#9c73eeb2f16f767fdf523d0f4cc4c749734d62e2"
|
||||
integrity sha512-pzgc95msPLcCHqOli7Hnabu/GRfSGSUWl5s2P6N13T/rgMB+NNeKbxCmzQiZT2yLOeLEPivV6YrW1oeQIwJxcg==
|
||||
prisma@3.8.1:
|
||||
version "3.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.8.1.tgz#44395cef7cbb1ea86216cb84ee02f856c08a7873"
|
||||
integrity sha512-Q8zHwS9m70TaD7qI8u+8hTAmiTpK+IpvRYF3Rgb/OeWGQJOMgZCFFvNCiSfoLEQ95wilK7ctW3KOpc9AuYnRUA==
|
||||
dependencies:
|
||||
"@prisma/engines" "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f"
|
||||
"@prisma/engines" "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
|
||||
|
||||
prismjs@^1.21.0, prismjs@~1.24.0:
|
||||
version "1.24.1"
|
||||
@ -16952,10 +16952,10 @@ strip-json-comments@^2.0.1:
|
||||
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
|
||||
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
|
||||
|
||||
stripe@8.156.0:
|
||||
version "8.156.0"
|
||||
resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.156.0.tgz#040de551df88d71ef670a8c8d4df114c3fa6eb4b"
|
||||
integrity sha512-q+bixlhaxnSI/Htk/iB1i5LhuZ557hL0pFgECBxQNhso1elxIsOsPOIXEuo3tSLJEb8CJSB7t/+Fyq6KP69tAQ==
|
||||
stripe@8.199.0:
|
||||
version "8.199.0"
|
||||
resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.199.0.tgz#dcd109f16ff0c33da638a0d154c966d0f20c73d1"
|
||||
integrity sha512-Bc5Zfp6eOOCdde9x5NPrAczeGSKuNwemzjsfGJXWtpbUfQXgJujzTGgkhx2YuzamqakDYJkTgf9w7Ry2uY8QNA==
|
||||
dependencies:
|
||||
"@types/node" ">=8.1.0"
|
||||
qs "^6.6.0"
|
||||
|
Reference in New Issue
Block a user