mirror of https://github.com/ghostfolio/ghostfolio
5 changed files with 372 additions and 5 deletions
@ -0,0 +1,298 @@ |
|||
import { getFactor } from '@ghostfolio/api/helper/portfolio.helper'; |
|||
import { parseDate, resetHours } from '@ghostfolio/common/helper'; |
|||
import { |
|||
HistoricalDataItem, |
|||
SymbolMetrics, |
|||
UniqueAsset |
|||
} from '@ghostfolio/common/interfaces'; |
|||
import { PortfolioSnapshot, TimelinePosition } from '@ghostfolio/common/models'; |
|||
|
|||
import { Big } from 'big.js'; |
|||
import { addDays, eachDayOfInterval } from 'date-fns'; |
|||
|
|||
import { PortfolioOrder } from '../../interfaces/portfolio-order.interface'; |
|||
import { TWRPortfolioCalculator } from '../twr/portfolio-calculator'; |
|||
|
|||
export class CPRPortfolioCalculator extends TWRPortfolioCalculator { |
|||
private holdings: { [date: string]: { [symbol: string]: Big } } = {}; |
|||
private holdingCurrencies: { [symbol: string]: string } = {}; |
|||
|
|||
protected calculateOverallPerformance( |
|||
positions: TimelinePosition[] |
|||
): PortfolioSnapshot { |
|||
return super.calculateOverallPerformance(positions); |
|||
} |
|||
|
|||
protected getSymbolMetrics({ |
|||
dataSource, |
|||
end, |
|||
exchangeRates, |
|||
isChartMode = false, |
|||
marketSymbolMap, |
|||
start, |
|||
step = 1, |
|||
symbol |
|||
}: { |
|||
end: Date; |
|||
exchangeRates: { [dateString: string]: number }; |
|||
isChartMode?: boolean; |
|||
marketSymbolMap: { |
|||
[date: string]: { [symbol: string]: Big }; |
|||
}; |
|||
start: Date; |
|||
step?: number; |
|||
} & UniqueAsset): SymbolMetrics { |
|||
return super.getSymbolMetrics({ |
|||
dataSource, |
|||
end, |
|||
exchangeRates, |
|||
isChartMode, |
|||
marketSymbolMap, |
|||
start, |
|||
step, |
|||
symbol |
|||
}); |
|||
} |
|||
|
|||
public override async getChartData({ |
|||
end = new Date(Date.now()), |
|||
start, |
|||
step = 1 |
|||
}: { |
|||
end?: Date; |
|||
start: Date; |
|||
step?: number; |
|||
}): Promise<HistoricalDataItem[]> { |
|||
const timelineHoldings = this.getHoldings(start, end); |
|||
const calculationDates = Object.keys(timelineHoldings) |
|||
.filter((date) => { |
|||
let parsed = parseDate(date); |
|||
parsed >= start && parsed <= end; |
|||
}) |
|||
.sort(); |
|||
let data: HistoricalDataItem[] = []; |
|||
|
|||
data.push({ |
|||
date: start.toDateString(), |
|||
netPerformanceInPercentage: 0, |
|||
netPerformanceInPercentageWithCurrencyEffect: 0, |
|||
investmentValueWithCurrencyEffect: 0, |
|||
netPerformance: 0, |
|||
netPerformanceWithCurrencyEffect: 0, |
|||
netWorth: 0, |
|||
totalAccountBalance: 0, |
|||
totalInvestment: 0, |
|||
totalInvestmentValueWithCurrencyEffect: 0, |
|||
value: 0, |
|||
valueWithCurrencyEffect: 0 |
|||
}); |
|||
|
|||
let totalInvestment = Object.keys( |
|||
timelineHoldings[start.toDateString()] |
|||
).reduce((sum, holding) => { |
|||
return sum.plus( |
|||
timelineHoldings[start.toDateString()][holding].mul( |
|||
this.marketMap[start.toDateString()][holding] |
|||
) |
|||
); |
|||
}, new Big(0)); |
|||
|
|||
let previousNetPerformanceInPercentage = new Big(0); |
|||
let previousNetPerformanceInPercentageWithCurrencyEffect = new Big(0); |
|||
|
|||
for (let i = 1; i < calculationDates.length; i++) { |
|||
const date = calculationDates[i]; |
|||
const previousDate = calculationDates[i - 1]; |
|||
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.mul( |
|||
netPerformanceInPercentage.plus(1) |
|||
); |
|||
previousNetPerformanceInPercentageWithCurrencyEffect = |
|||
previousNetPerformanceInPercentageWithCurrencyEffect.mul( |
|||
netPerformanceInPercentageWithCurrencyEffect.plus(1) |
|||
); |
|||
|
|||
data.push({ |
|||
date, |
|||
netPerformanceInPercentage: |
|||
previousNetPerformanceInPercentage.toNumber(), |
|||
netPerformanceInPercentageWithCurrencyEffect: |
|||
previousNetPerformanceInPercentageWithCurrencyEffect.toNumber() |
|||
}); |
|||
} |
|||
|
|||
return data; |
|||
} |
|||
|
|||
private async handleSingleHolding( |
|||
previousDate: string, |
|||
holding: string, |
|||
date: string, |
|||
totalInvestment, |
|||
timelineHoldings: { [date: string]: { [symbol: string]: Big } }, |
|||
netPerformanceInPercentage, |
|||
netPerformanceInPercentageWithCurrencyEffect, |
|||
newTotalInvestment |
|||
) { |
|||
const previousPrice = this.marketMap[previousDate][holding]; |
|||
const currentPrice = this.marketMap[date][holding]; |
|||
const previousPriceInBaseCurrency = |
|||
await this.exchangeRateDataService.toCurrencyAtDate( |
|||
previousPrice.toNumber(), |
|||
this.getCurrency(holding), |
|||
this.currency, |
|||
parseDate(previousDate) |
|||
); |
|||
const portfolioWeight = totalInvestment |
|||
? timelineHoldings[previousDate][holding] |
|||
.mul(previousPriceInBaseCurrency) |
|||
.div(totalInvestment) |
|||
: 0; |
|||
|
|||
netPerformanceInPercentage = netPerformanceInPercentage.plus( |
|||
currentPrice.div(previousPrice).minus(1).mul(portfolioWeight) |
|||
); |
|||
|
|||
const priceInBaseCurrency = |
|||
await this.exchangeRateDataService.toCurrencyAtDate( |
|||
currentPrice.toNumber(), |
|||
this.getCurrency(holding), |
|||
this.currency, |
|||
parseDate(date) |
|||
); |
|||
netPerformanceInPercentageWithCurrencyEffect = |
|||
netPerformanceInPercentageWithCurrencyEffect.plus( |
|||
new Big(priceInBaseCurrency) |
|||
.div(new Big(previousPriceInBaseCurrency)) |
|||
.minus(1) |
|||
.mul(portfolioWeight) |
|||
); |
|||
|
|||
newTotalInvestment = newTotalInvestment.plus( |
|||
timelineHoldings[date][holding].mul(priceInBaseCurrency) |
|||
); |
|||
return { |
|||
netPerformanceInPercentage, |
|||
netPerformanceInPercentageWithCurrencyEffect, |
|||
newTotalInvestment |
|||
}; |
|||
} |
|||
|
|||
private getCurrency(symbol: string) { |
|||
if (!this.holdingCurrencies[symbol]) { |
|||
this.holdingCurrencies[symbol] = this.activities.find( |
|||
(a) => a.SymbolProfile.symbol === symbol |
|||
).SymbolProfile.currency; |
|||
} |
|||
|
|||
return this.holdingCurrencies[symbol]; |
|||
} |
|||
|
|||
private getHoldings(start: Date, end: Date) { |
|||
if ( |
|||
this.holdings && |
|||
Object.keys(this.holdings).some((h) => parseDate(h) >= end) && |
|||
Object.keys(this.holdings).some((h) => parseDate(h) <= start) |
|||
) { |
|||
return this.holdings; |
|||
} |
|||
|
|||
this.computeHoldings(start, end); |
|||
return this.holdings; |
|||
} |
|||
|
|||
private computeHoldings(start: Date, end: Date) { |
|||
const investmentByDate = this.getInvestmentByDate(); |
|||
const transactionDates = Object.keys(investmentByDate).sort(); |
|||
let dates = eachDayOfInterval({ start, end }, { step: 1 }) |
|||
.map((date) => { |
|||
return resetHours(date); |
|||
}) |
|||
.sort((a, b) => a.getTime() - b.getTime()); |
|||
let currentHoldings: { [date: string]: { [symbol: string]: Big } } = {}; |
|||
|
|||
this.calculateInitialHoldings(investmentByDate, start, currentHoldings); |
|||
|
|||
for (let i = 1; i < dates.length; i++) { |
|||
const date = dates[i]; |
|||
const previousDate = dates[i - 1]; |
|||
if (transactionDates.some((d) => d === date.toDateString())) { |
|||
let holdings = { ...currentHoldings[previousDate.toDateString()] }; |
|||
investmentByDate[date.toDateString()].forEach((trade) => { |
|||
holdings[trade.SymbolProfile.symbol] = holdings[ |
|||
trade.SymbolProfile.symbol |
|||
].plus(trade.quantity.mul(getFactor(trade.type))); |
|||
}); |
|||
currentHoldings[date.toDateString()] = holdings; |
|||
} else { |
|||
currentHoldings[date.toDateString()] = |
|||
currentHoldings[previousDate.toDateString()]; |
|||
} |
|||
} |
|||
|
|||
this.holdings = currentHoldings; |
|||
} |
|||
|
|||
private 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; |
|||
}, {}); |
|||
|
|||
for (const symbol of Object.keys(preRangeTrades)) { |
|||
const trades: PortfolioOrder[] = preRangeTrades[symbol]; |
|||
let startQuantity = trades.reduce((sum, trade) => { |
|||
return sum.plus(trade.quantity.mul(getFactor(trade.type))); |
|||
}, new Big(0)); |
|||
currentHoldings[start.toDateString()][symbol] = startQuantity; |
|||
} |
|||
} |
|||
|
|||
private getInvestmentByDate(): { [date: string]: PortfolioOrder[] } { |
|||
return this.activities.reduce((groupedByDate, order) => { |
|||
if (!groupedByDate[order.date]) { |
|||
groupedByDate[order.date] = []; |
|||
} |
|||
|
|||
groupedByDate[order.date].push(order); |
|||
|
|||
return groupedByDate; |
|||
}, {}); |
|||
} |
|||
} |
Loading…
Reference in new issue