Compare commits
32 Commits
Author | SHA1 | Date | |
---|---|---|---|
b464fefc57 | |||
bcb7f5f522 | |||
f15b33e950 | |||
ca64492e77 | |||
761376d72d | |||
9c086edffe | |||
585f99e4df | |||
9d907b5eb5 | |||
ba05f5ba30 | |||
3261e3ee59 | |||
5607c6bb52 | |||
1c6050d3e3 | |||
38f2930ec6 | |||
556be61fff | |||
651b4bcff7 | |||
0a8d159f78 | |||
1a4109ebaa | |||
92e502e1c2 | |||
e344c43a5a | |||
d6b78f3457 | |||
9bbb856f66 | |||
d3707bbb87 | |||
7df53896f3 | |||
b2b3fde80e | |||
a83441b3ba | |||
075431d868 | |||
0168c1c4e8 | |||
07de8f87fc | |||
3e16041c16 | |||
5882b7914d | |||
69c9e259b1 | |||
aca37a27f9 |
95
CHANGELOG.md
95
CHANGELOG.md
@ -5,6 +5,101 @@ 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.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
|
||||
|
||||
- Added `GOOGLE_SHEETS` as a new data source type
|
||||
|
||||
### Changed
|
||||
|
||||
- Excluded the url pattern of shared portfolios in the `robots.txt` file
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.100.0 - 05.01.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Top 3_ and _Bottom 3_ performers to the analysis page
|
||||
- Added a blog post
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the routing of the create activity dialog
|
||||
- Fixed the link color in the blog posts
|
||||
|
||||
## 1.99.0 - 01.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,
|
||||
|
@ -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';
|
||||
}
|
865
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
865
apps/api/src/app/portfolio/portfolio-calculator-new.ts
Normal file
@ -0,0 +1,865 @@
|
||||
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,
|
||||
differenceInDays,
|
||||
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) {
|
||||
return netPerformancePercent.mul(daysInMarket).div(365);
|
||||
}
|
||||
|
||||
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),
|
||||
netAnnualizedPerformance: 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);
|
||||
let netAnnualizedPerformance = new Big(0);
|
||||
|
||||
// use Date.now() to use the mock for today
|
||||
const today = new Date(Date.now());
|
||||
|
||||
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)
|
||||
);
|
||||
|
||||
netAnnualizedPerformance = netAnnualizedPerformance.plus(
|
||||
this.getAnnualizedPerformancePercent({
|
||||
daysInMarket: differenceInDays(
|
||||
today,
|
||||
parseDate(currentPosition.firstBuyDate)
|
||||
),
|
||||
netPerformancePercent: currentPosition.netPerformancePercentage
|
||||
}).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);
|
||||
netAnnualizedPerformance =
|
||||
netAnnualizedPerformance.div(completeInitialValue);
|
||||
}
|
||||
|
||||
return {
|
||||
currentValue,
|
||||
grossPerformance,
|
||||
grossPerformancePercentage,
|
||||
hasErrors,
|
||||
netAnnualizedPerformance,
|
||||
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,16 +1,9 @@
|
||||
import { parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
differenceInCalendarDays,
|
||||
endOfDay,
|
||||
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';
|
||||
@ -67,15 +60,202 @@ function mockGetValue(symbol: string, date: Date) {
|
||||
|
||||
return { marketPrice: 0 };
|
||||
case 'VTI':
|
||||
return {
|
||||
marketPrice: new Big('144.38')
|
||||
.plus(
|
||||
new Big('0.08').mul(
|
||||
differenceInCalendarDays(date, parseDate('2019-02-01'))
|
||||
)
|
||||
)
|
||||
.toNumber()
|
||||
};
|
||||
switch (format(date, DATE_FORMAT)) {
|
||||
case '2019-01-01':
|
||||
return { marketPrice: 144.38 };
|
||||
case '2019-02-01':
|
||||
return { marketPrice: 144.38 };
|
||||
case '2019-03-01':
|
||||
return { marketPrice: 146.62 };
|
||||
case '2019-04-01':
|
||||
return { marketPrice: 149.1 };
|
||||
case '2019-05-01':
|
||||
return { marketPrice: 151.5 };
|
||||
case '2019-06-01':
|
||||
return { marketPrice: 153.98 };
|
||||
case '2019-07-01':
|
||||
return { marketPrice: 156.38 };
|
||||
case '2019-08-01':
|
||||
return { marketPrice: 158.86 };
|
||||
case '2019-08-03':
|
||||
return { marketPrice: 159.02 };
|
||||
case '2019-09-01':
|
||||
return { marketPrice: 161.34 };
|
||||
case '2019-10-01':
|
||||
return { marketPrice: 163.74 };
|
||||
case '2019-11-01':
|
||||
return { marketPrice: 166.22 };
|
||||
case '2019-12-01':
|
||||
return { marketPrice: 168.62 };
|
||||
case '2020-01-01':
|
||||
return { marketPrice: 171.1 };
|
||||
case '2020-02-01':
|
||||
return { marketPrice: 173.58 };
|
||||
case '2020-02-02':
|
||||
return { marketPrice: 173.66 };
|
||||
case '2020-03-01':
|
||||
return { marketPrice: 175.9 };
|
||||
case '2020-04-01':
|
||||
return { marketPrice: 178.38 };
|
||||
case '2020-05-01':
|
||||
return { marketPrice: 180.78 };
|
||||
case '2020-06-01':
|
||||
return { marketPrice: 183.26 };
|
||||
case '2020-07-01':
|
||||
return { marketPrice: 185.66 };
|
||||
case '2020-08-01':
|
||||
return { marketPrice: 188.14 };
|
||||
case '2020-08-02':
|
||||
return { marketPrice: 188.22 };
|
||||
case '2020-08-03':
|
||||
return { marketPrice: 188.3 };
|
||||
case '2020-09-01':
|
||||
return { marketPrice: 190.62 };
|
||||
case '2020-10-01':
|
||||
return { marketPrice: 193.02 };
|
||||
case '2020-11-01':
|
||||
return { marketPrice: 195.5 };
|
||||
case '2020-12-01':
|
||||
return { marketPrice: 197.9 };
|
||||
case '2021-01-01':
|
||||
return { marketPrice: 200.38 };
|
||||
case '2021-02-01':
|
||||
return { marketPrice: 202.86 };
|
||||
case '2021-03-01':
|
||||
return { marketPrice: 205.1 };
|
||||
case '2021-04-01':
|
||||
return { marketPrice: 207.58 };
|
||||
case '2021-05-01':
|
||||
return { marketPrice: 209.98 };
|
||||
case '2021-06-01':
|
||||
return { marketPrice: 212.46 };
|
||||
case '2021-06-02':
|
||||
return { marketPrice: 212.54 };
|
||||
case '2021-06-03':
|
||||
return { marketPrice: 212.62 };
|
||||
case '2021-06-04':
|
||||
return { marketPrice: 212.7 };
|
||||
case '2021-06-05':
|
||||
return { marketPrice: 212.78 };
|
||||
case '2021-06-06':
|
||||
return { marketPrice: 212.86 };
|
||||
case '2021-06-07':
|
||||
return { marketPrice: 212.94 };
|
||||
case '2021-06-08':
|
||||
return { marketPrice: 213.02 };
|
||||
case '2021-06-09':
|
||||
return { marketPrice: 213.1 };
|
||||
case '2021-06-10':
|
||||
return { marketPrice: 213.18 };
|
||||
case '2021-06-11':
|
||||
return { marketPrice: 213.26 };
|
||||
case '2021-06-12':
|
||||
return { marketPrice: 213.34 };
|
||||
case '2021-06-13':
|
||||
return { marketPrice: 213.42 };
|
||||
case '2021-06-14':
|
||||
return { marketPrice: 213.5 };
|
||||
case '2021-06-15':
|
||||
return { marketPrice: 213.58 };
|
||||
case '2021-06-16':
|
||||
return { marketPrice: 213.66 };
|
||||
case '2021-06-17':
|
||||
return { marketPrice: 213.74 };
|
||||
case '2021-06-18':
|
||||
return { marketPrice: 213.82 };
|
||||
case '2021-06-19':
|
||||
return { marketPrice: 213.9 };
|
||||
case '2021-06-20':
|
||||
return { marketPrice: 213.98 };
|
||||
case '2021-06-21':
|
||||
return { marketPrice: 214.06 };
|
||||
case '2021-06-22':
|
||||
return { marketPrice: 214.14 };
|
||||
case '2021-06-23':
|
||||
return { marketPrice: 214.22 };
|
||||
case '2021-06-24':
|
||||
return { marketPrice: 214.3 };
|
||||
case '2021-06-25':
|
||||
return { marketPrice: 214.38 };
|
||||
case '2021-06-26':
|
||||
return { marketPrice: 214.46 };
|
||||
case '2021-06-27':
|
||||
return { marketPrice: 214.54 };
|
||||
case '2021-06-28':
|
||||
return { marketPrice: 214.62 };
|
||||
case '2021-06-29':
|
||||
return { marketPrice: 214.7 };
|
||||
case '2021-06-30':
|
||||
return { marketPrice: 214.78 };
|
||||
case '2021-07-01':
|
||||
return { marketPrice: 214.86 };
|
||||
case '2021-07-02':
|
||||
return { marketPrice: 214.94 };
|
||||
case '2021-07-03':
|
||||
return { marketPrice: 215.02 };
|
||||
case '2021-07-04':
|
||||
return { marketPrice: 215.1 };
|
||||
case '2021-07-05':
|
||||
return { marketPrice: 215.18 };
|
||||
case '2021-07-06':
|
||||
return { marketPrice: 215.26 };
|
||||
case '2021-07-07':
|
||||
return { marketPrice: 215.34 };
|
||||
case '2021-07-08':
|
||||
return { marketPrice: 215.42 };
|
||||
case '2021-07-09':
|
||||
return { marketPrice: 215.5 };
|
||||
case '2021-07-10':
|
||||
return { marketPrice: 215.58 };
|
||||
case '2021-07-11':
|
||||
return { marketPrice: 215.66 };
|
||||
case '2021-07-12':
|
||||
return { marketPrice: 215.74 };
|
||||
case '2021-07-13':
|
||||
return { marketPrice: 215.82 };
|
||||
case '2021-07-14':
|
||||
return { marketPrice: 215.9 };
|
||||
case '2021-07-15':
|
||||
return { marketPrice: 215.98 };
|
||||
case '2021-07-16':
|
||||
return { marketPrice: 216.06 };
|
||||
case '2021-07-17':
|
||||
return { marketPrice: 216.14 };
|
||||
case '2021-07-18':
|
||||
return { marketPrice: 216.22 };
|
||||
case '2021-07-19':
|
||||
return { marketPrice: 216.3 };
|
||||
case '2021-07-20':
|
||||
return { marketPrice: 216.38 };
|
||||
case '2021-07-21':
|
||||
return { marketPrice: 216.46 };
|
||||
case '2021-07-22':
|
||||
return { marketPrice: 216.54 };
|
||||
case '2021-07-23':
|
||||
return { marketPrice: 216.62 };
|
||||
case '2021-07-24':
|
||||
return { marketPrice: 216.7 };
|
||||
case '2021-07-25':
|
||||
return { marketPrice: 216.78 };
|
||||
case '2021-07-26':
|
||||
return { marketPrice: 216.86 };
|
||||
case '2021-07-27':
|
||||
return { marketPrice: 216.94 };
|
||||
case '2021-07-28':
|
||||
return { marketPrice: 217.02 };
|
||||
case '2021-07-29':
|
||||
return { marketPrice: 217.1 };
|
||||
case '2021-07-30':
|
||||
return { marketPrice: 217.18 };
|
||||
case '2021-07-31':
|
||||
return { marketPrice: 217.26 };
|
||||
case '2021-08-01':
|
||||
return { marketPrice: 217.34 };
|
||||
case '2020-10-24':
|
||||
return { marketPrice: 194.86 };
|
||||
default:
|
||||
return { marketPrice: 0 };
|
||||
}
|
||||
|
||||
default:
|
||||
return { marketPrice: 0 };
|
||||
@ -87,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) {
|
||||
@ -1645,14 +1822,14 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformance: new Big('498.3'),
|
||||
netPerformance: new Big('498.3'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
||||
value: new Big('3422') // 20 * 171.1
|
||||
},
|
||||
{
|
||||
date: '2021-01-01',
|
||||
grossPerformance: new Big('349.35'),
|
||||
netPerformance: new Big('349.35'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
||||
value: new Big('1001.9') // 5 * 200.38
|
||||
}
|
||||
]);
|
||||
});
|
||||
@ -1765,14 +1942,14 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformance: new Big('498.3'),
|
||||
netPerformance: new Big('398.3'), // 100 fees
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
||||
value: new Big('3422') // 20 * 171.1
|
||||
},
|
||||
{
|
||||
date: '2021-01-01',
|
||||
grossPerformance: new Big('349.35'),
|
||||
netPerformance: new Big('199.35'), // 150 fees
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
||||
value: new Big('1001.9') // 5 * 200.38
|
||||
}
|
||||
]);
|
||||
});
|
||||
@ -1808,203 +1985,203 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformance: new Big('0'),
|
||||
netPerformance: new Big('0'),
|
||||
investment: new Big('1443.8'),
|
||||
value: new Big('1443.8') // 10 * (144.38 + days=0 * 0.08)
|
||||
value: new Big('1443.8') // 10 * 144.38
|
||||
},
|
||||
{
|
||||
date: '2019-03-01',
|
||||
grossPerformance: new Big('22.4'),
|
||||
netPerformance: new Big('22.4'),
|
||||
investment: new Big('1443.8'),
|
||||
value: new Big('1466.2') // 10 * (144.38 + days=28 * 0.08)
|
||||
value: new Big('1466.2') // 10 * 146.62
|
||||
},
|
||||
{
|
||||
date: '2019-04-01',
|
||||
grossPerformance: new Big('47.2'),
|
||||
netPerformance: new Big('47.2'),
|
||||
investment: new Big('1443.8'),
|
||||
value: new Big('1491') // 10 * (144.38 + days=59 * 0.08)
|
||||
value: new Big('1491') // 10 * 149.1
|
||||
},
|
||||
{
|
||||
date: '2019-05-01',
|
||||
grossPerformance: new Big('71.2'),
|
||||
netPerformance: new Big('71.2'),
|
||||
investment: new Big('1443.8'),
|
||||
value: new Big('1515') // 10 * (144.38 + days=89 * 0.08)
|
||||
value: new Big('1515') // 10 * 151.5
|
||||
},
|
||||
{
|
||||
date: '2019-06-01',
|
||||
grossPerformance: new Big('96'),
|
||||
netPerformance: new Big('96'),
|
||||
investment: new Big('1443.8'),
|
||||
value: new Big('1539.8') // 10 * (144.38 + days=120 * 0.08)
|
||||
value: new Big('1539.8') // 10 * 153.98
|
||||
},
|
||||
{
|
||||
date: '2019-07-01',
|
||||
grossPerformance: new Big('120'),
|
||||
netPerformance: new Big('120'),
|
||||
investment: new Big('1443.8'),
|
||||
value: new Big('1563.8') // 10 * (144.38 + days=150 * 0.08)
|
||||
value: new Big('1563.8') // 10 * 156.38
|
||||
},
|
||||
{
|
||||
date: '2019-08-01',
|
||||
grossPerformance: new Big('144.8'),
|
||||
netPerformance: new Big('144.8'),
|
||||
investment: new Big('1443.8'),
|
||||
value: new Big('1588.6') // 10 * (144.38 + days=181 * 0.08)
|
||||
value: new Big('1588.6') // 10 * 158.86
|
||||
},
|
||||
{
|
||||
date: '2019-09-01',
|
||||
grossPerformance: new Big('303.1'),
|
||||
netPerformance: new Big('303.1'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3226.8') // 20 * (144.38 + days=212 * 0.08)
|
||||
value: new Big('3226.8') // 20 * 161.34
|
||||
},
|
||||
{
|
||||
date: '2019-10-01',
|
||||
grossPerformance: new Big('351.1'),
|
||||
netPerformance: new Big('351.1'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3274.8') // 20 * (144.38 + days=242 * 0.08)
|
||||
value: new Big('3274.8') // 20 * 163.74
|
||||
},
|
||||
{
|
||||
date: '2019-11-01',
|
||||
grossPerformance: new Big('400.7'),
|
||||
netPerformance: new Big('400.7'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3324.4') // 20 * (144.38 + days=273 * 0.08)
|
||||
value: new Big('3324.4') // 20 * 166.22
|
||||
},
|
||||
{
|
||||
date: '2019-12-01',
|
||||
grossPerformance: new Big('448.7'),
|
||||
netPerformance: new Big('448.7'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3372.4') // 20 * (144.38 + days=303 * 0.08)
|
||||
value: new Big('3372.4') // 20 * 168.62
|
||||
},
|
||||
{
|
||||
date: '2020-01-01',
|
||||
grossPerformance: new Big('498.3'),
|
||||
netPerformance: new Big('498.3'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
||||
value: new Big('3422') // 20 * 171.1
|
||||
},
|
||||
{
|
||||
date: '2020-02-01',
|
||||
grossPerformance: new Big('547.9'),
|
||||
netPerformance: new Big('547.9'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3471.6') // 20 * (144.38 + days=365 * 0.08)
|
||||
value: new Big('3471.6') // 20 * 173.58
|
||||
},
|
||||
{
|
||||
date: '2020-03-01',
|
||||
grossPerformance: new Big('226.95'),
|
||||
netPerformance: new Big('226.95'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('879.5') // 5 * (144.38 + days=394 * 0.08)
|
||||
value: new Big('879.5') // 5 * 175.9
|
||||
},
|
||||
{
|
||||
date: '2020-04-01',
|
||||
grossPerformance: new Big('239.35'),
|
||||
netPerformance: new Big('239.35'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('891.9') // 5 * (144.38 + days=425 * 0.08)
|
||||
value: new Big('891.9') // 5 * 178.38
|
||||
},
|
||||
{
|
||||
date: '2020-05-01',
|
||||
grossPerformance: new Big('251.35'),
|
||||
netPerformance: new Big('251.35'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('903.9') // 5 * (144.38 + days=455 * 0.08)
|
||||
value: new Big('903.9') // 5 * 180.78
|
||||
},
|
||||
{
|
||||
date: '2020-06-01',
|
||||
grossPerformance: new Big('263.75'),
|
||||
netPerformance: new Big('263.75'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('916.3') // 5 * (144.38 + days=486 * 0.08)
|
||||
value: new Big('916.3') // 5 * 183.26
|
||||
},
|
||||
{
|
||||
date: '2020-07-01',
|
||||
grossPerformance: new Big('275.75'),
|
||||
netPerformance: new Big('275.75'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('928.3') // 5 * (144.38 + days=516 * 0.08)
|
||||
value: new Big('928.3') // 5 * 185.66
|
||||
},
|
||||
{
|
||||
date: '2020-08-01',
|
||||
grossPerformance: new Big('288.15'),
|
||||
netPerformance: new Big('288.15'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('940.7') // 5 * (144.38 + days=547 * 0.08)
|
||||
value: new Big('940.7') // 5 * 188.14
|
||||
},
|
||||
{
|
||||
date: '2020-09-01',
|
||||
grossPerformance: new Big('300.55'),
|
||||
netPerformance: new Big('300.55'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('953.1') // 5 * (144.38 + days=578 * 0.08)
|
||||
value: new Big('953.1') // 5 * 190.62
|
||||
},
|
||||
{
|
||||
date: '2020-10-01',
|
||||
grossPerformance: new Big('312.55'),
|
||||
netPerformance: new Big('312.55'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('965.1') // 5 * (144.38 + days=608 * 0.08)
|
||||
value: new Big('965.1') // 5 * 193.02
|
||||
},
|
||||
{
|
||||
date: '2020-11-01',
|
||||
grossPerformance: new Big('324.95'),
|
||||
netPerformance: new Big('324.95'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('977.5') // 5 * (144.38 + days=639 * 0.08)
|
||||
value: new Big('977.5') // 5 * 195.5
|
||||
},
|
||||
{
|
||||
date: '2020-12-01',
|
||||
grossPerformance: new Big('336.95'),
|
||||
netPerformance: new Big('336.95'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('989.5') // 5 * (144.38 + days=669 * 0.08)
|
||||
value: new Big('989.5') // 5 * 197.9
|
||||
},
|
||||
{
|
||||
date: '2021-01-01',
|
||||
grossPerformance: new Big('349.35'),
|
||||
netPerformance: new Big('349.35'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
||||
value: new Big('1001.9') // 5 * 200.38
|
||||
},
|
||||
{
|
||||
date: '2021-02-01',
|
||||
grossPerformance: new Big('358.85'),
|
||||
netPerformance: new Big('358.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
|
||||
value: new Big('3042.9') // 15 * 202.86
|
||||
},
|
||||
{
|
||||
date: '2021-03-01',
|
||||
grossPerformance: new Big('392.45'),
|
||||
netPerformance: new Big('392.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
|
||||
value: new Big('3076.5') // 15 * 205.1
|
||||
},
|
||||
{
|
||||
date: '2021-04-01',
|
||||
grossPerformance: new Big('429.65'),
|
||||
netPerformance: new Big('429.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
|
||||
value: new Big('3113.7') // 15 * 207.58
|
||||
},
|
||||
{
|
||||
date: '2021-05-01',
|
||||
grossPerformance: new Big('465.65'),
|
||||
netPerformance: new Big('465.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
|
||||
value: new Big('3149.7') // 15 * 209.98
|
||||
},
|
||||
{
|
||||
date: '2021-06-01',
|
||||
grossPerformance: new Big('502.85'),
|
||||
netPerformance: new Big('502.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
||||
value: new Big('3186.9') // 15 * 212.46
|
||||
}
|
||||
]);
|
||||
|
||||
@ -2047,49 +2224,49 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformance: new Big('498.3'),
|
||||
netPerformance: new Big('498.3'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
||||
value: new Big('3422') // 20 * 171.1
|
||||
},
|
||||
{
|
||||
date: '2021-01-01',
|
||||
grossPerformance: new Big('349.35'),
|
||||
netPerformance: new Big('349.35'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
||||
value: new Big('1001.9') // 5 * 200.38
|
||||
},
|
||||
{
|
||||
date: '2021-02-01',
|
||||
grossPerformance: new Big('358.85'),
|
||||
netPerformance: new Big('358.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
|
||||
value: new Big('3042.9') // 15 * 202.86
|
||||
},
|
||||
{
|
||||
date: '2021-03-01',
|
||||
grossPerformance: new Big('392.45'),
|
||||
netPerformance: new Big('392.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
|
||||
value: new Big('3076.5') // 15 * 205.1
|
||||
},
|
||||
{
|
||||
date: '2021-04-01',
|
||||
grossPerformance: new Big('429.65'),
|
||||
netPerformance: new Big('429.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
|
||||
value: new Big('3113.7') // 15 * 207.58
|
||||
},
|
||||
{
|
||||
date: '2021-05-01',
|
||||
grossPerformance: new Big('465.65'),
|
||||
netPerformance: new Big('465.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
|
||||
value: new Big('3149.7') // 15 * 209.98
|
||||
},
|
||||
{
|
||||
date: '2021-06-01',
|
||||
grossPerformance: new Big('502.85'),
|
||||
netPerformance: new Big('502.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
||||
value: new Big('3186.9') // 15 * 212.46
|
||||
}
|
||||
]);
|
||||
});
|
||||
@ -2134,252 +2311,252 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformance: new Big('498.3'),
|
||||
netPerformance: new Big('498.3'),
|
||||
investment: new Big('2923.7'),
|
||||
value: new Big('3422') // 20 * (144.38 + days=335 * 0.08)
|
||||
value: new Big('3422') // 20 * 171.1
|
||||
},
|
||||
{
|
||||
date: '2021-01-01',
|
||||
grossPerformance: new Big('349.35'),
|
||||
netPerformance: new Big('349.35'),
|
||||
investment: new Big('652.55'),
|
||||
value: new Big('1001.9') // 5 * (144.38 + days=700 * 0.08)
|
||||
value: new Big('1001.9') // 5 * 200.38
|
||||
},
|
||||
{
|
||||
date: '2021-02-01',
|
||||
grossPerformance: new Big('358.85'),
|
||||
netPerformance: new Big('358.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3042.9') // 15 * (144.38 + days=731 * 0.08)
|
||||
value: new Big('3042.9') // 15 * 202.86
|
||||
},
|
||||
{
|
||||
date: '2021-03-01',
|
||||
grossPerformance: new Big('392.45'),
|
||||
netPerformance: new Big('392.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3076.5') // 15 * (144.38 + days=759 * 0.08)
|
||||
value: new Big('3076.5') // 15 * 205.1
|
||||
},
|
||||
{
|
||||
date: '2021-04-01',
|
||||
grossPerformance: new Big('429.65'),
|
||||
netPerformance: new Big('429.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3113.7') // 15 * (144.38 + days=790 * 0.08)
|
||||
value: new Big('3113.7') // 15 * 207.58
|
||||
},
|
||||
{
|
||||
date: '2021-05-01',
|
||||
grossPerformance: new Big('465.65'),
|
||||
netPerformance: new Big('465.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3149.7') // 15 * (144.38 + days=820 * 0.08)
|
||||
value: new Big('3149.7') // 15 * 209.98
|
||||
},
|
||||
{
|
||||
date: '2021-06-01',
|
||||
grossPerformance: new Big('502.85'),
|
||||
netPerformance: new Big('502.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3186.9') // 15 * (144.38 + days=851 * 0.08)
|
||||
value: new Big('3186.9') // 15 * 212.46
|
||||
},
|
||||
{
|
||||
date: '2021-06-02',
|
||||
grossPerformance: new Big('504.05'),
|
||||
netPerformance: new Big('504.05'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3188.1') // 15 * (144.38 + days=852 * 0.08) / +1.2
|
||||
value: new Big('3188.1') // 15 * 212.54
|
||||
},
|
||||
{
|
||||
date: '2021-06-03',
|
||||
grossPerformance: new Big('505.25'),
|
||||
netPerformance: new Big('505.25'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3189.3') // +1.2
|
||||
value: new Big('3189.3') // 15 * 212.62
|
||||
},
|
||||
{
|
||||
date: '2021-06-04',
|
||||
grossPerformance: new Big('506.45'),
|
||||
netPerformance: new Big('506.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3190.5') // +1.2
|
||||
value: new Big('3190.5') // 15 * 212.7
|
||||
},
|
||||
{
|
||||
date: '2021-06-05',
|
||||
grossPerformance: new Big('507.65'),
|
||||
netPerformance: new Big('507.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3191.7') // +1.2
|
||||
value: new Big('3191.7') // 15 * 212.78
|
||||
},
|
||||
{
|
||||
date: '2021-06-06',
|
||||
grossPerformance: new Big('508.85'),
|
||||
netPerformance: new Big('508.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3192.9') // +1.2
|
||||
value: new Big('3192.9') // 15 * 212.86
|
||||
},
|
||||
{
|
||||
date: '2021-06-07',
|
||||
grossPerformance: new Big('510.05'),
|
||||
netPerformance: new Big('510.05'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3194.1') // +1.2
|
||||
value: new Big('3194.1') // 15 * 212.94
|
||||
},
|
||||
{
|
||||
date: '2021-06-08',
|
||||
grossPerformance: new Big('511.25'),
|
||||
netPerformance: new Big('511.25'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3195.3') // +1.2
|
||||
value: new Big('3195.3') // 15 * 213.02
|
||||
},
|
||||
{
|
||||
date: '2021-06-09',
|
||||
grossPerformance: new Big('512.45'),
|
||||
netPerformance: new Big('512.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3196.5') // +1.2
|
||||
value: new Big('3196.5') // 15 * 213.1
|
||||
},
|
||||
{
|
||||
date: '2021-06-10',
|
||||
grossPerformance: new Big('513.65'),
|
||||
netPerformance: new Big('513.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3197.7') // +1.2
|
||||
value: new Big('3197.7') // 15 * 213.18
|
||||
},
|
||||
{
|
||||
date: '2021-06-11',
|
||||
grossPerformance: new Big('514.85'),
|
||||
netPerformance: new Big('514.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3198.9') // +1.2
|
||||
value: new Big('3198.9') // 15 * 213.26
|
||||
},
|
||||
{
|
||||
date: '2021-06-12',
|
||||
grossPerformance: new Big('516.05'),
|
||||
netPerformance: new Big('516.05'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3200.1') // +1.2
|
||||
value: new Big('3200.1') // 15 * 213.34
|
||||
},
|
||||
{
|
||||
date: '2021-06-13',
|
||||
grossPerformance: new Big('517.25'),
|
||||
netPerformance: new Big('517.25'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3201.3') // +1.2
|
||||
value: new Big('3201.3') // 15 * 213.42
|
||||
},
|
||||
{
|
||||
date: '2021-06-14',
|
||||
grossPerformance: new Big('518.45'),
|
||||
netPerformance: new Big('518.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3202.5') // +1.2
|
||||
value: new Big('3202.5') // 15 * 213.5
|
||||
},
|
||||
{
|
||||
date: '2021-06-15',
|
||||
grossPerformance: new Big('519.65'),
|
||||
netPerformance: new Big('519.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3203.7') // +1.2
|
||||
value: new Big('3203.7') // 15 * 213.58
|
||||
},
|
||||
{
|
||||
date: '2021-06-16',
|
||||
grossPerformance: new Big('520.85'),
|
||||
netPerformance: new Big('520.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3204.9') // +1.2
|
||||
value: new Big('3204.9') // 15 * 213.66
|
||||
},
|
||||
{
|
||||
date: '2021-06-17',
|
||||
grossPerformance: new Big('522.05'),
|
||||
netPerformance: new Big('522.05'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3206.1') // +1.2
|
||||
value: new Big('3206.1') // 15 * 213.74
|
||||
},
|
||||
{
|
||||
date: '2021-06-18',
|
||||
grossPerformance: new Big('523.25'),
|
||||
netPerformance: new Big('523.25'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3207.3') // +1.2
|
||||
value: new Big('3207.3') // 15 * 213.82
|
||||
},
|
||||
{
|
||||
date: '2021-06-19',
|
||||
grossPerformance: new Big('524.45'),
|
||||
netPerformance: new Big('524.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3208.5') // +1.2
|
||||
value: new Big('3208.5') // 15 * 213.9
|
||||
},
|
||||
{
|
||||
date: '2021-06-20',
|
||||
grossPerformance: new Big('525.65'),
|
||||
netPerformance: new Big('525.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3209.7') // +1.2
|
||||
value: new Big('3209.7') // 15 * 213.98
|
||||
},
|
||||
{
|
||||
date: '2021-06-21',
|
||||
grossPerformance: new Big('526.85'),
|
||||
netPerformance: new Big('526.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3210.9') // +1.2
|
||||
value: new Big('3210.9') // 15 * 214.06
|
||||
},
|
||||
{
|
||||
date: '2021-06-22',
|
||||
grossPerformance: new Big('528.05'),
|
||||
netPerformance: new Big('528.05'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3212.1') // +1.2
|
||||
value: new Big('3212.1') // 15 * 214.14
|
||||
},
|
||||
{
|
||||
date: '2021-06-23',
|
||||
grossPerformance: new Big('529.25'),
|
||||
netPerformance: new Big('529.25'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3213.3') // +1.2
|
||||
value: new Big('3213.3') // 15 * 214.22
|
||||
},
|
||||
{
|
||||
date: '2021-06-24',
|
||||
grossPerformance: new Big('530.45'),
|
||||
netPerformance: new Big('530.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3214.5') // +1.2
|
||||
value: new Big('3214.5') // 15 * 214.3
|
||||
},
|
||||
{
|
||||
date: '2021-06-25',
|
||||
grossPerformance: new Big('531.65'),
|
||||
netPerformance: new Big('531.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3215.7') // +1.2
|
||||
value: new Big('3215.7') // 15 * 214.38
|
||||
},
|
||||
{
|
||||
date: '2021-06-26',
|
||||
grossPerformance: new Big('532.85'),
|
||||
netPerformance: new Big('532.85'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3216.9') // +1.2
|
||||
value: new Big('3216.9') // 15 * 214.46
|
||||
},
|
||||
{
|
||||
date: '2021-06-27',
|
||||
grossPerformance: new Big('534.05'),
|
||||
netPerformance: new Big('534.05'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3218.1') // +1.2
|
||||
value: new Big('3218.1') // 15 * 214.54
|
||||
},
|
||||
{
|
||||
date: '2021-06-28',
|
||||
grossPerformance: new Big('535.25'),
|
||||
netPerformance: new Big('535.25'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3219.3') // +1.2
|
||||
value: new Big('3219.3') // 15 * 214.62
|
||||
},
|
||||
{
|
||||
date: '2021-06-29',
|
||||
grossPerformance: new Big('536.45'),
|
||||
netPerformance: new Big('536.45'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3220.5') // +1.2
|
||||
value: new Big('3220.5') // 15 * 214.7
|
||||
},
|
||||
{
|
||||
date: '2021-06-30',
|
||||
grossPerformance: new Big('537.65'),
|
||||
netPerformance: new Big('537.65'),
|
||||
investment: new Big('2684.05'),
|
||||
value: new Big('3221.7') // +1.2
|
||||
value: new Big('3221.7') // 15 * 214.78
|
||||
}
|
||||
])
|
||||
);
|
||||
@ -2442,7 +2619,7 @@ describe('PortfolioCalculator', () => {
|
||||
grossPerformance: new Big('267.2'),
|
||||
netPerformance: new Big('267.2'),
|
||||
investment: new Big('11553.75'),
|
||||
value: new Big('11820.95') // 10 * (144.38 + days=334 * 0.08) + 5 * 2021.99
|
||||
value: new Big('11820.95') // 10 * 171.1 + 5 * 2021.99
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
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
|
||||
]
|
||||
})
|
||||
|
1194
apps/api/src/app/portfolio/portfolio.service-new.ts
Normal file
1194
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);
|
||||
|
||||
@ -895,8 +897,8 @@ export class PortfolioService {
|
||||
}: {
|
||||
cashDetails: CashDetails;
|
||||
investment: Big;
|
||||
value: Big;
|
||||
userCurrency: string;
|
||||
value: Big;
|
||||
}) {
|
||||
const cashPositions = {};
|
||||
|
||||
@ -1025,8 +1027,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 +1040,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 +1095,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 +1113,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
|
||||
};
|
||||
}
|
||||
|
@ -13,7 +13,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { DataSource, MarketData } from '@prisma/client';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
import { isDate, isEmpty } from 'lodash';
|
||||
|
||||
@ -37,8 +37,7 @@ export class SymbolController {
|
||||
@Query() { query = '' }
|
||||
): Promise<{ items: LookupItem[] }> {
|
||||
try {
|
||||
const encodedQuery = encodeURIComponent(query.toLowerCase());
|
||||
return this.symbolService.lookup(encodedQuery);
|
||||
return this.symbolService.lookup(query.toLowerCase());
|
||||
} catch {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),
|
||||
|
@ -93,32 +93,6 @@ export class SymbolService {
|
||||
try {
|
||||
const { items } = await this.dataProviderService.search(aQuery);
|
||||
results.items = items;
|
||||
|
||||
// Add custom symbols
|
||||
const ghostfolioSymbolProfiles =
|
||||
await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
name: {
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
for (const ghostfolioSymbolProfile of ghostfolioSymbolProfiles) {
|
||||
results.items.push(ghostfolioSymbolProfile);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
|
@ -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;
|
||||
|
||||
|
@ -13,6 +13,7 @@ export class ConfigurationService {
|
||||
ACCESS_TOKEN_SALT: str(),
|
||||
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
|
||||
CACHE_TTL: num({ default: 1 }),
|
||||
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
|
||||
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
|
||||
ENABLE_FEATURE_BLOG: bool({ default: false }),
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
|
||||
@ -25,6 +26,9 @@ export class ConfigurationService {
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||
GOOGLE_SHEETS_ID: str({ default: '' }),
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: str({ default: '' }),
|
||||
JWT_SECRET_KEY: str({}),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
|
@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
|
||||
return DataSource.ALPHA_VANTAGE;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aSymbol);
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const result = await this.alphaVantage.data.search(aQuery);
|
||||
|
||||
return {
|
||||
items: result?.bestMatches?.map((bestMatch) => {
|
||||
return {
|
||||
dataSource: DataSource.ALPHA_VANTAGE,
|
||||
dataSource: this.getName(),
|
||||
name: bestMatch['2. name'],
|
||||
symbol: bestMatch['1. symbol']
|
||||
};
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
|
||||
AlphaVantageService,
|
||||
DataProviderService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
RakutenRapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
rakutenRapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
|
@ -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;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@ -149,13 +158,13 @@ export class DataProviderService {
|
||||
return result;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const promises: Promise<{ items: LookupItem[] }>[] = [];
|
||||
let lookupItems: LookupItem[] = [];
|
||||
|
||||
for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
|
||||
promises.push(
|
||||
this.getDataProvider(DataSource[dataSource]).search(aSymbol)
|
||||
this.getDataProvider(DataSource[dataSource]).search(aQuery)
|
||||
);
|
||||
}
|
||||
|
||||
@ -176,7 +185,7 @@ export class DataProviderService {
|
||||
}
|
||||
|
||||
public getPrimaryDataSource(): DataSource {
|
||||
return DataSource[this.configurationService.get('DATA_SOURCES')[0]];
|
||||
return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
|
||||
}
|
||||
|
||||
private getDataProvider(providerName: DataSource) {
|
||||
|
@ -1,4 +1,10 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
@ -13,13 +19,6 @@ import * as bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} from '../../interfaces/interfaces';
|
||||
import { DataProviderInterface } from '../interfaces/data-provider.interface';
|
||||
|
||||
@Injectable()
|
||||
export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
|
||||
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
[symbol]: {
|
||||
marketPrice,
|
||||
currency: symbolProfile?.currency,
|
||||
dataSource: DataSource.GHOSTFOLIO,
|
||||
dataSource: this.getName(),
|
||||
marketState: MarketState.delayed
|
||||
}
|
||||
};
|
||||
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
|
||||
return DataSource.GHOSTFOLIO;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private extractNumberFromString(aString: string): number {
|
||||
|
@ -0,0 +1,181 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse,
|
||||
MarketState
|
||||
} 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, parseDate } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource } from '@prisma/client';
|
||||
import { format } from 'date-fns';
|
||||
import { GoogleSpreadsheet } from 'google-spreadsheet';
|
||||
|
||||
@Injectable()
|
||||
export class GoogleSheetsService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async get(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
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: 'Overview'
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbols: string[],
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const [symbol] = aSymbols;
|
||||
|
||||
const sheet = await this.getSheet({
|
||||
symbol,
|
||||
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID')
|
||||
});
|
||||
|
||||
const rows = await sheet.getRows();
|
||||
|
||||
const historicalData: {
|
||||
[date: string]: IDataProviderHistoricalResponse;
|
||||
} = {};
|
||||
|
||||
rows
|
||||
.filter((row, index) => {
|
||||
return index >= 1;
|
||||
})
|
||||
.forEach((row) => {
|
||||
const date = parseDate(row._rawData[0]);
|
||||
const close = parseFloat(row._rawData[1]);
|
||||
|
||||
historicalData[format(date, DATE_FORMAT)] = { marketPrice: close };
|
||||
});
|
||||
|
||||
return {
|
||||
[symbol]: historicalData
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.error(error);
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.GOOGLE_SHEETS;
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items = await this.prismaService.symbolProfile.findMany({
|
||||
select: {
|
||||
currency: true,
|
||||
dataSource: true,
|
||||
name: true,
|
||||
symbol: true
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
name: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
},
|
||||
{
|
||||
dataSource: this.getName(),
|
||||
symbol: {
|
||||
mode: 'insensitive',
|
||||
startsWith: aQuery
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
private async getSheet({
|
||||
sheetId,
|
||||
symbol
|
||||
}: {
|
||||
sheetId: string;
|
||||
symbol: string;
|
||||
}) {
|
||||
const doc = new GoogleSpreadsheet(sheetId);
|
||||
|
||||
await doc.useServiceAccountAuth({
|
||||
client_email: this.configurationService.get('GOOGLE_SHEETS_ACCOUNT'),
|
||||
private_key: this.configurationService
|
||||
.get('GOOGLE_SHEETS_PRIVATE_KEY')
|
||||
.replace(/\\n/g, '\n')
|
||||
});
|
||||
|
||||
await doc.loadInfo();
|
||||
|
||||
const sheet = doc.sheetsByTitle[symbol];
|
||||
|
||||
await sheet.loadCells();
|
||||
|
||||
return sheet;
|
||||
}
|
||||
}
|
@ -1,11 +1,10 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '../../interfaces/interfaces';
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface DataProviderInterface {
|
||||
canHandle(symbol: string): boolean;
|
||||
@ -23,5 +22,5 @@ export interface DataProviderInterface {
|
||||
|
||||
getName(): DataSource;
|
||||
|
||||
search(aSymbol: string): Promise<{ items: LookupItem[] }>;
|
||||
search(aQuery: string): Promise<{ items: LookupItem[] }>;
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return {
|
||||
[ghostfolioFearAndGreedIndexSymbol]: {
|
||||
currency: undefined,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
marketPrice: fgi.now.value,
|
||||
marketState: MarketState.open,
|
||||
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
|
||||
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subWeeks(getToday(), 1),
|
||||
marketPrice: fgi.oneWeekAgo.value
|
||||
}
|
||||
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subMonths(getToday(), 1),
|
||||
marketPrice: fgi.oneMonthAgo.value
|
||||
}
|
||||
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
await this.prismaService.marketData.create({
|
||||
data: {
|
||||
symbol,
|
||||
dataSource: DataSource.RAKUTEN,
|
||||
dataSource: this.getName(),
|
||||
date: subYears(getToday(), 1),
|
||||
marketPrice: fgi.oneYearAgo.value
|
||||
}
|
||||
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
return DataSource.RAKUTEN;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
|
@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
assetClass,
|
||||
assetSubClass,
|
||||
currency: value.price?.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: this.getName(),
|
||||
exchange: this.parseExchange(value.price?.exchangeName),
|
||||
marketState:
|
||||
value.price?.marketState === 'REGULAR' ||
|
||||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
return DataSource.YAHOO;
|
||||
}
|
||||
|
||||
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> {
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
const items: LookupItem[] = [];
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
`${this.yahooFinanceHostname}/v1/finance/search?q=${encodeURIComponent(
|
||||
aQuery
|
||||
)}&lang=en-US®ion=US"esCount=8&newsCount=0&enableFuzzyQuery=false"esQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
items.push({
|
||||
symbol,
|
||||
currency: value.currency,
|
||||
dataSource: DataSource.YAHOO,
|
||||
dataSource: this.getName(),
|
||||
name: value.name
|
||||
});
|
||||
}
|
||||
|
@ -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 };
|
||||
|
@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ACCESS_TOKEN_SALT: string;
|
||||
ALPHA_VANTAGE_API_KEY: string;
|
||||
CACHE_TTL: number;
|
||||
DATA_SOURCE_PRIMARY: string;
|
||||
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
|
||||
ENABLE_FEATURE_BLOG: boolean;
|
||||
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
|
||||
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
GOOGLE_SHEETS_ACCOUNT: string;
|
||||
GOOGLE_SHEETS_ID: string;
|
||||
GOOGLE_SHEETS_PRIVATE_KEY: string;
|
||||
JWT_SECRET_KEY: string;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
MAX_ORDERS_TO_IMPORT: number;
|
||||
|
@ -59,6 +59,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
|
||||
).then((m) => m.HelloGhostfolioPageModule)
|
||||
},
|
||||
{
|
||||
path: 'en/blog/2022/01/ghostfolio-first-months-in-open-source',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
|
||||
).then((m) => m.FirstMonthsInOpenSourcePageModule)
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
|
@ -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"
|
||||
|
@ -14,6 +14,7 @@
|
||||
<gf-positions
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positions"
|
||||
[range]="dateRange"
|
||||
|
@ -8,6 +8,7 @@ import {
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { defaultDateRangeOptions } from '@ghostfolio/common/config';
|
||||
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
@ -25,6 +26,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
public deviceType: string;
|
||||
public hasError: boolean;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public historicalDataItems: LineChartItem[];
|
||||
public isAllTimeHigh: boolean;
|
||||
public isAllTimeLow: boolean;
|
||||
@ -51,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
|
@ -13,7 +13,7 @@
|
||||
[showYAxis]="false"
|
||||
></gf-line-chart>
|
||||
<div
|
||||
*ngIf="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>
|
||||
|
@ -123,7 +123,12 @@
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
|
||||
<div *ngIf="dataSource.data.length === 0 && !isLoading" class="p-3 text-center">
|
||||
<div
|
||||
*ngIf="
|
||||
dataSource.data.length === 0 && hasPermissionToCreateOrder && !isLoading
|
||||
"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
<gf-no-transactions-info-indicator
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
|
@ -26,6 +26,7 @@ import { Subject, Subscription } from 'rxjs';
|
||||
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() positions: PortfolioPosition[];
|
||||
|
||||
|
@ -23,7 +23,10 @@
|
||||
[range]="range"
|
||||
></gf-position>
|
||||
</ng-container>
|
||||
<div *ngIf="!hasPositions" class="p-3 text-center">
|
||||
<div
|
||||
*ngIf="hasPermissionToCreateOrder && !hasPositions"
|
||||
class="p-3 text-center"
|
||||
>
|
||||
<gf-no-transactions-info-indicator
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
|
@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
|
||||
export class PositionsComponent implements OnChanges, OnInit {
|
||||
@Input() baseCurrency: string;
|
||||
@Input() deviceType: string;
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() positions: Position[];
|
||||
@Input() range: string;
|
||||
|
@ -1,7 +1,10 @@
|
||||
<div class="container p-0">
|
||||
<div class="row no-gutters">
|
||||
<div class="col">
|
||||
<mat-card *ngIf="rules === null" class="my-2 text-center">
|
||||
<mat-card
|
||||
*ngIf="hasPermissionToCreateOrder && rules === null"
|
||||
class="my-2 text-center"
|
||||
>
|
||||
<gf-no-transactions-info-indicator
|
||||
[hasBorder]="false"
|
||||
></gf-no-transactions-info-indicator>
|
||||
|
@ -8,6 +8,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
||||
styleUrls: ['./rules.component.scss']
|
||||
})
|
||||
export class RulesComponent {
|
||||
@Input() hasPermissionToCreateOrder: boolean;
|
||||
@Input() rules: PortfolioReportRule;
|
||||
|
||||
public constructor() {}
|
||||
|
@ -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>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
|
||||
@ -141,58 +141,59 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="h5">
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Aktie</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Altersvorsorge</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Anlage</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">App</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Cryptocurrency</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Feedback</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Fintech</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Ghostfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Investition</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Open Source</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Portfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Software</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Strategie</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Trading</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>TypeScript</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Vermögen</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Wealth Management</span
|
||||
>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Aktie</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Altersvorsorge</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Anlage</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">App</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cryptocurrency</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Feedback</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investition</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Open Source</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">OSS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Strategie</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Trading</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">TypeScript</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Vermögen</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
@ -7,9 +7,7 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [HalloGhostfolioPageComponent],
|
||||
exports: [],
|
||||
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class HalloGhostfolioPageModule {}
|
||||
|
@ -1,6 +1,6 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
|
||||
@ -136,42 +136,44 @@
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="h5">
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Cryptocurrency</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">ETF</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Fintech</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Ghostfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Investment</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Open Source</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2">OSS</span>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Portfolio</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Software</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Stock</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Strategy</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Wealth</span
|
||||
>
|
||||
<span class="badge badge-light font-weight-normal mr-2"
|
||||
>Wealth Management</span
|
||||
>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cryptocurrency</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">ETF</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Open Source</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">OSS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Stock</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Strategy</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
@ -7,9 +7,7 @@ import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [HelloGhostfolioPageComponent],
|
||||
exports: [],
|
||||
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class HelloGhostfolioPageModule {}
|
||||
|
@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: FirstMonthsInOpenSourcePageComponent,
|
||||
canActivate: [AuthGuard]
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class FirstMonthsInOpenSourceRoutingModule {}
|
@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'mb-5' },
|
||||
selector: 'gf-first-months-in-open-source-page',
|
||||
styleUrls: ['./first-months-in-open-source-page.scss'],
|
||||
templateUrl: './first-months-in-open-source-page.html'
|
||||
})
|
||||
export class FirstMonthsInOpenSourcePageComponent {}
|
@ -0,0 +1,185 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1" i18n>
|
||||
👻 Ghostfolio –
|
||||
<span class="text-nowrap">First months in Open Source</span>
|
||||
</h1>
|
||||
<div class="text-muted"><small>05.01.2022</small></div>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
In this article I would like to recap the first months running the
|
||||
open source project <a href="https://ghostfol.io">Ghostfolio</a>, a
|
||||
web-based personal finance management software.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">From 1* to 100 stars on GitHub</h2>
|
||||
<p>
|
||||
When I decided to
|
||||
<a [routerLink]="['/en', 'blog', '2021', '07', 'hello-ghostfolio']"
|
||||
>publish</a
|
||||
>
|
||||
the project as
|
||||
<a href="https://github.com/ghostfolio/ghostfolio"
|
||||
>open source software</a
|
||||
>
|
||||
(OSS), I did not know what exactly to expect. In the worst case,
|
||||
nobody would care. And in the best case, the repository would be
|
||||
overrun with contributions. The truth is probably somewhere in
|
||||
between.
|
||||
</p>
|
||||
<p>
|
||||
In the beginning, it felt quite weird to develop in public where
|
||||
anyone can observe the progress. Stupid mistakes remain visible
|
||||
forever. But this feeling, fortunately, quickly settled. I believe
|
||||
the benefits like all the learning clearly outweigh the drawbacks
|
||||
when you just do it.
|
||||
</p>
|
||||
<p>
|
||||
At the end of 2021, Ghostfolio reached an important milestone:
|
||||
<a href="https://twitter.com/ghostfolio_/status/1470075774640218121"
|
||||
>100 stars</a
|
||||
>
|
||||
on GitHub. This is really exciting with almost no marketing. I am a
|
||||
technical founder, so I prefer writing code over anything else. But
|
||||
there is so much more to make this project happen: writing
|
||||
documentation, maintaining bug reports and feature requests,
|
||||
supporting users and managing the community, keeping the SaaS
|
||||
running, etc.
|
||||
</p>
|
||||
<p>
|
||||
Reaching 100 stars will not only attract very early adopters, but
|
||||
also the early adopters. At the same time, the demands and
|
||||
expectations are also increasing.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">What is new?</h2>
|
||||
<p>
|
||||
During the last months, Ghostfolio has transformed from a one man
|
||||
project into a prospering wealth management application with 9
|
||||
contributors and counting. User feedback has directly shaped the
|
||||
direction of the product development.
|
||||
</p>
|
||||
<p>These are some selected key features:</p>
|
||||
<ul>
|
||||
<li>
|
||||
Simplified self-hosting with an
|
||||
<a href="https://hub.docker.com/r/ghostfolio/ghostfolio"
|
||||
>official Ghostfolio docker image</a
|
||||
>
|
||||
on Docker Hub
|
||||
</li>
|
||||
<li>Improved import for activities (transactions and dividend)</li>
|
||||
<li>Enriched market data for ETFs (region and industries)</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">What is coming?</h2>
|
||||
<p>Here is a brief overview of what I am planning in 2022.</p>
|
||||
<p>
|
||||
The goal remains to offer a simple and solid software to manage
|
||||
personal finances. Thus, the main focus is on the core
|
||||
functionality.
|
||||
</p>
|
||||
<p>
|
||||
My personal goal is to reach break-even with the Saas offering (<a
|
||||
[routerLink]="['/pricing']"
|
||||
>Ghostfolio Premium</a
|
||||
>) and regularly report about the progress and my learnings on this
|
||||
exciting journey.
|
||||
</p>
|
||||
<p>
|
||||
I have already started to build a
|
||||
<a
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>community</a
|
||||
>
|
||||
of users. In the future, I would like to involve more contributors
|
||||
to further extend the functionality of Ghostfolio (e.g. with new
|
||||
reports). Get in touch with me by email at
|
||||
<a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a> or on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> if you
|
||||
are interested, I’m happy to discuss ideas.
|
||||
</p>
|
||||
<p>
|
||||
I would like to say thank you for all your feedback and support
|
||||
during the last months.
|
||||
</p>
|
||||
<p>
|
||||
Have a great start into the new year and happy investing<br />
|
||||
Thomas from Ghostfolio
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<p>* Pro Tip: add the first star to your own open source project</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">BuildInPublic</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Community</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cryptocurrency</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Docker</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">ETF</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Image</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Open Source</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">OSS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Progress</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">SaaS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Stock</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Strategy</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { FirstMonthsInOpenSourceRoutingModule } from './first-months-in-open-source-page-routing.module';
|
||||
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [FirstMonthsInOpenSourcePageComponent],
|
||||
imports: [CommonModule, FirstMonthsInOpenSourceRoutingModule, RouterModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class FirstMonthsInOpenSourcePageModule {}
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -5,6 +5,26 @@
|
||||
<mat-card class="blog-container">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap mb-3 no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
[routerLink]="['/en', 'blog', '2022', '01', 'ghostfolio-first-months-in-open-source']"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="h6 m-0 text-truncate">
|
||||
First months in Open Source
|
||||
</div>
|
||||
<div class="d-flex text-muted">05.01.2021</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="flex-nowrap mb-3 no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
PortfolioPosition,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { ToggleOption } from '@ghostfolio/common/types';
|
||||
import { AssetClass } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
@ -36,6 +37,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public period = 'current';
|
||||
public periodOptions: ToggleOption[] = [
|
||||
{ label: 'Initial', value: 'original' },
|
||||
@ -120,6 +122,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
@ -155,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
|
||||
};
|
||||
|
@ -197,6 +197,7 @@
|
||||
<gf-positions-table
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[locale]="user?.settings?.locale"
|
||||
[positions]="positionsArray"
|
||||
></gf-positions-table>
|
||||
|
@ -2,9 +2,10 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { User } from '@ghostfolio/common/interfaces';
|
||||
import { Position, User } from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { sortBy } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -16,10 +17,12 @@ import { takeUntil } from 'rxjs/operators';
|
||||
templateUrl: './analysis-page.html'
|
||||
})
|
||||
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public bottom3: Position[];
|
||||
public daysInMarket: number;
|
||||
public deviceType: string;
|
||||
public hasImpersonationId: boolean;
|
||||
public investments: InvestmentItem[];
|
||||
public top3: Position[];
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
@ -58,6 +61,26 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchPositions({ range: 'max' })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ positions }) => {
|
||||
const positionsSorted = sortBy(
|
||||
positions,
|
||||
'netPerformancePercentage'
|
||||
).reverse();
|
||||
|
||||
this.top3 = positionsSorted.slice(0, 3);
|
||||
|
||||
if (positions?.length > 3) {
|
||||
this.bottom3 = positionsSorted.slice(-3).reverse();
|
||||
} else {
|
||||
this.bottom3 = [];
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
|
@ -5,7 +5,7 @@
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title class="align-items-center d-flex" i18n
|
||||
>Timeline</mat-card-title
|
||||
>Investment Timeline</mat-card-title
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
@ -19,4 +19,82 @@
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title class="align-items-center d-flex" i18n
|
||||
>Top 3</mat-card-title
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div *ngFor="let position of top3; let i = index" class="d-flex py-1">
|
||||
<div class="flex-grow-1 mr-2 text-truncate">
|
||||
{{ i + 1 }}. {{ position.name }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="!top3"
|
||||
animation="pulse"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title class="align-items-center d-flex" i18n
|
||||
>Bottom 3</mat-card-title
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<div
|
||||
*ngFor="let position of bottom3; let i = index"
|
||||
class="d-flex py-1"
|
||||
>
|
||||
<div class="flex-grow-1 mr-2 text-truncate">
|
||||
{{ i + 1 }}. {{ position.name }}
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
position="end"
|
||||
[colorizeSign]="true"
|
||||
[isPercent]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="position.netPerformancePercentage"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="!bottom3"
|
||||
animation="pulse"
|
||||
[theme]="{
|
||||
height: '1.5rem',
|
||||
width: '100%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { AnalysisPageRoutingModule } from './analysis-page-routing.module';
|
||||
import { AnalysisPageComponent } from './analysis-page.component';
|
||||
@ -13,7 +15,9 @@ import { AnalysisPageComponent } from './analysis-page.component';
|
||||
AnalysisPageRoutingModule,
|
||||
CommonModule,
|
||||
GfInvestmentChartModule,
|
||||
MatCardModule
|
||||
GfValueModule,
|
||||
MatCardModule,
|
||||
NgxSkeletonLoaderModule
|
||||
],
|
||||
providers: [],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -1,6 +1,8 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { PortfolioReportRule, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
@ -14,6 +16,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
|
||||
public accountClusterRiskRules: PortfolioReportRule[];
|
||||
public currencyClusterRiskRules: PortfolioReportRule[];
|
||||
public feeRules: PortfolioReportRule[];
|
||||
public hasPermissionToCreateOrder: boolean;
|
||||
public user: User;
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
@ -22,7 +26,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
|
||||
*/
|
||||
public constructor(
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService
|
||||
private dataService: DataService,
|
||||
private userService: UserService
|
||||
) {}
|
||||
|
||||
/**
|
||||
@ -41,6 +46,21 @@ export class ReportPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
if (state?.user) {
|
||||
this.user = state.user;
|
||||
|
||||
this.hasPermissionToCreateOrder = hasPermission(
|
||||
this.user.permissions,
|
||||
permissions.createOrder
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
|
@ -15,15 +15,24 @@
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<h4 class="m-0" i18n>Currency Cluster Risks</h4>
|
||||
<gf-rules [rules]="currencyClusterRiskRules"></gf-rules>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="currencyClusterRiskRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h4 class="m-0" i18n>Account Cluster Risks</h4>
|
||||
<gf-rules [rules]="accountClusterRiskRules"></gf-rules>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="accountClusterRiskRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="m-0" i18n>Fees</h4>
|
||||
<gf-rules [rules]="feeRules"></gf-rules>
|
||||
<gf-rules
|
||||
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
|
||||
[rules]="feeRules"
|
||||
></gf-rules>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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;
|
||||
@ -62,11 +63,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
this.routeQueryParams = route.queryParams
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((params) => {
|
||||
if (params['createDialog'] && this.hasPermissionToCreateOrder) {
|
||||
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"
|
||||
|
@ -4,6 +4,7 @@ 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';
|
||||
@ -169,14 +170,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,4 +1,5 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /p/*
|
||||
|
||||
Sitemap: https://ghostfol.io/sitemap.xml
|
||||
|
@ -28,6 +28,10 @@
|
||||
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||
<lastmod>2022-01-05T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pricing</loc>
|
||||
<lastmod>2022-01-01T00:00:00+00:00</lastmod>
|
||||
|
@ -38,7 +38,7 @@ body {
|
||||
|
||||
.blog {
|
||||
a {
|
||||
color: rgba(var(--palette-primary-500), 1);
|
||||
color: rgba(var(--palette-primary-500), 1) !important;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
|
@ -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;
|
||||
};
|
||||
};
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
12
package.json
12
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.99.0",
|
||||
"version": "1.107.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",
|
||||
@ -94,6 +94,7 @@
|
||||
"cryptocurrencies": "7.0.0",
|
||||
"date-fns": "2.22.1",
|
||||
"envalid": "7.2.1",
|
||||
"google-spreadsheet": "3.2.0",
|
||||
"http-status-codes": "2.2.0",
|
||||
"ionicons": "5.5.1",
|
||||
"lodash": "4.17.21",
|
||||
@ -105,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",
|
||||
@ -143,6 +144,7 @@
|
||||
"@types/big.js": "6.1.2",
|
||||
"@types/cache-manager": "3.4.2",
|
||||
"@types/color": "3.0.2",
|
||||
"@types/google-spreadsheet": "3.1.5",
|
||||
"@types/jest": "27.0.2",
|
||||
"@types/lodash": "4.14.174",
|
||||
"@types/node": "14.14.33",
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "DataSource" ADD VALUE 'GOOGLE_SHEETS';
|
@ -184,6 +184,7 @@ enum AssetSubClass {
|
||||
enum DataSource {
|
||||
ALPHA_VANTAGE
|
||||
GHOSTFOLIO
|
||||
GOOGLE_SHEETS
|
||||
RAKUTEN
|
||||
YAHOO
|
||||
}
|
||||
|
182
yarn.lock
182
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"
|
||||
@ -4487,6 +4487,11 @@
|
||||
"@types/minimatch" "*"
|
||||
"@types/node" "*"
|
||||
|
||||
"@types/google-spreadsheet@3.1.5":
|
||||
version "3.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/google-spreadsheet/-/google-spreadsheet-3.1.5.tgz#2bdc6f9f5372551e0506cb6ef3f562adcf44fc2e"
|
||||
integrity sha512-7N+mDtZ1pmya2RRFPPl4KYc2TRgiqCNBLUZfyrKfER+u751JgCO+C24/LzF70UmUm/zhHUbzRZ5mtfaxekQ1ZQ==
|
||||
|
||||
"@types/graceful-fs@^4.1.2":
|
||||
version "4.1.5"
|
||||
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15"
|
||||
@ -5284,6 +5289,13 @@ abbrev@1:
|
||||
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
|
||||
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
|
||||
|
||||
abort-controller@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
|
||||
integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
|
||||
dependencies:
|
||||
event-target-shim "^5.0.0"
|
||||
|
||||
accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
|
||||
version "1.3.7"
|
||||
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd"
|
||||
@ -5774,7 +5786,7 @@ array.prototype.map@^1.0.3:
|
||||
es-array-method-boxes-properly "^1.0.0"
|
||||
is-string "^1.0.5"
|
||||
|
||||
arrify@^2.0.1:
|
||||
arrify@^2.0.0, arrify@^2.0.1:
|
||||
version "2.0.1"
|
||||
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
|
||||
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
|
||||
@ -5903,6 +5915,13 @@ axios@0.24.0:
|
||||
dependencies:
|
||||
follow-redirects "^1.14.4"
|
||||
|
||||
axios@^0.21.4:
|
||||
version "0.21.4"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
|
||||
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
|
||||
dependencies:
|
||||
follow-redirects "^1.14.0"
|
||||
|
||||
axobject-query@2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.0.2.tgz#ea187abe5b9002b377f925d8bf7d1c561adf38f9"
|
||||
@ -6124,7 +6143,7 @@ balanced-match@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
|
||||
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
|
||||
base64-js@^1.0.2, base64-js@^1.2.0, base64-js@^1.3.1:
|
||||
base64-js@^1.0.2, base64-js@^1.2.0, base64-js@^1.3.0, base64-js@^1.3.1:
|
||||
version "1.5.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
@ -6190,6 +6209,11 @@ big.js@^5.2.2:
|
||||
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
|
||||
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==
|
||||
|
||||
bignumber.js@^9.0.0:
|
||||
version "9.0.2"
|
||||
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.2.tgz#71c6c6bed38de64e24a65ebe16cfcf23ae693673"
|
||||
integrity sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw==
|
||||
|
||||
bignumber.js@^9.0.1:
|
||||
version "9.0.1"
|
||||
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5"
|
||||
@ -8233,7 +8257,7 @@ ecc-jsbn@~0.1.1:
|
||||
jsbn "~0.1.0"
|
||||
safer-buffer "^2.1.0"
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
ecdsa-sig-formatter@1.0.11, ecdsa-sig-formatter@^1.0.11:
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
|
||||
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==
|
||||
@ -9064,6 +9088,11 @@ etag@~1.8.1:
|
||||
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
|
||||
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
|
||||
|
||||
event-target-shim@^5.0.0:
|
||||
version "5.0.1"
|
||||
resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
|
||||
integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
|
||||
|
||||
eventemitter-asyncresource@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz#734ff2e44bf448e627f7748f905d6bdd57bdb65b"
|
||||
@ -9233,7 +9262,7 @@ extend-shallow@^3.0.0, extend-shallow@^3.0.2:
|
||||
assign-symbols "^1.0.0"
|
||||
is-extendable "^1.0.1"
|
||||
|
||||
extend@^3.0.0, extend@~3.0.2:
|
||||
extend@^3.0.0, extend@^3.0.2, extend@~3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
|
||||
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
|
||||
@ -9324,6 +9353,11 @@ fast-safe-stringify@2.1.1:
|
||||
resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
|
||||
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
|
||||
|
||||
fast-text-encoding@^1.0.0:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/fast-text-encoding/-/fast-text-encoding-1.0.3.tgz#ec02ac8e01ab8a319af182dae2681213cfe9ce53"
|
||||
integrity sha512-dtm4QZH9nZtcDt8qJiOH9fcQd1NAgi+K1O2DbE6GG1PPCK/BWfOH3idCTRQ4ImXRUOyopDEgDEnVEE7Y/2Wrig==
|
||||
|
||||
fastparse@^1.1.2:
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9"
|
||||
@ -9587,6 +9621,11 @@ follow-redirects@^1.0.0:
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e"
|
||||
integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw==
|
||||
|
||||
follow-redirects@^1.14.0:
|
||||
version "1.14.6"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.6.tgz#8cfb281bbc035b3c067d6cd975b0f6ade6e855cd"
|
||||
integrity sha512-fhUl5EwSJbbl8AR+uYL2KQDxLkdSjZGR36xy46AO7cOMTrCMON6Sa28FmAnC2tRTDbd/Uuzz3aJBv7EBN7JH8A==
|
||||
|
||||
follow-redirects@^1.14.4:
|
||||
version "1.14.5"
|
||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.5.tgz#f09a5848981d3c772b5392309778523f8d85c381"
|
||||
@ -9831,6 +9870,25 @@ gauge@^4.0.0:
|
||||
strip-ansi "^6.0.1"
|
||||
wide-align "^1.1.2"
|
||||
|
||||
gaxios@^4.0.0:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/gaxios/-/gaxios-4.3.2.tgz#845827c2dc25a0213c8ab4155c7a28910f5be83f"
|
||||
integrity sha512-T+ap6GM6UZ0c4E6yb1y/hy2UB6hTrqhglp3XfmU9qbLCGRYhLVV5aRPpC4EmoG8N8zOnkYCgoBz+ScvGAARY6Q==
|
||||
dependencies:
|
||||
abort-controller "^3.0.0"
|
||||
extend "^3.0.2"
|
||||
https-proxy-agent "^5.0.0"
|
||||
is-stream "^2.0.0"
|
||||
node-fetch "^2.6.1"
|
||||
|
||||
gcp-metadata@^4.2.0:
|
||||
version "4.3.1"
|
||||
resolved "https://registry.yarnpkg.com/gcp-metadata/-/gcp-metadata-4.3.1.tgz#fb205fe6a90fef2fd9c85e6ba06e5559ee1eefa9"
|
||||
integrity sha512-x850LS5N7V1F3UcV7PoupzGsyD6iVwTVvsh3tbXfkctZnBnjW5yu5z1/3k3SehF7TyoTIe78rJs02GMMy+LF+A==
|
||||
dependencies:
|
||||
gaxios "^4.0.0"
|
||||
json-bigint "^1.0.0"
|
||||
|
||||
gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
|
||||
version "1.0.0-beta.2"
|
||||
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
|
||||
@ -10086,11 +10144,51 @@ globby@^9.0.0, globby@^9.2.0:
|
||||
pify "^4.0.1"
|
||||
slash "^2.0.0"
|
||||
|
||||
google-auth-library@^6.1.3:
|
||||
version "6.1.6"
|
||||
resolved "https://registry.yarnpkg.com/google-auth-library/-/google-auth-library-6.1.6.tgz#deacdcdb883d9ed6bac78bb5d79a078877fdf572"
|
||||
integrity sha512-Q+ZjUEvLQj/lrVHF/IQwRo6p3s8Nc44Zk/DALsN+ac3T4HY/g/3rrufkgtl+nZ1TW7DNAw5cTChdVp4apUXVgQ==
|
||||
dependencies:
|
||||
arrify "^2.0.0"
|
||||
base64-js "^1.3.0"
|
||||
ecdsa-sig-formatter "^1.0.11"
|
||||
fast-text-encoding "^1.0.0"
|
||||
gaxios "^4.0.0"
|
||||
gcp-metadata "^4.2.0"
|
||||
gtoken "^5.0.4"
|
||||
jws "^4.0.0"
|
||||
lru-cache "^6.0.0"
|
||||
|
||||
google-p12-pem@^3.0.3:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/google-p12-pem/-/google-p12-pem-3.1.2.tgz#c3d61c2da8e10843ff830fdb0d2059046238c1d4"
|
||||
integrity sha512-tjf3IQIt7tWCDsa0ofDQ1qqSCNzahXDxdAGJDbruWqu3eCg5CKLYKN+hi0s6lfvzYZ1GDVr+oDF9OOWlDSdf0A==
|
||||
dependencies:
|
||||
node-forge "^0.10.0"
|
||||
|
||||
google-spreadsheet@3.2.0:
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/google-spreadsheet/-/google-spreadsheet-3.2.0.tgz#ce8aa75c15705aa950ad52b091a6fc4d33dcb329"
|
||||
integrity sha512-z7XMaqb+26rdo8p51r5O03u8aPLAPzn5YhOXYJPcf2hdMVr0dUbIARgdkRdmGiBeoV/QoU/7VNhq1MMCLZv3kQ==
|
||||
dependencies:
|
||||
axios "^0.21.4"
|
||||
google-auth-library "^6.1.3"
|
||||
lodash "^4.17.21"
|
||||
|
||||
graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.1.9, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6:
|
||||
version "4.2.8"
|
||||
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
|
||||
integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg==
|
||||
|
||||
gtoken@^5.0.4:
|
||||
version "5.3.1"
|
||||
resolved "https://registry.yarnpkg.com/gtoken/-/gtoken-5.3.1.tgz#c1c2598a826f2b5df7c6bb53d7be6cf6d50c3c78"
|
||||
integrity sha512-yqOREjzLHcbzz1UrQoxhBtpk8KjrVhuqPE7od1K2uhyxG2BHjKZetlbLw/SPZak/QqTIQW+addS+EcjqQsZbwQ==
|
||||
dependencies:
|
||||
gaxios "^4.0.0"
|
||||
google-p12-pem "^3.0.3"
|
||||
jws "^4.0.0"
|
||||
|
||||
gzip-size@5.1.1:
|
||||
version "5.1.1"
|
||||
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274"
|
||||
@ -12175,6 +12273,13 @@ jsesc@~0.5.0:
|
||||
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
|
||||
integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=
|
||||
|
||||
json-bigint@^1.0.0:
|
||||
version "1.0.0"
|
||||
resolved "https://registry.yarnpkg.com/json-bigint/-/json-bigint-1.0.0.tgz#ae547823ac0cad8398667f8cd9ef4730f5b01ff1"
|
||||
integrity sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==
|
||||
dependencies:
|
||||
bignumber.js "^9.0.0"
|
||||
|
||||
json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2:
|
||||
version "1.0.2"
|
||||
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
|
||||
@ -12295,6 +12400,15 @@ jwa@^1.4.1:
|
||||
ecdsa-sig-formatter "1.0.11"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jwa@^2.0.0:
|
||||
version "2.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jwa/-/jwa-2.0.0.tgz#a7e9c3f29dae94027ebcaf49975c9345593410fc"
|
||||
integrity sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==
|
||||
dependencies:
|
||||
buffer-equal-constant-time "1.0.1"
|
||||
ecdsa-sig-formatter "1.0.11"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jwk-to-pem@^2.0.4:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz#151310bcfbcf731adc5ad9f379cbc8b395742906"
|
||||
@ -12312,6 +12426,14 @@ jws@^3.2.2:
|
||||
jwa "^1.4.1"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
jws@^4.0.0:
|
||||
version "4.0.0"
|
||||
resolved "https://registry.yarnpkg.com/jws/-/jws-4.0.0.tgz#2d4e8cf6a318ffaa12615e9dec7e86e6c97310f4"
|
||||
integrity sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==
|
||||
dependencies:
|
||||
jwa "^2.0.0"
|
||||
safe-buffer "^5.0.1"
|
||||
|
||||
karma-source-map-support@1.4.0:
|
||||
version "1.4.0"
|
||||
resolved "https://registry.yarnpkg.com/karma-source-map-support/-/karma-source-map-support-1.4.0.tgz#58526ceccf7e8730e56effd97a4de8d712ac0d6b"
|
||||
@ -14903,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"
|
||||
@ -16830,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