mirror of https://github.com/ghostfolio/ghostfolio
Browse Source
* Fix repeated historical market data gathering on weekends * Update changelogpull/7708/head^2
committed by
GitHub
12 changed files with 707 additions and 72 deletions
@ -0,0 +1,118 @@ |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
|
|||
import { MarketDataService } from './market-data.service'; |
|||
|
|||
describe('MarketDataService', () => { |
|||
let marketDataService: MarketDataService; |
|||
let prismaService: { |
|||
$transaction: jest.Mock; |
|||
marketData: { |
|||
createMany: jest.Mock; |
|||
deleteMany: jest.Mock; |
|||
upsert: jest.Mock; |
|||
}; |
|||
}; |
|||
|
|||
beforeEach(() => { |
|||
prismaService = { |
|||
$transaction: jest.fn(), |
|||
marketData: { |
|||
createMany: jest.fn(), |
|||
deleteMany: jest.fn(), |
|||
upsert: jest.fn().mockResolvedValue({}) |
|||
} |
|||
}; |
|||
|
|||
marketDataService = new MarketDataService(prismaService as any); |
|||
}); |
|||
|
|||
describe('updateMany', () => { |
|||
it('does not drop isCarriedForward', async () => { |
|||
prismaService.$transaction.mockImplementation((promises) => { |
|||
return Promise.all(promises); |
|||
}); |
|||
|
|||
await marketDataService.updateMany({ |
|||
data: [ |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2026-08-22'), |
|||
isCarriedForward: true, |
|||
marketPrice: 100, |
|||
state: 'CLOSE', |
|||
symbol: 'AAPL' |
|||
} |
|||
] |
|||
}); |
|||
|
|||
expect(prismaService.marketData.upsert).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
create: expect.objectContaining({ isCarriedForward: true }), |
|||
update: expect.objectContaining({ isCarriedForward: true }) |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('resets isCarriedForward if it is omitted by the caller', async () => { |
|||
prismaService.$transaction.mockImplementation((promises) => { |
|||
return Promise.all(promises); |
|||
}); |
|||
|
|||
await marketDataService.updateMany({ |
|||
data: [ |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2026-08-22'), |
|||
marketPrice: 100, |
|||
state: 'CLOSE', |
|||
symbol: 'AAPL' |
|||
} |
|||
] |
|||
}); |
|||
|
|||
expect(prismaService.marketData.upsert).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
update: expect.objectContaining({ isCarriedForward: false }) |
|||
}) |
|||
); |
|||
}); |
|||
}); |
|||
|
|||
describe('replaceForSymbol', () => { |
|||
it('does not drop isCarriedForward', async () => { |
|||
prismaService.$transaction.mockImplementation((callback) => { |
|||
return callback(prismaService); |
|||
}); |
|||
|
|||
await marketDataService.replaceForSymbol({ |
|||
data: [ |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2026-08-21'), |
|||
isCarriedForward: false, |
|||
marketPrice: 100, |
|||
state: 'CLOSE', |
|||
symbol: 'AAPL' |
|||
}, |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2026-08-22'), |
|||
isCarriedForward: true, |
|||
marketPrice: 100, |
|||
state: 'CLOSE', |
|||
symbol: 'AAPL' |
|||
} |
|||
], |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL' |
|||
}); |
|||
|
|||
const { data } = prismaService.marketData.createMany.mock.calls[0][0]; |
|||
|
|||
expect(data).toEqual([ |
|||
expect.objectContaining({ isCarriedForward: false }), |
|||
expect.objectContaining({ isCarriedForward: true }) |
|||
]); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,266 @@ |
|||
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; |
|||
import { |
|||
getAssetProfileIdentifier, |
|||
parseDate |
|||
} from '@ghostfolio/common/helper'; |
|||
|
|||
import { DataSource } from '@prisma/client'; |
|||
import { Job } from 'bull'; |
|||
|
|||
import { DataGatheringProcessor } from './data-gathering.processor'; |
|||
|
|||
describe('DataGatheringProcessor', () => { |
|||
let dataGatheringProcessor: DataGatheringProcessor; |
|||
let dataProviderService: { getHistoricalRaw: jest.Mock }; |
|||
let marketDataService: { replaceForSymbol: jest.Mock; updateMany: jest.Mock }; |
|||
|
|||
const createJob = ({ |
|||
dataSource, |
|||
date, |
|||
symbol |
|||
}: { |
|||
dataSource: DataSource; |
|||
date: string; |
|||
symbol: string; |
|||
}) => { |
|||
return { |
|||
data: { |
|||
dataSource, |
|||
symbol, |
|||
date: parseDate(date).toISOString() |
|||
} |
|||
} as unknown as Job<DataGatheringItem>; |
|||
}; |
|||
|
|||
const mockHistoricalData = ({ |
|||
dataSource, |
|||
prices, |
|||
symbol |
|||
}: { |
|||
dataSource: DataSource; |
|||
prices: { [date: string]: number }; |
|||
symbol: string; |
|||
}) => { |
|||
const assetProfileIdentifier = getAssetProfileIdentifier({ |
|||
dataSource, |
|||
symbol |
|||
}); |
|||
|
|||
const historicalData: { |
|||
[symbol: string]: { [date: string]: { marketPrice: number } }; |
|||
} = { [assetProfileIdentifier]: {} }; |
|||
|
|||
for (const [date, marketPrice] of Object.entries(prices)) { |
|||
historicalData[assetProfileIdentifier][date] = { marketPrice }; |
|||
} |
|||
|
|||
dataProviderService.getHistoricalRaw.mockResolvedValue(historicalData); |
|||
}; |
|||
|
|||
beforeAll(() => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2026-08-24').getTime()); |
|||
}); |
|||
|
|||
beforeEach(() => { |
|||
dataProviderService = { getHistoricalRaw: jest.fn() }; |
|||
marketDataService = { |
|||
replaceForSymbol: jest.fn(), |
|||
updateMany: jest.fn() |
|||
}; |
|||
|
|||
dataGatheringProcessor = new DataGatheringProcessor( |
|||
null, |
|||
dataProviderService as any, |
|||
marketDataService as any, |
|||
null |
|||
); |
|||
}); |
|||
|
|||
afterAll(() => { |
|||
jest.useRealTimers(); |
|||
}); |
|||
|
|||
it('writes an all-real series without carried forward market prices', async () => { |
|||
mockHistoricalData({ |
|||
dataSource: 'COINGECKO', |
|||
symbol: 'bitcoin', |
|||
prices: { |
|||
'2026-08-17': 1, |
|||
'2026-08-18': 2, |
|||
'2026-08-19': 3, |
|||
'2026-08-20': 4, |
|||
'2026-08-21': 5, |
|||
'2026-08-22': 6, |
|||
'2026-08-23': 7 |
|||
} |
|||
}); |
|||
|
|||
await dataGatheringProcessor.gatherHistoricalMarketData( |
|||
createJob({ |
|||
dataSource: 'COINGECKO', |
|||
date: '2026-08-17', |
|||
symbol: 'bitcoin' |
|||
}) |
|||
); |
|||
|
|||
const { data } = marketDataService.updateMany.mock.calls[0][0]; |
|||
|
|||
expect(data).toHaveLength(7); |
|||
expect( |
|||
data.every(({ isCarriedForward }) => { |
|||
return isCarriedForward === false; |
|||
}) |
|||
).toBe(true); |
|||
}); |
|||
|
|||
it('fills an interior gap with carried forward market prices', async () => { |
|||
mockHistoricalData({ |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL', |
|||
prices: { |
|||
'2026-08-17': 1, |
|||
'2026-08-18': 2, |
|||
'2026-08-21': 5, |
|||
'2026-08-22': 6, |
|||
'2026-08-23': 7 |
|||
} |
|||
}); |
|||
|
|||
await dataGatheringProcessor.gatherHistoricalMarketData( |
|||
createJob({ dataSource: 'YAHOO', date: '2026-08-17', symbol: 'AAPL' }) |
|||
); |
|||
|
|||
const { data } = marketDataService.updateMany.mock.calls[0][0]; |
|||
|
|||
expect(data).toEqual([ |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-17'), |
|||
isCarriedForward: false, |
|||
marketPrice: 1 |
|||
}), |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-18'), |
|||
isCarriedForward: false, |
|||
marketPrice: 2 |
|||
}), |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-19'), |
|||
isCarriedForward: true, |
|||
marketPrice: 2 |
|||
}), |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-20'), |
|||
isCarriedForward: true, |
|||
marketPrice: 2 |
|||
}), |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-21'), |
|||
isCarriedForward: false, |
|||
marketPrice: 5 |
|||
}), |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-22'), |
|||
isCarriedForward: false, |
|||
marketPrice: 6 |
|||
}), |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-23'), |
|||
isCarriedForward: false, |
|||
marketPrice: 7 |
|||
}) |
|||
]); |
|||
}); |
|||
|
|||
it('fills a trailing gap with carried forward market prices', async () => { |
|||
mockHistoricalData({ |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL', |
|||
prices: { |
|||
'2026-08-17': 1, |
|||
'2026-08-18': 2, |
|||
'2026-08-19': 3, |
|||
'2026-08-20': 4, |
|||
'2026-08-21': 5 |
|||
} |
|||
}); |
|||
|
|||
await dataGatheringProcessor.gatherHistoricalMarketData( |
|||
createJob({ dataSource: 'YAHOO', date: '2026-08-17', symbol: 'AAPL' }) |
|||
); |
|||
|
|||
const { data } = marketDataService.updateMany.mock.calls[0][0]; |
|||
|
|||
expect(data).toHaveLength(7); |
|||
expect(data.slice(5)).toEqual([ |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-22'), |
|||
isCarriedForward: true, |
|||
marketPrice: 5 |
|||
}), |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-23'), |
|||
isCarriedForward: true, |
|||
marketPrice: 5 |
|||
}) |
|||
]); |
|||
}); |
|||
|
|||
it('does not fill a leading gap', async () => { |
|||
mockHistoricalData({ |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL', |
|||
prices: { |
|||
'2026-08-19': 3, |
|||
'2026-08-20': 4, |
|||
'2026-08-21': 5, |
|||
'2026-08-22': 6, |
|||
'2026-08-23': 7 |
|||
} |
|||
}); |
|||
|
|||
await dataGatheringProcessor.gatherHistoricalMarketData( |
|||
createJob({ dataSource: 'YAHOO', date: '2026-08-17', symbol: 'AAPL' }) |
|||
); |
|||
|
|||
const { data } = marketDataService.updateMany.mock.calls[0][0]; |
|||
|
|||
expect(data).toHaveLength(5); |
|||
expect(data[0]).toEqual( |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-19'), |
|||
isCarriedForward: false, |
|||
marketPrice: 3 |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('labels a market price of 0 from the data provider as carried forward', async () => { |
|||
mockHistoricalData({ |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL', |
|||
prices: { |
|||
'2026-08-17': 1, |
|||
'2026-08-18': 2, |
|||
'2026-08-19': 0, |
|||
'2026-08-20': 4, |
|||
'2026-08-21': 5, |
|||
'2026-08-22': 6, |
|||
'2026-08-23': 7 |
|||
} |
|||
}); |
|||
|
|||
await dataGatheringProcessor.gatherHistoricalMarketData( |
|||
createJob({ dataSource: 'YAHOO', date: '2026-08-17', symbol: 'AAPL' }) |
|||
); |
|||
|
|||
const { data } = marketDataService.updateMany.mock.calls[0][0]; |
|||
|
|||
expect(data[2]).toEqual( |
|||
expect.objectContaining({ |
|||
date: parseDate('2026-08-19'), |
|||
isCarriedForward: true, |
|||
marketPrice: 2 |
|||
}) |
|||
); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,223 @@ |
|||
import { |
|||
DATA_GATHERING_QUEUE_PRIORITY_HIGH, |
|||
GATHER_HISTORICAL_MARKET_DATA_COOLDOWN_IN_MS |
|||
} from '@ghostfolio/common/config'; |
|||
import { parseDate } from '@ghostfolio/common/helper'; |
|||
|
|||
import { DataGatheringService } from './data-gathering.service'; |
|||
|
|||
describe('DataGatheringService', () => { |
|||
let dataGatheringQueue: { addBulk: jest.Mock; clean: jest.Mock }; |
|||
let dataGatheringService: DataGatheringService; |
|||
let dataProviderService: { getHistoricalRaw: jest.Mock }; |
|||
let prismaService: { marketData: { groupBy: jest.Mock; upsert: jest.Mock } }; |
|||
|
|||
beforeEach(() => { |
|||
dataGatheringQueue = { |
|||
addBulk: jest.fn().mockResolvedValue([]), |
|||
clean: jest.fn().mockResolvedValue([]) |
|||
}; |
|||
dataProviderService = { getHistoricalRaw: jest.fn() }; |
|||
prismaService = { |
|||
marketData: { |
|||
groupBy: jest.fn().mockResolvedValue([]), |
|||
upsert: jest.fn().mockResolvedValue({}) |
|||
} |
|||
}; |
|||
|
|||
dataGatheringService = new DataGatheringService( |
|||
null, |
|||
dataGatheringQueue as any, |
|||
dataProviderService as any, |
|||
null, |
|||
null, |
|||
prismaService as any, |
|||
null, |
|||
null |
|||
); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
jest.useRealTimers(); |
|||
}); |
|||
|
|||
describe('getAssetProfileIdentifiersWithRecentMarketData', () => { |
|||
it('excludes carried forward market prices from the query', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2026-08-23').getTime()); |
|||
|
|||
await dataGatheringService[ |
|||
'getAssetProfileIdentifiersWithRecentMarketData' |
|||
](); |
|||
|
|||
expect(prismaService.marketData.groupBy).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
where: expect.objectContaining({ |
|||
isCarriedForward: false, |
|||
state: 'CLOSE' |
|||
}) |
|||
}) |
|||
); |
|||
}); |
|||
|
|||
it('keeps a cryptocurrency with a real market price of yesterday on Sunday', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2026-08-23').getTime()); |
|||
|
|||
prismaService.marketData.groupBy.mockResolvedValue([ |
|||
{ |
|||
_max: { date: parseDate('2026-08-22') }, |
|||
dataSource: 'COINGECKO', |
|||
symbol: 'bitcoin' |
|||
}, |
|||
{ |
|||
_max: { date: parseDate('2026-08-21') }, |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL' |
|||
} |
|||
]); |
|||
|
|||
const assetProfileIdentifiers = |
|||
await dataGatheringService[ |
|||
'getAssetProfileIdentifiersWithRecentMarketData' |
|||
](); |
|||
|
|||
expect(assetProfileIdentifiers).toEqual([ |
|||
{ dataSource: 'COINGECKO', symbol: 'bitcoin' } |
|||
]); |
|||
}); |
|||
|
|||
it('drops a stock with a real market price of Friday on Monday', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2026-08-24').getTime()); |
|||
|
|||
prismaService.marketData.groupBy.mockResolvedValue([ |
|||
{ |
|||
_max: { date: parseDate('2026-08-23') }, |
|||
dataSource: 'COINGECKO', |
|||
symbol: 'bitcoin' |
|||
}, |
|||
{ |
|||
_max: { date: parseDate('2026-08-21') }, |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL' |
|||
} |
|||
]); |
|||
|
|||
const assetProfileIdentifiers = |
|||
await dataGatheringService[ |
|||
'getAssetProfileIdentifiersWithRecentMarketData' |
|||
](); |
|||
|
|||
expect(assetProfileIdentifiers).toEqual([ |
|||
{ dataSource: 'COINGECKO', symbol: 'bitcoin' } |
|||
]); |
|||
}); |
|||
|
|||
it('drops a stock with a late Friday close on Saturday', async () => { |
|||
jest.useFakeTimers().setSystemTime(parseDate('2026-08-22').getTime()); |
|||
|
|||
prismaService.marketData.groupBy.mockResolvedValue([ |
|||
{ |
|||
_max: { date: parseDate('2026-08-20') }, |
|||
dataSource: 'YAHOO', |
|||
symbol: 'AAPL' |
|||
}, |
|||
{ |
|||
_max: { date: parseDate('2026-08-21') }, |
|||
dataSource: 'YAHOO', |
|||
symbol: 'MSFT' |
|||
} |
|||
]); |
|||
|
|||
const assetProfileIdentifiers = |
|||
await dataGatheringService[ |
|||
'getAssetProfileIdentifiersWithRecentMarketData' |
|||
](); |
|||
|
|||
expect(assetProfileIdentifiers).toEqual([ |
|||
{ dataSource: 'YAHOO', symbol: 'MSFT' } |
|||
]); |
|||
}); |
|||
}); |
|||
|
|||
describe('gatherRecentMarketData', () => { |
|||
it('expires completed jobs which are older than the cooldown', async () => { |
|||
jest |
|||
.spyOn(dataGatheringService as any, 'getCurrencies7D') |
|||
.mockResolvedValue([]); |
|||
jest |
|||
.spyOn(dataGatheringService as any, 'getSymbols7D') |
|||
.mockResolvedValue([]); |
|||
|
|||
await dataGatheringService.gatherRecentMarketData(); |
|||
|
|||
expect(dataGatheringQueue.clean).toHaveBeenCalledWith( |
|||
GATHER_HISTORICAL_MARKET_DATA_COOLDOWN_IN_MS, |
|||
'completed' |
|||
); |
|||
}); |
|||
|
|||
it('retains its completed jobs for the duration of the cooldown', async () => { |
|||
jest |
|||
.spyOn(dataGatheringService as any, 'getCurrencies7D') |
|||
.mockResolvedValue([ |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2026-08-01'), |
|||
symbol: 'AAPL' |
|||
} |
|||
]); |
|||
jest |
|||
.spyOn(dataGatheringService as any, 'getSymbols7D') |
|||
.mockResolvedValue([]); |
|||
|
|||
await dataGatheringService.gatherRecentMarketData(); |
|||
|
|||
const [jobs] = dataGatheringQueue.addBulk.mock.calls[0]; |
|||
|
|||
expect(jobs[0].opts.removeOnComplete).toEqual({ |
|||
age: GATHER_HISTORICAL_MARKET_DATA_COOLDOWN_IN_MS / 1000 |
|||
}); |
|||
}); |
|||
}); |
|||
|
|||
describe('gatherSymbols', () => { |
|||
it('does not apply the cooldown to a manually triggered gathering', async () => { |
|||
await dataGatheringService.gatherSymbols({ |
|||
dataGatheringItems: [ |
|||
{ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2026-08-01'), |
|||
symbol: 'AAPL' |
|||
} |
|||
], |
|||
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH |
|||
}); |
|||
|
|||
const [jobs] = dataGatheringQueue.addBulk.mock.calls[0]; |
|||
|
|||
expect(jobs[0].opts.removeOnComplete).toBe(true); |
|||
}); |
|||
}); |
|||
|
|||
describe('gatherSymbolForDate', () => { |
|||
it('resets isCarriedForward on a previously carried forward market price', async () => { |
|||
dataProviderService.getHistoricalRaw.mockResolvedValue({ |
|||
'YAHOO-AAPL': { |
|||
'2026-08-22': { marketPrice: 100 } |
|||
} |
|||
}); |
|||
|
|||
await dataGatheringService.gatherSymbolForDate({ |
|||
dataSource: 'YAHOO', |
|||
date: parseDate('2026-08-22'), |
|||
symbol: 'AAPL' |
|||
}); |
|||
|
|||
expect(prismaService.marketData.upsert).toHaveBeenCalledWith( |
|||
expect.objectContaining({ |
|||
create: expect.objectContaining({ isCarriedForward: false }), |
|||
update: { marketPrice: 100, isCarriedForward: false } |
|||
}) |
|||
); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,2 @@ |
|||
-- AlterTable |
|||
ALTER TABLE "MarketData" ADD COLUMN "isCarriedForward" BOOLEAN NOT NULL DEFAULT false; |
|||
Loading…
Reference in new issue