Browse Source

Merge branch 'main' into task/migrate-create-and-edit-access-dialogs-to-dedicated-routes

pull/7711/head
Thomas Kaul 7 days ago
committed by GitHub
parent
commit
e9bface314
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 18
      CHANGELOG.md
  2. 28
      apps/api/src/app/admin/admin.service.ts
  3. 3
      apps/api/src/app/portfolio/current-rate.service.spec.ts
  4. 118
      apps/api/src/services/market-data/market-data.service.spec.ts
  5. 43
      apps/api/src/services/market-data/market-data.service.ts
  6. 266
      apps/api/src/services/queues/data-gathering/data-gathering.processor.spec.ts
  7. 12
      apps/api/src/services/queues/data-gathering/data-gathering.processor.ts
  8. 223
      apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts
  9. 53
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  10. 161
      apps/client/src/app/components/admin-platform/admin-platform.component.html
  11. 8
      apps/client/src/app/components/admin-platform/admin-platform.component.ts
  12. 326
      apps/client/src/app/components/admin-settings/admin-settings.component.html
  13. 1
      apps/client/src/app/components/admin-settings/admin-settings.component.ts
  14. 203
      apps/client/src/app/components/admin-tag/admin-tag.component.html
  15. 1
      libs/common/src/lib/config.ts
  16. 4
      package-lock.json
  17. 2
      package.json
  18. 2
      prisma/migrations/20260824000000_added_is_carried_forward_to_market_data/migration.sql
  19. 15
      prisma/schema.prisma

18
CHANGELOG.md

@ -11,6 +11,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Migrated the create and edit access dialogs to dedicated routes - Migrated the create and edit access dialogs to dedicated routes
## 3.60.0 - 2026-08-24
### Added
- Added `isCarriedForward` to the `MarketData` database schema
### Changed
- Improved the style of the table in the data providers management of the admin control panel
- Improved the style of the table in the platform management of the admin control panel
- Improved the style of the table in the tag management of the admin control panel
- Improved the historical market data gathering by storing the market prices carried forward for the most recent dates without data from the data provider, distinguished by `isCarriedForward`
- Introduced a cooldown of 12 hours for the historical market data gathering of a symbol by retaining the completed jobs
### Fixed
- Fixed the repeated historical market data gathering for symbols without weekend market data on Sundays and Mondays
## 3.59.1 - 2026-08-23 ## 3.59.1 - 2026-08-23
### Added ### Added

28
apps/api/src/app/admin/admin.service.ts

@ -347,7 +347,12 @@ export class AdminService {
} }
const marketDataItems = await this.prismaService.marketData.findMany({ const marketDataItems = await this.prismaService.marketData.findMany({
select: { date: true, marketPrice: true, state: true }, select: {
date: true,
isCarriedForward: true,
marketPrice: true,
state: true
},
where: { where: {
dataSource: sourceAssetProfileIdentifier.dataSource, dataSource: sourceAssetProfileIdentifier.dataSource,
symbol: sourceAssetProfileIdentifier.symbol symbol: sourceAssetProfileIdentifier.symbol
@ -370,15 +375,18 @@ export class AdminService {
where: { id: targetAssetProfile.id } where: { id: targetAssetProfile.id }
}), }),
this.prismaService.marketData.createMany({ this.prismaService.marketData.createMany({
data: marketDataItems.map(({ date, marketPrice, state }) => { data: marketDataItems.map(
return { ({ date, isCarriedForward, marketPrice, state }) => {
date, return {
marketPrice, date,
state, isCarriedForward,
dataSource: targetAssetProfileIdentifier.dataSource, marketPrice,
symbol: targetAssetProfileIdentifier.symbol state,
}; dataSource: targetAssetProfileIdentifier.dataSource,
}), symbol: targetAssetProfileIdentifier.symbol
};
}
),
skipDuplicates: true skipDuplicates: true
}), }),
// The market data has no relation to the asset profile and is therefore // The market data has no relation to the asset profile and is therefore

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

@ -20,6 +20,7 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
createdAt: date, createdAt: date,
dataSource: DataSource.YAHOO, dataSource: DataSource.YAHOO,
id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584', id: 'aefcbe3a-ee10-4c4f-9f2d-8ffad7b05584',
isCarriedForward: false,
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE' state: 'CLOSE'
}); });
@ -39,6 +40,7 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
dataSource: assetProfileIdentifiers[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateQuery.gte, date: dateQuery.gte,
id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d', id: '8fa48fde-f397-4b0d-adbc-fb940e830e6d',
isCarriedForward: false,
marketPrice: 1841.823902, marketPrice: 1841.823902,
state: 'CLOSE', state: 'CLOSE',
symbol: assetProfileIdentifiers[0].symbol symbol: assetProfileIdentifiers[0].symbol
@ -48,6 +50,7 @@ jest.mock('@ghostfolio/api/services/market-data/market-data.service', () => {
dataSource: assetProfileIdentifiers[0].dataSource, dataSource: assetProfileIdentifiers[0].dataSource,
date: dateQuery.lt, date: dateQuery.lt,
id: '082d6893-df27-4c91-8a5d-092e84315b56', id: '082d6893-df27-4c91-8a5d-092e84315b56',
isCarriedForward: false,
marketPrice: 1847.839966, marketPrice: 1847.839966,
state: 'CLOSE', state: 'CLOSE',
symbol: assetProfileIdentifiers[0].symbol symbol: assetProfileIdentifiers[0].symbol

118
apps/api/src/services/market-data/market-data.service.spec.ts

@ -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 })
]);
});
});
});

43
apps/api/src/services/market-data/market-data.service.ts

@ -2,7 +2,6 @@ import { DateQuery } from '@ghostfolio/api/app/portfolio/interfaces/date-query.i
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces'; import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT } from '@ghostfolio/common/config'; import { DEFAULT_PROCESSOR_GATHER_HISTORICAL_MARKET_DATA_TIMEOUT } from '@ghostfolio/common/config';
import { UpdateMarketDataDto } from '@ghostfolio/common/dtos';
import { resetHours } from '@ghostfolio/common/helper'; import { resetHours } from '@ghostfolio/common/helper';
import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces';
@ -189,13 +188,16 @@ export class MarketDataService {
}); });
await prisma.marketData.createMany({ await prisma.marketData.createMany({
data: data.map(({ date, marketPrice, state }) => ({ data: data.map(
dataSource, ({ date, isCarriedForward, marketPrice, state }) => ({
symbol, dataSource,
date: date as Date, symbol,
marketPrice: marketPrice as number, date: date as Date,
state: state as MarketDataState isCarriedForward: isCarriedForward as boolean,
})), marketPrice: marketPrice as number,
state: state as MarketDataState
})
),
skipDuplicates: true skipDuplicates: true
}); });
} }
@ -220,27 +222,6 @@ export class MarketDataService {
}); });
} }
public async updateMarketData(params: {
data: {
state: MarketDataState;
} & UpdateMarketDataDto;
where: Prisma.MarketDataWhereUniqueInput;
}): Promise<MarketData> {
const { data, where } = params;
return this.prismaService.marketData.upsert({
where,
create: {
dataSource: where.dataSource_date_symbol.dataSource,
date: where.dataSource_date_symbol.date,
marketPrice: data.marketPrice,
state: data.state,
symbol: where.dataSource_date_symbol.symbol
},
update: { marketPrice: data.marketPrice, state: data.state }
});
}
/** /**
* Upsert market data by imitating missing upsertMany functionality * Upsert market data by imitating missing upsertMany functionality
* with $transaction * with $transaction
@ -251,16 +232,18 @@ export class MarketDataService {
data: Prisma.MarketDataUpdateInput[]; data: Prisma.MarketDataUpdateInput[];
}): Promise<MarketData[]> { }): Promise<MarketData[]> {
const upsertPromises = data.map( const upsertPromises = data.map(
({ dataSource, date, marketPrice, symbol, state }) => { ({ dataSource, date, isCarriedForward, marketPrice, symbol, state }) => {
return this.prismaService.marketData.upsert({ return this.prismaService.marketData.upsert({
create: { create: {
dataSource: dataSource as DataSource, dataSource: dataSource as DataSource,
date: date as Date, date: date as Date,
isCarriedForward: isCarriedForward as boolean,
marketPrice: marketPrice as number, marketPrice: marketPrice as number,
state: state as MarketDataState, state: state as MarketDataState,
symbol: symbol as string symbol: symbol as string
}, },
update: { update: {
isCarriedForward: (isCarriedForward ?? false) as boolean,
marketPrice: marketPrice as number, marketPrice: marketPrice as number,
state: state as MarketDataState state: state as MarketDataState
}, },

266
apps/api/src/services/queues/data-gathering/data-gathering.processor.spec.ts

@ -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
})
);
});
});

12
apps/api/src/services/queues/data-gathering/data-gathering.processor.ts

@ -125,7 +125,6 @@ export class DataGatheringProcessor {
const data: Prisma.MarketDataUpdateInput[] = []; const data: Prisma.MarketDataUpdateInput[] = [];
let lastMarketPrice: number; let lastMarketPrice: number;
let numberOfMarketDataItemsToKeep = 0;
while ( while (
isBefore( isBefore(
@ -154,24 +153,15 @@ export class DataGatheringProcessor {
dataSource, dataSource,
symbol, symbol,
date: getStartOfUtcDate(currentDate), date: getStartOfUtcDate(currentDate),
isCarriedForward: lastMarketPrice !== marketPriceOfDataProvider,
marketPrice: lastMarketPrice, marketPrice: lastMarketPrice,
state: 'CLOSE' state: 'CLOSE'
}); });
if (marketPriceOfDataProvider) {
numberOfMarketDataItemsToKeep = data.length;
}
} }
currentDate = addDays(currentDate, 1); currentDate = addDays(currentDate, 1);
} }
// A gap at the end means that the market data is not available yet, in
// contrast to a gap in between, which means that the market was closed.
// Therefore, the market prices which are carried forward after the last
// market price of the data provider are discarded.
data.splice(numberOfMarketDataItemsToKeep);
if (force) { if (force) {
await this.marketDataService.replaceForSymbol({ await this.marketDataService.replaceForSymbol({
data, data,

223
apps/api/src/services/queues/data-gathering/data-gathering.service.spec.ts

@ -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 }
})
);
});
});
});

53
apps/api/src/services/queues/data-gathering/data-gathering.service.ts

@ -11,6 +11,7 @@ import {
DATA_GATHERING_QUEUE_PRIORITY_HIGH, DATA_GATHERING_QUEUE_PRIORITY_HIGH,
DATA_GATHERING_QUEUE_PRIORITY_LOW, DATA_GATHERING_QUEUE_PRIORITY_LOW,
DATA_GATHERING_QUEUE_PRIORITY_MEDIUM, DATA_GATHERING_QUEUE_PRIORITY_MEDIUM,
GATHER_HISTORICAL_MARKET_DATA_COOLDOWN_IN_MS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
PROPERTY_BENCHMARKS PROPERTY_BENCHMARKS
@ -30,7 +31,14 @@ import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common'; import { Inject, Injectable, Logger } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { Job, JobOptions, Queue } from 'bull'; import { Job, JobOptions, Queue } from 'bull';
import { format, min, subDays, subMilliseconds, subYears } from 'date-fns'; import {
format,
isBefore,
min,
subDays,
subMilliseconds,
subYears
} from 'date-fns';
import { isEmpty } from 'lodash'; import { isEmpty } from 'lodash';
import ms, { StringValue } from 'ms'; import ms, { StringValue } from 'ms';
@ -252,12 +260,23 @@ export class DataGatheringService {
} }
public async gatherRecentMarketData() { public async gatherRecentMarketData() {
await this.dataGatheringQueue.clean(
GATHER_HISTORICAL_MARKET_DATA_COOLDOWN_IN_MS,
'completed'
);
const removeOnComplete = {
age: GATHER_HISTORICAL_MARKET_DATA_COOLDOWN_IN_MS / 1000
};
await this.gatherSymbols({ await this.gatherSymbols({
removeOnComplete,
dataGatheringItems: await this.getCurrencies7D(), dataGatheringItems: await this.getCurrencies7D(),
priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH priority: DATA_GATHERING_QUEUE_PRIORITY_HIGH
}); });
await this.gatherSymbols({ await this.gatherSymbols({
removeOnComplete,
dataGatheringItems: await this.getSymbols7D({ dataGatheringItems: await this.getSymbols7D({
withUserSubscription: true withUserSubscription: true
}), }),
@ -265,6 +284,7 @@ export class DataGatheringService {
}); });
await this.gatherSymbols({ await this.gatherSymbols({
removeOnComplete,
dataGatheringItems: await this.getSymbols7D({ dataGatheringItems: await this.getSymbols7D({
withUserSubscription: false withUserSubscription: false
}), }),
@ -320,9 +340,10 @@ export class DataGatheringService {
dataSource, dataSource,
date, date,
marketPrice, marketPrice,
symbol symbol,
isCarriedForward: false
}, },
update: { marketPrice }, update: { marketPrice, isCarriedForward: false },
where: { dataSource_date_symbol: { dataSource, date, symbol } } where: { dataSource_date_symbol: { dataSource, date, symbol } }
}); });
} }
@ -336,11 +357,13 @@ export class DataGatheringService {
public async gatherSymbols({ public async gatherSymbols({
dataGatheringItems, dataGatheringItems,
force = false, force = false,
priority priority,
removeOnComplete = GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS.removeOnComplete
}: { }: {
dataGatheringItems: DataGatheringItem[]; dataGatheringItems: DataGatheringItem[];
force?: boolean; force?: boolean;
priority: number; priority: number;
removeOnComplete?: JobOptions['removeOnComplete'];
}): Promise<Job[]> { }): Promise<Job[]> {
return this.addJobsToQueue( return this.addJobsToQueue(
dataGatheringItems.map(({ dataSource, date, symbol }) => { dataGatheringItems.map(({ dataSource, date, symbol }) => {
@ -355,6 +378,7 @@ export class DataGatheringService {
opts: { opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS, ...GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS,
priority, priority,
removeOnComplete,
jobId: `${getAssetProfileIdentifier({ jobId: `${getAssetProfileIdentifier({
dataSource, dataSource,
symbol symbol
@ -397,22 +421,23 @@ export class DataGatheringService {
}); });
} }
private async getAssetProfileIdentifiersWithCompleteMarketData(): Promise< private async getAssetProfileIdentifiersWithRecentMarketData(): Promise<
AssetProfileIdentifier[] AssetProfileIdentifier[]
> { > {
return ( return (
await this.prismaService.marketData.groupBy({ await this.prismaService.marketData.groupBy({
_count: true, _max: { date: true },
by: ['dataSource', 'symbol'], by: ['dataSource', 'symbol'],
orderBy: [{ symbol: 'asc' }], orderBy: [{ symbol: 'asc' }],
where: { where: {
date: { gt: subDays(resetHours(new Date()), 7) }, date: { gt: subDays(resetHours(new Date()), 7) },
isCarriedForward: false,
state: 'CLOSE' state: 'CLOSE'
} }
}) })
) )
.filter(({ _count }) => { .filter(({ _max }) => {
return _count >= 6; return !isBefore(_max.date, getStartOfUtcDate(subDays(new Date(), 1)));
}) })
.map(({ dataSource, symbol }) => { .map(({ dataSource, symbol }) => {
return { dataSource, symbol }; return { dataSource, symbol };
@ -420,13 +445,13 @@ export class DataGatheringService {
} }
private async getCurrencies7D(): Promise<DataGatheringItem[]> { private async getCurrencies7D(): Promise<DataGatheringItem[]> {
const assetProfileIdentifiersWithCompleteMarketData = const assetProfileIdentifiersWithRecentMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData(); await this.getAssetProfileIdentifiersWithRecentMarketData();
return this.exchangeRateDataService return this.exchangeRateDataService
.getCurrencyPairs() .getCurrencyPairs()
.filter(({ dataSource, symbol }) => { .filter(({ dataSource, symbol }) => {
return !assetProfileIdentifiersWithCompleteMarketData.some((item) => { return !assetProfileIdentifiersWithRecentMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol; return item.dataSource === dataSource && item.symbol === symbol;
}); });
}) })
@ -485,8 +510,8 @@ export class DataGatheringService {
} }
); );
const assetProfileIdentifiersWithCompleteMarketData = const assetProfileIdentifiersWithRecentMarketData =
await this.getAssetProfileIdentifiersWithCompleteMarketData(); await this.getAssetProfileIdentifiersWithRecentMarketData();
return symbolProfiles return symbolProfiles
.filter(({ dataSource, scraperConfiguration, symbol }) => { .filter(({ dataSource, scraperConfiguration, symbol }) => {
@ -494,7 +519,7 @@ export class DataGatheringService {
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration); dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return ( return (
!assetProfileIdentifiersWithCompleteMarketData.some((item) => { !assetProfileIdentifiersWithRecentMarketData.some((item) => {
return item.dataSource === dataSource && item.symbol === symbol; return item.dataSource === dataSource && item.symbol === symbol;
}) && }) &&
(dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration) (dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration)

161
apps/client/src/app/components/admin-platform/admin-platform.component.html

@ -9,92 +9,99 @@
Add Platform Add Platform
</a> </a>
</div> </div>
<table <div class="overflow-x-auto">
class="gf-table w-100" <table
mat-table class="gf-table w-100"
matSort mat-table
matSortActive="name" matSort
matSortDirection="asc" matSortActive="name"
[dataSource]="dataSource" matSortDirection="asc"
> [dataSource]="dataSource"
<ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
<ng-container i18n>Name</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-entity-logo
class="d-inline mr-1"
[hasPlaceholder]="true"
[tooltip]="element.name"
[url]="element.url"
/>
<span>{{ element.name }}</span>
</td></ng-container
> >
<ng-container matColumnDef="icon" sticky>
<th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<gf-entity-logo
[hasPlaceholder]="true"
[tooltip]="element.name"
[url]="element.url"
/>
</td>
</ng-container>
<ng-container matColumnDef="url"> <ng-container matColumnDef="name">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="url"> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
<ng-container i18n>Url</ng-container> <ng-container i18n>Name</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
{{ element.url }} {{ element.name }}
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="accounts"> <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="justify-content-end px-1" </th>
mat-header-cell <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
mat-sort-header="accountCount" {{ element.url }}
> </td>
<ng-container i18n>Accounts</ng-container> </ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value
class="d-inline-block justify-content-end"
[locale]="locale()"
[value]="element.accountCount"
/>
</td>
</ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="accounts">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th> <th
<td *matCellDef="let element" class="px-1 text-center" mat-cell> *matHeaderCellDef
<button class="justify-content-end px-1"
class="mx-1 no-min-width px-2" mat-header-cell
mat-button mat-sort-header="accountCount"
[matMenuTriggerFor]="platformMenu"
(click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal" /> <ng-container i18n>Accounts</ng-container>
</button> </th>
<mat-menu #platformMenu="matMenu" xPosition="before"> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<button mat-menu-item (click)="onUpdatePlatform(element)"> <gf-value
<span class="align-items-center d-flex"> class="d-inline-block justify-content-end"
<ion-icon class="mr-2" name="create-outline" /> [locale]="locale()"
<span><ng-container i18n>Edit</ng-container>...</span> [value]="element.accountCount"
</span> />
</button> </td>
<hr class="m-0" /> </ng-container>
<ng-container matColumnDef="actions" stickyEnd>
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button <button
mat-menu-item class="mx-1 no-min-width px-2"
[disabled]="element.accountCount > 0" mat-button
(click)="onDeletePlatform(element.id)" [matMenuTriggerFor]="platformMenu"
(click)="$event.stopPropagation()"
> >
<span class="align-items-center d-flex"> <ion-icon name="ellipsis-horizontal" />
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> <mat-menu #platformMenu="matMenu" xPosition="before">
</td> <button mat-menu-item (click)="onUpdatePlatform(element)">
</ng-container> <span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="element.accountCount > 0"
(click)="onDeletePlatform(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</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>
<mat-paginator <mat-paginator
[class.d-none]="dataSource.data.length <= pageSize" [class.d-none]="dataSource.data.length <= pageSize"

8
apps/client/src/app/components/admin-platform/admin-platform.component.ts

@ -61,7 +61,13 @@ export class GfAdminPlatformComponent implements OnInit {
public readonly locale = input(getLocale()); public readonly locale = input(getLocale());
protected dataSource = new MatTableDataSource<Platform>(); protected dataSource = new MatTableDataSource<Platform>();
protected readonly displayedColumns = ['name', 'url', 'accounts', 'actions']; protected readonly displayedColumns = [
'icon',
'name',
'url',
'accounts',
'actions'
];
protected readonly pageSize = DEFAULT_PAGE_SIZE; protected readonly pageSize = DEFAULT_PAGE_SIZE;
protected platforms: Platform[]; protected platforms: Platform[];

326
apps/client/src/app/components/admin-settings/admin-settings.component.html

@ -40,179 +40,181 @@
</mat-card-actions> </mat-card-actions>
</mat-card> </mat-card>
} }
<table <div class="overflow-x-auto">
class="gf-table w-100" <table
mat-table class="gf-table w-100"
matSort mat-table
matSortActive="name" matSort
matSortDirection="asc" matSortActive="name"
[dataSource]="dataSource" matSortDirection="asc"
> [dataSource]="dataSource"
<ng-container matColumnDef="name"> >
<th <ng-container matColumnDef="icon" sticky>
*matHeaderCellDef <th *matHeaderCellDef class="px-1" mat-header-cell></th>
class="px-1" <td *matCellDef="let element" class="px-1 text-center" mat-cell>
mat-header-cell <gf-entity-logo [hasPlaceholder]="true" [url]="element.url" />
mat-sort-header="name" </td>
> </ng-container>
<ng-container i18n>Name</ng-container>
</th> <ng-container matColumnDef="name">
<td *matCellDef="let element" class="px-1" mat-cell> <th
<div class="d-flex align-items-center"> *matHeaderCellDef
<gf-entity-logo class="px-1"
class="mr-1" mat-header-cell
[hasPlaceholder]="true" mat-sort-header="name"
[url]="element.url" >
/> <ng-container i18n>Name</ng-container>
<div> </th>
@if (isGhostfolioDataProvider(element)) { <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<a @if (isGhostfolioDataProvider(element)) {
class="align-items-center d-inline-flex" <a
target="_blank" class="align-items-center d-inline-flex"
[href]="pricingUrl" target="_blank"
> [href]="pricingUrl"
Ghostfolio Premium >
<gf-premium-indicator Ghostfolio Premium
class="d-inline-block ml-1" <gf-premium-indicator
[enableLink]="false" class="d-inline-block ml-1"
/> [enableLink]="false"
@if (isGhostfolioApiKeyValid === false) { />
<span @if (isGhostfolioApiKeyValid === false) {
class="badge badge-pill badge-secondary ml-2 text-uppercase" <span
i18n class="badge badge-pill badge-secondary ml-2 text-uppercase"
>popular</span i18n
> >popular</span
} >
</a>
@if (isGhostfolioApiKeyValid === true) {
<div class="line-height-1">
<small class="text-muted">
<ng-container i18n>Valid until</ng-container>
{{
ghostfolioApiStatus?.subscription?.expiresAt
| date: defaultDateFormat
}}
</small>
</div>
} }
} @else { </a>
{{ element.name }} @if (isGhostfolioApiKeyValid === true) {
<div class="line-height-1">
<small class="text-muted">
<ng-container i18n>Valid until</ng-container>
{{
ghostfolioApiStatus?.subscription?.expiresAt
| date: defaultDateFormat
}}
</small>
</div>
} }
</div> } @else {
</div> {{ element.name }}
</td> }
</ng-container> </td>
</ng-container>
<ng-container matColumnDef="status"> <ng-container matColumnDef="status">
<th *matHeaderCellDef class="px-1" mat-header-cell> <th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Status</ng-container> <ng-container i18n>Status</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if ( @if (
hasGhostfolioApiKey && hasGhostfolioApiKey &&
isGhostfolioApiKeyValid === false && isGhostfolioApiKeyValid === false &&
isGhostfolioDataProvider(element) isGhostfolioDataProvider(element)
) { ) {
<span class="text-danger" i18n>Invalid API key</span> <span class="text-danger" i18n>Invalid API key</span>
} @else if ( } @else if (
hasGhostfolioApiKey || !isGhostfolioDataProvider(element) hasGhostfolioApiKey || !isGhostfolioDataProvider(element)
) { ) {
<gf-data-provider-status [dataSource]="element.dataSource" /> <gf-data-provider-status [dataSource]="element.dataSource" />
} }
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="assetProfileCount"> <ng-container matColumnDef="assetProfileCount">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header="assetProfileCount" mat-sort-header="assetProfileCount"
> >
<ng-container i18n>Asset Profiles</ng-container> <ng-container i18n>Asset Profiles</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="user?.settings?.locale" [locale]="user?.settings?.locale"
[value]="element.assetProfileCount" [value]="element.assetProfileCount"
/> />
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="usage"> <ng-container matColumnDef="usage">
<th *matHeaderCellDef class="px-1" mat-header-cell></th> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1" mat-cell> <td *matCellDef="let element" class="px-1" mat-cell>
@if ( @if (
isGhostfolioDataProvider(element) && isGhostfolioDataProvider(element) &&
isGhostfolioApiKeyValid === true isGhostfolioApiKeyValid === true
) { ) {
<div <div
[matTooltip]="ghostfolioApiStatusTooltip" [matTooltip]="ghostfolioApiStatusTooltip"
[matTooltipDisabled]="!ghostfolioApiStatus.isWithinSetupPeriod" [matTooltipDisabled]="
> !ghostfolioApiStatus.isWithinSetupPeriod
<mat-progress-bar
mode="determinate"
[value]="
100 -
(ghostfolioApiStatus.dailyRequests /
ghostfolioApiStatus.dailyRequestsMax) *
100
" "
/> >
<small class="text-muted"> <mat-progress-bar
{{ ghostfolioApiStatus.dailyRequests }} mode="determinate"
<ng-container i18n>of</ng-container> [value]="
{{ ghostfolioApiStatus.dailyRequestsMax }} 100 -
<ng-container i18n>daily requests</ng-container> (ghostfolioApiStatus.dailyRequests /
</small> ghostfolioApiStatus.dailyRequestsMax) *
</div> 100
} "
</td> />
</ng-container> <small class="text-muted">
{{ ghostfolioApiStatus.dailyRequests }}
<ng-container i18n>of</ng-container>
{{ ghostfolioApiStatus.dailyRequestsMax }}
<ng-container i18n>daily requests</ng-container>
</small>
</div>
}
</td>
</ng-container>
<ng-container matColumnDef="actions"> <ng-container matColumnDef="actions">
<th *matHeaderCellDef class="px-1" mat-header-cell></th> <th *matHeaderCellDef class="px-1" mat-header-cell></th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
@if (isGhostfolioDataProvider(element)) { @if (isGhostfolioDataProvider(element)) {
@if (hasGhostfolioApiKey) { @if (hasGhostfolioApiKey) {
<button <button
class="mx-1 no-min-width px-2" class="mx-1 no-min-width px-2"
mat-button mat-button
[matMenuTriggerFor]="ghostfolioApiMenu" [matMenuTriggerFor]="ghostfolioApiMenu"
(click)="$event.stopPropagation()" (click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal" /> <ion-icon name="ellipsis-horizontal" />
</button>
<mat-menu
#ghostfolioApiMenu="matMenu"
class="no-max-width"
xPosition="before"
>
<button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Remove API key</span>
</span>
</button> </button>
</mat-menu> <mat-menu
} @else if (hasGhostfolioApiKey === false) { #ghostfolioApiMenu="matMenu"
<button class="no-max-width"
class="special" xPosition="before"
mat-flat-button >
(click)="onSetGhostfolioApiKey()" <button mat-menu-item (click)="onRemoveGhostfolioApiKey()">
> <span class="align-items-center d-flex">
<ng-container i18n>Set API key</ng-container> <ion-icon class="mr-2" name="trash-outline" />
</button> <span i18n>Remove API key</span>
</span>
</button>
</mat-menu>
} @else if (hasGhostfolioApiKey === false) {
<button
class="special text-nowrap"
mat-flat-button
(click)="onSetGhostfolioApiKey()"
>
<ng-container i18n>Set API key</ng-container>
</button>
}
} }
} </td>
</td> </ng-container>
</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>
@if (isLoading) { @if (isLoading) {
<ngx-skeleton-loader <ngx-skeleton-loader
animation="pulse" animation="pulse"

1
apps/client/src/app/components/admin-settings/admin-settings.component.ts

@ -76,6 +76,7 @@ export class GfAdminSettingsComponent implements OnInit {
public dataSource = new MatTableDataSource<DataProviderInfo>(); public dataSource = new MatTableDataSource<DataProviderInfo>();
public defaultDateFormat: string; public defaultDateFormat: string;
public displayedColumns = [ public displayedColumns = [
'icon',
'name', 'name',
'status', 'status',
'assetProfileCount', 'assetProfileCount',

203
apps/client/src/app/components/admin-tag/admin-tag.component.html

@ -9,112 +9,119 @@
Add Tag Add Tag
</a> </a>
</div> </div>
<table <div class="overflow-x-auto">
class="gf-table w-100" <table
mat-table class="gf-table w-100"
matSort mat-table
matSortActive="name" matSort
matSortDirection="asc" matSortActive="name"
[dataSource]="dataSource" matSortDirection="asc"
> [dataSource]="dataSource"
<ng-container matColumnDef="name"> >
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name"> <ng-container matColumnDef="name">
<ng-container i18n>Name</ng-container> <th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="name">
</th> <ng-container i18n>Name</ng-container>
<td *matCellDef="let element" class="px-1" mat-cell> </th>
{{ translate(element.name) }} <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
</td> {{ translate(element.name) }}
</ng-container> </td>
</ng-container>
<ng-container matColumnDef="userId">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header="userId">
<ng-container i18n>User</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="accounts"> <ng-container matColumnDef="userId">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="px-1"
mat-header-cell mat-header-cell
mat-sort-header="accountCount" mat-sort-header="userId"
> >
<ng-container i18n>Accounts</ng-container> <ng-container i18n>User</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-nowrap" mat-cell>
<gf-value <span class="text-monospace">{{ element.userId }}</span>
class="d-inline-block justify-content-end" </td>
[locale]="locale()" </ng-container>
[value]="element.accountCount"
/>
</td>
</ng-container>
<ng-container matColumnDef="activities"> <ng-container matColumnDef="accounts">
<th <th
*matHeaderCellDef *matHeaderCellDef
class="justify-content-end px-1" class="justify-content-end px-1"
mat-header-cell mat-header-cell
mat-sort-header="activityCount" mat-sort-header="accountCount"
> >
<ng-container i18n>Activities</ng-container> <ng-container i18n>Accounts</ng-container>
</th> </th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<gf-value <gf-value
class="d-inline-block justify-content-end" class="d-inline-block justify-content-end"
[locale]="locale()" [locale]="locale()"
[value]="element.activityCount" [value]="element.accountCount"
/> />
</td> </td>
</ng-container> </ng-container>
<ng-container matColumnDef="actions" stickyEnd> <ng-container matColumnDef="activities">
<th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th> <th
<td *matCellDef="let element" class="px-1 text-center" mat-cell> *matHeaderCellDef
<button class="justify-content-end px-1"
class="mx-1 no-min-width px-2" mat-header-cell
mat-button mat-sort-header="activityCount"
[matMenuTriggerFor]="tagMenu"
(click)="$event.stopPropagation()"
> >
<ion-icon name="ellipsis-horizontal" /> <ng-container i18n>Activities</ng-container>
</button> </th>
<mat-menu #tagMenu="matMenu" xPosition="before"> <td *matCellDef="let element" class="px-1 text-right" mat-cell>
<button <gf-value
mat-menu-item class="d-inline-block justify-content-end"
[disabled]="isSystemTag(element)" [locale]="locale()"
(click)="onUpdateTag(element)" [value]="element.activityCount"
> />
<span class="align-items-center d-flex"> </td>
<ion-icon class="mr-2" name="create-outline" /> </ng-container>
<span><ng-container i18n>Edit</ng-container>...</span>
</span> <ng-container matColumnDef="actions" stickyEnd>
</button> <th *matHeaderCellDef class="px-1 text-center" mat-header-cell></th>
<hr class="m-0" /> <td *matCellDef="let element" class="px-1 text-center" mat-cell>
<button <button
mat-menu-item class="mx-1 no-min-width px-2"
[disabled]=" mat-button
element.accountCount > 0 || [matMenuTriggerFor]="tagMenu"
element.activityCount > 0 || (click)="$event.stopPropagation()"
isSystemTag(element)
"
(click)="onDeleteTag(element.id)"
> >
<span class="align-items-center d-flex"> <ion-icon name="ellipsis-horizontal" />
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</span>
</button> </button>
</mat-menu> <mat-menu #tagMenu="matMenu" xPosition="before">
</td> <button
</ng-container> mat-menu-item
[disabled]="isSystemTag(element)"
(click)="onUpdateTag(element)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="create-outline" />
<span><ng-container i18n>Edit</ng-container>...</span>
</span>
</button>
<hr class="m-0" />
<button
mat-menu-item
[disabled]="
element.accountCount > 0 ||
element.activityCount > 0 ||
isSystemTag(element)
"
(click)="onDeleteTag(element.id)"
>
<span class="align-items-center d-flex">
<ion-icon class="mr-2" name="trash-outline" />
<span i18n>Delete</span>
</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>
<mat-paginator <mat-paginator
[class.d-none]="dataSource.data.length <= pageSize" [class.d-none]="dataSource.data.length <= pageSize"

1
libs/common/src/lib/config.ts

@ -203,6 +203,7 @@ export const GATHER_ASSET_PROFILE_PROCESS_JOB_OPTIONS: JobOptions = {
removeOnComplete: true removeOnComplete: true
}; };
export const GATHER_HISTORICAL_MARKET_DATA_COOLDOWN_IN_MS = ms('12 hours');
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME = export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_NAME =
'GATHER_HISTORICAL_MARKET_DATA'; 'GATHER_HISTORICAL_MARKET_DATA';
export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = { export const GATHER_HISTORICAL_MARKET_DATA_PROCESS_JOB_OPTIONS: JobOptions = {

4
package-lock.json

@ -1,12 +1,12 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.59.1", "version": "3.60.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.59.1", "version": "3.60.0",
"hasInstallScript": true, "hasInstallScript": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"dependencies": { "dependencies": {

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "ghostfolio", "name": "ghostfolio",
"version": "3.59.1", "version": "3.60.0",
"homepage": "https://ghostfol.io", "homepage": "https://ghostfol.io",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"repository": "https://github.com/ghostfolio/ghostfolio", "repository": "https://github.com/ghostfolio/ghostfolio",

2
prisma/migrations/20260824000000_added_is_carried_forward_to_market_data/migration.sql

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "MarketData" ADD COLUMN "isCarriedForward" BOOLEAN NOT NULL DEFAULT false;

15
prisma/schema.prisma

@ -157,13 +157,14 @@ model AuthDevice {
} }
model MarketData { model MarketData {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
dataSource DataSource dataSource DataSource
date DateTime date DateTime
id String @id @default(uuid()) id String @id @default(uuid())
marketPrice Float isCarriedForward Boolean @default(false)
state MarketDataState @default(CLOSE) marketPrice Float
symbol String state MarketDataState @default(CLOSE)
symbol String
@@unique([dataSource, date, symbol]) @@unique([dataSource, date, symbol])
@@index([dataSource]) @@index([dataSource])

Loading…
Cancel
Save