Browse Source

Merge branch 'main' into main

pull/7079/head
Thomas Kaul 4 weeks ago
committed by GitHub
parent
commit
1889b40268
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      CHANGELOG.md
  2. 2
      README.md
  3. 20
      apps/api/src/app/admin/admin.service.ts
  4. 6
      apps/api/src/app/endpoints/market-data/market-data.controller.ts
  5. 4
      apps/api/src/app/endpoints/watchlist/watchlist.service.ts
  6. 2
      apps/api/src/app/import/import.service.ts
  7. 4
      apps/api/src/app/portfolio/current-rate.service.ts
  8. 31
      apps/api/src/app/symbol/symbol.service.ts
  9. 8
      apps/api/src/services/benchmark/benchmark.service.ts
  10. 1
      apps/api/src/services/cron/cron.service.ts
  11. 73
      apps/api/src/services/data-provider/data-provider.service.ts
  12. 9
      apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts
  13. 13
      apps/api/src/services/market-data/market-data.service.ts
  14. 83
      apps/api/src/services/queues/data-gathering/data-gathering.service.ts
  15. 2
      apps/api/src/services/symbol-profile/symbol-profile.service.ts
  16. 21
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts
  17. 17
      apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html
  18. 12
      apps/client/src/app/components/footer/footer.component.html
  19. 41
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts
  20. 96
      apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html
  21. 172
      apps/client/src/app/services/import-activities.service.ts
  22. 440
      apps/client/src/locales/messages.ca.xlf
  23. 446
      apps/client/src/locales/messages.de.xlf
  24. 446
      apps/client/src/locales/messages.es.xlf
  25. 446
      apps/client/src/locales/messages.fr.xlf
  26. 446
      apps/client/src/locales/messages.it.xlf
  27. 446
      apps/client/src/locales/messages.ko.xlf
  28. 446
      apps/client/src/locales/messages.nl.xlf
  29. 446
      apps/client/src/locales/messages.pl.xlf
  30. 446
      apps/client/src/locales/messages.pt.xlf
  31. 446
      apps/client/src/locales/messages.tr.xlf
  32. 432
      apps/client/src/locales/messages.uk.xlf
  33. 430
      apps/client/src/locales/messages.xlf
  34. 446
      apps/client/src/locales/messages.zh.xlf
  35. 12
      libs/common/src/lib/dtos/update-asset-profile.dto.ts
  36. 1
      libs/common/src/lib/interfaces/admin-user.interface.ts
  37. 8
      libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts
  38. 1
      libs/common/src/lib/interfaces/responses/export-response.interface.ts
  39. 4
      libs/ui/src/lib/page-tabs/page-tabs.component.html
  40. 9
      libs/ui/src/lib/page-tabs/page-tabs.component.scss
  41. 4
      libs/ui/src/lib/page-tabs/page-tabs.component.ts
  42. 2
      libs/ui/src/lib/services/admin.service.ts
  43. 8
      prisma/migrations/20260620163851_added_data_gathering_frequency_to_symbol_profile/migration.sql
  44. 7
      prisma/schema.prisma

11
CHANGELOG.md

@ -7,19 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
### Added
- Added an icon to indicate external links in the page tabs component
- Added the Korean (`ko`) language to the footer
- Added a data gathering frequency (`DAILY` or `HOURLY`) to the asset profile to control the market data gathering interval
### Changed
- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol`
- Changed the _Fear & Greed Index_ (market mood) in the markets overview to use the stored market data instead of a live quote
- Moved the endpoint to get the asset profiles from `GET api/v1/admin/market-data` to `GET api/v1/asset-profiles`
- Moved the endpoint to get the asset profile details from `GET api/v1/market-data/:dataSource/:symbol` to `GET api/v1/asset-profiles/:dataSource/:symbol`
- Added the selected asset profile count to the delete menu item of the historical market data table in the admin control panel
- Added the selected asset profile count to the deletion confirmation dialog of the historical market data table in the admin control panel
- Improved the sorting to be case-insensitive in the platform management of the admin control panel
- Improved the sorting to be case-insensitive in the tag management of the admin control panel
- Improved the language localization for German (`de`)
- Upgraded `yahoo-finance2` from version `3.14.2` to `3.15.3`
### Fixed
- Fixed an issue with the localization of the country names
- Fixed an issue in the data provider service where quotes could be missing for symbols that exist in multiple data sources by keying the quotes response by the asset profile identifier
## 3.12.0 - 2026-06-17

2
README.md

@ -362,7 +362,7 @@ Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ r
## Contributing
Ghostfolio is **100% free** and **open source**. We encourage and support an active and healthy community that accepts contributions from the public, including you.
Ghostfolio is **100% free** and **open source**. We support an active and healthy community and welcome contributions from everyone, including you.
Not sure what to work on? We have [some ideas](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22help+wanted%22%20no%3Aassignee), even for [newcomers](https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22%20no%3Aassignee). Please join the Ghostfolio [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) channel or post to [@ghostfolio\_](https://x.com/ghostfolio_) on _X_. We would love to hear from you.

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

@ -181,6 +181,24 @@ export class AdminService {
throw new NotFoundException(`User with ID ${id} not found`);
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
const subscriptions = await this.prismaService.subscription.findMany({
orderBy: {
expiresAt: 'desc'
},
where: {
userId: id
}
});
user.subscriptions = subscriptions.map((subscription) => {
return {
...subscription,
price: subscription.price ?? 0
};
});
}
return user;
}
@ -210,6 +228,7 @@ export class AdminService {
comment,
countries,
currency,
dataGatheringFrequency,
dataSource: newDataSource,
holdings,
isActive,
@ -293,6 +312,7 @@ export class AdminService {
const updatedSymbolProfile: Prisma.SymbolProfileUpdateInput = {
comment,
currency,
dataGatheringFrequency,
dataSource,
isActive,
scraperConfiguration,

6
apps/api/src/app/endpoints/market-data/market-data.controller.ts

@ -56,14 +56,16 @@ export class MarketDataController {
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceCryptocurrencies,
symbol: ghostfolioFearAndGreedIndexSymbolCryptocurrencies
}
},
useIntradayData: true
}),
this.symbolService.get({
includeHistoricalData,
dataGatheringItem: {
dataSource: ghostfolioFearAndGreedIndexDataSourceStocks,
symbol: ghostfolioFearAndGreedIndexSymbolStocks
}
},
useIntradayData: true
})
]);

4
apps/api/src/app/endpoints/watchlist/watchlist.service.ts

@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { DataGatheringService } from '@ghostfolio/api/services/queues/data-gathering/data-gathering.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { WatchlistResponse } from '@ghostfolio/common/interfaces';
import { BadRequestException, Injectable } from '@nestjs/common';
@ -127,7 +128,8 @@ export class WatchlistService {
const performancePercent =
this.benchmarkService.calculateChangeInPercentage(
allTimeHigh?.marketPrice,
quotes[symbol]?.marketPrice
quotes[getAssetProfileIdentifier({ dataSource, symbol })]
?.marketPrice
);
return {

2
apps/api/src/app/import/import.service.ts

@ -536,6 +536,8 @@ export class ImportService {
url,
comment: assetProfile.comment,
currency: assetProfile.currency,
dataGatheringFrequency:
assetProfile.dataGatheringFrequency ?? 'DAILY',
userId: dataSource === 'MANUAL' ? user.id : undefined
},
symbolProfileId: undefined,

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

@ -51,13 +51,13 @@ export class CurrentRateService {
const values: GetValueObject[] = [];
if (includesToday) {
const quotesBySymbol = await this.dataProviderService.getQuotes({
const quotes = await this.dataProviderService.getQuotes({
items: dataGatheringItems,
user: this.request?.user
});
for (const { dataSource, symbol } of dataGatheringItems) {
const quote = quotesBySymbol[symbol];
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (quote?.dataProviderInfo) {
dataProviderInfos.push(quote.dataProviderInfo);

31
apps/api/src/app/symbol/symbol.service.ts

@ -1,7 +1,10 @@
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import {
DataProviderHistoricalResponse,
HistoricalDataItem,
@ -24,15 +27,31 @@ export class SymbolService {
public async get({
dataGatheringItem,
includeHistoricalData
includeHistoricalData,
useIntradayData = false
}: {
dataGatheringItem: DataGatheringItem;
includeHistoricalData?: number;
useIntradayData?: boolean;
}): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes({
items: [dataGatheringItem]
});
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
let currency: string;
let marketPrice: number;
if (useIntradayData) {
const latestMarketData = await this.marketDataService.getLatest({
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
});
marketPrice = latestMarketData?.marketPrice;
} else {
const quotes = await this.dataProviderService.getQuotes({
items: [dataGatheringItem]
});
({ currency, marketPrice } =
quotes[getAssetProfileIdentifier(dataGatheringItem)] ?? {});
}
if (dataGatheringItem.dataSource && marketPrice >= 0) {
let historicalData: HistoricalDataItem[] = [];

8
apps/api/src/services/benchmark/benchmark.service.ts

@ -8,7 +8,10 @@ import {
CACHE_TTL_INFINITE,
PROPERTY_BENCHMARKS
} from '@ghostfolio/common/config';
import { calculateBenchmarkTrend } from '@ghostfolio/common/helper';
import {
calculateBenchmarkTrend,
getAssetProfileIdentifier
} from '@ghostfolio/common/helper';
import {
AssetProfileIdentifier,
Benchmark,
@ -266,8 +269,9 @@ export class BenchmarkService {
let storeInCache = true;
const benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { dataSource, symbol } = benchmarkAssetProfiles[index];
const { marketPrice } =
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
quotes[getAssetProfileIdentifier({ dataSource, symbol })] ?? {};
let performancePercentFromAllTimeHigh = 0;

1
apps/api/src/services/cron/cron.service.ts

@ -42,6 +42,7 @@ export class CronService {
public async runEveryHourAtRandomMinute() {
if (await this.isDataGatheringEnabled()) {
await this.dataGatheringService.gather7Days();
await this.dataGatheringService.gatherHourlySymbols();
}
}

73
apps/api/src/services/data-provider/data-provider.service.ts

@ -77,7 +77,9 @@ export class DataProviderService implements OnModuleInit {
useCache: false
});
if (quotes[symbol]?.marketPrice > 0) {
if (
quotes[getAssetProfileIdentifier({ dataSource, symbol })]?.marketPrice > 0
) {
return true;
}
@ -514,7 +516,6 @@ export class DataProviderService implements OnModuleInit {
return result;
}
// TODO: Change symbol in response to assetProfileIdentifier
public async getQuotes({
items,
requestTimeout,
@ -526,10 +527,12 @@ export class DataProviderService implements OnModuleInit {
useCache?: boolean;
user?: UserWithSettings;
}): Promise<{
[symbol: string]: DataProviderResponse;
[assetProfileIdentifier: string]: DataProviderResponse;
}> {
const response: {
[symbol: string]: DataProviderResponse;
[assetProfileIdentifier: string]: DataProviderResponse & {
symbol: string;
};
} = {};
const startTimeTotal = performance.now();
@ -538,11 +541,17 @@ export class DataProviderService implements OnModuleInit {
return symbol === `${DEFAULT_CURRENCY}USX`;
})
) {
response[`${DEFAULT_CURRENCY}USX`] = {
response[
getAssetProfileIdentifier({
dataSource: this.getDataSourceForExchangeRates(),
symbol: `${DEFAULT_CURRENCY}USX`
})
] = {
currency: 'USX',
dataSource: this.getDataSourceForExchangeRates(),
marketPrice: 100,
marketState: 'open'
marketState: 'open',
symbol: `${DEFAULT_CURRENCY}USX`
};
}
@ -557,8 +566,13 @@ export class DataProviderService implements OnModuleInit {
if (quoteString) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
const cachedDataProviderResponse = JSON.parse(
quoteString
) as DataProviderResponse;
response[getAssetProfileIdentifier({ dataSource, symbol })] = {
...cachedDataProviderResponse,
symbol
};
continue;
} catch {}
}
@ -646,14 +660,19 @@ export class DataProviderService implements OnModuleInit {
continue;
}
response[symbol] = dataProviderResponse;
response[
getAssetProfileIdentifier({
symbol,
dataSource: DataSource[dataSource]
})
] = { ...dataProviderResponse, symbol };
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
symbol,
dataSource: DataSource[dataSource]
}),
JSON.stringify(response[symbol]),
JSON.stringify(dataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL')
);
@ -663,7 +682,7 @@ export class DataProviderService implements OnModuleInit {
rootCurrency
} of DERIVED_CURRENCIES) {
if (symbol === `${DEFAULT_CURRENCY}${rootCurrency}`) {
response[`${DEFAULT_CURRENCY}${currency}`] = {
const derivedDataProviderResponse: DataProviderResponse = {
...dataProviderResponse,
currency,
marketPrice: new Big(
@ -674,12 +693,22 @@ export class DataProviderService implements OnModuleInit {
marketState: 'open'
};
response[
getAssetProfileIdentifier({
dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}`
})
] = {
...derivedDataProviderResponse,
symbol: `${DEFAULT_CURRENCY}${currency}`
};
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
dataSource: DataSource[dataSource],
symbol: `${DEFAULT_CURRENCY}${currency}`
}),
JSON.stringify(response[`${DEFAULT_CURRENCY}${currency}`]),
JSON.stringify(derivedDataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL')
);
}
@ -697,21 +726,21 @@ export class DataProviderService implements OnModuleInit {
try {
await this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
data: Object.values(response)
.filter(({ marketPrice, marketState }) => {
return (
isNumber(response[symbol].marketPrice) &&
response[symbol].marketPrice > 0 &&
response[symbol].marketState === 'open'
isNumber(marketPrice) &&
marketPrice > 0 &&
marketState === 'open'
);
})
.map((symbol) => {
.map((dataProviderResponse) => {
return {
symbol,
dataSource: response[symbol].dataSource,
dataSource: dataProviderResponse.dataSource,
date: getStartOfUtcDate(new Date()),
marketPrice: response[symbol].marketPrice,
state: 'INTRADAY'
marketPrice: dataProviderResponse.marketPrice,
state: 'INTRADAY',
symbol: dataProviderResponse.symbol
};
})
});

9
apps/api/src/services/exchange-rate-data/exchange-rate-data.service.ts

@ -11,6 +11,7 @@ import {
} from '@ghostfolio/common/config';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getYesterday,
resetHours
} from '@ghostfolio/common/helper';
@ -176,11 +177,13 @@ export class ExchangeRateDataService {
requestTimeout: ms('30 seconds')
});
for (const symbol of Object.keys(quotes)) {
if (isNumber(quotes[symbol].marketPrice)) {
for (const { dataSource, symbol } of this.currencyPairs) {
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (isNumber(quote?.marketPrice)) {
result[symbol] = {
[format(getYesterday(), DATE_FORMAT)]: {
marketPrice: quotes[symbol].marketPrice
marketPrice: quote.marketPrice
}
};
}

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

@ -40,6 +40,19 @@ export class MarketDataService {
});
}
public async getLatest({
dataSource,
symbol
}: AssetProfileIdentifier): Promise<MarketData> {
return this.prismaService.marketData.findFirst({
orderBy: [{ date: 'desc' }],
where: {
dataSource,
symbol
}
});
}
public async getMax({ dataSource, symbol }: AssetProfileIdentifier) {
return this.prismaService.marketData.findFirst({
select: {

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

@ -2,6 +2,7 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
import { DataEnhancerInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-enhancer.interface';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { DataGatheringItem } from '@ghostfolio/api/services/interfaces/interfaces';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
@ -17,6 +18,7 @@ import {
import {
DATE_FORMAT,
getAssetProfileIdentifier,
getStartOfUtcDate,
resetHours
} from '@ghostfolio/common/helper';
import {
@ -26,7 +28,7 @@ import {
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource } from '@prisma/client';
import { DataSource, Prisma } from '@prisma/client';
import { JobOptions, Queue } from 'bull';
import { format, min, subDays, subMilliseconds, subYears } from 'date-fns';
import { isEmpty } from 'lodash';
@ -43,6 +45,7 @@ export class DataGatheringService {
private readonly dataGatheringQueue: Queue,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly symbolProfileService: SymbolProfileService
@ -279,6 +282,46 @@ export class DataGatheringService {
}
}
public async gatherHourlySymbols() {
const assetProfileIdentifiers =
await this.getHourlyAssetProfileIdentifiers();
if (assetProfileIdentifiers.length <= 0) {
return;
}
const date = getStartOfUtcDate(new Date());
try {
const quotes = await this.dataProviderService.getQuotes({
items: assetProfileIdentifiers,
useCache: false
});
const data: Prisma.MarketDataUpdateInput[] = [];
for (const { dataSource, symbol } of assetProfileIdentifiers) {
const quote = quotes[getAssetProfileIdentifier({ dataSource, symbol })];
if (!quote?.marketPrice) {
continue;
}
data.push({
dataSource,
date,
symbol,
marketPrice: quote.marketPrice,
state: 'INTRADAY'
});
}
await this.marketDataService.updateMany({ data });
} catch (error) {
this.logger.error('Could not gather hourly market data', error);
}
}
public async gatherSymbols({
dataGatheringItems,
force = false,
@ -389,6 +432,36 @@ export class DataGatheringService {
return min([aStartDate, subYears(new Date(), 10)]);
}
private async getHourlyAssetProfileIdentifiers(): Promise<
AssetProfileIdentifier[]
> {
const symbolProfiles = await this.prismaService.symbolProfile.findMany({
orderBy: [{ symbol: 'asc' }, { dataSource: 'asc' }],
select: {
dataSource: true,
scraperConfiguration: true,
symbol: true
},
where: {
dataGatheringFrequency: 'HOURLY',
isActive: true
}
});
return symbolProfiles
.filter(({ dataSource, scraperConfiguration }) => {
const manualDataSourceWithScraperConfiguration =
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration
);
})
.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
});
}
private async getSymbols7D({
withUserSubscription = false
}: {
@ -469,14 +542,12 @@ export class DataGatheringService {
}
})
)
.filter((symbolProfile) => {
.filter(({ dataSource, scraperConfiguration }) => {
const manualDataSourceWithScraperConfiguration =
symbolProfile.dataSource === 'MANUAL' &&
!isEmpty(symbolProfile.scraperConfiguration);
dataSource === 'MANUAL' && !isEmpty(scraperConfiguration);
return (
symbolProfile.dataSource !== 'MANUAL' ||
manualDataSourceWithScraperConfiguration
dataSource !== 'MANUAL' || manualDataSourceWithScraperConfiguration
);
})
.map((symbolProfile) => {

2
apps/api/src/services/symbol-profile/symbol-profile.service.ts

@ -178,6 +178,7 @@ export class SymbolProfileService {
comment,
countries,
currency,
dataGatheringFrequency,
holdings,
isActive,
name,
@ -195,6 +196,7 @@ export class SymbolProfileService {
comment,
countries,
currency,
dataGatheringFrequency,
holdings,
isActive,
name,

21
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.component.ts

@ -74,6 +74,7 @@ import { IonIcon } from '@ionic/angular/standalone';
import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
MarketData,
Prisma,
SymbolProfile
@ -155,6 +156,7 @@ export class GfAssetProfileDialogComponent implements OnInit {
comment: '',
countries: ['', jsonValidator()],
currency: '',
dataGatheringFrequency: new FormControl<DataGatheringFrequency>('DAILY'),
historicalData: this.formBuilder.group({
csvString: ''
}),
@ -199,6 +201,20 @@ export class GfAssetProfileDialogComponent implements OnInit {
protected currencies: string[] = [];
protected readonly dataGatheringFrequencyValues: {
value: DataGatheringFrequency;
viewValue: string;
}[] = [
{
value: 'DAILY',
viewValue: $localize`Daily`
},
{
value: 'HOURLY',
viewValue: $localize`Hourly`
}
];
protected readonly dateRangeOptions = [
{
label: $localize`Current week` + ' (' + $localize`WTD` + ')',
@ -401,6 +417,8 @@ export class GfAssetProfileDialogComponent implements OnInit {
}) ?? []
),
currency: this.assetProfile?.currency ?? null,
dataGatheringFrequency:
this.assetProfile?.dataGatheringFrequency ?? 'DAILY',
historicalData: {
csvString: GfAssetProfileDialogComponent.HISTORICAL_DATA_TEMPLATE
},
@ -583,6 +601,9 @@ export class GfAssetProfileDialogComponent implements OnInit {
this.assetProfileForm.controls.assetSubClass.value ?? undefined,
comment: this.assetProfileForm.controls.comment.value || undefined,
currency: this.assetProfileForm.controls.currency.value ?? undefined,
dataGatheringFrequency:
this.assetProfileForm.controls.dataGatheringFrequency.value ??
undefined,
isActive: isBoolean(this.assetProfileForm.controls.isActive.value)
? this.assetProfileForm.controls.isActive.value
: undefined,

17
apps/client/src/app/components/admin-market-data/asset-profile-dialog/asset-profile-dialog.html

@ -113,7 +113,7 @@
<ion-icon name="reader-outline" />
<div class="d-none d-sm-block ml-2" i18n>Overview</div>
</ng-template>
<div class="container mt-3 p-0">
<div class="container px-0 py-3">
<div class="row w-100">
@if (isEditAssetProfileIdentifierMode) {
<div class="col-12 mb-4">
@ -444,6 +444,21 @@
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100 without-hint">
<mat-label i18n>Data Gathering Frequency</mat-label>
<mat-select formControlName="dataGatheringFrequency">
@for (
dataGatheringFrequencyValue of dataGatheringFrequencyValues;
track dataGatheringFrequencyValue.value
) {
<mat-option [value]="dataGatheringFrequencyValue.value">{{
dataGatheringFrequencyValue.viewValue
}}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</form>
</div>
</mat-tab>

12
apps/client/src/app/components/footer/footer.component.html

@ -141,13 +141,11 @@
<li>
<a href="../it" title="Ghostfolio in Italiano">Italiano</a>
</li>
<!--
<li>
<a href="../ko" title="Ghostfolio in Korean (한국어)"
>Korean (한국어)</a
>
</li>
-->
<li>
<a href="../ko" title="Ghostfolio in Korean (한국어)"
>Korean (한국어)</a
>
</li>
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>

41
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.component.ts

@ -1,5 +1,6 @@
import { getSum } from '@ghostfolio/common/helper';
import { AdminUserResponse } from '@ghostfolio/common/interfaces';
import { AdminService } from '@ghostfolio/ui/services';
import { AdminService, DataService } from '@ghostfolio/ui/services';
import { GfValueComponent } from '@ghostfolio/ui/value';
import {
@ -16,7 +17,11 @@ import { MatButtonModule } from '@angular/material/button';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { MatDialogModule } from '@angular/material/dialog';
import { MatMenuModule } from '@angular/material/menu';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { IonIcon } from '@ionic/angular/standalone';
import { Subscription } from '@prisma/client';
import { Big } from 'big.js';
import { differenceInDays } from 'date-fns';
import { addIcons } from 'ionicons';
import { ellipsisVertical } from 'ionicons/icons';
import { EMPTY } from 'rxjs';
@ -35,7 +40,8 @@ import {
IonIcon,
MatButtonModule,
MatDialogModule,
MatMenuModule
MatMenuModule,
MatTableModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
selector: 'gf-user-detail-dialog',
@ -43,18 +49,29 @@ import {
templateUrl: './user-detail-dialog.html'
})
export class GfUserDetailDialogComponent implements OnInit {
public baseCurrency: string;
public subscriptionsDataSource = new MatTableDataSource<Subscription>();
public subscriptionsDisplayedColumns = [
'createdAt',
'type',
'price',
'expiresAt'
];
public user: AdminUserResponse;
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: UserDetailDialogParams,
private dataService: DataService,
private destroyRef: DestroyRef,
public dialogRef: MatDialogRef<
GfUserDetailDialogComponent,
UserDetailDialogResult
>
) {
this.baseCurrency = this.dataService.fetchInfo().baseCurrency;
addIcons({
ellipsisVertical
});
@ -74,6 +91,8 @@ export class GfUserDetailDialogComponent implements OnInit {
.subscribe((user) => {
this.user = user;
this.subscriptionsDataSource.data = this.user.subscriptions ?? [];
this.changeDetectorRef.markForCheck();
});
}
@ -85,6 +104,24 @@ export class GfUserDetailDialogComponent implements OnInit {
});
}
public getSum() {
return getSum(
this.subscriptionsDataSource.data.map(({ price }) => {
return new Big(price);
})
).toNumber();
}
public getType({ createdAt, expiresAt, price }: Subscription) {
if (price) {
return $localize`Paid`;
}
return differenceInDays(expiresAt, createdAt) <= 90
? $localize`Trial`
: $localize`Coupon`;
}
public onClose() {
this.dialogRef.close();
}

96
apps/client/src/app/components/user-detail-dialog/user-detail-dialog.html

@ -124,6 +124,102 @@
>
</div>
</div>
@if (subscriptionsDataSource.data.length > 0) {
<div class="row">
<div class="col">
<h2 class="h6" i18n>Subscriptions</h2>
<div class="overflow-x-auto">
<table
class="gf-table w-100"
mat-table
[dataSource]="subscriptionsDataSource"
>
<ng-container matColumnDef="createdAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Created</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.createdAt"
/>
</td>
<td *matFooterCellDef class="px-1" i18n mat-footer-cell>
Total
</td>
</ng-container>
<ng-container matColumnDef="type">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Type</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
{{ getType(element) }}
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<ng-container matColumnDef="price">
<th *matHeaderCellDef class="px-1 text-right" mat-header-cell>
<ng-container i18n>Price</ng-container>
</th>
<td
*matCellDef="let element"
class="px-1 text-right"
mat-cell
>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="element.price"
/>
</td>
<td *matFooterCellDef class="px-1 text-right" mat-footer-cell>
<gf-value
class="d-inline-block justify-content-end"
[isCurrency]="true"
[locale]="data.locale"
[unit]="baseCurrency"
[value]="getSum()"
/>
</td>
</ng-container>
<ng-container matColumnDef="expiresAt">
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Expires</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<gf-value
[isDate]="true"
[locale]="data.locale"
[value]="element.expiresAt"
/>
</td>
<td *matFooterCellDef class="px-1" mat-footer-cell></td>
</ng-container>
<tr
*matHeaderRowDef="subscriptionsDisplayedColumns"
mat-header-row
></tr>
<tr
*matRowDef="let row; columns: subscriptionsDisplayedColumns"
mat-row
></tr>
<tr
*matFooterRowDef="subscriptionsDisplayedColumns"
mat-footer-row
></tr>
</table>
</div>
</div>
</div>
}
}
</div>
</div>

172
apps/client/src/app/services/import-activities.service.ts

@ -8,12 +8,11 @@ import { parseDate as parseDateHelper } from '@ghostfolio/common/helper';
import { Activity } from '@ghostfolio/common/interfaces';
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { inject, Injectable } from '@angular/core';
import { Account, DataSource, Type as ActivityType } from '@prisma/client';
import { isFinite } from 'lodash';
import { isFinite, isNumber, isString } from 'lodash';
import { parse as csvToJson } from 'papaparse';
import { EMPTY } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { firstValueFrom } from 'rxjs';
@Injectable({
providedIn: 'root'
@ -35,7 +34,7 @@ export class ImportActivitiesService {
'value'
];
public constructor(private http: HttpClient) {}
private readonly http = inject(HttpClient);
public async importCsv({
fileContent,
@ -49,7 +48,7 @@ export class ImportActivitiesService {
activities: Activity[];
assetProfiles: CreateAssetProfileWithMarketDataDto[];
}> {
const content = csvToJson(fileContent, {
const content = csvToJson<Record<string, unknown>>(fileContent, {
dynamicTyping: true,
header: true,
skipEmptyLines: true
@ -83,22 +82,22 @@ export class ImportActivitiesService {
assetProfiles.push({
currency,
symbol,
assetClass: null,
assetSubClass: null,
comment: null,
assetClass: undefined,
assetSubClass: undefined,
comment: undefined,
countries: [],
cusip: null,
cusip: undefined,
dataSource: DataSource.MANUAL,
figi: null,
figiComposite: null,
figiShareClass: null,
figi: undefined,
figiComposite: undefined,
figiShareClass: undefined,
holdings: [],
isActive: true,
isin: null,
isin: undefined,
marketData: [],
name: symbol,
sectors: [],
url: null
url: undefined
});
}
}
@ -126,7 +125,7 @@ export class ImportActivitiesService {
}): Promise<{
activities: Activity[];
}> {
return new Promise((resolve, reject) => {
return firstValueFrom(
this.postImport(
{
accounts,
@ -136,18 +135,7 @@ export class ImportActivitiesService {
},
isDryRun
)
.pipe(
catchError((error) => {
reject(error);
return EMPTY;
})
)
.subscribe({
next: (data) => {
resolve(data);
}
});
});
);
}
public importSelectedActivities({
@ -163,11 +151,9 @@ export class ImportActivitiesService {
}): Promise<{
activities: Activity[];
}> {
const importData: CreateOrderDto[] = [];
for (const activity of activities) {
importData.push(this.convertToCreateOrderDto(activity));
}
const importData = activities.map((activity) =>
this.convertToCreateOrderDto(activity)
);
return this.importJson({
accounts,
@ -191,14 +177,14 @@ export class ImportActivitiesService {
updateAccountBalance
}: Activity): CreateOrderDto {
return {
accountId,
comment,
fee,
quantity,
type,
unitPrice,
updateAccountBalance,
currency: currency ?? SymbolProfile.currency,
accountId: accountId ?? undefined,
comment: comment ?? undefined,
currency: currency ?? SymbolProfile.currency ?? '',
dataSource: SymbolProfile.dataSource,
date: date.toString(),
symbol: SymbolProfile.symbol,
@ -208,28 +194,32 @@ export class ImportActivitiesService {
};
}
private lowercaseKeys(aObject: any) {
return Object.keys(aObject).reduce((acc, key) => {
acc[key.toLowerCase()] = aObject[key];
return acc;
}, {});
private lowercaseKeys(
aObject: Record<string, unknown>
): Record<string, unknown> {
return Object.fromEntries(
Object.entries(aObject).map(([key, val]) => {
return [key.toLowerCase(), val];
})
);
}
private parseAccount({
item,
userAccounts
}: {
item: any;
item: Record<string, unknown>;
userAccounts: Account[];
}) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.ACCOUNT_KEYS) {
if (item[key]) {
return userAccounts.find((account) => {
const value = item[key];
if (isNumber(value) || isString(value)) {
return userAccounts.find(({ id, name }) => {
return (
account.id === item[key] ||
account.name.toLowerCase() === item[key].toLowerCase()
id === value || name?.toLowerCase() === String(value).toLowerCase()
);
})?.id;
}
@ -238,12 +228,14 @@ export class ImportActivitiesService {
return undefined;
}
private parseComment({ item }: { item: any }) {
private parseComment({ item }: { item: Record<string, unknown> }) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.COMMENT_KEYS) {
if (item[key]) {
return item[key];
const value = item[key];
if (isNumber(value) || isString(value)) {
return String(value);
}
}
@ -255,15 +247,17 @@ export class ImportActivitiesService {
index,
item
}: {
content: any[];
content: Record<string, unknown>[];
index: number;
item: any;
item: Record<string, unknown>;
}) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.CURRENCY_KEYS) {
if (item[key]) {
return item[key];
const value = item[key];
if (isString(value)) {
return value;
}
}
@ -273,12 +267,14 @@ export class ImportActivitiesService {
};
}
private parseDataSource({ item }: { item: any }) {
private parseDataSource({ item }: { item: Record<string, unknown> }) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.DATA_SOURCE_KEYS) {
if (item[key]) {
return DataSource[item[key].toUpperCase()];
const value = item[key];
if (isString(value)) {
return DataSource[value.toUpperCase() as keyof typeof DataSource];
}
}
@ -290,16 +286,22 @@ export class ImportActivitiesService {
index,
item
}: {
content: any[];
content: Record<string, unknown>[];
index: number;
item: any;
item: Record<string, unknown>;
}) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.DATE_KEYS) {
if (item[key]) {
const value = item[key];
if (isNumber(value) || isString(value)) {
try {
return parseDateHelper(item[key].toString()).toISOString();
const parsedDate = parseDateHelper(String(value));
if (parsedDate) {
return parsedDate.toISOString();
}
} catch {}
}
}
@ -315,15 +317,17 @@ export class ImportActivitiesService {
index,
item
}: {
content: any[];
content: Record<string, unknown>[];
index: number;
item: any;
item: Record<string, unknown>;
}) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.FEE_KEYS) {
if (isFinite(item[key])) {
return Math.abs(item[key]);
const value = item[key];
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
}
}
@ -338,15 +342,17 @@ export class ImportActivitiesService {
index,
item
}: {
content: any[];
content: Record<string, unknown>[];
index: number;
item: any;
item: Record<string, unknown>;
}) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.QUANTITY_KEYS) {
if (isFinite(item[key])) {
return Math.abs(item[key]);
const value = item[key];
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
}
}
@ -361,15 +367,17 @@ export class ImportActivitiesService {
index,
item
}: {
content: any[];
content: Record<string, unknown>[];
index: number;
item: any;
item: Record<string, unknown>;
}) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.SYMBOL_KEYS) {
if (item[key]) {
return item[key];
const value = item[key];
if (isNumber(value) || isString(value)) {
return String(value);
}
}
@ -384,15 +392,17 @@ export class ImportActivitiesService {
index,
item
}: {
content: any[];
content: Record<string, unknown>[];
index: number;
item: any;
item: Record<string, unknown>;
}): ActivityType {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.TYPE_KEYS) {
if (item[key]) {
switch (item[key].toLowerCase()) {
const value = item[key];
if (isString(value)) {
switch (value.toLowerCase()) {
case 'buy':
return 'BUY';
case 'dividend':
@ -422,15 +432,17 @@ export class ImportActivitiesService {
index,
item
}: {
content: any[];
content: Record<string, unknown>[];
index: number;
item: any;
item: Record<string, unknown>;
}) {
item = this.lowercaseKeys(item);
for (const key of ImportActivitiesService.UNIT_PRICE_KEYS) {
if (isFinite(item[key])) {
return Math.abs(item[key]);
const value = item[key];
if (isNumber(value) && isFinite(value)) {
return Math.abs(value);
}
}

440
apps/client/src/locales/messages.ca.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.de.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.es.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.fr.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.it.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.ko.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.nl.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.pl.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.pt.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.tr.xlf

File diff suppressed because it is too large

432
apps/client/src/locales/messages.uk.xlf

File diff suppressed because it is too large

430
apps/client/src/locales/messages.xlf

File diff suppressed because it is too large

446
apps/client/src/locales/messages.zh.xlf

File diff suppressed because it is too large

12
libs/common/src/lib/dtos/update-asset-profile.dto.ts

@ -1,6 +1,12 @@
import { IsCurrencyCode } from '@ghostfolio/common/validators/is-currency-code';
import { AssetClass, AssetSubClass, DataSource, Prisma } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource,
Prisma
} from '@prisma/client';
import {
IsArray,
IsBoolean,
@ -32,6 +38,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
currency?: string;
@IsEnum(DataGatheringFrequency)
@IsOptional()
dataGatheringFrequency?: DataGatheringFrequency;
@IsEnum(DataSource)
@IsOptional()
dataSource?: DataSource;

1
libs/common/src/lib/interfaces/admin-user.interface.ts

@ -12,4 +12,5 @@ export interface AdminUser {
provider: Provider;
role: Role;
subscription?: Subscription;
subscriptions?: Subscription[];
}

8
libs/common/src/lib/interfaces/enhanced-symbol-profile.interface.ts

@ -1,4 +1,9 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import {
AssetClass,
AssetSubClass,
DataGatheringFrequency,
DataSource
} from '@prisma/client';
import { Country } from './country.interface';
import { DataProviderInfo } from './data-provider-info.interface';
@ -15,6 +20,7 @@ export interface EnhancedSymbolProfile {
createdAt: Date;
currency?: string;
cusip?: string;
dataGatheringFrequency?: DataGatheringFrequency;
dataProviderInfo?: DataProviderInfo;
dataSource: DataSource;
dateOfFirstActivity?: Date;

1
libs/common/src/lib/interfaces/responses/export-response.interface.ts

@ -28,6 +28,7 @@ export interface ExportResponse {
assetProfiles: (Omit<
SymbolProfile,
| 'createdAt'
| 'dataGatheringFrequency'
| 'id'
| 'scraperConfiguration'
| 'symbolMapping'

4
libs/ui/src/lib/page-tabs/page-tabs.component.html

@ -20,6 +20,10 @@
<ng-container
*ngTemplateOutlet="tabContent; context: { $implicit: tab }"
/>
<ion-icon
class="d-none d-sm-block external-link-icon ml-auto text-muted"
name="open-outline"
/>
</button>
} @else {
<a

9
libs/ui/src/lib/page-tabs/page-tabs.component.scss

@ -6,6 +6,10 @@
height: 100%;
width: 100%;
.external-link-icon {
font-size: 0.75rem;
}
@include mat.tabs-overrides(
(
active-indicator-height: 0,
@ -65,6 +69,11 @@
background-color: rgba(var(--palette-foreground-base), 0.05);
font-weight: 500;
}
.mdc-tab__content,
.mdc-tab__text-label {
width: 100%;
}
}
}
}

4
libs/ui/src/lib/page-tabs/page-tabs.component.ts

@ -8,6 +8,8 @@ import {
import { MatTabsModule } from '@angular/material/tabs';
import { RouterModule } from '@angular/router';
import { IonIcon } from '@ionic/angular/standalone';
import { addIcons } from 'ionicons';
import { openOutline } from 'ionicons/icons';
import { DeviceDetectorService } from 'ngx-device-detector';
import { TabConfiguration } from './interfaces/interfaces';
@ -27,5 +29,7 @@ export class GfPageTabsComponent {
public constructor() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
addIcons({ openOutline });
}
}

2
libs/ui/src/lib/services/admin.service.ts

@ -189,6 +189,7 @@ export class AdminService {
comment,
countries,
currency,
dataGatheringFrequency,
dataSource: newDataSource,
isActive,
name,
@ -207,6 +208,7 @@ export class AdminService {
comment,
countries,
currency,
dataGatheringFrequency,
dataSource: newDataSource,
isActive,
name,

8
prisma/migrations/20260620163851_added_data_gathering_frequency_to_symbol_profile/migration.sql

@ -0,0 +1,8 @@
-- CreateEnum
CREATE TYPE "DataGatheringFrequency" AS ENUM ('DAILY', 'HOURLY');
-- AlterTable
ALTER TABLE "SymbolProfile" ADD COLUMN "dataGatheringFrequency" "DataGatheringFrequency" NOT NULL DEFAULT 'DAILY';
-- CreateIndex
CREATE INDEX "SymbolProfile_dataGatheringFrequency_idx" ON "SymbolProfile"("dataGatheringFrequency");

7
prisma/schema.prisma

@ -191,6 +191,7 @@ model SymbolProfile {
createdAt DateTime @default(now())
currency String
cusip String?
dataGatheringFrequency DataGatheringFrequency @default(DAILY)
dataSource DataSource
figi String?
figiComposite String?
@ -215,6 +216,7 @@ model SymbolProfile {
@@index([assetClass])
@@index([currency])
@@index([cusip])
@@index([dataGatheringFrequency])
@@index([dataSource])
@@index([isActive])
@@index([isin])
@ -316,6 +318,11 @@ enum AssetSubClass {
STOCK
}
enum DataGatheringFrequency {
DAILY
HOURLY
}
enum DataSource {
ALPHA_VANTAGE
COINGECKO

Loading…
Cancel
Save