added calcs
This commit is contained in:
parent
ef12c9d308
commit
7886b7cfa5
10 changed files with 4331 additions and 0 deletions
562
libs/utils/src/calculations/performance-metrics.ts
Normal file
562
libs/utils/src/calculations/performance-metrics.ts
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
/**
|
||||
* Performance Metrics and Analysis
|
||||
* Comprehensive performance measurement tools for trading strategies and portfolios
|
||||
*/
|
||||
|
||||
import { PortfolioMetrics } from './index';
|
||||
|
||||
export interface TradePerformance {
|
||||
totalTrades: number;
|
||||
winningTrades: number;
|
||||
losingTrades: number;
|
||||
winRate: number;
|
||||
averageWin: number;
|
||||
averageLoss: number;
|
||||
largestWin: number;
|
||||
largestLoss: number;
|
||||
profitFactor: number;
|
||||
expectancy: number;
|
||||
averageTradeReturn: number;
|
||||
consecutiveWins: number;
|
||||
consecutiveLosses: number;
|
||||
}
|
||||
|
||||
export interface DrawdownAnalysis {
|
||||
maxDrawdown: number;
|
||||
maxDrawdownDuration: number;
|
||||
averageDrawdown: number;
|
||||
drawdownPeriods: Array<{
|
||||
start: Date;
|
||||
end: Date;
|
||||
duration: number;
|
||||
magnitude: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface ReturnAnalysis {
|
||||
totalReturn: number;
|
||||
annualizedReturn: number;
|
||||
compoundAnnualGrowthRate: number;
|
||||
volatility: number;
|
||||
annualizedVolatility: number;
|
||||
skewness: number;
|
||||
kurtosis: number;
|
||||
bestMonth: number;
|
||||
worstMonth: number;
|
||||
positiveMonths: number;
|
||||
negativeMonths: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate comprehensive trade performance metrics
|
||||
*/
|
||||
export function analyzeTradePerformance(trades: Array<{ pnl: number; date: Date }>): TradePerformance {
|
||||
if (trades.length === 0) {
|
||||
return {
|
||||
totalTrades: 0,
|
||||
winningTrades: 0,
|
||||
losingTrades: 0,
|
||||
winRate: 0,
|
||||
averageWin: 0,
|
||||
averageLoss: 0,
|
||||
largestWin: 0,
|
||||
largestLoss: 0,
|
||||
profitFactor: 0,
|
||||
expectancy: 0,
|
||||
averageTradeReturn: 0,
|
||||
consecutiveWins: 0,
|
||||
consecutiveLosses: 0
|
||||
};
|
||||
}
|
||||
|
||||
const winningTrades = trades.filter(trade => trade.pnl > 0);
|
||||
const losingTrades = trades.filter(trade => trade.pnl < 0);
|
||||
|
||||
const totalWins = winningTrades.reduce((sum, trade) => sum + trade.pnl, 0);
|
||||
const totalLosses = Math.abs(losingTrades.reduce((sum, trade) => sum + trade.pnl, 0));
|
||||
|
||||
const averageWin = winningTrades.length > 0 ? totalWins / winningTrades.length : 0;
|
||||
const averageLoss = losingTrades.length > 0 ? totalLosses / losingTrades.length : 0;
|
||||
|
||||
const largestWin = winningTrades.length > 0 ? Math.max(...winningTrades.map(t => t.pnl)) : 0;
|
||||
const largestLoss = losingTrades.length > 0 ? Math.min(...losingTrades.map(t => t.pnl)) : 0;
|
||||
|
||||
const profitFactor = totalLosses > 0 ? totalWins / totalLosses : totalWins > 0 ? Infinity : 0;
|
||||
const winRate = winningTrades.length / trades.length;
|
||||
const expectancy = (winRate * averageWin) - ((1 - winRate) * averageLoss);
|
||||
|
||||
const totalPnL = trades.reduce((sum, trade) => sum + trade.pnl, 0);
|
||||
const averageTradeReturn = totalPnL / trades.length;
|
||||
|
||||
// Calculate consecutive wins/losses
|
||||
let consecutiveWins = 0;
|
||||
let consecutiveLosses = 0;
|
||||
let currentWinStreak = 0;
|
||||
let currentLossStreak = 0;
|
||||
|
||||
for (const trade of trades) {
|
||||
if (trade.pnl > 0) {
|
||||
currentWinStreak++;
|
||||
currentLossStreak = 0;
|
||||
consecutiveWins = Math.max(consecutiveWins, currentWinStreak);
|
||||
} else if (trade.pnl < 0) {
|
||||
currentLossStreak++;
|
||||
currentWinStreak = 0;
|
||||
consecutiveLosses = Math.max(consecutiveLosses, currentLossStreak);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalTrades: trades.length,
|
||||
winningTrades: winningTrades.length,
|
||||
losingTrades: losingTrades.length,
|
||||
winRate,
|
||||
averageWin,
|
||||
averageLoss,
|
||||
largestWin,
|
||||
largestLoss,
|
||||
profitFactor,
|
||||
expectancy,
|
||||
averageTradeReturn,
|
||||
consecutiveWins,
|
||||
consecutiveLosses
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze drawdown characteristics
|
||||
*/
|
||||
export function analyzeDrawdowns(equityCurve: Array<{ value: number; date: Date }>): DrawdownAnalysis {
|
||||
if (equityCurve.length < 2) {
|
||||
return {
|
||||
maxDrawdown: 0,
|
||||
maxDrawdownDuration: 0,
|
||||
averageDrawdown: 0,
|
||||
drawdownPeriods: []
|
||||
};
|
||||
}
|
||||
|
||||
let peak = equityCurve[0].value;
|
||||
let peakDate = equityCurve[0].date;
|
||||
let maxDrawdown = 0;
|
||||
let maxDrawdownDuration = 0;
|
||||
|
||||
const drawdownPeriods: Array<{
|
||||
start: Date;
|
||||
end: Date;
|
||||
duration: number;
|
||||
magnitude: number;
|
||||
}> = [];
|
||||
|
||||
let currentDrawdownStart: Date | null = null;
|
||||
let drawdowns: number[] = [];
|
||||
|
||||
for (let i = 1; i < equityCurve.length; i++) {
|
||||
const current = equityCurve[i];
|
||||
|
||||
if (current.value > peak) {
|
||||
// New peak - end any current drawdown
|
||||
if (currentDrawdownStart) {
|
||||
const drawdownMagnitude = (peak - equityCurve[i - 1].value) / peak;
|
||||
const duration = Math.floor((equityCurve[i - 1].date.getTime() - currentDrawdownStart.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
drawdownPeriods.push({
|
||||
start: currentDrawdownStart,
|
||||
end: equityCurve[i - 1].date,
|
||||
duration,
|
||||
magnitude: drawdownMagnitude
|
||||
});
|
||||
|
||||
drawdowns.push(drawdownMagnitude);
|
||||
maxDrawdownDuration = Math.max(maxDrawdownDuration, duration);
|
||||
currentDrawdownStart = null;
|
||||
}
|
||||
|
||||
peak = current.value;
|
||||
peakDate = current.date;
|
||||
} else {
|
||||
// In drawdown
|
||||
if (!currentDrawdownStart) {
|
||||
currentDrawdownStart = peakDate;
|
||||
}
|
||||
|
||||
const drawdown = (peak - current.value) / peak;
|
||||
maxDrawdown = Math.max(maxDrawdown, drawdown);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle ongoing drawdown
|
||||
if (currentDrawdownStart) {
|
||||
const lastPoint = equityCurve[equityCurve.length - 1];
|
||||
const drawdownMagnitude = (peak - lastPoint.value) / peak;
|
||||
const duration = Math.floor((lastPoint.date.getTime() - currentDrawdownStart.getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
drawdownPeriods.push({
|
||||
start: currentDrawdownStart,
|
||||
end: lastPoint.date,
|
||||
duration,
|
||||
magnitude: drawdownMagnitude
|
||||
});
|
||||
|
||||
drawdowns.push(drawdownMagnitude);
|
||||
maxDrawdownDuration = Math.max(maxDrawdownDuration, duration);
|
||||
}
|
||||
|
||||
const averageDrawdown = drawdowns.length > 0 ? drawdowns.reduce((sum, dd) => sum + dd, 0) / drawdowns.length : 0;
|
||||
|
||||
return {
|
||||
maxDrawdown,
|
||||
maxDrawdownDuration,
|
||||
averageDrawdown,
|
||||
drawdownPeriods
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze return characteristics
|
||||
*/
|
||||
export function analyzeReturns(
|
||||
returns: Array<{ return: number; date: Date }>,
|
||||
periodsPerYear: number = 252
|
||||
): ReturnAnalysis {
|
||||
if (returns.length === 0) {
|
||||
return {
|
||||
totalReturn: 0,
|
||||
annualizedReturn: 0,
|
||||
compoundAnnualGrowthRate: 0,
|
||||
volatility: 0,
|
||||
annualizedVolatility: 0,
|
||||
skewness: 0,
|
||||
kurtosis: 0,
|
||||
bestMonth: 0,
|
||||
worstMonth: 0,
|
||||
positiveMonths: 0,
|
||||
negativeMonths: 0
|
||||
};
|
||||
}
|
||||
|
||||
const returnValues = returns.map(r => r.return);
|
||||
|
||||
// Calculate basic statistics
|
||||
const totalReturn = returnValues.reduce((product, ret) => product * (1 + ret), 1) - 1;
|
||||
const averageReturn = returnValues.reduce((sum, ret) => sum + ret, 0) / returnValues.length;
|
||||
const annualizedReturn = Math.pow(1 + averageReturn, periodsPerYear) - 1;
|
||||
|
||||
// Calculate CAGR
|
||||
const years = returns.length / periodsPerYear;
|
||||
const cagr = years > 0 ? Math.pow(1 + totalReturn, 1 / years) - 1 : 0;
|
||||
|
||||
// Calculate volatility
|
||||
const variance = returnValues.reduce((sum, ret) => sum + Math.pow(ret - averageReturn, 2), 0) / (returnValues.length - 1);
|
||||
const volatility = Math.sqrt(variance);
|
||||
const annualizedVolatility = volatility * Math.sqrt(periodsPerYear);
|
||||
|
||||
// Calculate skewness and kurtosis
|
||||
const skewness = calculateSkewness(returnValues);
|
||||
const kurtosis = calculateKurtosis(returnValues);
|
||||
|
||||
// Monthly analysis
|
||||
const monthlyReturns = aggregateMonthlyReturns(returns);
|
||||
const bestMonth = monthlyReturns.length > 0 ? Math.max(...monthlyReturns) : 0;
|
||||
const worstMonth = monthlyReturns.length > 0 ? Math.min(...monthlyReturns) : 0;
|
||||
const positiveMonths = monthlyReturns.filter(ret => ret > 0).length;
|
||||
const negativeMonths = monthlyReturns.filter(ret => ret < 0).length;
|
||||
|
||||
return {
|
||||
totalReturn,
|
||||
annualizedReturn,
|
||||
compoundAnnualGrowthRate: cagr,
|
||||
volatility,
|
||||
annualizedVolatility,
|
||||
skewness,
|
||||
kurtosis,
|
||||
bestMonth,
|
||||
worstMonth,
|
||||
positiveMonths,
|
||||
negativeMonths
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate rolling performance metrics
|
||||
*/
|
||||
export function calculateRollingMetrics(
|
||||
returns: number[],
|
||||
windowSize: number,
|
||||
metricType: 'sharpe' | 'volatility' | 'return' = 'sharpe'
|
||||
): number[] {
|
||||
if (returns.length < windowSize) return [];
|
||||
|
||||
const rollingMetrics: number[] = [];
|
||||
|
||||
for (let i = windowSize - 1; i < returns.length; i++) {
|
||||
const window = returns.slice(i - windowSize + 1, i + 1);
|
||||
|
||||
switch (metricType) {
|
||||
case 'sharpe':
|
||||
rollingMetrics.push(calculateSharpeRatio(window));
|
||||
break;
|
||||
case 'volatility':
|
||||
rollingMetrics.push(calculateVolatility(window));
|
||||
break;
|
||||
case 'return':
|
||||
const avgReturn = window.reduce((sum, ret) => sum + ret, 0) / window.length;
|
||||
rollingMetrics.push(avgReturn);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return rollingMetrics;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate performance attribution
|
||||
*/
|
||||
export function strategyPerformanceAttribution(
|
||||
portfolioReturns: number[],
|
||||
benchmarkReturns: number[],
|
||||
sectorWeights: number[],
|
||||
sectorReturns: number[]
|
||||
): {
|
||||
allocationEffect: number;
|
||||
selectionEffect: number;
|
||||
interactionEffect: number;
|
||||
totalActiveReturn: number;
|
||||
} {
|
||||
if (portfolioReturns.length !== benchmarkReturns.length) {
|
||||
throw new Error('Portfolio and benchmark returns must have same length');
|
||||
}
|
||||
|
||||
const portfolioReturn = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / portfolioReturns.length;
|
||||
const benchmarkReturn = benchmarkReturns.reduce((sum, ret) => sum + ret, 0) / benchmarkReturns.length;
|
||||
|
||||
let allocationEffect = 0;
|
||||
let selectionEffect = 0;
|
||||
let interactionEffect = 0;
|
||||
|
||||
for (let i = 0; i < sectorWeights.length; i++) {
|
||||
const portfolioWeight = sectorWeights[i];
|
||||
const benchmarkWeight = 1 / sectorWeights.length; // Assuming equal benchmark weights
|
||||
const sectorReturn = sectorReturns[i];
|
||||
|
||||
// Allocation effect: (portfolio weight - benchmark weight) * (benchmark sector return - benchmark return)
|
||||
allocationEffect += (portfolioWeight - benchmarkWeight) * (sectorReturn - benchmarkReturn);
|
||||
|
||||
// Selection effect: benchmark weight * (portfolio sector return - benchmark sector return)
|
||||
selectionEffect += benchmarkWeight * (sectorReturn - sectorReturn); // Simplified
|
||||
|
||||
// Interaction effect: (portfolio weight - benchmark weight) * (portfolio sector return - benchmark sector return)
|
||||
interactionEffect += (portfolioWeight - benchmarkWeight) * (sectorReturn - sectorReturn); // Simplified
|
||||
}
|
||||
|
||||
const totalActiveReturn = portfolioReturn - benchmarkReturn;
|
||||
|
||||
return {
|
||||
allocationEffect,
|
||||
selectionEffect,
|
||||
interactionEffect,
|
||||
totalActiveReturn
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Omega ratio
|
||||
*/
|
||||
export function omegaRatio(returns: number[], threshold: number = 0): number {
|
||||
if (returns.length === 0) return 0;
|
||||
|
||||
const gains = returns.filter(ret => ret > threshold).reduce((sum, ret) => sum + (ret - threshold), 0);
|
||||
const losses = returns.filter(ret => ret < threshold).reduce((sum, ret) => sum + Math.abs(ret - threshold), 0);
|
||||
|
||||
return losses === 0 ? Infinity : gains / losses;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate gain-to-pain ratio
|
||||
*/
|
||||
export function gainToPainRatio(returns: number[]): number {
|
||||
if (returns.length === 0) return 0;
|
||||
|
||||
const totalGain = returns.reduce((sum, ret) => sum + ret, 0);
|
||||
const totalPain = returns.filter(ret => ret < 0).reduce((sum, ret) => sum + Math.abs(ret), 0);
|
||||
|
||||
return totalPain === 0 ? (totalGain > 0 ? Infinity : 0) : totalGain / totalPain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Martin ratio (modified Sharpe with downside deviation)
|
||||
*/
|
||||
export function martinRatio(returns: number[], riskFreeRate: number = 0): number {
|
||||
if (returns.length === 0) return 0;
|
||||
|
||||
const averageReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const downsideReturns = returns.filter(ret => ret < riskFreeRate);
|
||||
|
||||
if (downsideReturns.length === 0) return Infinity;
|
||||
|
||||
const downsideDeviation = Math.sqrt(
|
||||
downsideReturns.reduce((sum, ret) => sum + Math.pow(ret - riskFreeRate, 2), 0) / returns.length
|
||||
);
|
||||
|
||||
return downsideDeviation === 0 ? Infinity : (averageReturn - riskFreeRate) / downsideDeviation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate comprehensive portfolio metrics
|
||||
*/
|
||||
export function calculateStrategyMetrics(
|
||||
equityCurve: Array<{ value: number; date: Date }>,
|
||||
benchmarkReturns?: number[],
|
||||
riskFreeRate: number = 0.02
|
||||
): PortfolioMetrics {
|
||||
if (equityCurve.length < 2) {
|
||||
return {
|
||||
totalValue: 0,
|
||||
totalReturn: 0,
|
||||
totalReturnPercent: 0,
|
||||
dailyReturn: 0,
|
||||
dailyReturnPercent: 0,
|
||||
maxDrawdown: 0,
|
||||
sharpeRatio: 0,
|
||||
beta: 0,
|
||||
alpha: 0,
|
||||
volatility: 0
|
||||
};
|
||||
}
|
||||
|
||||
const returns = [];
|
||||
for (let i = 1; i < equityCurve.length; i++) {
|
||||
const ret = (equityCurve[i].value - equityCurve[i - 1].value) / equityCurve[i - 1].value;
|
||||
returns.push(ret);
|
||||
}
|
||||
|
||||
const totalValue = equityCurve[equityCurve.length - 1].value;
|
||||
const totalReturn = totalValue - equityCurve[0].value;
|
||||
const totalReturnPercent = (totalReturn / equityCurve[0].value) * 100;
|
||||
|
||||
const dailyReturn = returns[returns.length - 1];
|
||||
const dailyReturnPercent = dailyReturn * 100;
|
||||
|
||||
const maxDrawdown = analyzeDrawdowns(equityCurve).maxDrawdown;
|
||||
const sharpeRatio = calculateSharpeRatio(returns, riskFreeRate);
|
||||
const volatility = calculateVolatility(returns);
|
||||
|
||||
let beta = 0;
|
||||
let alpha = 0;
|
||||
|
||||
if (benchmarkReturns && benchmarkReturns.length === returns.length) {
|
||||
beta = calculateBeta(returns, benchmarkReturns);
|
||||
alpha = calculateAlpha(returns, benchmarkReturns, riskFreeRate);
|
||||
}
|
||||
|
||||
return {
|
||||
totalValue,
|
||||
totalReturn,
|
||||
totalReturnPercent,
|
||||
dailyReturn,
|
||||
dailyReturnPercent,
|
||||
maxDrawdown,
|
||||
sharpeRatio,
|
||||
beta,
|
||||
alpha,
|
||||
volatility
|
||||
};
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
function calculateSharpeRatio(returns: number[], riskFreeRate: number = 0): number {
|
||||
if (returns.length < 2) return 0;
|
||||
|
||||
const avgReturn = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - avgReturn, 2), 0) / (returns.length - 1);
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
return stdDev === 0 ? 0 : (avgReturn - riskFreeRate) / stdDev;
|
||||
}
|
||||
|
||||
function calculateVolatility(returns: number[]): number {
|
||||
if (returns.length < 2) return 0;
|
||||
|
||||
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / (returns.length - 1);
|
||||
|
||||
return Math.sqrt(variance);
|
||||
}
|
||||
|
||||
function calculateBeta(portfolioReturns: number[], marketReturns: number[]): number {
|
||||
if (portfolioReturns.length !== marketReturns.length || portfolioReturns.length < 2) return 0;
|
||||
|
||||
const portfolioMean = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / portfolioReturns.length;
|
||||
const marketMean = marketReturns.reduce((sum, ret) => sum + ret, 0) / marketReturns.length;
|
||||
|
||||
let covariance = 0;
|
||||
let marketVariance = 0;
|
||||
|
||||
for (let i = 0; i < portfolioReturns.length; i++) {
|
||||
const portfolioDiff = portfolioReturns[i] - portfolioMean;
|
||||
const marketDiff = marketReturns[i] - marketMean;
|
||||
|
||||
covariance += portfolioDiff * marketDiff;
|
||||
marketVariance += marketDiff * marketDiff;
|
||||
}
|
||||
|
||||
covariance /= (portfolioReturns.length - 1);
|
||||
marketVariance /= (marketReturns.length - 1);
|
||||
|
||||
return marketVariance === 0 ? 0 : covariance / marketVariance;
|
||||
}
|
||||
|
||||
function calculateAlpha(
|
||||
portfolioReturns: number[],
|
||||
marketReturns: number[],
|
||||
riskFreeRate: number
|
||||
): number {
|
||||
const portfolioMean = portfolioReturns.reduce((sum, ret) => sum + ret, 0) / portfolioReturns.length;
|
||||
const marketMean = marketReturns.reduce((sum, ret) => sum + ret, 0) / marketReturns.length;
|
||||
const beta = calculateBeta(portfolioReturns, marketReturns);
|
||||
|
||||
return portfolioMean - (riskFreeRate + beta * (marketMean - riskFreeRate));
|
||||
}
|
||||
|
||||
function calculateSkewness(returns: number[]): number {
|
||||
if (returns.length < 3) return 0;
|
||||
|
||||
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / returns.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
if (stdDev === 0) return 0;
|
||||
|
||||
const skew = returns.reduce((sum, ret) => sum + Math.pow((ret - mean) / stdDev, 3), 0) / returns.length;
|
||||
|
||||
return skew;
|
||||
}
|
||||
|
||||
function calculateKurtosis(returns: number[]): number {
|
||||
if (returns.length < 4) return 0;
|
||||
|
||||
const mean = returns.reduce((sum, ret) => sum + ret, 0) / returns.length;
|
||||
const variance = returns.reduce((sum, ret) => sum + Math.pow(ret - mean, 2), 0) / returns.length;
|
||||
const stdDev = Math.sqrt(variance);
|
||||
|
||||
if (stdDev === 0) return 0;
|
||||
|
||||
const kurt = returns.reduce((sum, ret) => sum + Math.pow((ret - mean) / stdDev, 4), 0) / returns.length;
|
||||
|
||||
return kurt - 3; // Excess kurtosis
|
||||
}
|
||||
|
||||
function aggregateMonthlyReturns(returns: Array<{ return: number; date: Date }>): number[] {
|
||||
const monthlyReturns: { [key: string]: number } = {};
|
||||
|
||||
for (const ret of returns) {
|
||||
const monthKey = `${ret.date.getFullYear()}-${ret.date.getMonth()}`;
|
||||
if (!monthlyReturns[monthKey]) {
|
||||
monthlyReturns[monthKey] = 1;
|
||||
}
|
||||
monthlyReturns[monthKey] *= (1 + ret.return);
|
||||
}
|
||||
|
||||
return Object.values(monthlyReturns).map(cumReturn => cumReturn - 1);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue