mirror of https://github.com/ghostfolio/ghostfolio
committed by
GitHub
120 changed files with 4973 additions and 4936 deletions
@ -0,0 +1,2 @@ |
|||
# Check formatting on modified and uncommitted files, stop the commit if issues are found |
|||
npm run format:write --uncommitted || exit 1 |
@ -0,0 +1,10 @@ |
|||
import { DataSource } from '@prisma/client'; |
|||
import { IsEnum, IsString } from 'class-validator'; |
|||
|
|||
export class CreateWatchlistItemDto { |
|||
@IsEnum(DataSource) |
|||
dataSource: DataSource; |
|||
|
|||
@IsString() |
|||
symbol: string; |
|||
} |
@ -0,0 +1,85 @@ |
|||
import { HasPermission } from '@ghostfolio/api/decorators/has-permission.decorator'; |
|||
import { HasPermissionGuard } from '@ghostfolio/api/guards/has-permission.guard'; |
|||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.interceptor'; |
|||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.interceptor'; |
|||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; |
|||
import { permissions } from '@ghostfolio/common/permissions'; |
|||
import { RequestWithUser } from '@ghostfolio/common/types'; |
|||
|
|||
import { |
|||
Body, |
|||
Controller, |
|||
Delete, |
|||
Get, |
|||
HttpException, |
|||
Inject, |
|||
Param, |
|||
Post, |
|||
UseGuards, |
|||
UseInterceptors |
|||
} from '@nestjs/common'; |
|||
import { REQUEST } from '@nestjs/core'; |
|||
import { AuthGuard } from '@nestjs/passport'; |
|||
import { DataSource } from '@prisma/client'; |
|||
import { StatusCodes, getReasonPhrase } from 'http-status-codes'; |
|||
|
|||
import { CreateWatchlistItemDto } from './create-watchlist-item.dto'; |
|||
import { WatchlistService } from './watchlist.service'; |
|||
|
|||
@Controller('watchlist') |
|||
export class WatchlistController { |
|||
public constructor( |
|||
@Inject(REQUEST) private readonly request: RequestWithUser, |
|||
private readonly watchlistService: WatchlistService |
|||
) {} |
|||
|
|||
@Post() |
|||
@HasPermission(permissions.createWatchlistItem) |
|||
@UseGuards(AuthGuard('jwt')) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async createWatchlistItem(@Body() data: CreateWatchlistItemDto) { |
|||
return this.watchlistService.createWatchlistItem({ |
|||
dataSource: data.dataSource, |
|||
symbol: data.symbol, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Delete(':dataSource/:symbol') |
|||
@HasPermission(permissions.deleteWatchlistItem) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInRequestInterceptor) |
|||
public async deleteWatchlistItem( |
|||
@Param('dataSource') dataSource: DataSource, |
|||
@Param('symbol') symbol: string |
|||
) { |
|||
const watchlistItem = await this.watchlistService |
|||
.getWatchlistItems(this.request.user.id) |
|||
.then((items) => { |
|||
return items.find((item) => { |
|||
return item.dataSource === dataSource && item.symbol === symbol; |
|||
}); |
|||
}); |
|||
|
|||
if (!watchlistItem) { |
|||
throw new HttpException( |
|||
getReasonPhrase(StatusCodes.NOT_FOUND), |
|||
StatusCodes.NOT_FOUND |
|||
); |
|||
} |
|||
|
|||
return this.watchlistService.deleteWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId: this.request.user.id |
|||
}); |
|||
} |
|||
|
|||
@Get() |
|||
@HasPermission(permissions.readWatchlist) |
|||
@UseGuards(AuthGuard('jwt'), HasPermissionGuard) |
|||
@UseInterceptors(TransformDataSourceInResponseInterceptor) |
|||
public async getWatchlistItems(): Promise<AssetProfileIdentifier[]> { |
|||
return this.watchlistService.getWatchlistItems(this.request.user.id); |
|||
} |
|||
} |
@ -0,0 +1,19 @@ |
|||
import { TransformDataSourceInRequestModule } from '@ghostfolio/api/interceptors/transform-data-source-in-request/transform-data-source-in-request.module'; |
|||
import { TransformDataSourceInResponseModule } from '@ghostfolio/api/interceptors/transform-data-source-in-response/transform-data-source-in-response.module'; |
|||
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module'; |
|||
|
|||
import { Module } from '@nestjs/common'; |
|||
|
|||
import { WatchlistController } from './watchlist.controller'; |
|||
import { WatchlistService } from './watchlist.service'; |
|||
|
|||
@Module({ |
|||
controllers: [WatchlistController], |
|||
imports: [ |
|||
PrismaModule, |
|||
TransformDataSourceInRequestModule, |
|||
TransformDataSourceInResponseModule |
|||
], |
|||
providers: [WatchlistService] |
|||
}) |
|||
export class WatchlistModule {} |
@ -0,0 +1,79 @@ |
|||
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; |
|||
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Injectable, NotFoundException } from '@nestjs/common'; |
|||
import { DataSource } from '@prisma/client'; |
|||
|
|||
@Injectable() |
|||
export class WatchlistService { |
|||
public constructor(private readonly prismaService: PrismaService) {} |
|||
|
|||
public async createWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId |
|||
}: { |
|||
dataSource: DataSource; |
|||
symbol: string; |
|||
userId: string; |
|||
}): Promise<void> { |
|||
const symbolProfile = await this.prismaService.symbolProfile.findUnique({ |
|||
where: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
}); |
|||
|
|||
if (!symbolProfile) { |
|||
throw new NotFoundException( |
|||
`Asset profile not found for ${symbol} (${dataSource})` |
|||
); |
|||
} |
|||
|
|||
await this.prismaService.user.update({ |
|||
data: { |
|||
watchlist: { |
|||
connect: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
} |
|||
|
|||
public async deleteWatchlistItem({ |
|||
dataSource, |
|||
symbol, |
|||
userId |
|||
}: { |
|||
dataSource: DataSource; |
|||
symbol: string; |
|||
userId: string; |
|||
}) { |
|||
await this.prismaService.user.update({ |
|||
data: { |
|||
watchlist: { |
|||
disconnect: { |
|||
dataSource_symbol: { dataSource, symbol } |
|||
} |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
} |
|||
|
|||
public async getWatchlistItems( |
|||
userId: string |
|||
): Promise<AssetProfileIdentifier[]> { |
|||
const user = await this.prismaService.user.findUnique({ |
|||
select: { |
|||
watchlist: { |
|||
select: { dataSource: true, symbol: true } |
|||
} |
|||
}, |
|||
where: { id: userId } |
|||
}); |
|||
|
|||
return user.watchlist ?? []; |
|||
} |
|||
} |
@ -1,595 +0,0 @@ |
|||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
|||
import { OrderService } from '@ghostfolio/api/app/order/order.service'; |
|||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
|||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; |
|||
import { LogPerformance } from '@ghostfolio/api/interceptors/performance-logging/performance-logging.interceptor'; |
|||
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
|||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
|||
import { IDataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
|||
import { DATE_FORMAT, parseDate, resetHours } from '@ghostfolio/common/helper'; |
|||
import { Filter, HistoricalDataItem } from '@ghostfolio/common/interfaces'; |
|||
|
|||
import { Inject, Logger } from '@nestjs/common'; |
|||
import { Big } from 'big.js'; |
|||
import { |
|||
addDays, |
|||
eachDayOfInterval, |
|||
endOfDay, |
|||
format, |
|||
isAfter, |
|||
isBefore, |
|||
subDays |
|||
} from 'date-fns'; |
|||
|
|||
import { CurrentRateService } from '../../current-rate.service'; |
|||
import { DateQuery } from '../../interfaces/date-query.interface'; |
|||
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface'; |
|||
import { RoaiPortfolioCalculator } from '../roai/portfolio-calculator'; |
|||
|
|||
export class CPRPortfolioCalculator extends RoaiPortfolioCalculator { |
|||
private holdings: { [date: string]: { [symbol: string]: Big } } = {}; |
|||
private holdingCurrencies: { [symbol: string]: string } = {}; |
|||
|
|||
constructor( |
|||
{ |
|||
accountBalanceItems, |
|||
activities, |
|||
configurationService, |
|||
currency, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService, |
|||
userId, |
|||
filters |
|||
}: { |
|||
accountBalanceItems: HistoricalDataItem[]; |
|||
activities: Activity[]; |
|||
configurationService: ConfigurationService; |
|||
currency: string; |
|||
currentRateService: CurrentRateService; |
|||
exchangeRateDataService: ExchangeRateDataService; |
|||
portfolioSnapshotService: PortfolioSnapshotService; |
|||
redisCacheService: RedisCacheService; |
|||
filters: Filter[]; |
|||
userId: string; |
|||
}, |
|||
@Inject() |
|||
private orderService: OrderService |
|||
) { |
|||
super({ |
|||
accountBalanceItems, |
|||
activities, |
|||
configurationService, |
|||
currency, |
|||
filters, |
|||
currentRateService, |
|||
exchangeRateDataService, |
|||
portfolioSnapshotService, |
|||
redisCacheService, |
|||
userId |
|||
}); |
|||
} |
|||
|
|||
@LogPerformance |
|||
public async getPerformanceWithTimeWeightedReturn({ |
|||
start, |
|||
end |
|||
}: { |
|||
start: Date; |
|||
end: Date; |
|||
}): Promise<{ chart: HistoricalDataItem[] }> { |
|||
const item = await super.getPerformance({ |
|||
end, |
|||
start |
|||
}); |
|||
|
|||
const itemResult = item.chart; |
|||
const dates = itemResult.map((item) => parseDate(item.date)); |
|||
const timeWeighted = await this.getTimeWeightedChartData({ |
|||
dates |
|||
}); |
|||
|
|||
item.chart = itemResult.map((itemInt) => { |
|||
const timeWeightedItem = timeWeighted.find( |
|||
(timeWeightedItem) => timeWeightedItem.date === itemInt.date |
|||
); |
|||
if (timeWeightedItem) { |
|||
itemInt.timeWeightedPerformance = |
|||
timeWeightedItem.netPerformanceInPercentage; |
|||
itemInt.timeWeightedPerformanceWithCurrencyEffect = |
|||
timeWeightedItem.netPerformanceInPercentageWithCurrencyEffect; |
|||
} |
|||
|
|||
return itemInt; |
|||
}); |
|||
return item; |
|||
} |
|||
|
|||
@LogPerformance |
|||
public async getUnfilteredNetWorth(currency: string): Promise<Big> { |
|||
const activities = await this.orderService.getOrders({ |
|||
userId: this.userId, |
|||
userCurrency: currency, |
|||
types: ['BUY', 'SELL', 'STAKE'], |
|||
withExcludedAccounts: true |
|||
}); |
|||
const orders = this.activitiesToPortfolioOrder(activities.activities); |
|||
const start = orders.reduce( |
|||
(date, order) => |
|||
parseDate(date.date).getTime() < parseDate(order.date).getTime() |
|||
? date |
|||
: order, |
|||
{ date: orders[0].date } |
|||
).date; |
|||
|
|||
const end = new Date(Date.now()); |
|||
|
|||
const holdings = await this.getHoldings(orders, parseDate(start), end); |
|||
const marketMap = await this.currentRateService.getValues({ |
|||
dataGatheringItems: this.mapToDataGatheringItems(orders), |
|||
dateQuery: { in: [end] } |
|||
}); |
|||
const endString = format(end, DATE_FORMAT); |
|||
const exchangeRates = await Promise.all( |
|||
Object.keys(holdings[endString]).map(async (holding) => { |
|||
const symbolCurrency = this.getCurrencyFromActivities(orders, holding); |
|||
const exchangeRate = |
|||
await this.exchangeRateDataService.toCurrencyAtDate( |
|||
1, |
|||
symbolCurrency, |
|||
this.currency, |
|||
end |
|||
); |
|||
return { symbolCurrency, exchangeRate }; |
|||
}) |
|||
); |
|||
const currencyRates = exchangeRates.reduce<{ [currency: string]: number }>( |
|||
(all, currency): { [currency: string]: number } => { |
|||
all[currency.symbolCurrency] ??= currency.exchangeRate; |
|||
return all; |
|||
}, |
|||
{} |
|||
); |
|||
|
|||
const totalInvestment = await Object.keys(holdings[endString]).reduce( |
|||
(sum, holding) => { |
|||
if (!holdings[endString][holding].toNumber()) { |
|||
return sum; |
|||
} |
|||
const symbol = marketMap.values.find((m) => m.symbol === holding); |
|||
|
|||
if (symbol?.marketPrice === undefined) { |
|||
Logger.warn( |
|||
`Missing historical market data for ${holding} (${end})`, |
|||
'PortfolioCalculator' |
|||
); |
|||
return sum; |
|||
} else { |
|||
const symbolCurrency = this.getCurrency(holding); |
|||
const price = new Big(currencyRates[symbolCurrency]).mul( |
|||
symbol.marketPrice |
|||
); |
|||
return sum.plus(new Big(price).mul(holdings[endString][holding])); |
|||
} |
|||
}, |
|||
new Big(0) |
|||
); |
|||
return totalInvestment; |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected async getTimeWeightedChartData({ |
|||
dates |
|||
}: { |
|||
dates?: Date[]; |
|||
}): Promise<HistoricalDataItem[]> { |
|||
dates = dates.sort((a, b) => a.getTime() - b.getTime()); |
|||
const start = dates[0]; |
|||
const end = dates[dates.length - 1]; |
|||
const marketMapTask = this.computeMarketMap({ |
|||
gte: start, |
|||
lt: addDays(end, 1) |
|||
}); |
|||
const timelineHoldings = await this.getHoldings( |
|||
this.activities, |
|||
start, |
|||
end |
|||
); |
|||
|
|||
const data: HistoricalDataItem[] = []; |
|||
const startString = format(start, DATE_FORMAT); |
|||
|
|||
data.push({ |
|||
date: startString, |
|||
netPerformanceInPercentage: 0, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0, |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 0, |
|||
netPerformanceWithCurrencyEffect: 0, |
|||
netWorth: 0, |
|||
totalAccountBalance: 0, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0, |
|||
value: 0, |
|||
valueWithCurrencyEffect: 0 |
|||
}); |
|||
|
|||
this.marketMap = await marketMapTask; |
|||
|
|||
let totalInvestment = Object.keys(timelineHoldings[startString]).reduce( |
|||
(sum, holding) => { |
|||
return sum.plus( |
|||
timelineHoldings[startString][holding].mul( |
|||
this.marketMap[startString][holding] ?? new Big(0) |
|||
) |
|||
); |
|||
}, |
|||
new Big(0) |
|||
); |
|||
|
|||
let previousNetPerformanceInPercentage = new Big(0); |
|||
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0); |
|||
|
|||
for (let i = 1; i < dates.length; i++) { |
|||
const date = format(dates[i], DATE_FORMAT); |
|||
const previousDate = format(dates[i - 1], DATE_FORMAT); |
|||
const holdings = timelineHoldings[previousDate]; |
|||
let newTotalInvestment = new Big(0); |
|||
let netPerformanceInPercentage = new Big(0); |
|||
let netPerformanceInPercentageWithCurrencyEffect = new Big(0); |
|||
|
|||
for (const holding of Object.keys(holdings)) { |
|||
({ |
|||
netPerformanceInPercentage, |
|||
netPerformanceInPercentageWithCurrencyEffect, |
|||
newTotalInvestment |
|||
} = await this.handleSingleHolding( |
|||
previousDate, |
|||
holding, |
|||
date, |
|||
totalInvestment, |
|||
timelineHoldings, |
|||
netPerformanceInPercentage, |
|||
netPerformanceInPercentageWithCurrencyEffect, |
|||
newTotalInvestment |
|||
)); |
|||
} |
|||
totalInvestment = newTotalInvestment; |
|||
|
|||
previousNetPerformanceInPercentage = previousNetPerformanceInPercentage |
|||
.plus(1) |
|||
.mul(netPerformanceInPercentage.plus(1)) |
|||
.minus(1); |
|||
previousNetPerformanceInPercentageWithCurrencyEffect = |
|||
previousNetPerformanceInPercentageWithCurrencyEffect |
|||
.plus(1) |
|||
.mul(netPerformanceInPercentageWithCurrencyEffect.plus(1)) |
|||
.minus(1); |
|||
|
|||
data.push({ |
|||
date, |
|||
netPerformanceInPercentage: previousNetPerformanceInPercentage |
|||
.mul(100) |
|||
.toNumber(), |
|||
netPerformanceInPercentageWithCurrencyEffect: |
|||
previousNetPerformanceInPercentageWithCurrencyEffect |
|||
.mul(100) |
|||
.toNumber() |
|||
}); |
|||
} |
|||
|
|||
return data; |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected async handleSingleHolding( |
|||
previousDate: string, |
|||
holding: string, |
|||
date: string, |
|||
totalInvestment: Big, |
|||
timelineHoldings: { [date: string]: { [symbol: string]: Big } }, |
|||
netPerformanceInPercentage: Big, |
|||
netPerformanceInPercentageWithCurrencyEffect: Big, |
|||
newTotalInvestment: Big |
|||
) { |
|||
const previousPrice = |
|||
Object.keys(this.marketMap).indexOf(previousDate) > 0 |
|||
? this.marketMap[previousDate][holding] |
|||
: undefined; |
|||
const priceDictionary = this.marketMap[date]; |
|||
let currentPrice = |
|||
priceDictionary !== undefined ? priceDictionary[holding] : previousPrice; |
|||
currentPrice ??= previousPrice; |
|||
const previousHolding = timelineHoldings[previousDate][holding]; |
|||
|
|||
const priceInBaseCurrency = currentPrice |
|||
? new Big( |
|||
await this.exchangeRateDataService.toCurrencyAtDate( |
|||
currentPrice?.toNumber() ?? 0, |
|||
this.getCurrency(holding), |
|||
this.currency, |
|||
parseDate(date) |
|||
) |
|||
) |
|||
: new Big(0); |
|||
|
|||
if (previousHolding.eq(0)) { |
|||
return { |
|||
netPerformanceInPercentage: netPerformanceInPercentage, |
|||
netPerformanceInPercentageWithCurrencyEffect: |
|||
netPerformanceInPercentageWithCurrencyEffect, |
|||
newTotalInvestment: newTotalInvestment.plus( |
|||
timelineHoldings[date][holding].mul(priceInBaseCurrency) |
|||
) |
|||
}; |
|||
} |
|||
if (previousPrice === undefined || currentPrice === undefined) { |
|||
Logger.warn( |
|||
`Missing historical market data for ${holding} (${previousPrice === undefined ? previousDate : date}})`, |
|||
'PortfolioCalculator' |
|||
); |
|||
return { |
|||
netPerformanceInPercentage: netPerformanceInPercentage, |
|||
netPerformanceInPercentageWithCurrencyEffect: |
|||
netPerformanceInPercentageWithCurrencyEffect, |
|||
newTotalInvestment: newTotalInvestment.plus( |
|||
timelineHoldings[date][holding].mul(priceInBaseCurrency) |
|||
) |
|||
}; |
|||
} |
|||
const previousPriceInBaseCurrency = previousPrice |
|||
? new Big( |
|||
await this.exchangeRateDataService.toCurrencyAtDate( |
|||
previousPrice?.toNumber() ?? 0, |
|||
this.getCurrency(holding), |
|||
this.currency, |
|||
parseDate(previousDate) |
|||
) |
|||
) |
|||
: new Big(0); |
|||
const portfolioWeight = totalInvestment.toNumber() |
|||
? previousHolding.mul(previousPriceInBaseCurrency).div(totalInvestment) |
|||
: new Big(0); |
|||
|
|||
netPerformanceInPercentage = netPerformanceInPercentage.plus( |
|||
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight) |
|||
); |
|||
|
|||
netPerformanceInPercentageWithCurrencyEffect = |
|||
netPerformanceInPercentageWithCurrencyEffect.plus( |
|||
priceInBaseCurrency |
|||
.div(previousPriceInBaseCurrency) |
|||
.minus(1) |
|||
.mul(portfolioWeight) |
|||
); |
|||
|
|||
newTotalInvestment = newTotalInvestment.plus( |
|||
timelineHoldings[date][holding].mul(priceInBaseCurrency) |
|||
); |
|||
return { |
|||
netPerformanceInPercentage, |
|||
netPerformanceInPercentageWithCurrencyEffect, |
|||
newTotalInvestment |
|||
}; |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected getCurrency(symbol: string) { |
|||
return this.getCurrencyFromActivities(this.activities, symbol); |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected getCurrencyFromActivities( |
|||
activities: PortfolioOrder[], |
|||
symbol: string |
|||
) { |
|||
if (!this.holdingCurrencies[symbol]) { |
|||
this.holdingCurrencies[symbol] = activities.find( |
|||
(a) => a.SymbolProfile.symbol === symbol |
|||
).SymbolProfile.currency; |
|||
} |
|||
|
|||
return this.holdingCurrencies[symbol]; |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected async getHoldings( |
|||
activities: PortfolioOrder[], |
|||
start: Date, |
|||
end: Date |
|||
) { |
|||
if ( |
|||
this.holdings && |
|||
Object.keys(this.holdings).some((h) => |
|||
isAfter(parseDate(h), subDays(end, 1)) |
|||
) && |
|||
Object.keys(this.holdings).some((h) => |
|||
isBefore(parseDate(h), addDays(start, 1)) |
|||
) |
|||
) { |
|||
return this.holdings; |
|||
} |
|||
|
|||
this.computeHoldings(activities, start, end); |
|||
return this.holdings; |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected async computeHoldings( |
|||
activities: PortfolioOrder[], |
|||
start: Date, |
|||
end: Date |
|||
) { |
|||
const investmentByDate = this.getInvestmentByDate(activities); |
|||
this.calculateHoldings(investmentByDate, start, end); |
|||
} |
|||
|
|||
private calculateHoldings( |
|||
investmentByDate: { [date: string]: PortfolioOrder[] }, |
|||
start: Date, |
|||
end: Date |
|||
) { |
|||
const transactionDates = Object.keys(investmentByDate).sort(); |
|||
const dates = eachDayOfInterval({ start, end }, { step: 1 }) |
|||
.map((date) => { |
|||
return resetHours(date); |
|||
}) |
|||
.sort((a, b) => a.getTime() - b.getTime()); |
|||
const currentHoldings: { [date: string]: { [symbol: string]: Big } } = {}; |
|||
|
|||
this.calculateInitialHoldings(investmentByDate, start, currentHoldings); |
|||
|
|||
for (let i = 1; i < dates.length; i++) { |
|||
const dateString = format(dates[i], DATE_FORMAT); |
|||
const previousDateString = format(dates[i - 1], DATE_FORMAT); |
|||
if (transactionDates.some((d) => d === dateString)) { |
|||
const holdings = { ...currentHoldings[previousDateString] }; |
|||
investmentByDate[dateString].forEach((trade) => { |
|||
holdings[trade.SymbolProfile.symbol] ??= new Big(0); |
|||
holdings[trade.SymbolProfile.symbol] = holdings[ |
|||
trade.SymbolProfile.symbol |
|||
].plus(trade.quantity.mul(getFactor(trade.type))); |
|||
}); |
|||
currentHoldings[dateString] = holdings; |
|||
} else { |
|||
currentHoldings[dateString] = currentHoldings[previousDateString]; |
|||
} |
|||
} |
|||
|
|||
this.holdings = currentHoldings; |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected calculateInitialHoldings( |
|||
investmentByDate: { [date: string]: PortfolioOrder[] }, |
|||
start: Date, |
|||
currentHoldings: { [date: string]: { [symbol: string]: Big } } |
|||
) { |
|||
const preRangeTrades = Object.keys(investmentByDate) |
|||
.filter((date) => resetHours(new Date(date)) <= start) |
|||
.map((date) => investmentByDate[date]) |
|||
.reduce((a, b) => a.concat(b), []) |
|||
.reduce((groupBySymbol, trade) => { |
|||
if (!groupBySymbol[trade.SymbolProfile.symbol]) { |
|||
groupBySymbol[trade.SymbolProfile.symbol] = []; |
|||
} |
|||
|
|||
groupBySymbol[trade.SymbolProfile.symbol].push(trade); |
|||
|
|||
return groupBySymbol; |
|||
}, {}); |
|||
|
|||
currentHoldings[format(start, DATE_FORMAT)] = {}; |
|||
|
|||
for (const symbol of Object.keys(preRangeTrades)) { |
|||
const trades: PortfolioOrder[] = preRangeTrades[symbol]; |
|||
const startQuantity = trades.reduce((sum, trade) => { |
|||
return sum.plus(trade.quantity.mul(getFactor(trade.type))); |
|||
}, new Big(0)); |
|||
currentHoldings[format(start, DATE_FORMAT)][symbol] = startQuantity; |
|||
} |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected getInvestmentByDate(activities: PortfolioOrder[]): { |
|||
[date: string]: PortfolioOrder[]; |
|||
} { |
|||
return activities.reduce((groupedByDate, order) => { |
|||
if (!groupedByDate[order.date]) { |
|||
groupedByDate[order.date] = []; |
|||
} |
|||
|
|||
groupedByDate[order.date].push(order); |
|||
|
|||
return groupedByDate; |
|||
}, {}); |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected mapToDataGatheringItems( |
|||
orders: PortfolioOrder[] |
|||
): IDataGatheringItem[] { |
|||
return orders |
|||
.map((activity) => { |
|||
return { |
|||
symbol: activity.SymbolProfile.symbol, |
|||
dataSource: activity.SymbolProfile.dataSource |
|||
}; |
|||
}) |
|||
.filter( |
|||
(gathering, i, arr) => |
|||
arr.findIndex((t) => t.symbol === gathering.symbol) === i |
|||
); |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected async computeMarketMap(dateQuery: DateQuery): Promise<{ |
|||
[date: string]: { [symbol: string]: Big }; |
|||
}> { |
|||
const dataGatheringItems: IDataGatheringItem[] = |
|||
this.mapToDataGatheringItems(this.activities); |
|||
const { values: marketSymbols } = await this.currentRateService.getValues({ |
|||
dataGatheringItems, |
|||
dateQuery |
|||
}); |
|||
|
|||
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 |
|||
); |
|||
} |
|||
} |
|||
|
|||
return marketSymbolMap; |
|||
} |
|||
|
|||
@LogPerformance |
|||
protected activitiesToPortfolioOrder( |
|||
activities: Activity[] |
|||
): PortfolioOrder[] { |
|||
return activities |
|||
.map( |
|||
({ |
|||
date, |
|||
fee, |
|||
quantity, |
|||
SymbolProfile, |
|||
tags = [], |
|||
type, |
|||
unitPrice |
|||
}) => { |
|||
if (isAfter(date, new Date(Date.now()))) { |
|||
// Adapt date to today if activity is in future (e.g. liability)
|
|||
// to include it in the interval
|
|||
date = endOfDay(new Date(Date.now())); |
|||
} |
|||
|
|||
return { |
|||
SymbolProfile, |
|||
tags, |
|||
type, |
|||
date: format(date, DATE_FORMAT), |
|||
fee: new Big(fee), |
|||
quantity: new Big(quantity), |
|||
unitPrice: new Big(unitPrice) |
|||
}; |
|||
} |
|||
) |
|||
.sort((a, b) => { |
|||
return a.date?.localeCompare(b.date); |
|||
}); |
|||
} |
|||
} |
@ -0,0 +1,29 @@ |
|||
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator'; |
|||
import { |
|||
AssetProfileIdentifier, |
|||
SymbolMetrics |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { PortfolioSnapshot } from '@ghostfolio/common/models'; |
|||
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
|||
|
|||
export class RoiPortfolioCalculator extends PortfolioCalculator { |
|||
protected calculateOverallPerformance(): PortfolioSnapshot { |
|||
throw new Error('Method not implemented.'); |
|||
} |
|||
|
|||
protected getPerformanceCalculationType() { |
|||
return PerformanceCalculationType.ROI; |
|||
} |
|||
|
|||
protected getSymbolMetrics({}: { |
|||
end: Date; |
|||
exchangeRates: { [dateString: string]: number }; |
|||
marketSymbolMap: { |
|||
[date: string]: { [symbol: string]: Big }; |
|||
}; |
|||
start: Date; |
|||
step?: number; |
|||
} & AssetProfileIdentifier): SymbolMetrics { |
|||
throw new Error('Method not implemented.'); |
|||
} |
|||
} |
File diff suppressed because it is too large
@ -0,0 +1,7 @@ |
|||
export class AssetProfileDelistedError extends Error { |
|||
public constructor(message: string) { |
|||
super(message); |
|||
|
|||
this.name = 'AssetProfileDelistedError'; |
|||
} |
|||
} |
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
File diff suppressed because it is too large
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue