Compare commits

..

32 Commits

Author SHA1 Message Date
b464fefc57 Release 1.107.0 (#650) 2022-01-24 21:43:37 +01:00
bcb7f5f522 Feature/add feature toggle for new calculation engine (#649)
* Add feature toggle for new calculation engine

* Update changelog
2022-01-24 21:38:59 +01:00
f15b33e950 Portfolio calculator rework (#632)
* Portfolio calculator rework

Co-authored-by: Reto Kaul <retokaul@sublimd.com>
2022-01-24 20:35:13 +01:00
ca64492e77 Bugfix/fix styling in activities table footer (#648)
* Fix styling

* Update changelog
2022-01-24 20:12:54 +01:00
761376d72d Release 1.106.0 (#647) 2022-01-23 17:41:54 +01:00
9c086edffe Feature/extend historical data view in admin control (#646)
* Extend market data view

* Update changelog
2022-01-23 17:02:12 +01:00
585f99e4df Feature/add summary row to activities table (#645)
* Add summary row to activities table

* Update changelog
2022-01-23 11:39:30 +01:00
9d907b5eb5 Bugfix/improve the redirection on logout (#642)
* Improve logout

* Update changelog
2022-01-22 09:38:01 +01:00
ba05f5ba30 Feature/upgrade prisma to version 3.8.1 (#640)
* Upgrade prisma to version 3.8.1

* Update changelog
2022-01-22 09:36:58 +01:00
3261e3ee59 Feature/upgrade stripe dependencies (#641)
* Upgrade stripe dependencies

* Update changelog
2022-01-21 20:30:41 +01:00
5607c6bb52 Update blog url (#639) 2022-01-21 20:07:56 +01:00
1c6050d3e3 Release 1.105.0 (#638) 2022-01-20 21:35:56 +01:00
38f2930ec6 Feature/improve data provider service (#637)
* Improve data provider service

* Update changelog
2022-01-20 21:34:23 +01:00
556be61fff Bugfix/fix unresolved account names in reports (#636)
* Fix unresolved account names

* Update changelog
2022-01-19 21:28:15 +01:00
651b4bcff7 Release 1.104.0 (#631) 2022-01-16 15:45:28 +01:00
0a8d159f78 Bugfix/fix missing symbol profile data connection in import (#630)
* Fix missing symbol profile data connection in import

* Update changelog
2022-01-16 15:31:56 +01:00
1a4109ebaa Bugfix/fix fallback to load currencies directly from data provider (#629)
* Fix fallback

* Update changelog
2022-01-16 13:46:00 +01:00
92e502e1c2 Release 1.103.0 (#628) 2022-01-13 20:33:31 +01:00
e344c43a5a Bugfix/fix currency of value in position detail dialog (#627)
* Fix currency

* Update changelog
2022-01-13 20:25:21 +01:00
d6b78f3457 Feature/add links to statistics section (#626)
* Add links and clean up style

* Update changelog
2022-01-13 19:07:23 +01:00
9bbb856f66 Release 1.102.0 (#625) 2022-01-11 19:53:23 +01:00
d3707bbb87 Bugfix/fix preselected default account in create activity dialog (#624)
* Fix preselected default account

* Update changelog
2022-01-11 19:50:22 +01:00
7df53896f3 Feature/start eliminating data source from order (#622)
* Start eliminating dataSource from order

* Update changelog
2022-01-11 19:49:45 +01:00
b2b3fde80e Bugfix/support multiple accounts with the same name (#623)
* Support multiple accounts with the same name

* Update changelog
2022-01-10 21:23:47 +01:00
a83441b3ba Release 1.101.0 (#621) 2022-01-08 18:21:33 +01:00
075431d868 Feature/add google sheets as data source (#620)
* Add google sheets as data source

* Update changelog
2022-01-08 18:19:25 +01:00
0168c1c4e8 Feature/exclude url pattern of shared portfolios in robots.txt (#619)
* Exclude shared portfolios

* Update changelog
2022-01-08 09:37:54 +01:00
07de8f87fc Set market prices explicitly (#618)
* Set market prices explicitly

* Set comments explicitly
2022-01-07 08:09:12 +01:00
3e16041c16 Release 1.100.0 (#617) 2022-01-05 20:24:34 +01:00
5882b7914d Feature/add first months in open source blog post (#616)
* Add blog post

* Update changelog
2022-01-05 20:22:59 +01:00
69c9e259b1 Bugfix/fix routing of create activity dialog (#615)
* Fix routing of create activity dialog

* Update changelog
2022-01-03 21:31:55 +01:00
aca37a27f9 Feature/add top performers to analysis page (#613)
* Add Top 3 / Bottom 3 performers

* Update changelog
2022-01-02 13:29:45 +01:00
89 changed files with 3932 additions and 599 deletions

View File

@ -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/), 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). 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 ## 1.99.0 - 01.01.2022
### Added ### Added

View File

@ -12,7 +12,7 @@
<strong>Open Source Wealth Management Software made for Humans</strong> <strong>Open Source Wealth Management Software made for Humans</strong>
</p> </p>
<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>
<p> <p>
<a href="#contributing"> <a href="#contributing">

View File

@ -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 { UserService } from '@ghostfolio/api/app/user/user.service';
import { import {
nullifyValuesInObject, nullifyValuesInObject,
@ -35,7 +35,7 @@ export class AccountController {
public constructor( public constructor(
private readonly accountService: AccountService, private readonly accountService: AccountService,
private readonly impersonationService: ImpersonationService, private readonly impersonationService: ImpersonationService,
private readonly portfolioService: PortfolioService, private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -91,10 +91,9 @@ export class AccountController {
this.request.user.id this.request.user.id
); );
let accountsWithAggregations = let accountsWithAggregations = await this.portfolioServiceStrategy
await this.portfolioService.getAccountsWithAggregations( .get()
impersonationUserId || this.request.user.id .getAccountsWithAggregations(impersonationUserId || this.request.user.id);
);
if ( if (
impersonationUserId || impersonationUserId ||

View File

@ -9,7 +9,8 @@ import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { import {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails AdminMarketDataDetails,
AdminMarketDataItem
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Property } from '@prisma/client'; import { Property } from '@prisma/client';
@ -56,12 +57,67 @@ export class AdminService {
} }
public async getMarketData(): Promise<AdminMarketData> { public async getMarketData(): Promise<AdminMarketData> {
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 { return {
marketData: await ( dataSource,
await this.dataGatheringService.getSymbolsMax() marketDataItemCount,
).map((symbol) => { symbol
return 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]
}; };
} }

View File

@ -1,5 +1,5 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; 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 { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
@ -15,10 +15,11 @@ import { ImportService } from './import.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
OrderModule,
PrismaModule, PrismaModule,
RedisCacheModule RedisCacheModule
], ],
controllers: [ImportController], controllers: [ImportController],
providers: [CacheService, ImportService, OrderService] providers: [CacheService, ImportService]
}) })
export class ImportModule {} export class ImportModule {}

View File

@ -34,11 +34,6 @@ export class ImportService {
unitPrice unitPrice
} of orders) { } of orders) {
await this.orderService.createOrder({ await this.orderService.createOrder({
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
currency, currency,
dataSource, dataSource,
fee, fee,
@ -46,7 +41,26 @@ export class ImportService {
symbol, symbol,
type, type,
unitPrice, unitPrice,
Account: {
connect: {
id_userId: { userId, id: accountId }
}
},
date: parseISO(<string>(<unknown>date)), date: parseISO(<string>(<unknown>date)),
SymbolProfile: {
connectOrCreate: {
create: {
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource,
symbol
}
}
}
},
User: { connect: { id: userId } } User: { connect: { id: userId } }
}); });
} }

View 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;
}

View File

@ -23,6 +23,7 @@ import { parseISO } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { CreateOrderDto } from './create-order.dto'; import { CreateOrderDto } from './create-order.dto';
import { Activities } from './interfaces/activities.interface';
import { OrderService } from './order.service'; import { OrderService } from './order.service';
import { UpdateOrderDto } from './update-order.dto'; import { UpdateOrderDto } from './update-order.dto';
@ -59,14 +60,16 @@ export class OrderController {
@UseGuards(AuthGuard('jwt')) @UseGuards(AuthGuard('jwt'))
public async getAllOrders( public async getAllOrders(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<OrderModel[]> { ): Promise<Activities> {
const impersonationUserId = const impersonationUserId =
await this.impersonationService.validateImpersonationId( await this.impersonationService.validateImpersonationId(
impersonationId, impersonationId,
this.request.user.id 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, includeDrafts: true,
userId: impersonationUserId || this.request.user.id userId: impersonationUserId || this.request.user.id
}); });
@ -75,15 +78,17 @@ export class OrderController {
impersonationUserId || impersonationUserId ||
this.userService.isRestrictedView(this.request.user) this.userService.isRestrictedView(this.request.user)
) { ) {
orders = nullifyValuesInObjects(orders, [ activities = nullifyValuesInObjects(activities, [
'fee', 'fee',
'feeInBaseCurrency',
'quantity', 'quantity',
'unitPrice', 'unitPrice',
'value' 'value',
'valueInBaseCurrency'
]); ]);
} }
return orders; return { activities };
} }
@Get(':id') @Get(':id')
@ -116,23 +121,23 @@ export class OrderController {
return this.orderService.createOrder({ return this.orderService.createOrder({
...data, ...data,
date,
Account: { Account: {
connect: { connect: {
id_userId: { id: accountId, userId: this.request.user.id } id_userId: { id: accountId, userId: this.request.user.id }
} }
}, },
date,
SymbolProfile: { SymbolProfile: {
connectOrCreate: { connectOrCreate: {
create: {
dataSource: data.dataSource,
symbol: data.symbol
},
where: { where: {
dataSource_symbol: { dataSource_symbol: {
dataSource: data.dataSource, dataSource: data.dataSource,
symbol: data.symbol symbol: data.symbol
} }
},
create: {
dataSource: data.dataSource,
symbol: data.symbol
} }
} }
}, },

View File

@ -4,6 +4,7 @@ import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module'; import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.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 { ImpersonationModule } from '@ghostfolio/api/services/impersonation.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
@ -16,6 +17,7 @@ import { OrderService } from './order.service';
ConfigurationModule, ConfigurationModule,
DataGatheringModule, DataGatheringModule,
DataProviderModule, DataProviderModule,
ExchangeRateDataModule,
ImpersonationModule, ImpersonationModule,
PrismaModule, PrismaModule,
RedisCacheModule, RedisCacheModule,

View File

@ -1,5 +1,6 @@
import { CacheService } from '@ghostfolio/api/app/cache/cache.service'; import { CacheService } from '@ghostfolio/api/app/cache/cache.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering.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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common'; 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 Big from 'big.js';
import { endOfToday, isAfter } from 'date-fns'; import { endOfToday, isAfter } from 'date-fns';
import { Activity } from './interfaces/activities.interface';
@Injectable() @Injectable()
export class OrderService { export class OrderService {
public constructor( public constructor(
private readonly cacheService: CacheService, private readonly cacheService: CacheService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly dataGatheringService: DataGatheringService, private readonly dataGatheringService: DataGatheringService,
private readonly prismaService: PrismaService private readonly prismaService: PrismaService
) {} ) {}
@ -86,12 +90,14 @@ export class OrderService {
public async getOrders({ public async getOrders({
includeDrafts = false, includeDrafts = false,
types, types,
userCurrency,
userId userId
}: { }: {
includeDrafts?: boolean; includeDrafts?: boolean;
types?: TypeOfOrder[]; types?: TypeOfOrder[];
userCurrency: string;
userId: string; userId: string;
}) { }): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId }; const where: Prisma.OrderWhereInput = { userId };
if (includeDrafts === false) { if (includeDrafts === false) {
@ -124,12 +130,21 @@ export class OrderService {
orderBy: { date: 'asc' } orderBy: { date: 'asc' }
}) })
).map((order) => { ).map((order) => {
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
return { return {
...order, ...order,
value: new Big(order.quantity) value,
.mul(order.unitPrice) feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
.plus(order.fee) order.fee,
.toNumber() order.currency,
userCurrency
),
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
order.currency,
userCurrency
)
}; };
}); });
} }

View File

@ -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 () => { it('getValues', async () => {
expect( expect(
await currentRateService.getValues({ await currentRateService.getValues({

View File

@ -7,7 +7,6 @@ import { isBefore, isToday } from 'date-fns';
import { flatten } from 'lodash'; import { flatten } from 'lodash';
import { GetValueObject } from './interfaces/get-value-object.interface'; import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
@Injectable() @Injectable()
@ -18,46 +17,6 @@ export class CurrentRateService {
private readonly marketDataService: MarketDataService 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({ public async getValues({
currencies, currencies,
dataGatheringItems, dataGatheringItems,

View File

@ -1,6 +0,0 @@
export interface GetValueParams {
currency: string;
date: Date;
symbol: string;
userCurrency: string;
}

View File

@ -0,0 +1,5 @@
import { PortfolioOrder } from './portfolio-order.interface';
export interface PortfolioOrderItem extends PortfolioOrder {
itemType?: '' | 'start' | 'end';
}

View 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))
);
}
}

View File

@ -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 { DataSource } from '@prisma/client';
import Big from 'big.js'; import Big from 'big.js';
import { import { addDays, endOfDay, format, isBefore, isSameDay } from 'date-fns';
addDays,
differenceInCalendarDays,
endOfDay,
isBefore,
isSameDay
} from 'date-fns';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { GetValueParams } from './interfaces/get-value-params.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface'; import { GetValuesParams } from './interfaces/get-values-params.interface';
import { PortfolioOrder } from './interfaces/portfolio-order.interface'; import { PortfolioOrder } from './interfaces/portfolio-order.interface';
import { TimelinePeriod } from './interfaces/timeline-period.interface'; import { TimelinePeriod } from './interfaces/timeline-period.interface';
@ -67,15 +60,202 @@ function mockGetValue(symbol: string, date: Date) {
return { marketPrice: 0 }; return { marketPrice: 0 };
case 'VTI': case 'VTI':
return { switch (format(date, DATE_FORMAT)) {
marketPrice: new Big('144.38') case '2019-01-01':
.plus( return { marketPrice: 144.38 };
new Big('0.08').mul( case '2019-02-01':
differenceInCalendarDays(date, parseDate('2019-02-01')) return { marketPrice: 144.38 };
) case '2019-03-01':
) return { marketPrice: 146.62 };
.toNumber() 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: default:
return { marketPrice: 0 }; return { marketPrice: 0 };
@ -87,9 +267,6 @@ jest.mock('./current-rate.service', () => {
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
CurrentRateService: jest.fn().mockImplementation(() => { CurrentRateService: jest.fn().mockImplementation(() => {
return { return {
getValue: ({ date, symbol }: GetValueParams) => {
return Promise.resolve(mockGetValue(symbol, date));
},
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => { getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
const result = []; const result = [];
if (dateQuery.lt) { if (dateQuery.lt) {
@ -1645,14 +1822,14 @@ describe('PortfolioCalculator', () => {
grossPerformance: new Big('498.3'), grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'), netPerformance: new Big('498.3'),
investment: new Big('2923.7'), 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', date: '2021-01-01',
grossPerformance: new Big('349.35'), grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'), netPerformance: new Big('349.35'),
investment: new Big('652.55'), 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'), grossPerformance: new Big('498.3'),
netPerformance: new Big('398.3'), // 100 fees netPerformance: new Big('398.3'), // 100 fees
investment: new Big('2923.7'), 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', date: '2021-01-01',
grossPerformance: new Big('349.35'), grossPerformance: new Big('349.35'),
netPerformance: new Big('199.35'), // 150 fees netPerformance: new Big('199.35'), // 150 fees
investment: new Big('652.55'), 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'), grossPerformance: new Big('0'),
netPerformance: new Big('0'), netPerformance: new Big('0'),
investment: new Big('1443.8'), 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', date: '2019-03-01',
grossPerformance: new Big('22.4'), grossPerformance: new Big('22.4'),
netPerformance: new Big('22.4'), netPerformance: new Big('22.4'),
investment: new Big('1443.8'), 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', date: '2019-04-01',
grossPerformance: new Big('47.2'), grossPerformance: new Big('47.2'),
netPerformance: new Big('47.2'), netPerformance: new Big('47.2'),
investment: new Big('1443.8'), 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', date: '2019-05-01',
grossPerformance: new Big('71.2'), grossPerformance: new Big('71.2'),
netPerformance: new Big('71.2'), netPerformance: new Big('71.2'),
investment: new Big('1443.8'), 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', date: '2019-06-01',
grossPerformance: new Big('96'), grossPerformance: new Big('96'),
netPerformance: new Big('96'), netPerformance: new Big('96'),
investment: new Big('1443.8'), 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', date: '2019-07-01',
grossPerformance: new Big('120'), grossPerformance: new Big('120'),
netPerformance: new Big('120'), netPerformance: new Big('120'),
investment: new Big('1443.8'), 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', date: '2019-08-01',
grossPerformance: new Big('144.8'), grossPerformance: new Big('144.8'),
netPerformance: new Big('144.8'), netPerformance: new Big('144.8'),
investment: new Big('1443.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', date: '2019-09-01',
grossPerformance: new Big('303.1'), grossPerformance: new Big('303.1'),
netPerformance: new Big('303.1'), netPerformance: new Big('303.1'),
investment: new Big('2923.7'), 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', date: '2019-10-01',
grossPerformance: new Big('351.1'), grossPerformance: new Big('351.1'),
netPerformance: new Big('351.1'), netPerformance: new Big('351.1'),
investment: new Big('2923.7'), 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', date: '2019-11-01',
grossPerformance: new Big('400.7'), grossPerformance: new Big('400.7'),
netPerformance: new Big('400.7'), netPerformance: new Big('400.7'),
investment: new Big('2923.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', date: '2019-12-01',
grossPerformance: new Big('448.7'), grossPerformance: new Big('448.7'),
netPerformance: new Big('448.7'), netPerformance: new Big('448.7'),
investment: new Big('2923.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', date: '2020-01-01',
grossPerformance: new Big('498.3'), grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'), netPerformance: new Big('498.3'),
investment: new Big('2923.7'), 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', date: '2020-02-01',
grossPerformance: new Big('547.9'), grossPerformance: new Big('547.9'),
netPerformance: new Big('547.9'), netPerformance: new Big('547.9'),
investment: new Big('2923.7'), 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', date: '2020-03-01',
grossPerformance: new Big('226.95'), grossPerformance: new Big('226.95'),
netPerformance: new Big('226.95'), netPerformance: new Big('226.95'),
investment: new Big('652.55'), 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', date: '2020-04-01',
grossPerformance: new Big('239.35'), grossPerformance: new Big('239.35'),
netPerformance: new Big('239.35'), netPerformance: new Big('239.35'),
investment: new Big('652.55'), 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', date: '2020-05-01',
grossPerformance: new Big('251.35'), grossPerformance: new Big('251.35'),
netPerformance: new Big('251.35'), netPerformance: new Big('251.35'),
investment: new Big('652.55'), 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', date: '2020-06-01',
grossPerformance: new Big('263.75'), grossPerformance: new Big('263.75'),
netPerformance: new Big('263.75'), netPerformance: new Big('263.75'),
investment: new Big('652.55'), 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', date: '2020-07-01',
grossPerformance: new Big('275.75'), grossPerformance: new Big('275.75'),
netPerformance: new Big('275.75'), netPerformance: new Big('275.75'),
investment: new Big('652.55'), 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', date: '2020-08-01',
grossPerformance: new Big('288.15'), grossPerformance: new Big('288.15'),
netPerformance: new Big('288.15'), netPerformance: new Big('288.15'),
investment: new Big('652.55'), 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', date: '2020-09-01',
grossPerformance: new Big('300.55'), grossPerformance: new Big('300.55'),
netPerformance: new Big('300.55'), netPerformance: new Big('300.55'),
investment: new Big('652.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', date: '2020-10-01',
grossPerformance: new Big('312.55'), grossPerformance: new Big('312.55'),
netPerformance: new Big('312.55'), netPerformance: new Big('312.55'),
investment: new Big('652.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', date: '2020-11-01',
grossPerformance: new Big('324.95'), grossPerformance: new Big('324.95'),
netPerformance: new Big('324.95'), netPerformance: new Big('324.95'),
investment: new Big('652.55'), 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', date: '2020-12-01',
grossPerformance: new Big('336.95'), grossPerformance: new Big('336.95'),
netPerformance: new Big('336.95'), netPerformance: new Big('336.95'),
investment: new Big('652.55'), 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', date: '2021-01-01',
grossPerformance: new Big('349.35'), grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'), netPerformance: new Big('349.35'),
investment: new Big('652.55'), 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', date: '2021-02-01',
grossPerformance: new Big('358.85'), grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'), netPerformance: new Big('358.85'),
investment: new Big('2684.05'), 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', date: '2021-03-01',
grossPerformance: new Big('392.45'), grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'), netPerformance: new Big('392.45'),
investment: new Big('2684.05'), 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', date: '2021-04-01',
grossPerformance: new Big('429.65'), grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'), netPerformance: new Big('429.65'),
investment: new Big('2684.05'), 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', date: '2021-05-01',
grossPerformance: new Big('465.65'), grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'), netPerformance: new Big('465.65'),
investment: new Big('2684.05'), 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', date: '2021-06-01',
grossPerformance: new Big('502.85'), grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'), netPerformance: new Big('502.85'),
investment: new Big('2684.05'), 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'), grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'), netPerformance: new Big('498.3'),
investment: new Big('2923.7'), 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', date: '2021-01-01',
grossPerformance: new Big('349.35'), grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'), netPerformance: new Big('349.35'),
investment: new Big('652.55'), 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', date: '2021-02-01',
grossPerformance: new Big('358.85'), grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'), netPerformance: new Big('358.85'),
investment: new Big('2684.05'), 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', date: '2021-03-01',
grossPerformance: new Big('392.45'), grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'), netPerformance: new Big('392.45'),
investment: new Big('2684.05'), 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', date: '2021-04-01',
grossPerformance: new Big('429.65'), grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'), netPerformance: new Big('429.65'),
investment: new Big('2684.05'), 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', date: '2021-05-01',
grossPerformance: new Big('465.65'), grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'), netPerformance: new Big('465.65'),
investment: new Big('2684.05'), 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', date: '2021-06-01',
grossPerformance: new Big('502.85'), grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'), netPerformance: new Big('502.85'),
investment: new Big('2684.05'), 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'), grossPerformance: new Big('498.3'),
netPerformance: new Big('498.3'), netPerformance: new Big('498.3'),
investment: new Big('2923.7'), 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', date: '2021-01-01',
grossPerformance: new Big('349.35'), grossPerformance: new Big('349.35'),
netPerformance: new Big('349.35'), netPerformance: new Big('349.35'),
investment: new Big('652.55'), 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', date: '2021-02-01',
grossPerformance: new Big('358.85'), grossPerformance: new Big('358.85'),
netPerformance: new Big('358.85'), netPerformance: new Big('358.85'),
investment: new Big('2684.05'), 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', date: '2021-03-01',
grossPerformance: new Big('392.45'), grossPerformance: new Big('392.45'),
netPerformance: new Big('392.45'), netPerformance: new Big('392.45'),
investment: new Big('2684.05'), 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', date: '2021-04-01',
grossPerformance: new Big('429.65'), grossPerformance: new Big('429.65'),
netPerformance: new Big('429.65'), netPerformance: new Big('429.65'),
investment: new Big('2684.05'), 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', date: '2021-05-01',
grossPerformance: new Big('465.65'), grossPerformance: new Big('465.65'),
netPerformance: new Big('465.65'), netPerformance: new Big('465.65'),
investment: new Big('2684.05'), 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', date: '2021-06-01',
grossPerformance: new Big('502.85'), grossPerformance: new Big('502.85'),
netPerformance: new Big('502.85'), netPerformance: new Big('502.85'),
investment: new Big('2684.05'), 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', date: '2021-06-02',
grossPerformance: new Big('504.05'), grossPerformance: new Big('504.05'),
netPerformance: new Big('504.05'), netPerformance: new Big('504.05'),
investment: new Big('2684.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', date: '2021-06-03',
grossPerformance: new Big('505.25'), grossPerformance: new Big('505.25'),
netPerformance: new Big('505.25'), netPerformance: new Big('505.25'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3189.3') // +1.2 value: new Big('3189.3') // 15 * 212.62
}, },
{ {
date: '2021-06-04', date: '2021-06-04',
grossPerformance: new Big('506.45'), grossPerformance: new Big('506.45'),
netPerformance: new Big('506.45'), netPerformance: new Big('506.45'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3190.5') // +1.2 value: new Big('3190.5') // 15 * 212.7
}, },
{ {
date: '2021-06-05', date: '2021-06-05',
grossPerformance: new Big('507.65'), grossPerformance: new Big('507.65'),
netPerformance: new Big('507.65'), netPerformance: new Big('507.65'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3191.7') // +1.2 value: new Big('3191.7') // 15 * 212.78
}, },
{ {
date: '2021-06-06', date: '2021-06-06',
grossPerformance: new Big('508.85'), grossPerformance: new Big('508.85'),
netPerformance: new Big('508.85'), netPerformance: new Big('508.85'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3192.9') // +1.2 value: new Big('3192.9') // 15 * 212.86
}, },
{ {
date: '2021-06-07', date: '2021-06-07',
grossPerformance: new Big('510.05'), grossPerformance: new Big('510.05'),
netPerformance: new Big('510.05'), netPerformance: new Big('510.05'),
investment: new Big('2684.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', date: '2021-06-08',
grossPerformance: new Big('511.25'), grossPerformance: new Big('511.25'),
netPerformance: new Big('511.25'), netPerformance: new Big('511.25'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3195.3') // +1.2 value: new Big('3195.3') // 15 * 213.02
}, },
{ {
date: '2021-06-09', date: '2021-06-09',
grossPerformance: new Big('512.45'), grossPerformance: new Big('512.45'),
netPerformance: new Big('512.45'), netPerformance: new Big('512.45'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3196.5') // +1.2 value: new Big('3196.5') // 15 * 213.1
}, },
{ {
date: '2021-06-10', date: '2021-06-10',
grossPerformance: new Big('513.65'), grossPerformance: new Big('513.65'),
netPerformance: new Big('513.65'), netPerformance: new Big('513.65'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3197.7') // +1.2 value: new Big('3197.7') // 15 * 213.18
}, },
{ {
date: '2021-06-11', date: '2021-06-11',
grossPerformance: new Big('514.85'), grossPerformance: new Big('514.85'),
netPerformance: new Big('514.85'), netPerformance: new Big('514.85'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3198.9') // +1.2 value: new Big('3198.9') // 15 * 213.26
}, },
{ {
date: '2021-06-12', date: '2021-06-12',
grossPerformance: new Big('516.05'), grossPerformance: new Big('516.05'),
netPerformance: new Big('516.05'), netPerformance: new Big('516.05'),
investment: new Big('2684.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', date: '2021-06-13',
grossPerformance: new Big('517.25'), grossPerformance: new Big('517.25'),
netPerformance: new Big('517.25'), netPerformance: new Big('517.25'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3201.3') // +1.2 value: new Big('3201.3') // 15 * 213.42
}, },
{ {
date: '2021-06-14', date: '2021-06-14',
grossPerformance: new Big('518.45'), grossPerformance: new Big('518.45'),
netPerformance: new Big('518.45'), netPerformance: new Big('518.45'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3202.5') // +1.2 value: new Big('3202.5') // 15 * 213.5
}, },
{ {
date: '2021-06-15', date: '2021-06-15',
grossPerformance: new Big('519.65'), grossPerformance: new Big('519.65'),
netPerformance: new Big('519.65'), netPerformance: new Big('519.65'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3203.7') // +1.2 value: new Big('3203.7') // 15 * 213.58
}, },
{ {
date: '2021-06-16', date: '2021-06-16',
grossPerformance: new Big('520.85'), grossPerformance: new Big('520.85'),
netPerformance: new Big('520.85'), netPerformance: new Big('520.85'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3204.9') // +1.2 value: new Big('3204.9') // 15 * 213.66
}, },
{ {
date: '2021-06-17', date: '2021-06-17',
grossPerformance: new Big('522.05'), grossPerformance: new Big('522.05'),
netPerformance: new Big('522.05'), netPerformance: new Big('522.05'),
investment: new Big('2684.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', date: '2021-06-18',
grossPerformance: new Big('523.25'), grossPerformance: new Big('523.25'),
netPerformance: new Big('523.25'), netPerformance: new Big('523.25'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3207.3') // +1.2 value: new Big('3207.3') // 15 * 213.82
}, },
{ {
date: '2021-06-19', date: '2021-06-19',
grossPerformance: new Big('524.45'), grossPerformance: new Big('524.45'),
netPerformance: new Big('524.45'), netPerformance: new Big('524.45'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3208.5') // +1.2 value: new Big('3208.5') // 15 * 213.9
}, },
{ {
date: '2021-06-20', date: '2021-06-20',
grossPerformance: new Big('525.65'), grossPerformance: new Big('525.65'),
netPerformance: new Big('525.65'), netPerformance: new Big('525.65'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3209.7') // +1.2 value: new Big('3209.7') // 15 * 213.98
}, },
{ {
date: '2021-06-21', date: '2021-06-21',
grossPerformance: new Big('526.85'), grossPerformance: new Big('526.85'),
netPerformance: new Big('526.85'), netPerformance: new Big('526.85'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3210.9') // +1.2 value: new Big('3210.9') // 15 * 214.06
}, },
{ {
date: '2021-06-22', date: '2021-06-22',
grossPerformance: new Big('528.05'), grossPerformance: new Big('528.05'),
netPerformance: new Big('528.05'), netPerformance: new Big('528.05'),
investment: new Big('2684.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', date: '2021-06-23',
grossPerformance: new Big('529.25'), grossPerformance: new Big('529.25'),
netPerformance: new Big('529.25'), netPerformance: new Big('529.25'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3213.3') // +1.2 value: new Big('3213.3') // 15 * 214.22
}, },
{ {
date: '2021-06-24', date: '2021-06-24',
grossPerformance: new Big('530.45'), grossPerformance: new Big('530.45'),
netPerformance: new Big('530.45'), netPerformance: new Big('530.45'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3214.5') // +1.2 value: new Big('3214.5') // 15 * 214.3
}, },
{ {
date: '2021-06-25', date: '2021-06-25',
grossPerformance: new Big('531.65'), grossPerformance: new Big('531.65'),
netPerformance: new Big('531.65'), netPerformance: new Big('531.65'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3215.7') // +1.2 value: new Big('3215.7') // 15 * 214.38
}, },
{ {
date: '2021-06-26', date: '2021-06-26',
grossPerformance: new Big('532.85'), grossPerformance: new Big('532.85'),
netPerformance: new Big('532.85'), netPerformance: new Big('532.85'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3216.9') // +1.2 value: new Big('3216.9') // 15 * 214.46
}, },
{ {
date: '2021-06-27', date: '2021-06-27',
grossPerformance: new Big('534.05'), grossPerformance: new Big('534.05'),
netPerformance: new Big('534.05'), netPerformance: new Big('534.05'),
investment: new Big('2684.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', date: '2021-06-28',
grossPerformance: new Big('535.25'), grossPerformance: new Big('535.25'),
netPerformance: new Big('535.25'), netPerformance: new Big('535.25'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3219.3') // +1.2 value: new Big('3219.3') // 15 * 214.62
}, },
{ {
date: '2021-06-29', date: '2021-06-29',
grossPerformance: new Big('536.45'), grossPerformance: new Big('536.45'),
netPerformance: new Big('536.45'), netPerformance: new Big('536.45'),
investment: new Big('2684.05'), investment: new Big('2684.05'),
value: new Big('3220.5') // +1.2 value: new Big('3220.5') // 15 * 214.7
}, },
{ {
date: '2021-06-30', date: '2021-06-30',
grossPerformance: new Big('537.65'), grossPerformance: new Big('537.65'),
netPerformance: new Big('537.65'), netPerformance: new Big('537.65'),
investment: new Big('2684.05'), 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'), grossPerformance: new Big('267.2'),
netPerformance: new Big('267.2'), netPerformance: new Big('267.2'),
investment: new Big('11553.75'), 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
} }
]); ]);
}); });

View 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;
}
}

View File

@ -35,7 +35,7 @@ import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface'; import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from './interfaces/portfolio-positions.interface'; import { PortfolioPositions } from './interfaces/portfolio-positions.interface';
import { PortfolioService } from './portfolio.service'; import { PortfolioServiceStrategy } from './portfolio-service.strategy';
@Controller('portfolio') @Controller('portfolio')
export class PortfolioController { export class PortfolioController {
@ -43,7 +43,7 @@ export class PortfolioController {
private readonly accessService: AccessService, private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService, private readonly configurationService: ConfigurationService,
private readonly exchangeRateDataService: ExchangeRateDataService, private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly portfolioService: PortfolioService, private readonly portfolioServiceStrategy: PortfolioServiceStrategy,
@Inject(REQUEST) private readonly request: RequestWithUser, @Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService private readonly userService: UserService
) {} ) {}
@ -55,10 +55,9 @@ export class PortfolioController {
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioChart> { ): Promise<PortfolioChart> {
const historicalDataContainer = await this.portfolioService.getChart( const historicalDataContainer = await this.portfolioServiceStrategy
impersonationId, .get()
range .getChart(impersonationId, range);
);
let chartData = historicalDataContainer.items; let chartData = historicalDataContainer.items;
@ -116,11 +115,9 @@ export class PortfolioController {
let hasError = false; let hasError = false;
const { accounts, holdings, hasErrors } = const { accounts, holdings, hasErrors } =
await this.portfolioService.getDetails( await this.portfolioServiceStrategy
impersonationId, .get()
this.request.user.id, .getDetails(impersonationId, this.request.user.id, range);
range
);
if (hasErrors || hasNotDefinedValuesInObject(holdings)) { if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
hasError = true; hasError = true;
@ -178,9 +175,9 @@ export class PortfolioController {
return <any>res.json({}); return <any>res.json({});
} }
let investments = await this.portfolioService.getInvestments( let investments = await this.portfolioServiceStrategy
impersonationId .get()
); .getInvestments(impersonationId);
if ( if (
impersonationId || impersonationId ||
@ -207,10 +204,9 @@ export class PortfolioController {
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> { ): Promise<{ hasErrors: boolean; performance: PortfolioPerformance }> {
const performanceInformation = await this.portfolioService.getPerformance( const performanceInformation = await this.portfolioServiceStrategy
impersonationId, .get()
range .getPerformance(impersonationId, range);
);
if ( if (
impersonationId || impersonationId ||
@ -232,10 +228,9 @@ export class PortfolioController {
@Query('range') range, @Query('range') range,
@Res() res: Response @Res() res: Response
): Promise<PortfolioPositions> { ): Promise<PortfolioPositions> {
const result = await this.portfolioService.getPositions( const result = await this.portfolioServiceStrategy
impersonationId, .get()
range .getPositions(impersonationId, range);
);
if ( if (
impersonationId || impersonationId ||
@ -274,10 +269,9 @@ export class PortfolioController {
hasDetails = user.subscription.type === 'Premium'; hasDetails = user.subscription.type === 'Premium';
} }
const { holdings } = await this.portfolioService.getDetails( const { holdings } = await this.portfolioServiceStrategy
access.userId, .get()
access.userId .getDetails(access.userId, access.userId);
);
const portfolioPublicDetails: PortfolioPublicDetails = { const portfolioPublicDetails: PortfolioPublicDetails = {
hasDetails, hasDetails,
@ -318,7 +312,9 @@ export class PortfolioController {
public async getSummary( public async getSummary(
@Headers('impersonation-id') impersonationId @Headers('impersonation-id') impersonationId
): Promise<PortfolioSummary> { ): Promise<PortfolioSummary> {
let summary = await this.portfolioService.getSummary(impersonationId); let summary = await this.portfolioServiceStrategy
.get()
.getSummary(impersonationId);
if ( if (
impersonationId || impersonationId ||
@ -347,10 +343,9 @@ export class PortfolioController {
@Headers('impersonation-id') impersonationId: string, @Headers('impersonation-id') impersonationId: string,
@Param('symbol') symbol @Param('symbol') symbol
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
let position = await this.portfolioService.getPosition( let position = await this.portfolioServiceStrategy
impersonationId, .get()
symbol .getPosition(impersonationId, symbol);
);
if (position) { if (position) {
if ( if (
@ -391,7 +386,9 @@ export class PortfolioController {
} }
return <any>( return <any>(
res.json(await this.portfolioService.getReport(impersonationId)) res.json(
await this.portfolioServiceStrategy.get().getReport(impersonationId)
)
); );
} }
} }

View File

@ -13,12 +13,14 @@ import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.mod
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { CurrentRateService } from './current-rate.service'; import { CurrentRateService } from './current-rate.service';
import { PortfolioServiceStrategy } from './portfolio-service.strategy';
import { PortfolioController } from './portfolio.controller'; import { PortfolioController } from './portfolio.controller';
import { PortfolioService } from './portfolio.service'; import { PortfolioService } from './portfolio.service';
import { PortfolioServiceNew } from './portfolio.service-new';
import { RulesService } from './rules.service'; import { RulesService } from './rules.service';
@Module({ @Module({
exports: [PortfolioService], exports: [PortfolioServiceStrategy],
imports: [ imports: [
AccessModule, AccessModule,
ConfigurationModule, ConfigurationModule,
@ -37,6 +39,8 @@ import { RulesService } from './rules.service';
AccountService, AccountService,
CurrentRateService, CurrentRateService,
PortfolioService, PortfolioService,
PortfolioServiceNew,
PortfolioServiceStrategy,
RulesService RulesService
] ]
}) })

File diff suppressed because it is too large Load Diff

View File

@ -107,7 +107,7 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
), ),
value: details.accounts[account.name]?.current ?? 0 value: details.accounts[account.id]?.current ?? 0
}; };
delete result.Order; delete result.Order;
@ -388,11 +388,12 @@ export class PortfolioService {
aImpersonationId: string, aImpersonationId: string,
aSymbol: string aSymbol: string
): Promise<PortfolioPositionDetail> { ): Promise<PortfolioPositionDetail> {
const userCurrency = this.request.user.Settings.currency;
const userId = await this.getUserId(aImpersonationId, this.request.user.id); const userId = await this.getUserId(aImpersonationId, this.request.user.id);
const orders = (await this.orderService.getOrders({ userId })).filter( const orders = (
(order) => order.symbol === aSymbol await this.orderService.getOrders({ userCurrency, userId })
); ).filter((order) => order.symbol === aSymbol);
if (orders.length <= 0) { if (orders.length <= 0) {
return { return {
@ -428,7 +429,7 @@ export class PortfolioService {
}) })
.map((order) => ({ .map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big(order.fee), fee: new Big(order.fee),
name: order.SymbolProfile?.name, name: order.SymbolProfile?.name,
@ -846,24 +847,25 @@ export class PortfolioService {
} }
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> { 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 userId = await this.getUserId(aImpersonationId, this.request.user.id);
const performanceInformation = await this.getPerformance(aImpersonationId); const performanceInformation = await this.getPerformance(aImpersonationId);
const { balance } = await this.accountService.getCashDetails( const { balance } = await this.accountService.getCashDetails(
userId, userId,
currency userCurrency
); );
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
userCurrency,
userId userId
}); });
const dividend = this.getDividend(orders).toNumber(); const dividend = this.getDividend(orders).toNumber();
const fees = this.getFees(orders).toNumber(); const fees = this.getFees(orders).toNumber();
const firstOrderDate = orders[0]?.date; const firstOrderDate = orders[0]?.date;
const totalBuy = this.getTotalByType(orders, currency, 'BUY'); const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
const totalSell = this.getTotalByType(orders, currency, 'SELL'); const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
const committedFunds = new Big(totalBuy).sub(totalSell); const committedFunds = new Big(totalBuy).sub(totalSell);
@ -895,8 +897,8 @@ export class PortfolioService {
}: { }: {
cashDetails: CashDetails; cashDetails: CashDetails;
investment: Big; investment: Big;
value: Big;
userCurrency: string; userCurrency: string;
value: Big;
}) { }) {
const cashPositions = {}; const cashPositions = {};
@ -1025,8 +1027,11 @@ export class PortfolioService {
transactionPoints: TransactionPoint[]; transactionPoints: TransactionPoint[];
orders: OrderWithAccount[]; orders: OrderWithAccount[];
}> { }> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const orders = await this.orderService.getOrders({ const orders = await this.orderService.getOrders({
includeDrafts, includeDrafts,
userCurrency,
userId, userId,
types: ['BUY', 'SELL'] types: ['BUY', 'SELL']
}); });
@ -1035,10 +1040,9 @@ export class PortfolioService {
return { transactionPoints: [], orders: [] }; return { transactionPoints: [], orders: [] };
} }
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({ const portfolioOrders: PortfolioOrder[] = orders.map((order) => ({
currency: order.currency, currency: order.currency,
dataSource: order.dataSource, dataSource: order.SymbolProfile?.dataSource ?? order.dataSource,
date: format(order.date, DATE_FORMAT), date: format(order.date, DATE_FORMAT),
fee: new Big( fee: new Big(
this.exchangeRateDataService.toCurrency( this.exchangeRateDataService.toCurrency(
@ -1091,10 +1095,11 @@ export class PortfolioService {
account.currency, account.currency,
userCurrency userCurrency
); );
accounts[account.name] = { accounts[account.id] = {
balance: convertedBalance, balance: convertedBalance,
currency: account.currency, currency: account.currency,
current: convertedBalance, current: convertedBalance,
name: account.name,
original: convertedBalance original: convertedBalance
}; };
@ -1108,16 +1113,17 @@ export class PortfolioService {
originalValueOfSymbol *= -1; originalValueOfSymbol *= -1;
} }
if (accounts[order.Account?.name || UNKNOWN_KEY]?.current) { if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.name || UNKNOWN_KEY].current += accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol; currentValueOfSymbol;
accounts[order.Account?.name || UNKNOWN_KEY].original += accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol; originalValueOfSymbol;
} else { } else {
accounts[order.Account?.name || UNKNOWN_KEY] = { accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0, balance: 0,
currency: order.Account?.currency, currency: order.Account?.currency,
current: currentValueOfSymbol, current: currentValueOfSymbol,
name: account.name,
original: originalValueOfSymbol original: originalValueOfSymbol
}; };
} }

View File

@ -13,7 +13,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
import { isDate, isEmpty } from 'lodash'; import { isDate, isEmpty } from 'lodash';
@ -37,8 +37,7 @@ export class SymbolController {
@Query() { query = '' } @Query() { query = '' }
): Promise<{ items: LookupItem[] }> { ): Promise<{ items: LookupItem[] }> {
try { try {
const encodedQuery = encodeURIComponent(query.toLowerCase()); return this.symbolService.lookup(query.toLowerCase());
return this.symbolService.lookup(encodedQuery);
} catch { } catch {
throw new HttpException( throw new HttpException(
getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR), getReasonPhrase(StatusCodes.INTERNAL_SERVER_ERROR),

View File

@ -93,32 +93,6 @@ export class SymbolService {
try { try {
const { items } = await this.dataProviderService.search(aQuery); const { items } = await this.dataProviderService.search(aQuery);
results.items = items; 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; return results;
} catch (error) { } catch (error) {
Logger.error(error); Logger.error(error);

View File

@ -1,6 +1,11 @@
import { IsBoolean } from 'class-validator'; import { IsBoolean, IsOptional } from 'class-validator';
export class UpdateUserSettingDto { export class UpdateUserSettingDto {
@IsBoolean() @IsBoolean()
@IsOptional()
isNewCalculationEngine?: boolean;
@IsBoolean()
@IsOptional()
isRestrictedView?: boolean; isRestrictedView?: boolean;
} }

View File

@ -23,7 +23,7 @@ import {
import { REQUEST } from '@nestjs/core'; import { REQUEST } from '@nestjs/core';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { Provider, Role } from '@prisma/client'; import { Provider } from '@prisma/client';
import { User as UserModel } from '@prisma/client'; import { User as UserModel } from '@prisma/client';
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -115,6 +115,12 @@ export class UserController {
...data ...data
}; };
for (const key in userSettings) {
if (userSettings[key] === false) {
delete userSettings[key];
}
}
return await this.userService.updateUserSetting({ return await this.userService.updateUserSetting({
userSettings, userSettings,
userId: this.request.user.id userId: this.request.user.id

View File

@ -25,17 +25,17 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
}; };
} = {}; } = {};
for (const account of Object.keys(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
accounts[account] = { accounts[accountId] = {
name: account, name: account.name,
investment: this.accounts[account].current investment: account.current
}; };
} }
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(accounts).forEach((account) => { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxItem) {
maxItem = account; maxItem = account;
} }
@ -47,7 +47,7 @@ export class AccountClusterRiskCurrentInvestment extends Rule<Settings> {
if (account.investment > maxItem?.investment) { if (account.investment > maxItem?.investment) {
maxItem = account; maxItem = account;
} }
}); }
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -19,35 +19,35 @@ export class AccountClusterRiskInitialInvestment extends Rule<Settings> {
} }
public evaluate(ruleSettings?: Settings) { public evaluate(ruleSettings?: Settings) {
const platforms: { const accounts: {
[symbol: string]: Pick<PortfolioPosition, 'name'> & { [symbol: string]: Pick<PortfolioPosition, 'name'> & {
investment: number; investment: number;
}; };
} = {}; } = {};
for (const account of Object.keys(this.accounts)) { for (const [accountId, account] of Object.entries(this.accounts)) {
platforms[account] = { accounts[accountId] = {
name: account, name: account.name,
investment: this.accounts[account].original investment: account.original
}; };
} }
let maxItem; let maxItem;
let totalInvestment = 0; let totalInvestment = 0;
Object.values(platforms).forEach((platform) => { for (const account of Object.values(accounts)) {
if (!maxItem) { if (!maxItem) {
maxItem = platform; maxItem = account;
} }
// Calculate total investment // Calculate total investment
totalInvestment += platform.investment; totalInvestment += account.investment;
// Find maximum // Find maximum
if (platform.investment > maxItem?.investment) { if (account.investment > maxItem?.investment) {
maxItem = platform; maxItem = account;
}
} }
});
const maxInvestmentRatio = maxItem.investment / totalInvestment; const maxInvestmentRatio = maxItem.investment / totalInvestment;

View File

@ -13,6 +13,7 @@ export class ConfigurationService {
ACCESS_TOKEN_SALT: str(), ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }), ALPHA_VANTAGE_API_KEY: str({ default: '' }),
CACHE_TTL: num({ default: 1 }), CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }), DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),
ENABLE_FEATURE_BLOG: bool({ default: false }), ENABLE_FEATURE_BLOG: bool({ default: false }),
ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }), ENABLE_FEATURE_CUSTOM_SYMBOLS: bool({ default: false }),
@ -25,6 +26,9 @@ export class ConfigurationService {
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }), GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
GOOGLE_SECRET: str({ default: 'dummySecret' }), 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({}), JWT_SECRET_KEY: str({}),
MAX_ITEM_IN_CACHE: num({ default: 9999 }), MAX_ITEM_IN_CACHE: num({ default: 9999 }),
MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }), MAX_ORDERS_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),

View File

@ -88,13 +88,13 @@ export class AlphaVantageService implements DataProviderInterface {
return DataSource.ALPHA_VANTAGE; return DataSource.ALPHA_VANTAGE;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aSymbol); const result = await this.alphaVantage.data.search(aQuery);
return { return {
items: result?.bestMatches?.map((bestMatch) => { items: result?.bestMatches?.map((bestMatch) => {
return { return {
dataSource: DataSource.ALPHA_VANTAGE, dataSource: this.getName(),
name: bestMatch['2. name'], name: bestMatch['2. name'],
symbol: bestMatch['1. symbol'] symbol: bestMatch['1. symbol']
}; };

View File

@ -1,6 +1,7 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.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 { 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 { 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 { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma.module'; import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
@ -21,12 +22,14 @@ import { DataProviderService } from './data-provider.service';
AlphaVantageService, AlphaVantageService,
DataProviderService, DataProviderService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService, YahooFinanceService,
{ {
inject: [ inject: [
AlphaVantageService, AlphaVantageService,
GhostfolioScraperApiService, GhostfolioScraperApiService,
GoogleSheetsService,
RakutenRapidApiService, RakutenRapidApiService,
YahooFinanceService YahooFinanceService
], ],
@ -34,11 +37,13 @@ import { DataProviderService } from './data-provider.service';
useFactory: ( useFactory: (
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
) => [ ) => [
alphaVantageService, alphaVantageService,
ghostfolioScraperApiService, ghostfolioScraperApiService,
googleSheetsService,
rakutenRapidApiService, rakutenRapidApiService,
yahooFinanceService yahooFinanceService
] ]

View File

@ -12,7 +12,7 @@ import { Granularity } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData } from '@prisma/client'; import { DataSource, MarketData } from '@prisma/client';
import { format, isValid } from 'date-fns'; import { format, isValid } from 'date-fns';
import { isEmpty } from 'lodash'; import { groupBy, isEmpty } from 'lodash';
@Injectable() @Injectable()
export class DataProviderService { export class DataProviderService {
@ -30,18 +30,27 @@ export class DataProviderService {
[symbol: string]: IDataProviderResponse; [symbol: string]: IDataProviderResponse;
} = {}; } = {};
for (const item of items) { const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const dataProvider = this.getDataProvider(item.dataSource);
response[item.symbol] = (await dataProvider.get([item.symbol]))[
item.symbol
];
}
const promises = []; 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( 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; return result;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const promises: Promise<{ items: LookupItem[] }>[] = []; const promises: Promise<{ items: LookupItem[] }>[] = [];
let lookupItems: LookupItem[] = []; let lookupItems: LookupItem[] = [];
for (const dataSource of this.configurationService.get('DATA_SOURCES')) { for (const dataSource of this.configurationService.get('DATA_SOURCES')) {
promises.push( promises.push(
this.getDataProvider(DataSource[dataSource]).search(aSymbol) this.getDataProvider(DataSource[dataSource]).search(aQuery)
); );
} }
@ -176,7 +185,7 @@ export class DataProviderService {
} }
public getPrimaryDataSource(): DataSource { public getPrimaryDataSource(): DataSource {
return DataSource[this.configurationService.get('DATA_SOURCES')[0]]; return DataSource[this.configurationService.get('DATA_SOURCE_PRIMARY')];
} }
private getDataProvider(providerName: DataSource) { private getDataProvider(providerName: DataSource) {

View File

@ -1,4 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; 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 { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service'; import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { import {
@ -13,13 +19,6 @@ import * as bent from 'bent';
import * as cheerio from 'cheerio'; import * as cheerio from 'cheerio';
import { format } from 'date-fns'; import { format } from 'date-fns';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse,
MarketState
} from '../../interfaces/interfaces';
import { DataProviderInterface } from '../interfaces/data-provider.interface';
@Injectable() @Injectable()
export class GhostfolioScraperApiService implements DataProviderInterface { export class GhostfolioScraperApiService implements DataProviderInterface {
private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g; private static NUMERIC_REGEXP = /[-]{0,1}[\d]*[.,]{0,1}[\d]+/g;
@ -59,7 +58,7 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
[symbol]: { [symbol]: {
marketPrice, marketPrice,
currency: symbolProfile?.currency, currency: symbolProfile?.currency,
dataSource: DataSource.GHOSTFOLIO, dataSource: this.getName(),
marketState: MarketState.delayed marketState: MarketState.delayed
} }
}; };
@ -116,8 +115,35 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
return DataSource.GHOSTFOLIO; return DataSource.GHOSTFOLIO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; 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 { private extractNumberFromString(aString: string): number {

View File

@ -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;
}
}

View File

@ -1,11 +1,10 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
import { import {
IDataProviderHistoricalResponse, IDataProviderHistoricalResponse,
IDataProviderResponse IDataProviderResponse
} from '../../interfaces/interfaces'; } from '@ghostfolio/api/services/interfaces/interfaces';
import { Granularity } from '@ghostfolio/common/types';
import { DataSource } from '@prisma/client';
export interface DataProviderInterface { export interface DataProviderInterface {
canHandle(symbol: string): boolean; canHandle(symbol: string): boolean;
@ -23,5 +22,5 @@ export interface DataProviderInterface {
getName(): DataSource; getName(): DataSource;
search(aSymbol: string): Promise<{ items: LookupItem[] }>; search(aQuery: string): Promise<{ items: LookupItem[] }>;
} }

View File

@ -45,7 +45,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return { return {
[ghostfolioFearAndGreedIndexSymbol]: { [ghostfolioFearAndGreedIndexSymbol]: {
currency: undefined, currency: undefined,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
marketPrice: fgi.now.value, marketPrice: fgi.now.value,
marketState: MarketState.open, marketState: MarketState.open,
name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME name: RakutenRapidApiService.FEAR_AND_GREED_INDEX_NAME
@ -85,7 +85,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subWeeks(getToday(), 1), date: subWeeks(getToday(), 1),
marketPrice: fgi.oneWeekAgo.value marketPrice: fgi.oneWeekAgo.value
} }
@ -94,7 +94,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subMonths(getToday(), 1), date: subMonths(getToday(), 1),
marketPrice: fgi.oneMonthAgo.value marketPrice: fgi.oneMonthAgo.value
} }
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
await this.prismaService.marketData.create({ await this.prismaService.marketData.create({
data: { data: {
symbol, symbol,
dataSource: DataSource.RAKUTEN, dataSource: this.getName(),
date: subYears(getToday(), 1), date: subYears(getToday(), 1),
marketPrice: fgi.oneYearAgo.value marketPrice: fgi.oneYearAgo.value
} }
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
return DataSource.RAKUTEN; return DataSource.RAKUTEN;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
return { items: [] }; return { items: [] };
} }

View File

@ -103,7 +103,7 @@ export class YahooFinanceService implements DataProviderInterface {
assetClass, assetClass,
assetSubClass, assetSubClass,
currency: value.price?.currency, currency: value.price?.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
exchange: this.parseExchange(value.price?.exchangeName), exchange: this.parseExchange(value.price?.exchangeName),
marketState: marketState:
value.price?.marketState === 'REGULAR' || value.price?.marketState === 'REGULAR' ||
@ -221,12 +221,14 @@ export class YahooFinanceService implements DataProviderInterface {
return DataSource.YAHOO; return DataSource.YAHOO;
} }
public async search(aSymbol: string): Promise<{ items: LookupItem[] }> { public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = []; const items: LookupItem[] = [];
try { try {
const get = bent( const get = bent(
`${this.yahooFinanceHostname}/v1/finance/search?q=${aSymbol}&lang=en-US&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=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&region=US&quotesCount=8&newsCount=0&enableFuzzyQuery=false&quotesQueryId=tss_match_phrase_query&multiQuoteQueryId=multi_quote_single_token_query&newsQueryId=news_cie_vespa&enableCb=true&enableNavLinks=false&enableEnhancedTrivialQuery=true`,
'GET', 'GET',
'json', 'json',
200 200
@ -268,7 +270,7 @@ export class YahooFinanceService implements DataProviderInterface {
items.push({ items.push({
symbol, symbol,
currency: value.currency, currency: value.currency,
dataSource: DataSource.YAHOO, dataSource: this.getName(),
name: value.name name: value.name
}); });
} }

View File

@ -58,9 +58,9 @@ export class ExchangeRateDataService {
getYesterday() getYesterday()
); );
if (isEmpty(result)) { if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback // 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( const historicalData = await this.dataProviderService.get(
this.currencyPairs.map(({ dataSource, symbol }) => { this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };

View File

@ -4,6 +4,7 @@ export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string; ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string; ALPHA_VANTAGE_API_KEY: string;
CACHE_TTL: number; CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid? DATA_SOURCES: string | string[]; // string is not correct, error in envalid?
ENABLE_FEATURE_BLOG: boolean; ENABLE_FEATURE_BLOG: boolean;
ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean; ENABLE_FEATURE_CUSTOM_SYMBOLS: boolean;
@ -16,6 +17,9 @@ export interface Environment extends CleanedEnvAccessors {
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
GOOGLE_CLIENT_ID: string; GOOGLE_CLIENT_ID: string;
GOOGLE_SECRET: string; GOOGLE_SECRET: string;
GOOGLE_SHEETS_ACCOUNT: string;
GOOGLE_SHEETS_ID: string;
GOOGLE_SHEETS_PRIVATE_KEY: string;
JWT_SECRET_KEY: string; JWT_SECRET_KEY: string;
MAX_ITEM_IN_CACHE: number; MAX_ITEM_IN_CACHE: number;
MAX_ORDERS_TO_IMPORT: number; MAX_ORDERS_TO_IMPORT: number;

View File

@ -59,6 +59,13 @@ const routes: Routes = [
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module' './pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule) ).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', path: 'home',
loadChildren: () => loadChildren: () =>

View File

@ -89,7 +89,7 @@ export class AppComponent implements OnDestroy, OnInit {
this.tokenStorageService.signOut(); this.tokenStorageService.signOut();
this.userService.remove(); this.userService.remove();
this.router.navigate(['/']); document.location.href = '/';
} }
public ngOnDestroy() { public ngOnDestroy() {

View File

@ -15,7 +15,7 @@
>(Default)</span >(Default)</span
> >
</td> </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>
<ng-container matColumnDef="currency"> <ng-container matColumnDef="currency">

View File

@ -6,7 +6,9 @@
<tr class="mat-header-row"> <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>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>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> <th class="mat-header-cell px-1 py-2"></th>
</tr> </tr>
</thead> </thead>
@ -16,11 +18,13 @@
class="cursor-pointer mat-row" class="cursor-pointer mat-row"
(click)="setCurrentSymbol(item.symbol)" (click)="setCurrentSymbol(item.symbol)"
> >
<td class="mat-cell px-1 py-2">{{ item.symbol }}</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.dataSource }}</td>
<td class="mat-cell px-1 py-2"> <td class="mat-cell px-1 py-2">
{{ (item.date | date: defaultDateFormat) ?? '' }} {{ (item.date | date: defaultDateFormat) ?? '' }}
</td> </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"> <td class="mat-cell px-1 py-2">
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"

View File

@ -14,6 +14,7 @@
<gf-positions <gf-positions
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positions" [positions]="positions"
[range]="dateRange" [range]="dateRange"

View File

@ -8,6 +8,7 @@ import {
import { UserService } from '@ghostfolio/client/services/user/user.service'; import { UserService } from '@ghostfolio/client/services/user/user.service';
import { defaultDateRangeOptions } from '@ghostfolio/common/config'; import { defaultDateRangeOptions } from '@ghostfolio/common/config';
import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces'; import { PortfolioPerformance, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { DateRange } from '@ghostfolio/common/types'; import { DateRange } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface'; import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -25,6 +26,7 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
public deviceType: string; public deviceType: string;
public hasError: boolean; public hasError: boolean;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public historicalDataItems: LineChartItem[]; public historicalDataItems: LineChartItem[];
public isAllTimeHigh: boolean; public isAllTimeHigh: boolean;
public isAllTimeLow: boolean; public isAllTimeLow: boolean;
@ -51,6 +53,11 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
}); });

View File

@ -13,7 +13,7 @@
[showYAxis]="false" [showYAxis]="false"
></gf-line-chart> ></gf-line-chart>
<div <div
*ngIf="historicalDataItems?.length === 0" *ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100" class="align-items-center d-flex h-100 justify-content-center w-100"
> >
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">

View File

@ -12,7 +12,7 @@
<div class="col-12 d-flex justify-content-center mb-3"> <div class="col-12 d-flex justify-content-center mb-3">
<gf-value <gf-value
size="large" size="large"
[currency]="currency" [currency]="data.baseCurrency"
[locale]="data.locale" [locale]="data.locale"
[value]="value" [value]="value"
></gf-value> ></gf-value>

View File

@ -123,7 +123,12 @@
}" }"
></ngx-skeleton-loader> ></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 <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

@ -26,6 +26,7 @@ import { Subject, Subscription } from 'rxjs';
export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit { export class PositionsTableComponent implements OnChanges, OnDestroy, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale: string;
@Input() positions: PortfolioPosition[]; @Input() positions: PortfolioPosition[];

View File

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

View File

@ -17,6 +17,7 @@ import { Position } from '@ghostfolio/common/interfaces';
export class PositionsComponent implements OnChanges, OnInit { export class PositionsComponent implements OnChanges, OnInit {
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateOrder: boolean;
@Input() locale: string; @Input() locale: string;
@Input() positions: Position[]; @Input() positions: Position[];
@Input() range: string; @Input() range: string;

View File

@ -1,7 +1,10 @@
<div class="container p-0"> <div class="container p-0">
<div class="row no-gutters"> <div class="row no-gutters">
<div class="col"> <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 <gf-no-transactions-info-indicator
[hasBorder]="false" [hasBorder]="false"
></gf-no-transactions-info-indicator> ></gf-no-transactions-info-indicator>

View File

@ -8,6 +8,7 @@ import { PortfolioReportRule } from '@ghostfolio/common/interfaces';
styleUrls: ['./rules.component.scss'] styleUrls: ['./rules.component.scss']
}) })
export class RulesComponent { export class RulesComponent {
@Input() hasPermissionToCreateOrder: boolean;
@Input() rules: PortfolioReportRule; @Input() rules: PortfolioReportRule;
public constructor() {} public constructor() {}

View File

@ -132,16 +132,35 @@
</div> </div>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.slackCommunityUsers ?? '-' }}</h3> <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> <div class="h6 mb-0" i18n>Users in Slack community</div>
</a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<h3 class="mb-0">{{ statistics?.gitHubContributors ?? '-' }}</h3> <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> <div class="h6 mb-0" i18n>Contributors on GitHub</div>
</a>
</div> </div>
<div class="col-xs-12 col-md-4 my-2"> <div class="col-xs-12 col-md-4 my-2">
<a
class="d-block"
href="https://github.com/ghostfolio/ghostfolio/stargazers"
>
<h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3> <h3 class="mb-0">{{ statistics?.gitHubStargazers ?? '-' }}</h3>
<div class="h6 mb-0" i18n>Stars on GitHub</div> <div class="h6 mb-0" i18n>Stars on GitHub</div>
</a>
</div> </div>
</div> </div>
</mat-card-content> </mat-card-content>
@ -150,22 +169,28 @@
</div> </div>
<div class="row"> <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 <div
class="col-md-6 col-xs-12 my-2" class="col-md-6 col-xs-12 my-2"
[ngClass]="{ 'offset-md-3': !hasPermissionForBlog }" [ngClass]="{ 'offset-md-3': !hasPermissionForBlog }"
> >
<a <a
class="py-2 w-100" class="py-2 w-100"
color="primary"
i18n i18n
mat-stroked-button mat-stroked-button
[routerLink]="['/about', 'changelog']" [routerLink]="['/about', 'changelog']"
>Changelog & License</a >Changelog & License</a
> >
</div> </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>
</div> </div>

View File

@ -2,13 +2,8 @@
color: rgb(var(--dark-primary-text)); color: rgb(var(--dark-primary-text));
display: block; display: block;
a {
color: rgb(var(--dark-primary-text));
}
.mat-card { .mat-card {
&.about-container, &.about-container {
&.changelog {
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1);
font-weight: 500; 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 { .independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-dark.svg'); background-image: url('/assets/bootstrapped-dark.svg');
background-position: center; background-position: center;
@ -57,10 +29,6 @@
:host-context(.is-dark-theme) { :host-context(.is-dark-theme) {
color: rgb(var(--light-primary-text)); color: rgb(var(--light-primary-text));
a {
color: rgb(var(--light-primary-text));
}
.mat-card { .mat-card {
.independent-and-bootstrapped-logo { .independent-and-bootstrapped-logo {
background-image: url('/assets/bootstrapped-light.svg'); background-image: url('/assets/bootstrapped-light.svg');

View File

@ -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() { public onRedeemCoupon() {
let couponCode = prompt('Please enter your coupon code:'); let couponCode = prompt('Please enter your coupon code:');
couponCode = couponCode?.trim(); couponCode = couponCode?.trim();

View File

@ -135,6 +135,23 @@
></mat-slide-toggle> ></mat-slide-toggle>
</div> </div>
</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-content>
</mat-card> </mat-card>
</div> </div>

View File

@ -1,6 +1,6 @@
<div class="blog container"> <div class="blog container">
<div class="row"> <div class="row">
<div class="col"> <div class="col-md-8 offset-md-2">
<article> <article>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1> <h1 class="mb-1" i18n>Hallo Ghostfolio 👋</h1>
@ -141,58 +141,59 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<ul class="list-inline"> <ul class="list-inline">
<li class="h5"> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Aktie</span>
>Aktie</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Altersvorsorge</span>
>Altersvorsorge</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Anlage</span>
>Anlage</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2">App</span> <span class="badge badge-light">App</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Cryptocurrency</span <li class="list-inline-item">
> <span class="badge badge-light">Cryptocurrency</span>
<span class="badge badge-light font-weight-normal mr-2">ETF</span> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Feedback</span <span class="badge badge-light">Feedback</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Fintech</span <span class="badge badge-light">Fintech</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Ghostfolio</span <span class="badge badge-light">Ghostfolio</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Investition</span <span class="badge badge-light">Investition</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Open Source</span <span class="badge badge-light">Open Source</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2">OSS</span> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">OSS</span>
>Portfolio</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Portfolio</span>
>Software</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Software</span>
>Strategie</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Strategie</span>
>Trading</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Trading</span>
>TypeScript</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">TypeScript</span>
>Vermögen</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Vermögen</span>
>Wealth Management</span </li>
> <li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li> </li>
</ul> </ul>
</section> </section>

View File

@ -7,9 +7,7 @@ import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component'
@NgModule({ @NgModule({
declarations: [HalloGhostfolioPageComponent], declarations: [HalloGhostfolioPageComponent],
exports: [],
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule], imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class HalloGhostfolioPageModule {} export class HalloGhostfolioPageModule {}

View File

@ -1,6 +1,6 @@
<div class="blog container"> <div class="blog container">
<div class="row"> <div class="row">
<div class="col"> <div class="col-md-8 offset-md-2">
<article> <article>
<div class="mb-4 text-center"> <div class="mb-4 text-center">
<h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1> <h1 class="mb-1" i18n>Hello Ghostfolio 👋</h1>
@ -136,42 +136,44 @@
</section> </section>
<section class="mb-4"> <section class="mb-4">
<ul class="list-inline"> <ul class="list-inline">
<li class="h5"> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2" <span class="badge badge-light">Cryptocurrency</span>
>Cryptocurrency</span </li>
> <li class="list-inline-item">
<span class="badge badge-light font-weight-normal mr-2">ETF</span> <span class="badge badge-light">ETF</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Fintech</span <li class="list-inline-item">
> <span class="badge badge-light">Fintech</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Ghostfolio</span <li class="list-inline-item">
> <span class="badge badge-light">Ghostfolio</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Investment</span <li class="list-inline-item">
> <span class="badge badge-light">Investment</span>
<span class="badge badge-light font-weight-normal mr-2" </li>
>Open Source</span <li class="list-inline-item">
> <span class="badge badge-light">Open Source</span>
<span class="badge badge-light font-weight-normal mr-2">OSS</span> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Portfolio</span <span class="badge badge-light">OSS</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Software</span <span class="badge badge-light">Portfolio</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Stock</span <span class="badge badge-light">Software</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Strategy</span <span class="badge badge-light">Stock</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Wealth</span <span class="badge badge-light">Strategy</span>
> </li>
<span class="badge badge-light font-weight-normal mr-2" <li class="list-inline-item">
>Wealth Management</span <span class="badge badge-light">Wealth</span>
> </li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth Management</span>
</li> </li>
</ul> </ul>
</section> </section>

View File

@ -7,9 +7,7 @@ import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component'
@NgModule({ @NgModule({
declarations: [HelloGhostfolioPageComponent], declarations: [HelloGhostfolioPageComponent],
exports: [],
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule], imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]
}) })
export class HelloGhostfolioPageModule {} export class HelloGhostfolioPageModule {}

View File

@ -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 {}

View File

@ -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 {}

View File

@ -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, Im 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>

View File

@ -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 {}

View File

@ -5,6 +5,26 @@
<mat-card class="blog-container"> <mat-card class="blog-container">
<mat-card-content> <mat-card-content>
<div class="container p-0"> <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"> <div class="flex-nowrap mb-3 no-gutters row">
<a <a
class="d-flex w-100" class="d-flex w-100"

View File

@ -12,6 +12,7 @@ import {
PortfolioPosition, PortfolioPosition,
User User
} from '@ghostfolio/common/interfaces'; } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { ToggleOption } from '@ghostfolio/common/types'; import { ToggleOption } from '@ghostfolio/common/types';
import { AssetClass } from '@prisma/client'; import { AssetClass } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
@ -36,6 +37,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
}; };
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public hasPermissionToCreateOrder: boolean;
public period = 'current'; public period = 'current';
public periodOptions: ToggleOption[] = [ public periodOptions: ToggleOption[] = [
{ label: 'Initial', value: 'original' }, { label: 'Initial', value: 'original' },
@ -120,6 +122,11 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
if (state?.user) { if (state?.user) {
this.user = state.user; this.user = state.user;
this.hasPermissionToCreateOrder = hasPermission(
this.user.permissions,
permissions.createOrder
);
this.changeDetectorRef.markForCheck(); 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.portfolioDetails.accounts
)) { )) {
this.accounts[name] = { this.accounts[id] = {
name, name,
value: aPeriod === 'original' ? original : current value: aPeriod === 'original' ? original : current
}; };

View File

@ -197,6 +197,7 @@
<gf-positions-table <gf-positions-table
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[positions]="positionsArray" [positions]="positionsArray"
></gf-positions-table> ></gf-positions-table>

View File

@ -2,9 +2,10 @@ import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.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 { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash';
import { DeviceDetectorService } from 'ngx-device-detector'; import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs'; import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -16,10 +17,12 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './analysis-page.html' templateUrl: './analysis-page.html'
}) })
export class AnalysisPageComponent implements OnDestroy, OnInit { export class AnalysisPageComponent implements OnDestroy, OnInit {
public bottom3: Position[];
public daysInMarket: number; public daysInMarket: number;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
public investments: InvestmentItem[]; public investments: InvestmentItem[];
public top3: Position[];
public user: User; public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -58,6 +61,26 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
this.changeDetectorRef.markForCheck(); 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 this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {

View File

@ -5,7 +5,7 @@
<mat-card class="mb-3"> <mat-card class="mb-3">
<mat-card-header> <mat-card-header>
<mat-card-title class="align-items-center d-flex" i18n <mat-card-title class="align-items-center d-flex" i18n
>Timeline</mat-card-title >Investment Timeline</mat-card-title
> >
</mat-card-header> </mat-card-header>
<mat-card-content> <mat-card-content>
@ -19,4 +19,82 @@
</mat-card> </mat-card>
</div> </div>
</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> </div>

View File

@ -2,6 +2,8 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatCardModule } from '@angular/material/card'; import { MatCardModule } from '@angular/material/card';
import { GfInvestmentChartModule } from '@ghostfolio/client/components/investment-chart/investment-chart.module'; 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 { AnalysisPageRoutingModule } from './analysis-page-routing.module';
import { AnalysisPageComponent } from './analysis-page.component'; import { AnalysisPageComponent } from './analysis-page.component';
@ -13,7 +15,9 @@ import { AnalysisPageComponent } from './analysis-page.component';
AnalysisPageRoutingModule, AnalysisPageRoutingModule,
CommonModule, CommonModule,
GfInvestmentChartModule, GfInvestmentChartModule,
MatCardModule GfValueModule,
MatCardModule,
NgxSkeletonLoaderModule
], ],
providers: [], providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA] schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -1,6 +1,8 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service'; 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 { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -14,6 +16,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
public accountClusterRiskRules: PortfolioReportRule[]; public accountClusterRiskRules: PortfolioReportRule[];
public currencyClusterRiskRules: PortfolioReportRule[]; public currencyClusterRiskRules: PortfolioReportRule[];
public feeRules: PortfolioReportRule[]; public feeRules: PortfolioReportRule[];
public hasPermissionToCreateOrder: boolean;
public user: User;
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -22,7 +26,8 @@ export class ReportPageComponent implements OnDestroy, OnInit {
*/ */
public constructor( public constructor(
private changeDetectorRef: ChangeDetectorRef, 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.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() { public ngOnDestroy() {

View File

@ -15,15 +15,24 @@
</p> </p>
<div class="mb-4"> <div class="mb-4">
<h4 class="m-0" i18n>Currency Cluster Risks</h4> <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>
<div class="mb-4"> <div class="mb-4">
<h4 class="m-0" i18n>Account Cluster Risks</h4> <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>
<div> <div>
<h4 class="m-0" i18n>Fees</h4> <h4 class="m-0" i18n>Fees</h4>
<gf-rules [rules]="feeRules"></gf-rules> <gf-rules
[hasPermissionToCreateOrder]="hasPermissionToCreateOrder"
[rules]="feeRules"
></gf-rules>
</div> </div>
</div> </div>
</div> </div>

View File

@ -3,6 +3,7 @@ import { MatDialog } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar'; import { MatSnackBar } from '@angular/material/snack-bar';
import { ActivatedRoute, Router } from '@angular/router'; import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; 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 { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component'; import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service'; import { DataService } from '@ghostfolio/client/services/data.service';
@ -28,6 +29,7 @@ import { ImportTransactionDialog } from './import-transaction-dialog/import-tran
templateUrl: './transactions-page.html' templateUrl: './transactions-page.html'
}) })
export class TransactionsPageComponent implements OnDestroy, OnInit { export class TransactionsPageComponent implements OnDestroy, OnInit {
public activities: Activity[];
public defaultAccountId: string; public defaultAccountId: string;
public deviceType: string; public deviceType: string;
public hasImpersonationId: boolean; public hasImpersonationId: boolean;
@ -35,7 +37,6 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
public hasPermissionToDeleteOrder: boolean; public hasPermissionToDeleteOrder: boolean;
public hasPermissionToImportOrders: boolean; public hasPermissionToImportOrders: boolean;
public routeQueryParams: Subscription; public routeQueryParams: Subscription;
public transactions: OrderModel[];
public user: User; public user: User;
private primaryDataSource: DataSource; private primaryDataSource: DataSource;
@ -62,11 +63,11 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.routeQueryParams = route.queryParams this.routeQueryParams = route.queryParams
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((params) => { .subscribe((params) => {
if (params['createDialog'] && this.hasPermissionToCreateOrder) { if (params['createDialog']) {
this.openCreateTransactionDialog(); this.openCreateTransactionDialog();
} else if (params['editDialog']) { } else if (params['editDialog']) {
if (this.transactions) { if (this.activities) {
const transaction = this.transactions.find(({ id }) => { const transaction = this.activities.find(({ id }) => {
return id === params['transactionId']; return id === params['transactionId'];
}); });
@ -106,20 +107,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => { .subscribe((state) => {
if (state?.user) { if (state?.user) {
this.user = state.user; this.updateUser(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.changeDetectorRef.markForCheck(); this.changeDetectorRef.markForCheck();
} }
@ -132,10 +120,10 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.dataService this.dataService
.fetchOrders() .fetchOrders()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((response) => { .subscribe(({ activities }) => {
this.transactions = response; this.activities = activities;
if (this.hasPermissionToCreateOrder && this.transactions?.length <= 0) { if (this.hasPermissionToCreateOrder && this.activities?.length <= 0) {
this.router.navigate([], { queryParams: { createDialog: true } }); this.router.navigate([], { queryParams: { createDialog: true } });
} }
@ -352,6 +340,12 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
} }
private openCreateTransactionDialog(aTransaction?: OrderModel): void { private openCreateTransactionDialog(aTransaction?: OrderModel): void {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.updateUser(user);
const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, { const dialogRef = this.dialog.open(CreateOrUpdateTransactionDialog, {
data: { data: {
accounts: this.user?.accounts?.filter((account) => { accounts: this.user?.accounts?.filter((account) => {
@ -390,6 +384,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
this.router.navigate(['.'], { relativeTo: this.route }); this.router.navigate(['.'], { relativeTo: this.route });
}); });
});
} }
private openPositionDialog({ symbol }: { symbol: string }) { private openPositionDialog({ symbol }: { symbol: string }) {
@ -397,7 +392,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
.get() .get()
.pipe(takeUntil(this.unsubscribeSubject)) .pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => { .subscribe((user) => {
this.user = user; this.updateUser(user);
const dialogRef = this.dialog.open(PositionDetailDialog, { const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false, 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
);
}
} }

View File

@ -3,7 +3,7 @@
<div class="col"> <div class="col">
<h3 class="d-flex justify-content-center mb-3" i18n>Activities</h3> <h3 class="d-flex justify-content-center mb-3" i18n>Activities</h3>
<gf-activities-table <gf-activities-table
[activities]="transactions" [activities]="activities"
[baseCurrency]="user?.settings?.baseCurrency" [baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType" [deviceType]="deviceType"
[hasPermissionToCreateActivity]="hasPermissionToCreateOrder" [hasPermissionToCreateActivity]="hasPermissionToCreateOrder"

View File

@ -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 { CreateAccountDto } from '@ghostfolio/api/app/account/create-account.dto';
import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto'; import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.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 { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface'; import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface'; import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
@ -169,14 +170,14 @@ export class DataService {
); );
} }
public fetchOrders(): Observable<OrderModel[]> { public fetchOrders(): Observable<Activities> {
return this.http.get<any[]>('/api/order').pipe( return this.http.get<any>('/api/order').pipe(
map((data) => { map(({ activities }) => {
for (const item of data) { for (const activity of activities) {
item.createdAt = parseISO(item.createdAt); activity.createdAt = parseISO(activity.createdAt);
item.date = parseISO(item.date); activity.date = parseISO(activity.date);
} }
return data; return { activities };
}) })
); );
} }

View File

@ -1,4 +1,5 @@
User-agent: * User-agent: *
Allow: / Allow: /
Disallow: /p/*
Sitemap: https://ghostfol.io/sitemap.xml Sitemap: https://ghostfol.io/sitemap.xml

View File

@ -28,6 +28,10 @@
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc> <loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>2022-01-01T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>
</url> </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> <url>
<loc>https://ghostfol.io/pricing</loc> <loc>https://ghostfol.io/pricing</loc>
<lastmod>2022-01-01T00:00:00+00:00</lastmod> <lastmod>2022-01-01T00:00:00+00:00</lastmod>

View File

@ -38,7 +38,7 @@ body {
.blog { .blog {
a { a {
color: rgba(var(--palette-primary-500), 1); color: rgba(var(--palette-primary-500), 1) !important;
font-weight: 500; font-weight: 500;
&:hover { &:hover {

View File

@ -1,7 +1,12 @@
import { DataSource } from '@prisma/client';
export interface AdminMarketData { export interface AdminMarketData {
marketData: AdminMarketDataItem[]; marketData: AdminMarketDataItem[];
} }
export interface AdminMarketDataItem { export interface AdminMarketDataItem {
dataSource: DataSource;
date?: Date;
marketDataItemCount?: number;
symbol: string; symbol: string;
} }

View File

@ -2,7 +2,10 @@ import { Access } from './access.interface';
import { Accounts } from './accounts.interface'; import { Accounts } from './accounts.interface';
import { AdminData } from './admin-data.interface'; import { AdminData } from './admin-data.interface';
import { AdminMarketDataDetails } from './admin-market-data-details.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 { Coupon } from './coupon.interface';
import { Export } from './export.interface'; import { Export } from './export.interface';
import { InfoItem } from './info-item.interface'; import { InfoItem } from './info-item.interface';
@ -29,6 +32,7 @@ export {
AdminData, AdminData,
AdminMarketData, AdminMarketData,
AdminMarketDataDetails, AdminMarketDataDetails,
AdminMarketDataItem,
Coupon, Coupon,
Export, Export,
InfoItem, InfoItem,

View File

@ -2,10 +2,11 @@ import { PortfolioPosition } from '@ghostfolio/common/interfaces';
export interface PortfolioDetails { export interface PortfolioDetails {
accounts: { accounts: {
[name: string]: { [id: string]: {
balance: number; balance: number;
currency: string; currency: string;
current: number; current: number;
name: string;
original: number; original: number;
}; };
}; };

View File

@ -58,6 +58,11 @@
> >
{{ dataSource.data.length - i }} {{ dataSource.data.length - i }}
</td> </td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container> </ng-container>
<ng-container matColumnDef="date"> <ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
@ -68,13 +73,14 @@
{{ element.date | date: defaultDateFormat }} {{ element.date | date: defaultDateFormat }}
</div> </div>
</td> </td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>Total</td>
</ng-container> </ng-container>
<ng-container matColumnDef="type"> <ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header> <th *matHeaderCellDef class="px-1" i18n mat-header-cell mat-sort-header>
Type Type
</th> </th>
<td mat-cell *matCellDef="let element" class="px-1"> <td *matCellDef="let element" mat-cell class="px-1">
<div <div
class="d-inline-flex p-1 type-badge" class="d-inline-flex p-1 type-badge"
[ngClass]="{ [ngClass]="{
@ -93,6 +99,7 @@
<span class="d-none d-lg-block mx-1">{{ element.type }}</span> <span class="d-none d-lg-block mx-1">{{ element.type }}</span>
</div> </div>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<ng-container matColumnDef="symbol"> <ng-container matColumnDef="symbol">
@ -107,6 +114,7 @@
> >
</div> </div>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<ng-container matColumnDef="currency"> <ng-container matColumnDef="currency">
@ -122,6 +130,9 @@
<td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell> <td *matCellDef="let element" class="d-none d-lg-table-cell px-1" mat-cell>
{{ element.currency }} {{ element.currency }}
</td> </td>
<td *matFooterCellDef class="d-none d-lg-table-cell px-1" mat-footer-cell>
{{ baseCurrency }}
</td>
</ng-container> </ng-container>
<ng-container matColumnDef="quantity"> <ng-container matColumnDef="quantity">
@ -143,6 +154,11 @@
></gf-value> ></gf-value>
</div> </div>
</td> </td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container> </ng-container>
<ng-container matColumnDef="unitPrice"> <ng-container matColumnDef="unitPrice">
@ -164,6 +180,11 @@
></gf-value> ></gf-value>
</div> </div>
</td> </td>
<td
*matFooterCellDef
class="d-none d-lg-table-cell px-1"
mat-footer-cell
></td>
</ng-container> </ng-container>
<ng-container matColumnDef="fee"> <ng-container matColumnDef="fee">
@ -176,7 +197,7 @@
> >
Fee Fee
</th> </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"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
@ -185,6 +206,15 @@
></gf-value> ></gf-value>
</div> </div>
</td> </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>
<ng-container matColumnDef="value"> <ng-container matColumnDef="value">
@ -197,7 +227,7 @@
> >
Value Value
</th> </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"> <div class="d-flex justify-content-end">
<gf-value <gf-value
[isCurrency]="true" [isCurrency]="true"
@ -206,6 +236,15 @@
></gf-value> ></gf-value>
</div> </div>
</td> </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>
<ng-container matColumnDef="account"> <ng-container matColumnDef="account">
@ -223,6 +262,7 @@
<span class="d-none d-lg-block">{{ element.Account?.name }}</span> <span class="d-none d-lg-block">{{ element.Account?.name }}</span>
</div> </div>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
@ -276,6 +316,7 @@
</button> </button>
</mat-menu> </mat-menu>
</td> </td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container> </ng-container>
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> <tr *matHeaderRowDef="displayedColumns" mat-header-row></tr>
@ -291,6 +332,11 @@
" "
[ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }" [ngClass]="{ 'cursor-pointer': hasPermissionToOpenDetails && !row.isDraft }"
></tr> ></tr>
<tr
*matFooterRowDef="displayedColumns"
mat-footer-row
[ngClass]="{ 'd-none': isLoading || dataSource.data.length === 0 }"
></tr>
</table> </table>
<ngx-skeleton-loader <ngx-skeleton-loader

View File

@ -15,6 +15,16 @@
} }
.mat-table { .mat-table {
td {
&.mat-footer-cell {
border-top: 1px solid
rgba(
var(--palette-foreground-divider),
var(--palette-foreground-divider-alpha)
);
}
}
th { th {
::ng-deep { ::ng-deep {
.mat-sort-header-container { .mat-sort-header-container {
@ -55,6 +65,15 @@
} }
.mat-table { .mat-table {
td {
&.mat-footer-cell {
border-top-color: rgba(
var(--palette-foreground-divider-dark),
var(--palette-foreground-divider-dark-alpha)
);
}
}
.type-badge { .type-badge {
background-color: rgba( background-color: rgba(
var(--palette-foreground-text-dark), var(--palette-foreground-text-dark),

View File

@ -7,7 +7,6 @@ import {
Input, Input,
OnChanges, OnChanges,
OnDestroy, OnDestroy,
OnInit,
Output, Output,
ViewChild ViewChild
} from '@angular/core'; } from '@angular/core';
@ -20,9 +19,12 @@ import { MatChipInputEvent } from '@angular/material/chips';
import { MatSort } from '@angular/material/sort'; import { MatSort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table'; import { MatTableDataSource } from '@angular/material/table';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config'; import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
import { OrderWithAccount } from '@ghostfolio/common/types'; import { OrderWithAccount } from '@ghostfolio/common/types';
import Big from 'big.js';
import { endOfToday, format, isAfter } from 'date-fns'; import { endOfToday, format, isAfter } from 'date-fns';
import { isNumber } from 'lodash';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'; import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { takeUntil } from 'rxjs/operators'; import { takeUntil } from 'rxjs/operators';
@ -36,7 +38,7 @@ const SEARCH_STRING_SEPARATOR = ',';
templateUrl: './activities-table.component.html' templateUrl: './activities-table.component.html'
}) })
export class ActivitiesTableComponent implements OnChanges, OnDestroy { export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@Input() activities: OrderWithAccount[]; @Input() activities: Activity[];
@Input() baseCurrency: string; @Input() baseCurrency: string;
@Input() deviceType: string; @Input() deviceType: string;
@Input() hasPermissionToCreateActivity: boolean; @Input() hasPermissionToCreateActivity: boolean;
@ -57,8 +59,7 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>; @ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
@ViewChild(MatSort) sort: MatSort; @ViewChild(MatSort) sort: MatSort;
public dataSource: MatTableDataSource<OrderWithAccount> = public dataSource: MatTableDataSource<Activity> = new MatTableDataSource();
new MatTableDataSource();
public defaultDateFormat = DEFAULT_DATE_FORMAT; public defaultDateFormat = DEFAULT_DATE_FORMAT;
public displayedColumns = []; public displayedColumns = [];
public endOfToday = endOfToday(); public endOfToday = endOfToday();
@ -71,6 +72,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public searchControl = new FormControl(); public searchControl = new FormControl();
public searchKeywords: string[] = []; public searchKeywords: string[] = [];
public separatorKeysCodes: number[] = [ENTER, COMMA]; public separatorKeysCodes: number[] = [ENTER, COMMA];
public totalFees: number;
public totalValue: number;
private allFilters: string[]; private allFilters: string[];
private unsubscribeSubject = new Subject<void>(); private unsubscribeSubject = new Subject<void>();
@ -218,6 +221,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
); );
this.filters$.next(this.allFilters); this.filters$.next(this.allFilters);
this.totalFees = this.getTotalFees();
this.totalValue = this.getTotalValue();
} }
private getSearchableFieldValues(activities: OrderWithAccount[]): string[] { private getSearchableFieldValues(activities: OrderWithAccount[]): string[] {
@ -263,4 +269,36 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
return item !== undefined; 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();
}
} }

View File

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "1.99.0", "version": "1.107.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"scripts": { "scripts": {
@ -69,11 +69,11 @@
"@nestjs/schedule": "1.0.2", "@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2", "@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "13.4.1", "@nrwl/angular": "13.4.1",
"@prisma/client": "3.7.0", "@prisma/client": "3.8.1",
"@simplewebauthn/browser": "4.1.0", "@simplewebauthn/browser": "4.1.0",
"@simplewebauthn/server": "4.1.0", "@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0", "@simplewebauthn/typescript-types": "4.0.0",
"@stripe/stripe-js": "1.15.0", "@stripe/stripe-js": "1.22.0",
"@types/papaparse": "5.2.6", "@types/papaparse": "5.2.6",
"alphavantage": "2.2.0", "alphavantage": "2.2.0",
"angular-material-css-vars": "3.0.0", "angular-material-css-vars": "3.0.0",
@ -94,6 +94,7 @@
"cryptocurrencies": "7.0.0", "cryptocurrencies": "7.0.0",
"date-fns": "2.22.1", "date-fns": "2.22.1",
"envalid": "7.2.1", "envalid": "7.2.1",
"google-spreadsheet": "3.2.0",
"http-status-codes": "2.2.0", "http-status-codes": "2.2.0",
"ionicons": "5.5.1", "ionicons": "5.5.1",
"lodash": "4.17.21", "lodash": "4.17.21",
@ -105,11 +106,11 @@
"passport": "0.4.1", "passport": "0.4.1",
"passport-google-oauth20": "2.0.0", "passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0", "passport-jwt": "4.0.0",
"prisma": "3.7.0", "prisma": "3.8.1",
"reflect-metadata": "0.1.13", "reflect-metadata": "0.1.13",
"round-to": "5.0.0", "round-to": "5.0.0",
"rxjs": "7.4.0", "rxjs": "7.4.0",
"stripe": "8.156.0", "stripe": "8.199.0",
"svgmap": "2.6.0", "svgmap": "2.6.0",
"tslib": "2.0.0", "tslib": "2.0.0",
"uuid": "8.3.2", "uuid": "8.3.2",
@ -143,6 +144,7 @@
"@types/big.js": "6.1.2", "@types/big.js": "6.1.2",
"@types/cache-manager": "3.4.2", "@types/cache-manager": "3.4.2",
"@types/color": "3.0.2", "@types/color": "3.0.2",
"@types/google-spreadsheet": "3.1.5",
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/lodash": "4.14.174", "@types/lodash": "4.14.174",
"@types/node": "14.14.33", "@types/node": "14.14.33",

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "DataSource" ADD VALUE 'GOOGLE_SHEETS';

View File

@ -184,6 +184,7 @@ enum AssetSubClass {
enum DataSource { enum DataSource {
ALPHA_VANTAGE ALPHA_VANTAGE
GHOSTFOLIO GHOSTFOLIO
GOOGLE_SHEETS
RAKUTEN RAKUTEN
YAHOO YAHOO
} }

182
yarn.lock
View File

@ -3349,22 +3349,22 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw== integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
"@prisma/client@3.7.0": "@prisma/client@3.8.1":
version "3.7.0" version "3.8.1"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.7.0.tgz#9cafc105f12635c95e9b7e7b18e8fbf52cf3f18a" resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.8.1.tgz#c11eda8e84760867552ffde4de7b48fb2cf1e1c0"
integrity sha512-fUJMvBOX5C7JPc0e3CJD6Gbelbu4dMJB4ScYpiht8HMUnRShw20ULOipTopjNtl6ekHQJ4muI7pXlQxWS9nMbw== integrity sha512-NxD1Xbkx1eT1mxSwo1RwZe665mqBETs0VxohuwNfFIxMqcp0g6d4TgugPxwZ4Jb4e5wCu8mQ9quMedhNWIWcZQ==
dependencies: 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": "@prisma/engines-version@3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f":
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f" version "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#055f36ac8b06c301332c14963cd0d6c795942c90" resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f.tgz#4c8d9744b5e54650a8ba5fde0a711399d6adba24"
integrity sha512-+qx2b+HK7BKF4VCa0LZ/t1QCXsu6SmvhUQyJkOD2aPpmOzket4fEnSKQZSB0i5tl7rwCDsvAiSeK8o7rf+yvwg== integrity sha512-G2JH6yWt6ixGKmsRmVgaQYahfwMopim0u/XLIZUo2o/mZ5jdu7+BL+2V5lZr7XiG1axhyrpvlyqE/c0OgYSl3g==
"@prisma/engines@3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f": "@prisma/engines@3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f":
version "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f" version "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f.tgz#12f28d5b78519fbd84c89a5bdff457ff5095e7a2" resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f.tgz#4479099b99f6a082ce5843ee7208943ccedd127f"
integrity sha512-W549ub5NlgexNhR8EFstA/UwAWq3Zq0w9aNkraqsozVCt2CsX+lK4TK7IW5OZVSnxHwRjrgEAt3r9yPy8nZQRg== integrity sha512-bHYubuItSN/DGYo36aDu7xJiJmK52JOSHs4MK+KbceAtwS20BCWadRgtpQ3iZ2EXfN/B1T0iCXlNraaNwnpU2w==
"@samverschueren/stream-to-observable@^0.3.0": "@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1" version "0.3.1"
@ -4347,10 +4347,10 @@
resolve-from "^5.0.0" resolve-from "^5.0.0"
store2 "^2.12.0" store2 "^2.12.0"
"@stripe/stripe-js@1.15.0": "@stripe/stripe-js@1.22.0":
version "1.15.0" version "1.22.0"
resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.15.0.tgz#86178cfbe66151910b09b03595e60048ab4c698e" resolved "https://registry.yarnpkg.com/@stripe/stripe-js/-/stripe-js-1.22.0.tgz#9d3d2f0a1ce81f185ec477fd7cc67544b2b2a00c"
integrity sha512-KQsNPc+uVQkc8dewwz1A6uHOWeU2cWoZyNIbsx5mtmperr5TPxw4u8M20WOa22n6zmIOh/zLdzEe8DYK/0IjBw== integrity sha512-fm8TR8r4LwbXgBIYdPmeMjJJkxxFC66tvoliNnmXOpUgZSgQKoNPW3ON0ZphZIiif1oqWNhAaSrr7tOvGu+AFg==
"@tootallnate/once@1": "@tootallnate/once@1":
version "1.1.2" version "1.1.2"
@ -4487,6 +4487,11 @@
"@types/minimatch" "*" "@types/minimatch" "*"
"@types/node" "*" "@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": "@types/graceful-fs@^4.1.2":
version "4.1.5" version "4.1.5"
resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.5.tgz#21ffba0d98da4350db64891f92a9e5db3cdb4e15" 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" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q== 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: accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.7:
version "1.3.7" version "1.3.7"
resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" 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" es-array-method-boxes-properly "^1.0.0"
is-string "^1.0.5" is-string "^1.0.5"
arrify@^2.0.1: arrify@^2.0.0, arrify@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa" resolved "https://registry.yarnpkg.com/arrify/-/arrify-2.0.1.tgz#c9655e9331e0abcd588d2a7cad7e9956f66701fa"
integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug== integrity sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==
@ -5903,6 +5915,13 @@ axios@0.24.0:
dependencies: dependencies:
follow-redirects "^1.14.4" 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: axobject-query@2.0.2:
version "2.0.2" version "2.0.2"
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.0.2.tgz#ea187abe5b9002b377f925d8bf7d1c561adf38f9" 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" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== 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" version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== 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" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== 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: bignumber.js@^9.0.1:
version "9.0.1" version "9.0.1"
resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.1.tgz#8d7ba124c882bfd8e43260c67475518d0689e4e5" 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" jsbn "~0.1.0"
safer-buffer "^2.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" version "1.0.11"
resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf"
integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== 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" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 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: eventemitter-asyncresource@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/eventemitter-asyncresource/-/eventemitter-asyncresource-1.0.0.tgz#734ff2e44bf448e627f7748f905d6bdd57bdb65b" 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" assign-symbols "^1.0.0"
is-extendable "^1.0.1" 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" version "3.0.2"
resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== 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" resolved "https://registry.yarnpkg.com/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz#c406a83b6e70d9e35ce3b30a81141df30aeba884"
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA== 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: fastparse@^1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/fastparse/-/fastparse-1.1.2.tgz#91728c5a5942eced8531283c79441ee4122c35a9" 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" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.3.tgz#6ada78118d8d24caee595595accdc0ac6abd022e"
integrity sha512-3MkHxknWMUtb23apkgz/83fDoe+y+qr0TdgacGIA7bew+QLBo3vdgEN2xEsuXNivpFy4CyDhBBZnNZOtalmenw== 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: follow-redirects@^1.14.4:
version "1.14.5" version "1.14.5"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.5.tgz#f09a5848981d3c772b5392309778523f8d85c381" 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" strip-ansi "^6.0.1"
wide-align "^1.1.2" 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: gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2:
version "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" 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" pify "^4.0.1"
slash "^2.0.0" 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: 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" version "4.2.8"
resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.8.tgz#e412b8d33f5e006593cbd3cee6df9f2cebbe802a"
integrity sha512-qkIilPUYcNhJpd33n0GBXTB1MMPp14TxEsEs0pTrsSVucApsYzW5V+Q8Qxhik6KU3evy+qkAAowTByymK0avdg== 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: gzip-size@5.1.1:
version "5.1.1" version "5.1.1"
resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-5.1.1.tgz#cb9bee692f87c0612b232840a873904e4c135274" 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" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0= 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: json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2:
version "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" 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" ecdsa-sig-formatter "1.0.11"
safe-buffer "^5.0.1" 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: jwk-to-pem@^2.0.4:
version "2.0.5" version "2.0.5"
resolved "https://registry.yarnpkg.com/jwk-to-pem/-/jwk-to-pem-2.0.5.tgz#151310bcfbcf731adc5ad9f379cbc8b395742906" 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" jwa "^1.4.1"
safe-buffer "^5.0.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: karma-source-map-support@1.4.0:
version "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" 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" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE= integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
prisma@3.7.0: prisma@3.8.1:
version "3.7.0" version "3.8.1"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.7.0.tgz#9c73eeb2f16f767fdf523d0f4cc4c749734d62e2" resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.8.1.tgz#44395cef7cbb1ea86216cb84ee02f856c08a7873"
integrity sha512-pzgc95msPLcCHqOli7Hnabu/GRfSGSUWl5s2P6N13T/rgMB+NNeKbxCmzQiZT2yLOeLEPivV6YrW1oeQIwJxcg== integrity sha512-Q8zHwS9m70TaD7qI8u+8hTAmiTpK+IpvRYF3Rgb/OeWGQJOMgZCFFvNCiSfoLEQ95wilK7ctW3KOpc9AuYnRUA==
dependencies: dependencies:
"@prisma/engines" "3.7.0-31.8746e055198f517658c08a0c426c7eec87f5a85f" "@prisma/engines" "3.8.0-43.34df67547cf5598f5a6cd3eb45f14ee70c3fb86f"
prismjs@^1.21.0, prismjs@~1.24.0: prismjs@^1.21.0, prismjs@~1.24.0:
version "1.24.1" 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" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
stripe@8.156.0: stripe@8.199.0:
version "8.156.0" version "8.199.0"
resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.156.0.tgz#040de551df88d71ef670a8c8d4df114c3fa6eb4b" resolved "https://registry.yarnpkg.com/stripe/-/stripe-8.199.0.tgz#dcd109f16ff0c33da638a0d154c966d0f20c73d1"
integrity sha512-q+bixlhaxnSI/Htk/iB1i5LhuZ557hL0pFgECBxQNhso1elxIsOsPOIXEuo3tSLJEb8CJSB7t/+Fyq6KP69tAQ== integrity sha512-Bc5Zfp6eOOCdde9x5NPrAczeGSKuNwemzjsfGJXWtpbUfQXgJujzTGgkhx2YuzamqakDYJkTgf9w7Ry2uY8QNA==
dependencies: dependencies:
"@types/node" ">=8.1.0" "@types/node" ">=8.1.0"
qs "^6.6.0" qs "^6.6.0"