mirror of https://github.com/ghostfolio/ghostfolio
134 changed files with 9518 additions and 5168 deletions
@ -0,0 +1,241 @@ |
|||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
loadActivityExportFile, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let activityDtos: CreateOrderDto[]; |
||||
|
|
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
activityDtos = loadActivityExportFile( |
||||
|
join(__dirname, '../../../../../../../test/import/ok-btceur.json') |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with BTCUSD buy (in EUR)', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = activityDtos.map((activity) => ({ |
||||
|
...activityDummyData, |
||||
|
...activity, |
||||
|
date: parseDate(activity.date), |
||||
|
feeInAssetProfileCurrency: 4.46, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: activity.dataSource, |
||||
|
name: 'Bitcoin', |
||||
|
symbol: activity.symbol |
||||
|
}, |
||||
|
unitPriceInAssetProfileCurrency: 44558.42 |
||||
|
})); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'USD', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
|
date: '2021-12-11', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
netWorth: 0, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
|
valueWithCurrencyEffect: 0 |
||||
|
}); |
||||
|
|
||||
|
/** |
||||
|
* Closing price on 2021-12-12: 50098.3 |
||||
|
*/ |
||||
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
|
date: '2021-12-12', |
||||
|
investmentValueWithCurrencyEffect: 44558.42, |
||||
|
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
|
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceWithCurrencyEffect: 5535.42, |
||||
|
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
valueWithCurrencyEffect: 50098.3 |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
portfolioSnapshot.historicalData[ |
||||
|
portfolioSnapshot.historicalData.length - 1 |
||||
|
] |
||||
|
).toEqual({ |
||||
|
date: '2022-01-14', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: -1463.18, |
||||
|
netPerformanceInPercentage: -0.032837340282712, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, |
||||
|
netPerformanceWithCurrencyEffect: -1463.18, |
||||
|
netWorth: 43099.7, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 43099.7, |
||||
|
valueWithCurrencyEffect: 43099.7 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('43099.7'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('44558.42'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('4.46'), |
||||
|
feeInBaseCurrency: new Big('4.46'), |
||||
|
firstBuyDate: '2021-12-12', |
||||
|
grossPerformance: new Big('-1458.72'), |
||||
|
grossPerformancePercentage: new Big('-0.03273724696701543726'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.03273724696701543726' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('-1458.72'), |
||||
|
investment: new Big('44558.42'), |
||||
|
investmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
netPerformance: new Big('-1463.18'), |
||||
|
netPerformancePercentage: new Big('-0.03283734028271199921'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('-0.03283734028271199921') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('-1463.18') |
||||
|
}, |
||||
|
marketPrice: 43099.7, |
||||
|
marketPriceInBaseCurrency: 43099.7, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'BTCUSD', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('44558.42'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('43099.7') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('4.46'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('44558.42'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2021-12-12', investment: new Big('44558.42') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2021-12-01', investment: 44558.42 }, |
||||
|
{ date: '2022-01-01', investment: 0 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -0,0 +1,241 @@ |
|||||
|
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto'; |
||||
|
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface'; |
||||
|
import { |
||||
|
activityDummyData, |
||||
|
loadActivityExportFile, |
||||
|
symbolProfileDummyData, |
||||
|
userDummyData |
||||
|
} from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; |
||||
|
import { PortfolioCalculatorFactory } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator.factory'; |
||||
|
import { CurrentRateService } from '@ghostfolio/api/app/portfolio/current-rate.service'; |
||||
|
import { CurrentRateServiceMock } from '@ghostfolio/api/app/portfolio/current-rate.service.mock'; |
||||
|
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service'; |
||||
|
import { RedisCacheServiceMock } from '@ghostfolio/api/app/redis-cache/redis-cache.service.mock'; |
||||
|
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; |
||||
|
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; |
||||
|
import { PortfolioSnapshotService } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service'; |
||||
|
import { PortfolioSnapshotServiceMock } from '@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service.mock'; |
||||
|
import { parseDate } from '@ghostfolio/common/helper'; |
||||
|
import { PerformanceCalculationType } from '@ghostfolio/common/types/performance-calculation-type.type'; |
||||
|
|
||||
|
import { Big } from 'big.js'; |
||||
|
import { join } from 'path'; |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/portfolio/current-rate.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
CurrentRateService: jest.fn().mockImplementation(() => { |
||||
|
return CurrentRateServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
jest.mock( |
||||
|
'@ghostfolio/api/services/queues/portfolio-snapshot/portfolio-snapshot.service', |
||||
|
() => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
PortfolioSnapshotService: jest.fn().mockImplementation(() => { |
||||
|
return PortfolioSnapshotServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
} |
||||
|
); |
||||
|
|
||||
|
jest.mock('@ghostfolio/api/app/redis-cache/redis-cache.service', () => { |
||||
|
return { |
||||
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
|
RedisCacheService: jest.fn().mockImplementation(() => { |
||||
|
return RedisCacheServiceMock; |
||||
|
}) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
describe('PortfolioCalculator', () => { |
||||
|
let activityDtos: CreateOrderDto[]; |
||||
|
|
||||
|
let configurationService: ConfigurationService; |
||||
|
let currentRateService: CurrentRateService; |
||||
|
let exchangeRateDataService: ExchangeRateDataService; |
||||
|
let portfolioCalculatorFactory: PortfolioCalculatorFactory; |
||||
|
let portfolioSnapshotService: PortfolioSnapshotService; |
||||
|
let redisCacheService: RedisCacheService; |
||||
|
|
||||
|
beforeAll(() => { |
||||
|
activityDtos = loadActivityExportFile( |
||||
|
join(__dirname, '../../../../../../../test/import/ok-btcusd.json') |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
beforeEach(() => { |
||||
|
configurationService = new ConfigurationService(); |
||||
|
|
||||
|
currentRateService = new CurrentRateService(null, null, null, null); |
||||
|
|
||||
|
exchangeRateDataService = new ExchangeRateDataService( |
||||
|
null, |
||||
|
null, |
||||
|
null, |
||||
|
null |
||||
|
); |
||||
|
|
||||
|
portfolioSnapshotService = new PortfolioSnapshotService(null); |
||||
|
|
||||
|
redisCacheService = new RedisCacheService(null, null); |
||||
|
|
||||
|
portfolioCalculatorFactory = new PortfolioCalculatorFactory( |
||||
|
configurationService, |
||||
|
currentRateService, |
||||
|
exchangeRateDataService, |
||||
|
portfolioSnapshotService, |
||||
|
redisCacheService |
||||
|
); |
||||
|
}); |
||||
|
|
||||
|
describe('get current positions', () => { |
||||
|
it.only('with BTCUSD buy (in USD)', async () => { |
||||
|
jest.useFakeTimers().setSystemTime(parseDate('2022-01-14').getTime()); |
||||
|
|
||||
|
const activities: Activity[] = activityDtos.map((activity) => ({ |
||||
|
...activityDummyData, |
||||
|
...activity, |
||||
|
date: parseDate(activity.date), |
||||
|
feeInAssetProfileCurrency: 4.46, |
||||
|
SymbolProfile: { |
||||
|
...symbolProfileDummyData, |
||||
|
currency: 'USD', |
||||
|
dataSource: activity.dataSource, |
||||
|
name: 'Bitcoin', |
||||
|
symbol: activity.symbol |
||||
|
}, |
||||
|
unitPriceInAssetProfileCurrency: 44558.42 |
||||
|
})); |
||||
|
|
||||
|
const portfolioCalculator = portfolioCalculatorFactory.createCalculator({ |
||||
|
activities, |
||||
|
calculationType: PerformanceCalculationType.ROAI, |
||||
|
currency: 'USD', |
||||
|
userId: userDummyData.id |
||||
|
}); |
||||
|
|
||||
|
const portfolioSnapshot = await portfolioCalculator.computeSnapshot(); |
||||
|
|
||||
|
const investments = portfolioCalculator.getInvestments(); |
||||
|
|
||||
|
const investmentsByMonth = portfolioCalculator.getInvestmentsByGroup({ |
||||
|
data: portfolioSnapshot.historicalData, |
||||
|
groupBy: 'month' |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot.historicalData[0]).toEqual({ |
||||
|
date: '2021-12-11', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: 0, |
||||
|
netPerformanceInPercentage: 0, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0, |
||||
|
netPerformanceWithCurrencyEffect: 0, |
||||
|
netWorth: 0, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 0, |
||||
|
totalInvestmentValueWithCurrencyEffect: 0, |
||||
|
value: 0, |
||||
|
valueWithCurrencyEffect: 0 |
||||
|
}); |
||||
|
|
||||
|
/** |
||||
|
* Closing price on 2021-12-12: 50098.3 |
||||
|
*/ |
||||
|
expect(portfolioSnapshot.historicalData[1]).toEqual({ |
||||
|
date: '2021-12-12', |
||||
|
investmentValueWithCurrencyEffect: 44558.42, |
||||
|
netPerformance: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
|
netPerformanceInPercentage: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceInPercentageWithCurrencyEffect: 0.12422837255001412, // 5535.42 ÷ 44558.42 = 0.12422837255001412
|
||||
|
netPerformanceWithCurrencyEffect: 5535.42, // 1 * (50098.3 - 44558.42) - 4.46 = 5535.42
|
||||
|
netWorth: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 50098.3, // 1 * 50098.3 = 50098.3
|
||||
|
valueWithCurrencyEffect: 50098.3 |
||||
|
}); |
||||
|
|
||||
|
expect( |
||||
|
portfolioSnapshot.historicalData[ |
||||
|
portfolioSnapshot.historicalData.length - 1 |
||||
|
] |
||||
|
).toEqual({ |
||||
|
date: '2022-01-14', |
||||
|
investmentValueWithCurrencyEffect: 0, |
||||
|
netPerformance: -1463.18, |
||||
|
netPerformanceInPercentage: -0.032837340282712, |
||||
|
netPerformanceInPercentageWithCurrencyEffect: -0.032837340282712, |
||||
|
netPerformanceWithCurrencyEffect: -1463.18, |
||||
|
netWorth: 43099.7, |
||||
|
totalAccountBalance: 0, |
||||
|
totalInvestment: 44558.42, |
||||
|
totalInvestmentValueWithCurrencyEffect: 44558.42, |
||||
|
value: 43099.7, |
||||
|
valueWithCurrencyEffect: 43099.7 |
||||
|
}); |
||||
|
|
||||
|
expect(portfolioSnapshot).toMatchObject({ |
||||
|
currentValueInBaseCurrency: new Big('43099.7'), |
||||
|
errors: [], |
||||
|
hasErrors: false, |
||||
|
positions: [ |
||||
|
{ |
||||
|
averagePrice: new Big('44558.42'), |
||||
|
currency: 'USD', |
||||
|
dataSource: 'YAHOO', |
||||
|
dividend: new Big('0'), |
||||
|
dividendInBaseCurrency: new Big('0'), |
||||
|
fee: new Big('4.46'), |
||||
|
feeInBaseCurrency: new Big('4.46'), |
||||
|
firstBuyDate: '2021-12-12', |
||||
|
grossPerformance: new Big('-1458.72'), |
||||
|
grossPerformancePercentage: new Big('-0.03273724696701543726'), |
||||
|
grossPerformancePercentageWithCurrencyEffect: new Big( |
||||
|
'-0.03273724696701543726' |
||||
|
), |
||||
|
grossPerformanceWithCurrencyEffect: new Big('-1458.72'), |
||||
|
investment: new Big('44558.42'), |
||||
|
investmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
netPerformance: new Big('-1463.18'), |
||||
|
netPerformancePercentage: new Big('-0.03283734028271199921'), |
||||
|
netPerformancePercentageWithCurrencyEffectMap: { |
||||
|
max: new Big('-0.03283734028271199921') |
||||
|
}, |
||||
|
netPerformanceWithCurrencyEffectMap: { |
||||
|
max: new Big('-1463.18') |
||||
|
}, |
||||
|
marketPrice: 43099.7, |
||||
|
marketPriceInBaseCurrency: 43099.7, |
||||
|
quantity: new Big('1'), |
||||
|
symbol: 'BTCUSD', |
||||
|
tags: [], |
||||
|
timeWeightedInvestment: new Big('44558.42'), |
||||
|
timeWeightedInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
transactionCount: 1, |
||||
|
valueInBaseCurrency: new Big('43099.7') |
||||
|
} |
||||
|
], |
||||
|
totalFeesWithCurrencyEffect: new Big('4.46'), |
||||
|
totalInterestWithCurrencyEffect: new Big('0'), |
||||
|
totalInvestment: new Big('44558.42'), |
||||
|
totalInvestmentWithCurrencyEffect: new Big('44558.42'), |
||||
|
totalLiabilitiesWithCurrencyEffect: new Big('0'), |
||||
|
totalValuablesWithCurrencyEffect: new Big('0') |
||||
|
}); |
||||
|
|
||||
|
expect(investments).toEqual([ |
||||
|
{ date: '2021-12-12', investment: new Big('44558.42') } |
||||
|
]); |
||||
|
|
||||
|
expect(investmentsByMonth).toEqual([ |
||||
|
{ date: '2021-12-01', investment: 44558.42 }, |
||||
|
{ date: '2022-01-01', investment: 0 } |
||||
|
]); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
@ -1,23 +0,0 @@ |
|||||
import { PortfolioItem, Position } from '@ghostfolio/common/interfaces'; |
|
||||
|
|
||||
import { Order } from '../order'; |
|
||||
|
|
||||
export interface PortfolioInterface { |
|
||||
get(aDate?: Date): PortfolioItem[]; |
|
||||
|
|
||||
getFees(): number; |
|
||||
|
|
||||
getPositions(aDate: Date): { |
|
||||
[symbol: string]: Position; |
|
||||
}; |
|
||||
|
|
||||
getSymbols(aDate?: Date): string[]; |
|
||||
|
|
||||
getTotalBuy(): number; |
|
||||
|
|
||||
getTotalSell(): number; |
|
||||
|
|
||||
getOrders(): Order[]; |
|
||||
|
|
||||
getValue(aDate?: Date): number; |
|
||||
} |
|
@ -1,83 +0,0 @@ |
|||||
import { IOrder } from '@ghostfolio/api/services/interfaces/interfaces'; |
|
||||
|
|
||||
import { Account, SymbolProfile, Type as ActivityType } from '@prisma/client'; |
|
||||
import { v4 as uuidv4 } from 'uuid'; |
|
||||
|
|
||||
export class Order { |
|
||||
private account: Account; |
|
||||
private currency: string; |
|
||||
private fee: number; |
|
||||
private date: string; |
|
||||
private id: string; |
|
||||
private isDraft: boolean; |
|
||||
private quantity: number; |
|
||||
private symbol: string; |
|
||||
private symbolProfile: SymbolProfile; |
|
||||
private total: number; |
|
||||
private type: ActivityType; |
|
||||
private unitPrice: number; |
|
||||
|
|
||||
public constructor(data: IOrder) { |
|
||||
this.account = data.account; |
|
||||
this.currency = data.currency; |
|
||||
this.fee = data.fee; |
|
||||
this.date = data.date; |
|
||||
this.id = data.id || uuidv4(); |
|
||||
this.isDraft = data.isDraft; |
|
||||
this.quantity = data.quantity; |
|
||||
this.symbol = data.symbol; |
|
||||
this.symbolProfile = data.symbolProfile; |
|
||||
this.type = data.type; |
|
||||
this.unitPrice = data.unitPrice; |
|
||||
|
|
||||
this.total = this.quantity * data.unitPrice; |
|
||||
} |
|
||||
|
|
||||
public getAccount() { |
|
||||
return this.account; |
|
||||
} |
|
||||
|
|
||||
public getCurrency() { |
|
||||
return this.currency; |
|
||||
} |
|
||||
|
|
||||
public getDate() { |
|
||||
return this.date; |
|
||||
} |
|
||||
|
|
||||
public getFee() { |
|
||||
return this.fee; |
|
||||
} |
|
||||
|
|
||||
public getId() { |
|
||||
return this.id; |
|
||||
} |
|
||||
|
|
||||
public getIsDraft() { |
|
||||
return this.isDraft; |
|
||||
} |
|
||||
|
|
||||
public getQuantity() { |
|
||||
return this.quantity; |
|
||||
} |
|
||||
|
|
||||
public getSymbol() { |
|
||||
return this.symbol; |
|
||||
} |
|
||||
|
|
||||
getSymbolProfile() { |
|
||||
return this.symbolProfile; |
|
||||
} |
|
||||
|
|
||||
public getTotal() { |
|
||||
return this.total; |
|
||||
} |
|
||||
|
|
||||
public getType() { |
|
||||
return this.type; |
|
||||
} |
|
||||
|
|
||||
public getUnitPrice() { |
|
||||
return this.unitPrice; |
|
||||
} |
|
||||
} |
|
@ -1,115 +1,94 @@ |
|||||
<div class="container"> |
<div class="d-flex justify-content-end"> |
||||
<div class="row"> |
<a |
||||
<div class="col"> |
color="primary" |
||||
<div class="d-flex justify-content-end"> |
i18n |
||||
<a |
mat-flat-button |
||||
color="primary" |
[queryParams]="{ createPlatformDialog: true }" |
||||
i18n |
[routerLink]="[]" |
||||
mat-flat-button |
> |
||||
[queryParams]="{ createPlatformDialog: true }" |
Add Platform |
||||
[routerLink]="[]" |
</a> |
||||
> |
</div> |
||||
Add Platform |
<table |
||||
</a> |
class="gf-table w-100" |
||||
</div> |
mat-table |
||||
<table |
matSort |
||||
class="gf-table w-100" |
matSortActive="name" |
||||
mat-table |
matSortDirection="asc" |
||||
matSort |
[dataSource]="dataSource" |
||||
matSortActive="name" |
> |
||||
matSortDirection="asc" |
<ng-container matColumnDef="name"> |
||||
[dataSource]="dataSource" |
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name"> |
||||
> |
<ng-container i18n>Name</ng-container> |
||||
<ng-container matColumnDef="name"> |
</th> |
||||
<th |
<td *matCellDef="let element" class="px-1" mat-cell> |
||||
*matHeaderCellDef |
@if (element.url) { |
||||
class="px-1" |
<gf-asset-profile-icon |
||||
mat-header-cell |
class="d-inline mr-1" |
||||
mat-sort-header="name" |
[tooltip]="element.name" |
||||
> |
[url]="element.url" |
||||
<ng-container i18n>Name</ng-container> |
/> |
||||
</th> |
} |
||||
<td *matCellDef="let element" class="px-1" mat-cell> |
<span>{{ element.name }}</span> |
||||
@if (element.url) { |
</td></ng-container |
||||
<gf-asset-profile-icon |
> |
||||
class="d-inline mr-1" |
|
||||
[tooltip]="element.name" |
|
||||
[url]="element.url" |
|
||||
/> |
|
||||
} |
|
||||
<span>{{ element.name }}</span> |
|
||||
</td></ng-container |
|
||||
> |
|
||||
|
|
||||
<ng-container matColumnDef="url"> |
<ng-container matColumnDef="url"> |
||||
<th |
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="url"> |
||||
*matHeaderCellDef |
<ng-container i18n>Url</ng-container> |
||||
class="px-1" |
</th> |
||||
mat-header-cell |
<td *matCellDef="let element" class="px-1" mat-cell> |
||||
mat-sort-header="url" |
{{ element.url }} |
||||
> |
</td> |
||||
<ng-container i18n>Url</ng-container> |
</ng-container> |
||||
</th> |
|
||||
<td *matCellDef="let element" class="px-1" mat-cell> |
|
||||
{{ element.url }} |
|
||||
</td> |
|
||||
</ng-container> |
|
||||
|
|
||||
<ng-container matColumnDef="accounts"> |
<ng-container matColumnDef="accounts"> |
||||
<th |
<th |
||||
*matHeaderCellDef |
*matHeaderCellDef |
||||
class="px-1" |
class="px-1" |
||||
mat-header-cell |
mat-header-cell |
||||
mat-sort-header="accountCount" |
mat-sort-header="accountCount" |
||||
> |
> |
||||
<ng-container i18n>Accounts</ng-container> |
<ng-container i18n>Accounts</ng-container> |
||||
</th> |
</th> |
||||
<td *matCellDef="let element" class="px-1" mat-cell> |
<td *matCellDef="let element" class="px-1 text-right" mat-cell> |
||||
{{ element.accountCount }} |
{{ element.accountCount }} |
||||
</td> |
</td> |
||||
</ng-container> |
</ng-container> |
||||
|
|
||||
<ng-container matColumnDef="actions" stickyEnd> |
<ng-container matColumnDef="actions" stickyEnd> |
||||
<th |
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th> |
||||
*matHeaderCellDef |
<td *matCellDef="let element" class="px-1 text-center" mat-cell> |
||||
class="px-1 text-center" |
<button |
||||
i18n |
class="mx-1 no-min-width px-2" |
||||
mat-header-cell |
mat-button |
||||
></th> |
[matMenuTriggerFor]="platformMenu" |
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell> |
(click)="$event.stopPropagation()" |
||||
<button |
> |
||||
class="mx-1 no-min-width px-2" |
<ion-icon name="ellipsis-horizontal" /> |
||||
mat-button |
</button> |
||||
[matMenuTriggerFor]="platformMenu" |
<mat-menu #platformMenu="matMenu" xPosition="before"> |
||||
(click)="$event.stopPropagation()" |
<button mat-menu-item (click)="onUpdatePlatform(element)"> |
||||
> |
<span class="align-items-center d-flex"> |
||||
<ion-icon name="ellipsis-horizontal" /> |
<ion-icon class="mr-2" name="create-outline" /> |
||||
</button> |
<span i18n>Edit</span> |
||||
<mat-menu #platformMenu="matMenu" xPosition="before"> |
</span> |
||||
<button mat-menu-item (click)="onUpdatePlatform(element)"> |
</button> |
||||
<span class="align-items-center d-flex"> |
<hr class="m-0" /> |
||||
<ion-icon class="mr-2" name="create-outline" /> |
<button |
||||
<span i18n>Edit</span> |
mat-menu-item |
||||
</span> |
[disabled]="element.accountCount > 0" |
||||
</button> |
(click)="onDeletePlatform(element.id)" |
||||
<hr class="m-0" /> |
> |
||||
<button |
<span class="align-items-center d-flex"> |
||||
mat-menu-item |
<ion-icon class="mr-2" name="trash-outline" /> |
||||
[disabled]="element.accountCount > 0" |
<span i18n>Delete</span> |
||||
(click)="onDeletePlatform(element.id)" |
</span> |
||||
> |
</button> |
||||
<span class="align-items-center d-flex"> |
</mat-menu> |
||||
<ion-icon class="mr-2" name="trash-outline" /> |
</td> |
||||
<span i18n>Delete</span> |
</ng-container> |
||||
</span> |
|
||||
</button> |
|
||||
</mat-menu> |
|
||||
</td> |
|
||||
</ng-container> |
|
||||
|
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> |
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> |
||||
</table> |
</table> |
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
@ -1,3 +1,15 @@ |
|||||
:host { |
:host { |
||||
display: block; |
display: block; |
||||
|
|
||||
|
.mat-mdc-progress-bar { |
||||
|
--mdc-linear-progress-active-indicator-height: 0.5rem; |
||||
|
--mdc-linear-progress-track-height: 0.5rem; |
||||
|
border-radius: 0.25rem; |
||||
|
|
||||
|
::ng-deep { |
||||
|
.mdc-linear-progress__buffer-bar { |
||||
|
background-color: rgb(var(--palette-background-unselected-chip)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
} |
} |
||||
|
@ -1,7 +1,4 @@ |
|||||
import { User } from '@ghostfolio/common/interfaces'; |
|
||||
|
|
||||
export interface GhostfolioPremiumApiDialogParams { |
export interface GhostfolioPremiumApiDialogParams { |
||||
deviceType: string; |
deviceType: string; |
||||
pricingUrl: string; |
pricingUrl: string; |
||||
user: User; |
|
||||
} |
} |
||||
|
@ -1,121 +1,100 @@ |
|||||
<div class="container"> |
<div class="d-flex justify-content-end"> |
||||
<div class="row"> |
<a |
||||
<div class="col"> |
color="primary" |
||||
<div class="d-flex justify-content-end"> |
i18n |
||||
<a |
mat-flat-button |
||||
color="primary" |
[queryParams]="{ createTagDialog: true }" |
||||
i18n |
[routerLink]="[]" |
||||
mat-flat-button |
> |
||||
[queryParams]="{ createTagDialog: true }" |
Add Tag |
||||
[routerLink]="[]" |
</a> |
||||
> |
</div> |
||||
Add Tag |
<table |
||||
</a> |
class="gf-table w-100" |
||||
</div> |
mat-table |
||||
<table |
matSort |
||||
class="gf-table w-100" |
matSortActive="name" |
||||
mat-table |
matSortDirection="asc" |
||||
matSort |
[dataSource]="dataSource" |
||||
matSortActive="name" |
> |
||||
matSortDirection="asc" |
<ng-container matColumnDef="name"> |
||||
[dataSource]="dataSource" |
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name"> |
||||
> |
<ng-container i18n>Name</ng-container> |
||||
<ng-container matColumnDef="name"> |
</th> |
||||
<th |
<td *matCellDef="let element" class="px-1" mat-cell> |
||||
*matHeaderCellDef |
{{ element.name }} |
||||
class="px-1" |
</td> |
||||
mat-header-cell |
</ng-container> |
||||
mat-sort-header="name" |
|
||||
> |
|
||||
<ng-container i18n>Name</ng-container> |
|
||||
</th> |
|
||||
<td *matCellDef="let element" class="px-1" mat-cell> |
|
||||
{{ element.name }} |
|
||||
</td> |
|
||||
</ng-container> |
|
||||
|
|
||||
<ng-container matColumnDef="userId"> |
<ng-container matColumnDef="userId"> |
||||
<th |
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="userId"> |
||||
*matHeaderCellDef |
<ng-container i18n>User</ng-container> |
||||
class="px-1" |
</th> |
||||
mat-header-cell |
<td *matCellDef="let element" class="px-1" mat-cell> |
||||
mat-sort-header="userId" |
<span class="text-monospace">{{ element.userId }}</span> |
||||
> |
</td> |
||||
<ng-container i18n>User</ng-container> |
</ng-container> |
||||
</th> |
|
||||
<td *matCellDef="let element" class="px-1" mat-cell> |
|
||||
<span class="text-monospace">{{ element.userId }}</span> |
|
||||
</td> |
|
||||
</ng-container> |
|
||||
|
|
||||
<ng-container matColumnDef="activities"> |
<ng-container matColumnDef="activities"> |
||||
<th |
<th |
||||
*matHeaderCellDef |
*matHeaderCellDef |
||||
class="px-1" |
class="px-1" |
||||
mat-header-cell |
mat-header-cell |
||||
mat-sort-header="activityCount" |
mat-sort-header="activityCount" |
||||
> |
> |
||||
<ng-container i18n>Activities</ng-container> |
<ng-container i18n>Activities</ng-container> |
||||
</th> |
</th> |
||||
<td *matCellDef="let element" class="px-1" mat-cell> |
<td *matCellDef="let element" class="px-1 text-right" mat-cell> |
||||
{{ element.activityCount }} |
{{ element.activityCount }} |
||||
</td> |
</td> |
||||
</ng-container> |
</ng-container> |
||||
<ng-container matColumnDef="holdings"> |
<ng-container matColumnDef="holdings"> |
||||
<th |
<th |
||||
*matHeaderCellDef |
*matHeaderCellDef |
||||
class="px-1" |
class="px-1" |
||||
mat-header-cell |
mat-header-cell |
||||
mat-sort-header="holdingCount" |
mat-sort-header="holdingCount" |
||||
> |
> |
||||
<ng-container i18n>Holdings</ng-container> |
<ng-container i18n>Holdings</ng-container> |
||||
</th> |
</th> |
||||
<td *matCellDef="let element" class="px-1" mat-cell> |
<td *matCellDef="let element" class="px-1" mat-cell> |
||||
{{ element.holdingCount }} |
{{ element.holdingCount }} |
||||
</td> |
</td> |
||||
</ng-container> |
</ng-container> |
||||
|
|
||||
<ng-container matColumnDef="actions" stickyEnd> |
<ng-container matColumnDef="actions" stickyEnd> |
||||
<th |
<th *matHeaderCellDef class="px-1 text-center" i18n mat-header-cell></th> |
||||
*matHeaderCellDef |
<td *matCellDef="let element" class="px-1 text-center" mat-cell> |
||||
class="px-1 text-center" |
<button |
||||
i18n |
class="mx-1 no-min-width px-2" |
||||
mat-header-cell |
mat-button |
||||
></th> |
[matMenuTriggerFor]="tagMenu" |
||||
<td *matCellDef="let element" class="px-1 text-center" mat-cell> |
(click)="$event.stopPropagation()" |
||||
<button |
> |
||||
class="mx-1 no-min-width px-2" |
<ion-icon name="ellipsis-horizontal" /> |
||||
mat-button |
</button> |
||||
[matMenuTriggerFor]="tagMenu" |
<mat-menu #tagMenu="matMenu" xPosition="before"> |
||||
(click)="$event.stopPropagation()" |
<button mat-menu-item (click)="onUpdateTag(element)"> |
||||
> |
<span class="align-items-center d-flex"> |
||||
<ion-icon name="ellipsis-horizontal" /> |
<ion-icon class="mr-2" name="create-outline" /> |
||||
</button> |
<span i18n>Edit</span> |
||||
<mat-menu #tagMenu="matMenu" xPosition="before"> |
</span> |
||||
<button mat-menu-item (click)="onUpdateTag(element)"> |
</button> |
||||
<span class="align-items-center d-flex"> |
<hr class="m-0" /> |
||||
<ion-icon class="mr-2" name="create-outline" /> |
<button |
||||
<span i18n>Edit</span> |
mat-menu-item |
||||
</span> |
[disabled]="element.activityCount > 0" |
||||
</button> |
(click)="onDeleteTag(element.id)" |
||||
<hr class="m-0" /> |
> |
||||
<button |
<span class="align-items-center d-flex"> |
||||
mat-menu-item |
<ion-icon class="mr-2" name="trash-outline" /> |
||||
[disabled]="element.activityCount > 0" |
<span i18n>Delete</span> |
||||
(click)="onDeleteTag(element.id)" |
</span> |
||||
> |
</button> |
||||
<span class="align-items-center d-flex"> |
</mat-menu> |
||||
<ion-icon class="mr-2" name="trash-outline" /> |
</td> |
||||
<span i18n>Delete</span> |
</ng-container> |
||||
</span> |
|
||||
</button> |
|
||||
</mat-menu> |
|
||||
</td> |
|
||||
</ng-container> |
|
||||
|
|
||||
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
<tr *matHeaderRowDef="displayedColumns" mat-header-row></tr> |
||||
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> |
<tr *matRowDef="let row; columns: displayedColumns" mat-row></tr> |
||||
</table> |
</table> |
||||
</div> |
|
||||
</div> |
|
||||
</div> |
|
||||
|
@ -0,0 +1,3 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
@ -0,0 +1,92 @@ |
|||||
|
import { GfSymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
Component, |
||||
|
OnDestroy, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { |
||||
|
AbstractControl, |
||||
|
FormBuilder, |
||||
|
FormControl, |
||||
|
FormGroup, |
||||
|
FormsModule, |
||||
|
ReactiveFormsModule, |
||||
|
ValidationErrors, |
||||
|
Validators |
||||
|
} from '@angular/forms'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialogModule, MatDialogRef } from '@angular/material/dialog'; |
||||
|
import { MatFormFieldModule } from '@angular/material/form-field'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
host: { class: 'h-100' }, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
FormsModule, |
||||
|
GfSymbolAutocompleteComponent, |
||||
|
MatButtonModule, |
||||
|
MatDialogModule, |
||||
|
MatFormFieldModule, |
||||
|
ReactiveFormsModule |
||||
|
], |
||||
|
selector: 'gf-create-watchlist-item-dialog', |
||||
|
styleUrls: ['./create-watchlist-item-dialog.component.scss'], |
||||
|
templateUrl: 'create-watchlist-item-dialog.html' |
||||
|
}) |
||||
|
export class CreateWatchlistItemDialogComponent implements OnInit, OnDestroy { |
||||
|
public createWatchlistItemForm: FormGroup; |
||||
|
|
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
|
public constructor( |
||||
|
public readonly dialogRef: MatDialogRef<CreateWatchlistItemDialogComponent>, |
||||
|
public readonly formBuilder: FormBuilder |
||||
|
) {} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.createWatchlistItemForm = this.formBuilder.group( |
||||
|
{ |
||||
|
searchSymbol: new FormControl(null, [Validators.required]) |
||||
|
}, |
||||
|
{ |
||||
|
validators: this.validator |
||||
|
} |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
public onCancel() { |
||||
|
this.dialogRef.close(); |
||||
|
} |
||||
|
|
||||
|
public onSubmit() { |
||||
|
this.dialogRef.close({ |
||||
|
dataSource: |
||||
|
this.createWatchlistItemForm.get('searchSymbol').value.dataSource, |
||||
|
symbol: this.createWatchlistItemForm.get('searchSymbol').value.symbol |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.unsubscribeSubject.next(); |
||||
|
this.unsubscribeSubject.complete(); |
||||
|
} |
||||
|
|
||||
|
private validator(control: AbstractControl): ValidationErrors { |
||||
|
const searchSymbolControl = control.get('searchSymbol'); |
||||
|
|
||||
|
if ( |
||||
|
searchSymbolControl.valid && |
||||
|
searchSymbolControl.value.dataSource && |
||||
|
searchSymbolControl.value.symbol |
||||
|
) { |
||||
|
return { incomplete: false }; |
||||
|
} |
||||
|
|
||||
|
return { incomplete: true }; |
||||
|
} |
||||
|
} |
@ -0,0 +1,25 @@ |
|||||
|
<form |
||||
|
class="d-flex flex-column h-100" |
||||
|
[formGroup]="createWatchlistItemForm" |
||||
|
(keyup.enter)="createWatchlistItemForm.valid && onSubmit()" |
||||
|
(ngSubmit)="onSubmit()" |
||||
|
> |
||||
|
<h1 i18n mat-dialog-title>Add asset to watchlist</h1> |
||||
|
<div class="flex-grow-1 py-3" mat-dialog-content> |
||||
|
<mat-form-field appearance="outline" class="w-100"> |
||||
|
<mat-label i18n>Name, symbol or ISIN</mat-label> |
||||
|
<gf-symbol-autocomplete formControlName="searchSymbol" /> |
||||
|
</mat-form-field> |
||||
|
</div> |
||||
|
<div class="d-flex justify-content-end" mat-dialog-actions> |
||||
|
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button> |
||||
|
<button |
||||
|
color="primary" |
||||
|
mat-flat-button |
||||
|
type="submit" |
||||
|
[disabled]="createWatchlistItemForm.hasError('incomplete')" |
||||
|
> |
||||
|
<ng-container i18n>Save</ng-container> |
||||
|
</button> |
||||
|
</div> |
||||
|
</form> |
@ -0,0 +1,4 @@ |
|||||
|
export interface CreateWatchlistItemDialogParams { |
||||
|
deviceType: string; |
||||
|
locale: string; |
||||
|
} |
@ -0,0 +1,185 @@ |
|||||
|
import { DataService } from '@ghostfolio/client/services/data.service'; |
||||
|
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service'; |
||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service'; |
||||
|
import { |
||||
|
AssetProfileIdentifier, |
||||
|
Benchmark, |
||||
|
User |
||||
|
} from '@ghostfolio/common/interfaces'; |
||||
|
import { hasPermission, permissions } from '@ghostfolio/common/permissions'; |
||||
|
import { BenchmarkTrend } from '@ghostfolio/common/types'; |
||||
|
import { GfBenchmarkComponent } from '@ghostfolio/ui/benchmark'; |
||||
|
import { GfPremiumIndicatorComponent } from '@ghostfolio/ui/premium-indicator'; |
||||
|
|
||||
|
import { CommonModule } from '@angular/common'; |
||||
|
import { |
||||
|
ChangeDetectionStrategy, |
||||
|
ChangeDetectorRef, |
||||
|
Component, |
||||
|
CUSTOM_ELEMENTS_SCHEMA, |
||||
|
OnDestroy, |
||||
|
OnInit |
||||
|
} from '@angular/core'; |
||||
|
import { MatButtonModule } from '@angular/material/button'; |
||||
|
import { MatDialog } from '@angular/material/dialog'; |
||||
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; |
||||
|
import { DeviceDetectorService } from 'ngx-device-detector'; |
||||
|
import { Subject } from 'rxjs'; |
||||
|
import { takeUntil } from 'rxjs/operators'; |
||||
|
|
||||
|
import { CreateWatchlistItemDialogComponent } from './create-watchlist-item-dialog/create-watchlist-item-dialog.component'; |
||||
|
import { CreateWatchlistItemDialogParams } from './create-watchlist-item-dialog/interfaces/interfaces'; |
||||
|
|
||||
|
@Component({ |
||||
|
changeDetection: ChangeDetectionStrategy.OnPush, |
||||
|
imports: [ |
||||
|
CommonModule, |
||||
|
GfBenchmarkComponent, |
||||
|
GfPremiumIndicatorComponent, |
||||
|
MatButtonModule, |
||||
|
RouterModule |
||||
|
], |
||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA], |
||||
|
selector: 'gf-home-watchlist', |
||||
|
styleUrls: ['./home-watchlist.scss'], |
||||
|
templateUrl: './home-watchlist.html' |
||||
|
}) |
||||
|
export class HomeWatchlistComponent implements OnDestroy, OnInit { |
||||
|
public deviceType: string; |
||||
|
public hasImpersonationId: boolean; |
||||
|
public hasPermissionToCreateWatchlistItem: boolean; |
||||
|
public hasPermissionToDeleteWatchlistItem: boolean; |
||||
|
public user: User; |
||||
|
public watchlist: Benchmark[]; |
||||
|
|
||||
|
private unsubscribeSubject = new Subject<void>(); |
||||
|
|
||||
|
public constructor( |
||||
|
private changeDetectorRef: ChangeDetectorRef, |
||||
|
private dataService: DataService, |
||||
|
private deviceService: DeviceDetectorService, |
||||
|
private dialog: MatDialog, |
||||
|
private impersonationStorageService: ImpersonationStorageService, |
||||
|
private route: ActivatedRoute, |
||||
|
private router: Router, |
||||
|
private userService: UserService |
||||
|
) { |
||||
|
this.deviceType = this.deviceService.getDeviceInfo().deviceType; |
||||
|
|
||||
|
this.impersonationStorageService |
||||
|
.onChangeHasImpersonation() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((impersonationId) => { |
||||
|
this.hasImpersonationId = !!impersonationId; |
||||
|
}); |
||||
|
|
||||
|
this.route.queryParams |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((params) => { |
||||
|
if (params['createWatchlistItemDialog']) { |
||||
|
this.openCreateWatchlistItemDialog(); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
this.userService.stateChanged |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((state) => { |
||||
|
if (state?.user) { |
||||
|
this.user = state.user; |
||||
|
|
||||
|
this.hasPermissionToCreateWatchlistItem = |
||||
|
!this.hasImpersonationId && |
||||
|
hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.createWatchlistItem |
||||
|
); |
||||
|
this.hasPermissionToDeleteWatchlistItem = |
||||
|
!this.hasImpersonationId && |
||||
|
hasPermission( |
||||
|
this.user.permissions, |
||||
|
permissions.deleteWatchlistItem |
||||
|
); |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnInit() { |
||||
|
this.loadWatchlistData(); |
||||
|
} |
||||
|
|
||||
|
public onWatchlistItemDeleted({ |
||||
|
dataSource, |
||||
|
symbol |
||||
|
}: AssetProfileIdentifier) { |
||||
|
this.dataService |
||||
|
.deleteWatchlistItem({ dataSource, symbol }) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe({ |
||||
|
next: () => { |
||||
|
return this.loadWatchlistData(); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
public ngOnDestroy() { |
||||
|
this.unsubscribeSubject.next(); |
||||
|
this.unsubscribeSubject.complete(); |
||||
|
} |
||||
|
|
||||
|
private loadWatchlistData() { |
||||
|
this.dataService |
||||
|
.fetchWatchlist() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(({ watchlist }) => { |
||||
|
this.watchlist = watchlist.map( |
||||
|
({ dataSource, marketCondition, name, performances, symbol }) => ({ |
||||
|
dataSource, |
||||
|
marketCondition, |
||||
|
name, |
||||
|
performances, |
||||
|
symbol, |
||||
|
trend50d: 'UNKNOWN' as BenchmarkTrend, |
||||
|
trend200d: 'UNKNOWN' as BenchmarkTrend |
||||
|
}) |
||||
|
); |
||||
|
|
||||
|
this.changeDetectorRef.markForCheck(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
private openCreateWatchlistItemDialog() { |
||||
|
this.userService |
||||
|
.get() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe((user) => { |
||||
|
this.user = user; |
||||
|
|
||||
|
const dialogRef = this.dialog.open(CreateWatchlistItemDialogComponent, { |
||||
|
autoFocus: false, |
||||
|
data: { |
||||
|
deviceType: this.deviceType, |
||||
|
locale: this.user?.settings?.locale |
||||
|
} as CreateWatchlistItemDialogParams, |
||||
|
width: this.deviceType === 'mobile' ? '100vw' : '50rem' |
||||
|
}); |
||||
|
|
||||
|
dialogRef |
||||
|
.afterClosed() |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe(({ dataSource, symbol } = {}) => { |
||||
|
if (dataSource && symbol) { |
||||
|
this.dataService |
||||
|
.postWatchlistItem({ dataSource, symbol }) |
||||
|
.pipe(takeUntil(this.unsubscribeSubject)) |
||||
|
.subscribe({ |
||||
|
next: () => this.loadWatchlistData() |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
this.router.navigate(['.'], { relativeTo: this.route }); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
@ -0,0 +1,35 @@ |
|||||
|
<div class="container"> |
||||
|
<h1 class="d-none d-sm-block h3 mb-4"> |
||||
|
<span class="align-items-center d-flex justify-content-center"> |
||||
|
<span i18n>Watchlist</span> |
||||
|
@if (user?.subscription?.type === 'Basic') { |
||||
|
<gf-premium-indicator class="ml-1" /> |
||||
|
} |
||||
|
</span> |
||||
|
</h1> |
||||
|
<div class="row"> |
||||
|
<div class="col-xs-12 col-md-8 offset-md-2"> |
||||
|
<gf-benchmark |
||||
|
[benchmarks]="watchlist" |
||||
|
[deviceType]="deviceType" |
||||
|
[hasPermissionToDeleteItem]="hasPermissionToDeleteWatchlistItem" |
||||
|
[locale]="user?.settings?.locale || undefined" |
||||
|
[user]="user" |
||||
|
(itemDeleted)="onWatchlistItemDeleted($event)" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
@if (!hasImpersonationId && hasPermissionToCreateWatchlistItem) { |
||||
|
<div class="fab-container"> |
||||
|
<a |
||||
|
class="align-items-center d-flex justify-content-center" |
||||
|
color="primary" |
||||
|
mat-fab |
||||
|
[queryParams]="{ createWatchlistItemDialog: true }" |
||||
|
[routerLink]="[]" |
||||
|
> |
||||
|
<ion-icon name="add-outline" size="large" /> |
||||
|
</a> |
||||
|
</div> |
||||
|
} |
@ -0,0 +1,3 @@ |
|||||
|
:host { |
||||
|
display: block; |
||||
|
} |
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue