Browse Source

Merge 42630fbaa6 into cf0bdba6eb

pull/7948/merge
Thomas Kaul 3 hours ago
committed by GitHub
parent
commit
50dc0fb92c
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      CHANGELOG.md
  2. 96
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  3. 27
      apps/api/src/app/portfolio/current-rate.service.ts
  4. 59
      apps/api/src/app/portfolio/portfolio.service.spec.ts
  5. 32
      apps/api/src/app/portfolio/portfolio.service.ts
  6. 96
      apps/api/src/services/data-provider/manual/manual.service.spec.ts
  7. 29
      apps/api/src/services/data-provider/manual/manual.service.ts

5
CHANGELOG.md

@ -9,8 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Changed
- Improved the get quotes functionality of the _Manual_ service
- Upgraded `chartjs-chart-treemap` from version `4.2.0` to `4.2.2`
### Fixed
- Fixed an issue where holdings without a quote have been valued at the unit price of the latest activity instead of the latest market price
## 3.74.0 - 2026-09-27
### Added

96
apps/api/src/app/portfolio/current-rate.service.spec.ts

@ -1,9 +1,12 @@
import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
import { DataSource, MarketData } from '@prisma/client';
import { DataSource, MarketData, Order } from '@prisma/client';
import { addDays, subDays } from 'date-fns';
import { CurrentRateService } from './current-rate.service';
import { DateQuery } from './interfaces/date-query.interface';
@ -25,6 +28,9 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
state: 'CLOSE'
});
},
getLatest: () => {
return Promise.resolve<MarketData>(null);
},
getRange: ({
assetProfileIdentifiers,
dateQuery
@ -97,12 +103,19 @@ jest.mock('@ghostfolio/api/services/property/property.service', () => {
});
describe('CurrentRateService', () => {
let activitiesService: ActivitiesService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let marketDataService: MarketDataService;
let propertyService: PropertyService;
beforeAll(async () => {
activitiesService = {
getLatestActivity: () => {
return Promise.resolve<Order>(null);
}
} as unknown as ActivitiesService;
propertyService = new PropertyService(null);
dataProviderService = new DataProviderService(
@ -117,7 +130,7 @@ describe('CurrentRateService', () => {
marketDataService = new MarketDataService(null);
currentRateService = new CurrentRateService(
null,
activitiesService,
dataProviderService,
marketDataService
);
@ -151,4 +164,83 @@ describe('CurrentRateService', () => {
]
});
});
describe('getValues without a quote', () => {
const today = resetHours(new Date());
const yesterday = subDays(today, 1);
const dataGatheringItems: AssetProfileIdentifier[] = [
{ dataSource: DataSource.YAHOO, symbol: 'AMZN' }
];
const dateQuery: DateQuery = {
gte: yesterday,
lt: addDays(today, 1)
};
beforeEach(() => {
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({});
jest.spyOn(marketDataService, 'getRangeCount').mockResolvedValue(0);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should fall back to the latest market price', async () => {
const getLatestActivity = jest
.spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order);
jest.spyOn(marketDataService, 'getLatest').mockResolvedValue({
createdAt: yesterday,
dataSource: DataSource.YAHOO,
date: yesterday,
id: '4f2b8a1e-7c3d-4e5f-9a6b-1c2d3e4f5a6b',
isCarriedForward: false,
marketPrice: 1847.839966,
state: 'CLOSE',
symbol: 'AMZN'
});
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery
});
expect(getLatestActivity).not.toHaveBeenCalled();
expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1847.839966,
symbol: 'AMZN'
}
]);
});
it('should fall back to the unit price of the latest activity without market data', async () => {
jest
.spyOn(activitiesService, 'getLatestActivity')
.mockResolvedValue({ unitPrice: 1000 } as Order);
jest.spyOn(marketDataService, 'getLatest').mockResolvedValue(null);
const { errors, values } = await currentRateService.getValues({
dataGatheringItems,
dateQuery
});
expect(errors).toEqual(dataGatheringItems);
expect(values).toEqual([
{
dataSource: DataSource.YAHOO,
date: today,
marketPrice: 1000,
symbol: 'AMZN'
}
]);
});
});
});

27
apps/api/src/app/portfolio/current-rate.service.ts

@ -132,18 +132,29 @@ export class CurrentRateService {
});
if (!value) {
// Fallback to unit price of latest activity
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
const latestMarketData = await this.marketDataService.getLatest({
dataSource,
symbol
});
let marketPrice = latestMarketData?.marketPrice;
if (!marketPrice) {
// Fallback to unit price of latest activity
const latestActivity =
await this.activitiesService.getLatestActivity({
dataSource,
symbol
});
marketPrice = latestActivity?.unitPrice ?? 0;
}
value = {
dataSource,
marketPrice,
symbol,
date: today,
marketPrice: latestActivity?.unitPrice ?? 0
date: today
};
response.values.push(value);

59
apps/api/src/app/portfolio/portfolio.service.spec.ts

@ -4,6 +4,7 @@ import { ActivitiesService } from '@ghostfolio/api/app/activities/activities.ser
import { PortfolioCalculator } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator';
import { 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 { UserService } from '@ghostfolio/api/app/user/user.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
@ -33,6 +34,7 @@ describe('PortfolioService', () => {
let accountService: AccountService;
let activitiesService: ActivitiesService;
let configurationService: ConfigurationService;
let currentRateService: CurrentRateService;
let dataProviderService: DataProviderService;
let exchangeRateDataService: ExchangeRateDataService;
let portfolioCalculatorFactory: PortfolioCalculatorFactory;
@ -82,6 +84,12 @@ describe('PortfolioService', () => {
null
);
currentRateService = new CurrentRateService(
activitiesService,
dataProviderService,
null
);
portfolioCalculatorFactory = new PortfolioCalculatorFactory(
configurationService,
null,
@ -110,6 +118,7 @@ describe('PortfolioService', () => {
activitiesService,
null,
portfolioCalculatorFactory,
currentRateService,
dataProviderService,
exchangeRateDataService,
null,
@ -901,15 +910,19 @@ describe('PortfolioService', () => {
count: 4
});
const getQuotes = jest
.spyOn(dataProviderService, 'getQuotes')
const getValues = jest
.spyOn(currentRateService, 'getValues')
.mockResolvedValue({
'YAHOO-AAPL': {
currency: 'CHF',
dataSource: DataSource.YAHOO,
marketPrice: 200,
marketState: 'open'
}
dataProviderInfos: [],
errors: [],
values: [
{
dataSource: DataSource.YAHOO,
date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
});
const summary = await getSummary({
@ -921,10 +934,10 @@ describe('PortfolioService', () => {
userId: userDummyData.id
});
// The closed holding (MSFT) does not need a quote
expect(getQuotes).toHaveBeenCalledWith(
// The closed holding (MSFT) does not need a market price
expect(getValues).toHaveBeenCalledWith(
expect.objectContaining({
items: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
dataGatheringItems: [{ dataSource: DataSource.YAHOO, symbol: 'AAPL' }]
})
);
@ -961,13 +974,17 @@ describe('PortfolioService', () => {
count: 2
});
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({
'YAHOO-AAPL': {
currency: 'USD',
dataSource: DataSource.YAHOO,
marketPrice: 200,
marketState: 'open'
}
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: [
{
dataSource: DataSource.YAHOO,
date: new Date(),
marketPrice: 200,
symbol: 'AAPL'
}
]
});
jest
@ -1018,7 +1035,11 @@ describe('PortfolioService', () => {
count: 2
});
jest.spyOn(dataProviderService, 'getQuotes').mockResolvedValue({});
jest.spyOn(currentRateService, 'getValues').mockResolvedValue({
dataProviderInfos: [],
errors: [],
values: []
});
const summary = await getSummary({
balanceInBaseCurrency: 1000,

32
apps/api/src/app/portfolio/portfolio.service.ts

@ -94,18 +94,21 @@ import {
import { Big } from 'big.js';
import {
differenceInDays,
endOfDay,
format,
isAfter,
isBefore,
isSameMonth,
isSameYear,
parseISO,
set
set,
startOfDay
} from 'date-fns';
import { groupBy } from 'lodash';
import { PortfolioCalculator } from './calculator/portfolio-calculator';
import { PortfolioCalculatorFactory } from './calculator/portfolio-calculator.factory';
import { CurrentRateService } from './current-rate.service';
import { RulesService } from './rules.service';
const Fuse = require('fuse.js');
@ -125,6 +128,7 @@ export class PortfolioService {
private readonly activitiesService: ActivitiesService,
private readonly benchmarkService: BenchmarkService,
private readonly calculatorFactory: PortfolioCalculatorFactory,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly i18nService: I18nService,
@ -2539,11 +2543,15 @@ export class PortfolioService {
return !quantity.eq(0);
});
const quotes =
const now = new Date();
// Get the market prices of today with the same fallback as the portfolio
// calculator
const { values } =
openHoldings.length > 0
? await this.dataProviderService.getQuotes({
? await this.currentRateService.getValues({
subscriptionType,
items: openHoldings.map(
dataGatheringItems: openHoldings.map(
({
latestActivity: {
assetProfile: { dataSource, symbol }
@ -2551,16 +2559,24 @@ export class PortfolioService {
}) => {
return { dataSource, symbol };
}
)
),
dateQuery: {
gte: startOfDay(now),
lt: endOfDay(now)
}
})
: {};
: { values: [] };
return getSum(
openHoldings.map(({ latestActivity, quantity }) => {
const { assetProfile, currency, unitPrice } = latestActivity;
const marketPrice =
quotes[getAssetProfileIdentifier(assetProfile)]?.marketPrice;
const marketPrice = values.find((value) => {
return (
getAssetProfileIdentifier(value) ===
getAssetProfileIdentifier(assetProfile)
);
})?.marketPrice;
if (!marketPrice) {
// Fall back to the unit price of the latest activity without a

96
apps/api/src/services/data-provider/manual/manual.service.spec.ts

@ -0,0 +1,96 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { EnhancedAssetProfile } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
import { ManualService } from './manual.service';
jest.mock('@ghostfolio/api/services/fetch/fetch.service', () => {
return {
FetchService: jest.fn()
};
});
describe('ManualService', () => {
let manualService: ManualService;
let prismaService: {
marketData: {
findFirst: jest.Mock;
};
};
let symbolProfileService: SymbolProfileService;
beforeEach(() => {
prismaService = {
marketData: {
findFirst: jest.fn()
}
};
symbolProfileService = new SymbolProfileService(null);
manualService = new ManualService(
null,
null,
prismaService as unknown as PrismaService,
symbolProfileService
);
});
describe('getQuotes', () => {
it('should use the latest market price of each symbol of the manual data source', async () => {
const latestMarketPrices: { [symbol: string]: number } = {
A: 13,
B: 22
};
prismaService.marketData.findFirst.mockImplementation(
({ where: { symbol } }) => {
return Promise.resolve(
latestMarketPrices[symbol]
? { symbol, marketPrice: latestMarketPrices[symbol] }
: null
);
}
);
jest.spyOn(symbolProfileService, 'getSymbolProfiles').mockResolvedValue(
['A', 'B', 'C'].map((symbol) => {
return { symbol, currency: 'USD' };
}) as EnhancedAssetProfile[]
);
const quotes = await manualService.getQuotes({
symbols: ['A', 'B', 'C']
});
expect(prismaService.marketData.findFirst).toHaveBeenCalledWith({
orderBy: { date: 'desc' },
where: { dataSource: DataSource.MANUAL, symbol: 'A' }
});
expect(quotes).toEqual({
A: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 13,
marketState: 'delayed'
},
B: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 22,
marketState: 'delayed'
},
// Without market data
C: {
currency: 'USD',
dataSource: DataSource.MANUAL,
marketPrice: 0,
marketState: 'delayed'
}
});
});
});
});

29
apps/api/src/services/data-provider/manual/manual.service.ts

@ -150,18 +150,21 @@ export class ManualService implements DataProviderInterface {
})
);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],
orderBy: {
date: 'desc'
},
take: symbols.length,
where: {
symbol: {
in: symbols
}
}
});
// Query the latest market data per symbol because distinct loads all
// the market data of the symbols into memory
const marketData = await Promise.all(
symbols.map((symbol) => {
return this.prismaService.marketData.findFirst({
orderBy: {
date: 'desc'
},
where: {
symbol,
dataSource: this.getName()
}
});
})
);
const symbolProfilesToScrape = symbolProfiles.filter(
({ scraperConfiguration }) => {
@ -202,7 +205,7 @@ export class ManualService implements DataProviderInterface {
marketPrice =
marketPrice ??
marketData.find((marketDataItem) => {
return marketDataItem.symbol === symbol;
return marketDataItem?.symbol === symbol;
})?.marketPrice ??
0;

Loading…
Cancel
Save