Compare commits

...

38 Commits

Author SHA1 Message Date
421809ae95 Release 1.285.0 (#2114) 2023-07-01 19:14:26 +02:00
d3234f9e77 Feature/add blog post exploring the path to fire (#2113)
* Add blog post: Exploring the Path to FIRE

* Update changelog
2023-07-01 18:33:31 +02:00
a40be2f744 Feature/extract locales 20230701 (#2112)
* Improve i18n

* Update changelog
2023-07-01 18:30:09 +02:00
e62da06c5c Feature/extend scraper configuration support (#2110)
* Extend scraper configuration support

* Update changelog
2023-07-01 11:08:10 +02:00
b7f635bdfc Increase frequency (#2111) 2023-07-01 11:06:34 +02:00
0a465f125d Feature/add pagination to market data table in admin control panel (#2108)
* Add pagination

* Update changelog
2023-07-01 10:49:00 +02:00
c02e390bc1 Rename Slack channel to community (#2091) 2023-06-28 15:59:38 +02:00
f9bec0d793 Release 1.284.0 (#2106) 2023-06-27 18:47:44 +02:00
2f44748f79 Feature/upgrade internet identity dependencies to version 0.15.7 (#2105)
* Disable crossOriginOpenerPolicy

* Upgrade Internet Identity dependencies

* Update changelog
2023-06-27 18:46:03 +02:00
97504756be Feature/add currency to cash balance in create or update account dialog (#2104)
* Add currency as text suffix to cash balance

* Update changelog
2023-06-27 18:33:50 +02:00
6a802a62a0 Add ability to search for indices and fix gf-symbol-autocomplete validation (#2094)
* Bugfix/Fix gf-symbol-autocomplete validation

* Feature/Add ability to search for indices

* Update changelog
2023-06-26 18:38:24 +02:00
51ca26bb4d Release 1.283.5 (#2103) 2023-06-25 13:39:39 +02:00
2ecc8dbc4e Release 1.283.4 (#2101) 2023-06-24 18:41:37 +02:00
c0e0e2401e Release 1.283.3 (#2100) 2023-06-24 18:25:44 +02:00
1a30c180bc Release 1.283.2 (#2099) 2023-06-24 18:05:12 +02:00
39d4f80f36 Release 1.283.1 (#2098) 2023-06-24 17:49:12 +02:00
3693091ad6 Release 1.283.0 (#2097) 2023-06-24 17:14:04 +02:00
bf52f1137d Feature/setup helmet (#2096)
* Setup helmet

* Update changelog
2023-06-24 17:12:05 +02:00
54ea6c84b4 Feature/add caching for quotes (#2095)
* Add caching for quotes

* Update changelog
2023-06-24 13:06:28 +02:00
689e50ae1a Improve bug report template (#2092)
* Update Slack community link
* Remove checkboxes
2023-06-23 08:52:24 +02:00
677757fdf0 Feature/improve import dividends dialog (#2086)
* Improve dialog

* Add loading indicator
* Improve selected item of holding selector

* Update changelog
2023-06-22 20:29:50 +02:00
58d9816f01 Feature/extend symbol search component by asset sub classes (#2087)
* Add asset sub classes

* Update changelog
2023-06-21 16:09:18 +02:00
5f3d445f1d Release 1.282.0 (#2085) 2023-06-19 20:58:30 +02:00
fce6caebc2 Fix arm64 prisma binary target (#2082)
* Fix arm64 prisma binary target

* Update changelog
2023-06-19 20:56:28 +02:00
d0a4f5c000 Feature/added ability to add asset profile in admin control panel (#2075)
* Added ability to add asset profile in admin control panel

* Update changelog
2023-06-19 20:50:11 +02:00
b5e2a3aa91 Feature/harmonize use of permissions on about and landing page (#2084)
* Harmonize use of permissions

* About page
* Landing page

* About changelog
2023-06-19 20:29:36 +02:00
f47883fb0b Feature/add icon to external links in footer (#2083)
* Add icon for external links

* Update changelog
2023-06-19 20:29:12 +02:00
2932744a68 Feature/improve language localization for german 20230618 (#2081)
* Update translations

* Update changelog
2023-06-19 19:40:10 +02:00
73c0f02e06 Add the final translations for Portuguese (#2079) 2023-06-18 08:59:16 +02:00
382fe24f29 Release 1.281.0 (#2080) 2023-06-17 17:38:30 +02:00
908876ca6e Feature/setup language localization for portuguese (#2076)
* Set up Portuguese

* Update changelog
2023-06-17 17:26:40 +02:00
99cf9f8802 Feature/translation pt 2 (#2074)
* Add more Portuguese translations
2023-06-16 20:47:06 +02:00
7444ff97fc Feature/translation pt (#2073)
* Complete Portuguese translations for home screen and various other Portuguese translations
2023-06-15 08:34:47 +02:00
834a48466e Feature/add liabilities to feature page (#2072)
* Add section for liabilities

* Update changelog
2023-06-14 20:08:04 +02:00
a9526430c2 Improve column headers in holdings table for mobile (#2071)
* Improve column headers in holdings table for mobile

* Update changelog
2023-06-13 21:00:56 +02:00
fce3b2084e Feature/extract symbol search to component (#2003) (#2056)
* Extract symbol search to component (#2003)

* Update changelog
2023-06-13 20:36:16 +02:00
f5a50a95de Feature/upgrade prisma to version 4.15.0 (#2070)
* Upgrade prisma to version 4.15.0

* Update changelog
2023-06-12 15:16:43 +02:00
06dfb91f82 Release 1.280.1 (#2069) 2023-06-10 21:41:39 +02:00
90 changed files with 4878 additions and 2649 deletions

View File

@ -6,7 +6,7 @@ labels: ''
assignees: ''
---
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed in our [Slack](https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg) community or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
**Bug Description**
@ -36,9 +36,7 @@ The Issue tracker is **ONLY** used for reporting bugs. New features should be di
<!-- Please complete the following information -->
- [ ] Cloud
- [ ] Self-hosted
- Cloud or Self-hosted
- Ghostfolio Version X.Y.Z
- Browser
- OS

View File

@ -5,7 +5,76 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## 1.280.0 - 2023-06-10
## 1.285.0 - 2023-07-01
### Added
- Added a blog post: _Exploring the Path to Financial Independence and Retiring Early (FIRE)_
- Added pagination to the historical market data table of the admin control panel
- Added the attribute `headers` to the scraper configuration
### Changed
- Extended the asset profile details dialog in the admin control panel by the scraper configuration
- Improved the language localization for German (`de`)
## 1.284.0 - 2023-06-27
### Added
- Added the currency to the cash balance in the create or update account dialog
- Added the ability to add an index for benchmarks as an asset profile in the admin control panel
### Changed
- Upgraded the _Internet Identity_ dependencies from version `0.15.1` to `0.15.7`
### Fixed
- Fixed an issue with the clone functionality of a transaction caused by the symbol search component
## 1.283.5 - 2023-06-25
### Added
- Added the caching for current market prices
- Added a loading indicator to the import dividends dialog
- Set up the `helmet` middleware to protect the app from web vulnerabilities by setting HTTP headers
### Changed
- Improved the selected item of the holding selector in the import dividends dialog
- Extended the symbol search component by asset sub classes
## 1.282.0 - 2023-06-19
### Added
- Added an icon to the external links in the footer navigation
- Added the ability to add an asset profile in the admin control panel
### Changed
- Harmonized the use of permissions on the about page
- Harmonized the use of permissions on the landing page
- Improved the language localization for German (`de`)
- Improved the language localization for Portuguese (`pt`)
- Updated the binary targets of `linux-arm64-openssl` for `prisma`
## 1.281.0 - 2023-06-17
### Added
- Extended the feature overview page by liabilities
- Set up the language localization for Portuguese (`pt`)
### Changed
- Extracted the symbol search to a dedicated component
- Improved the column headers in the holdings table for mobile
- Upgraded `prisma` from version `4.14.1` to `4.15.0`
## 1.280.1 - 2023-06-10
### Added
@ -799,7 +868,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support for the dividend timeline grouped by year
- Added support for the investment timeline grouped by year
- Set up the language localization for Français (`fr`)
- Set up the language localization for Português (`pt`)
### Changed
@ -1079,7 +1147,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added support to change the appearance (dark mode) in user settings
- Added the total amount chart to the investment timeline
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
- Set up the `prettier` plugin `prettier-plugin-organize-attributes`
### Changed

View File

@ -1,7 +1,9 @@
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data/market-data.service';
import { PropertyDto } from '@ghostfolio/api/services/property/property.dto';
import {
DEFAULT_PAGE_SIZE,
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
@ -26,11 +28,12 @@ import {
Post,
Put,
Query,
UseGuards
UseGuards,
UseInterceptors
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData } from '@prisma/client';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -245,7 +248,11 @@ export class AdminController {
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> {
if (
!hasPermission(
@ -270,7 +277,13 @@ export class AdminController {
})
];
return this.adminService.getMarketData(filters);
return this.adminService.getMarketData({
filters,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@Get('market-data/:dataSource/:symbol')
@ -328,6 +341,28 @@ export class AdminController {
});
}
@Post('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
public async addProfileData(
@Param('dataSource') dataSource: DataSource,
@Param('symbol') symbol: string
): Promise<SymbolProfile | never> {
if (
!hasPermission(
this.request.user.permissions,
permissions.accessAdminControl
)
) {
throw new HttpException(
getReasonPhrase(StatusCodes.FORBIDDEN),
StatusCodes.FORBIDDEN
);
}
return this.adminService.addAssetProfile({ dataSource, symbol });
}
@Delete('profile-data/:dataSource/:symbol')
@UseGuards(AuthGuard('jwt'))
public async deleteProfileData(

View File

@ -1,21 +1,24 @@
import { SubscriptionService } from '@ghostfolio/api/app/subscription/subscription.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
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';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
DEFAULT_PAGE_SIZE,
PROPERTY_CURRENCIES
} from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property } from '@prisma/client';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { groupBy } from 'lodash';
@ -25,6 +28,7 @@ export class AdminService {
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
@ -35,6 +39,38 @@ export class AdminService {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async addAssetProfile({
dataSource,
symbol
}: UniqueAsset): Promise<SymbolProfile | never> {
try {
const assetProfiles = await this.dataProviderService.getAssetProfiles([
{ dataSource, symbol }
]);
if (!assetProfiles[symbol]?.currency) {
throw new BadRequestException(
`Asset profile not found for ${symbol} (${dataSource})`
);
}
return await this.symbolProfileService.add(
assetProfiles[symbol] as Prisma.SymbolProfileCreateInput
);
} catch (error) {
if (
error instanceof Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new BadRequestException(
`Asset profile of ${symbol} (${dataSource}) already exists`
);
}
throw error;
}
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
await this.symbolProfileService.delete({ dataSource, symbol });
@ -65,7 +101,21 @@ export class AdminService {
};
}
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
public async getMarketData({
filters,
sortColumn,
sortDirection,
skip,
take = DEFAULT_PAGE_SIZE
}: {
filters?: Filter[];
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
@ -75,42 +125,33 @@ export class AdminService {
}
);
const marketData = await this.prismaService.marketData.groupBy({
const marketDataItems = await this.prismaService.marketData.groupBy({
_count: true,
by: ['dataSource', 'symbol']
});
let currencyPairsToGather: AdminMarketDataItem[] = [];
if (filtersByAssetSubClass) {
where.assetSubClass = AssetSubClass[filtersByAssetSubClass[0].id];
} else {
currencyPairsToGather = this.exchangeRateDataService
.getCurrencyPairs()
.map(({ dataSource, symbol }) => {
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
return {
dataSource,
marketDataItemCount,
symbol,
assetClass: 'CASH',
countriesCount: 0,
sectorsCount: 0
};
});
}
const symbolProfilesToGather: AdminMarketDataItem[] = (
await this.prismaService.symbolProfile.findMany({
if (sortColumn) {
orderBy = [{ [sortColumn]: sortDirection }];
if (sortColumn === 'activitiesCount') {
orderBy = {
Order: {
_count: sortDirection
}
};
}
}
const [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
take,
where,
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
@ -129,38 +170,48 @@ export class AdminService {
sectors: true,
symbol: true
}
})
).map((symbolProfile) => {
const countriesCount = symbolProfile.countries
? Object.keys(symbolProfile.countries).length
: 0;
const marketDataItemCount =
marketData.find((marketDataItem) => {
return (
marketDataItem.dataSource === symbolProfile.dataSource &&
marketDataItem.symbol === symbolProfile.symbol
);
})?._count ?? 0;
const sectorsCount = symbolProfile.sectors
? Object.keys(symbolProfile.sectors).length
: 0;
return {
countriesCount,
marketDataItemCount,
sectorsCount,
activitiesCount: symbolProfile._count.Order,
assetClass: symbolProfile.assetClass,
assetSubClass: symbolProfile.assetSubClass,
comment: symbolProfile.comment,
dataSource: symbolProfile.dataSource,
date: symbolProfile.Order?.[0]?.date,
symbol: symbolProfile.symbol
};
});
}),
this.prismaService.symbolProfile.count({ where })
]);
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
count,
marketData: assetProfiles.map(
({
_count,
assetClass,
assetSubClass,
comment,
countries,
dataSource,
Order,
sectors,
symbol
}) => {
const countriesCount = countries ? Object.keys(countries).length : 0;
const marketDataItemCount =
marketDataItems.find((marketDataItem) => {
return (
marketDataItem.dataSource === dataSource &&
marketDataItem.symbol === symbol
);
})?._count ?? 0;
const sectorsCount = sectors ? Object.keys(sectors).length : 0;
return {
assetClass,
assetSubClass,
comment,
countriesCount,
dataSource,
symbol,
marketDataItemCount,
sectorsCount,
activitiesCount: _count.Order,
date: Order?.[0]?.date
};
}
)
};
}
@ -198,12 +249,14 @@ export class AdminService {
public async patchAssetProfileData({
comment,
dataSource,
scraperConfiguration,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
await this.symbolProfileService.updateSymbolProfile({
comment,
dataSource,
scraperConfiguration,
symbol,
symbolMapping
});

View File

@ -1,3 +1,4 @@
import { Prisma } from '@prisma/client';
import { IsObject, IsOptional, IsString } from 'class-validator';
export class UpdateAssetProfileDto {
@ -5,6 +6,10 @@ export class UpdateAssetProfileDto {
@IsOptional()
comment?: string;
@IsObject()
@IsOptional()
scraperConfiguration?: Prisma.InputJsonObject;
@IsObject()
@IsOptional()
symbolMapping?: {

View File

@ -104,6 +104,11 @@ export class FrontendMiddleware implements NestMiddleware {
) {
featureGraphicPath = 'assets/images/blog/20230520.jpg';
title = `Unlock your Financial Potential with Ghostfolio - ${title}`;
} else if (
request.path.startsWith('/en/blog/2023/07/exploring-the-path-to-fire')
) {
featureGraphicPath = 'assets/images/blog/20230701.jpg';
title = `Exploring the Path to FIRE - ${title}`;
}
if (

View File

@ -204,7 +204,7 @@ export class OrderService {
where
});
if (order.type === 'ITEM') {
if (order.type === 'ITEM' || order.type === 'LIABILITY') {
await this.symbolProfileService.deleteById(order.symbolProfileId);
}
@ -375,7 +375,7 @@ export class OrderService {
let isDraft = false;
if (data.type === 'ITEM') {
if (data.type === 'ITEM' || data.type === 'LIABILITY') {
delete data.SymbolProfile.connect;
} else {
delete data.SymbolProfile.update;

View File

@ -98,7 +98,8 @@ describe('CurrentRateService', () => {
[],
null,
null,
propertyService
propertyService,
null
);
exchangeRateDataService = new ExchangeRateDataService(
null,

View File

@ -1,4 +1,5 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
@ -13,6 +14,10 @@ export class RedisCacheService {
return await this.cache.get(key);
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`;
}
public async remove(key: string) {
await this.cache.del(key);
}

View File

@ -36,10 +36,12 @@ export class SymbolController {
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async lookupSymbol(
@Query() { query = '' }
@Query('includeIndices') includeIndices: boolean = false,
@Query('query') query = ''
): Promise<{ items: LookupItem[] }> {
try {
return this.symbolService.lookup({
includeIndices,
query: query.toLowerCase(),
user: this.request.user
});

View File

@ -81,9 +81,11 @@ export class SymbolService {
}
public async lookup({
includeIndices = false,
query,
user
}: {
includeIndices?: boolean;
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
@ -95,6 +97,7 @@ export class SymbolService {
try {
const { items } = await this.dataProviderService.search({
includeIndices,
query,
user
});

View File

@ -166,7 +166,7 @@ export class UserService {
this.subscriptionService.getSubscription(Subscription);
if (
Analytics?.activityCount % 20 === 0 &&
Analytics?.activityCount % 10 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);

View File

@ -1,7 +1,9 @@
import { Logger, ValidationPipe, VersioningType } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import * as bodyParser from 'body-parser';
import helmet from 'helmet';
import { AppModule } from './app/app.module';
import { environment } from './environments/environment';
@ -10,11 +12,12 @@ async function bootstrap() {
const configApp = await NestFactory.create(AppModule);
const configService = configApp.get<ConfigService>(ConfigService);
const app = await NestFactory.create(AppModule, {
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
logger: environment.production
? ['error', 'log', 'warn']
: ['debug', 'error', 'log', 'verbose', 'warn']
});
app.enableCors();
app.enableVersioning({
defaultVersion: '1',
@ -32,6 +35,22 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
if (configService.get<string>('ENABLE_FEATURE_SUBSCRIPTION') === 'true') {
app.use(
helmet({
contentSecurityPolicy: {
directives: {
frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe
scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe
scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers
styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles
}
},
crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity)
})
);
}
const BASE_CURRENCY = configService.get<string>('BASE_CURRENCY');
const HOST = configService.get<string>('HOST') || '0.0.0.0';
const PORT = configService.get<number>('PORT') || 3333;

View File

@ -16,6 +16,7 @@ export class ConfigurationService {
default: 'USD'
}),
BETTER_UPTIME_API_KEY: str({ default: '' }),
CACHE_QUOTES_TTL: num({ default: 1 }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_EXCHANGE_RATES: str({ default: DataSource.YAHOO }),
DATA_SOURCE_IMPORT: str({ default: DataSource.YAHOO }),

View File

@ -114,8 +114,14 @@ export class AlphaVantageService implements DataProviderInterface {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(aQuery);
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const result = await this.alphaVantage.data.search(query);
return {
items: result?.bestMatches?.map((bestMatch) => {

View File

@ -164,16 +164,17 @@ export class CoinGeckoService implements DataProviderInterface {
return 'bitcoin';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
try {
const get = bent(
`${this.URL}/search?query=${aQuery}`,
'GET',
'json',
200
);
const get = bent(`${this.URL}/search?query=${query}`, 'GET', 'json', 200);
const { coins } = await get();
items = coins.map(({ id: symbol, name }) => {

View File

@ -1,3 +1,4 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
@ -26,6 +27,7 @@ import { DataProviderService } from './data-provider.service';
MarketDataModule,
PrismaModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [

View File

@ -1,3 +1,4 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
@ -27,7 +28,8 @@ export class DataProviderService {
private readonly dataProviderInterfaces: DataProviderInterface[],
private readonly marketDataService: MarketDataService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
private readonly propertyService: PropertyService,
private readonly redisCacheService: RedisCacheService
) {
this.initialize();
}
@ -235,9 +237,43 @@ export class DataProviderService {
} = {};
const startTimeTotal = performance.now();
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
// Get items from cache
const itemsToFetch: IDataGatheringItem[] = [];
const promises = [];
for (const { dataSource, symbol } of items) {
const quoteString = await this.redisCacheService.get(
this.redisCacheService.getQuoteKey({ dataSource, symbol })
);
if (quoteString) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
} catch {}
}
if (!quoteString) {
itemsToFetch.push({ dataSource, symbol });
}
}
const numberOfItemsInCache = Object.keys(response)?.length;
if (numberOfItemsInCache) {
Logger.debug(
`Fetched ${numberOfItemsInCache} quote${
numberOfItemsInCache > 1 ? 's' : ''
} from cache in ${((performance.now() - startTimeTotal) / 1000).toFixed(
3
)} seconds`
);
}
const itemsGroupedByDataSource = groupBy(itemsToFetch, ({ dataSource }) => {
return dataSource;
});
const promises: Promise<any>[] = [];
for (const [dataSource, dataGatheringItems] of Object.entries(
itemsGroupedByDataSource
@ -271,6 +307,15 @@ export class DataProviderService {
result
)) {
response[symbol] = dataProviderResponse;
this.redisCacheService.set(
this.redisCacheService.getQuoteKey({
dataSource: DataSource[dataSource],
symbol
}),
JSON.stringify(dataProviderResponse),
this.configurationService.get('CACHE_QUOTES_TTL')
);
}
Logger.debug(
@ -283,7 +328,7 @@ export class DataProviderService {
);
try {
await this.marketDataService.updateMany({
this.marketDataService.updateMany({
data: Object.keys(response)
.filter((symbol) => {
return (
@ -322,9 +367,11 @@ export class DataProviderService {
}
public async search({
includeIndices = false,
query,
user
}: {
includeIndices?: boolean;
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
@ -347,7 +394,12 @@ export class DataProviderService {
}
for (const dataSource of dataSources) {
promises.push(this.getDataProvider(DataSource[dataSource]).search(query));
promises.push(
this.getDataProvider(DataSource[dataSource]).search({
includeIndices,
query
})
);
}
const searchResults = await Promise.all(promises);

View File

@ -156,7 +156,7 @@ export class EodHistoricalDataService implements DataProviderInterface {
return !symbol.endsWith('.FOREX');
})
.map((symbol) => {
return this.search(symbol);
return this.search({ query: symbol });
})
);
@ -219,8 +219,14 @@ export class EodHistoricalDataService implements DataProviderInterface {
return 'AAPL.US';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
const searchResult = await this.getSearchResult(aQuery);
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const searchResult = await this.getSearchResult(query);
return {
items: searchResult

View File

@ -143,12 +143,18 @@ export class FinancialModelingPrepService implements DataProviderInterface {
return 'AAPL';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items: LookupItem[] = [];
try {
const get = bent(
`${this.URL}/search?query=${aQuery}&apikey=${this.apiKey}`,
`${this.URL}/search?query=${query}&apikey=${this.apiKey}`,
'GET',
'json',
200

View File

@ -153,7 +153,13 @@ export class GoogleSheetsService implements DataProviderInterface {
return 'INDEXSP:.INX';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
@ -169,14 +175,14 @@ export class GoogleSheetsService implements DataProviderInterface {
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
}
]

View File

@ -42,5 +42,11 @@ export interface DataProviderInterface {
getTestSymbol(): string;
search(aQuery: string): Promise<{ items: LookupItem[] }>;
search({
includeIndices,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }>;
}

View File

@ -67,8 +67,12 @@ export class ManualService implements DataProviderInterface {
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[{ symbol, dataSource: this.getName() }]
);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration ?? {};
const {
defaultMarketPrice,
headers = {},
selector,
url
} = symbolProfile.scraperConfiguration ?? {};
if (defaultMarketPrice) {
const historical: {
@ -91,7 +95,7 @@ export class ManualService implements DataProviderInterface {
return {};
}
const get = bent(url, 'GET', 'string', 200, {});
const get = bent(url, 'GET', 'string', 200, headers);
const html = await get();
const $ = cheerio.load(html);
@ -171,7 +175,13 @@ export class ManualService implements DataProviderInterface {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
let items = await this.prismaService.symbolProfile.findMany({
select: {
assetClass: true,
@ -187,14 +197,14 @@ export class ManualService implements DataProviderInterface {
dataSource: this.getName(),
name: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
},
{
dataSource: this.getName(),
symbol: {
mode: 'insensitive',
startsWith: aQuery
startsWith: query
}
}
]

View File

@ -117,7 +117,13 @@ export class RapidApiService implements DataProviderInterface {
return undefined;
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
return { items: [] };
}

View File

@ -275,11 +275,23 @@ export class YahooFinanceService implements DataProviderInterface {
return 'AAPL';
}
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
public async search({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}): Promise<{ items: LookupItem[] }> {
const items: LookupItem[] = [];
try {
const searchResult = await yahooFinance.search(aQuery);
const quoteTypes = ['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'];
if (includeIndices) {
quoteTypes.push('INDEX');
}
const searchResult = await yahooFinance.search(query);
const quotes = searchResult.quotes
.filter((quote) => {
@ -295,7 +307,7 @@ export class YahooFinanceService implements DataProviderInterface {
this.baseCurrency
)
)) ||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
quoteTypes.includes(quoteType)
);
})
.filter(({ quoteType, symbol }) => {

View File

@ -5,6 +5,7 @@ export interface Environment extends CleanedEnvAccessors {
ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
BETTER_UPTIME_API_KEY: string;
CACHE_QUOTES_TTL: number;
CACHE_TTL: number;
DATA_SOURCE_EXCHANGE_RATES: string;
DATA_SOURCE_IMPORT: string;

View File

@ -15,6 +15,12 @@ import { continents, countries } from 'countries-list';
export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {}
public async add(
assetProfile: Prisma.SymbolProfileCreateInput
): Promise<SymbolProfile | never> {
return this.prismaService.symbolProfile.create({ data: assetProfile });
}
public async delete({ dataSource, symbol }: UniqueAsset) {
return this.prismaService.symbolProfile.delete({
where: { dataSource_symbol: { dataSource, symbol } }
@ -90,11 +96,12 @@ export class SymbolProfileService {
public updateSymbolProfile({
comment,
dataSource,
scraperConfiguration,
symbol,
symbolMapping
}: Prisma.SymbolProfileUpdateInput & UniqueAsset) {
return this.prismaService.symbolProfile.update({
data: { comment, symbolMapping },
data: { comment, scraperConfiguration, symbolMapping },
where: { dataSource_symbol: { dataSource, symbol } }
});
}
@ -189,6 +196,8 @@ export class SymbolProfileService {
if (scraperConfiguration) {
return {
defaultMarketPrice: scraperConfiguration.defaultMarketPrice as number,
headers:
scraperConfiguration.headers as ScraperConfiguration['headers'],
selector: scraperConfiguration.selector as string,
url: scraperConfiguration.url as string
};

View File

@ -18,36 +18,6 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/about/about-page.module').then((m) => m.AboutPageModule)
})),
...[
'about/changelog',
/////
'a-propos/changelog',
'informazioni-su/changelog',
'over/changelog',
'sobre/changelog',
'ueber-uns/changelog'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/changelog/changelog-page.module').then(
(m) => m.ChangelogPageModule
)
})),
...[
'about/privacy-policy',
/////
'a-propos/politique-de-confidentialite',
'informazioni-su/informativa-sulla-privacy',
'over/privacybeleid',
'sobre/politica-de-privacidad',
'ueber-uns/datenschutzbestimmungen'
].map((path) => ({
path,
loadChildren: () =>
import('./pages/about/privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
})),
{
path: 'account',
loadChildren: () =>
@ -168,6 +138,13 @@ const routes: Routes = [
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
},
{
path: 'blog/2023/07/exploring-the-path-to-fire',
loadChildren: () =>
import(
'./pages/blog/2023/07/exploring-the-path-to-fire/exploring-the-path-to-fire-page.module'
).then((m) => m.ExploringThePathToFirePageModule)
},
{
path: 'demo',
loadChildren: () =>
@ -179,6 +156,7 @@ const routes: Routes = [
'domande-piu-frequenti',
'foire-aux-questions',
'haeufig-gestellte-fragen',
'perguntas-mais-frequentes',
'preguntas-mas-frecuentes',
'vaak-gestelde-vragen'
].map((path) => ({
@ -243,6 +221,7 @@ const routes: Routes = [
'pricing',
/////
'precios',
'precos',
'preise',
'prezzi',
'prijzen',
@ -259,6 +238,7 @@ const routes: Routes = [
/////
'enregistrement',
'iscrizione',
'registo',
'registratie',
'registrierung',
'registro'

View File

@ -89,7 +89,7 @@
<li>
<a i18n [routerLink]="['/about', 'license']">License</a>
</li>
<li *ngIf="hasPermissionForSubscription">
<li *ngIf="hasPermissionForStatistics">
<a [routerLink]="['/open']">Open Startup</a>
</li>
<li *ngIf="hasPermissionForSubscription">
@ -101,9 +101,13 @@
>
</li>
<li *ngIf="hasPermissionForSubscription">
<a href="https://status.ghostfol.io" title="Ghostfolio Status"
>Status</a
>
<a
class="align-items-baseline d-flex"
href="https://status.ghostfol.io"
target="_blank"
title="Ghostfolio Status"
>Status<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
</ul>
</div>
@ -112,24 +116,30 @@
<ul class="list-unstyled">
<li>
<a
class="align-items-baseline d-flex"
href="https://github.com/ghostfolio/ghostfolio"
target="_blank"
title="Find Ghostfolio on GitHub"
>GitHub</a
>
>GitHub<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
title="Join the Ghostfolio Slack community"
>Slack</a
>
>Slack<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
<li>
<a
class="align-items-baseline d-flex"
href="https://twitter.com/ghostfolio_"
target="_blank"
title="Follow Ghostfolio on Twitter"
>Twitter</a
>
>Twitter<ion-icon class="ml-1" name="open-outline"></ion-icon
></a>
</li>
<li>&nbsp;</li>
<li>
@ -150,6 +160,9 @@
<li>
<a href="../nl" title="Ghostfolio in Nederlands">Nederlands</a>
</li>
<li>
<a href="../pt" title="Ghostfolio in Português">Português</a>
</li>
</ul>
</div>
</div>

View File

@ -33,6 +33,7 @@ export class AppComponent implements OnDestroy, OnInit {
public currentYear = new Date().getFullYear();
public deviceType: string;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public info: InfoItem;
@ -70,6 +71,11 @@ export class AppComponent implements OnDestroy, OnInit {
permissions.enableSubscription
);
this.hasPermissionForStatistics = hasPermission(
this.info?.globalPermissions,
permissions.enableStatistics
);
this.hasPermissionToAccessFearAndGreedIndex = hasPermission(
this.info?.globalPermissions,
permissions.enableFearAndGreedIndex

View File

@ -1,4 +1,5 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
@ -7,23 +8,26 @@ import {
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatSort, Sort } from '@angular/material/sort';
import { MatTableDataSource } from '@angular/material/table';
import { ActivatedRoute, Router } from '@angular/router';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Filter, UniqueAsset, User } from '@ghostfolio/common/interfaces';
import { AdminMarketDataItem } from '@ghostfolio/common/interfaces/admin-market-data.interface';
import { translate } from '@ghostfolio/ui/i18n';
import { AssetSubClass, DataSource } from '@prisma/client';
import { AssetSubClass, DataSource, Prisma } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { distinctUntilChanged, switchMap, takeUntil } from 'rxjs/operators';
import { AssetProfileDialog } from './asset-profile-dialog/asset-profile-dialog.component';
import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/interfaces';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog/create-asset-profile-dialog.component';
import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/interfaces/interfaces';
import { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
@ -31,7 +35,10 @@ import { AssetProfileDialogParams } from './asset-profile-dialog/interfaces/inte
styleUrls: ['./admin-market-data.scss'],
templateUrl: './admin-market-data.html'
})
export class AdminMarketDataComponent implements OnDestroy, OnInit {
export class AdminMarketDataComponent
implements AfterViewInit, OnDestroy, OnInit
{
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
public activeFilters: Filter[] = [];
@ -73,6 +80,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public filters$ = new Subject<Filter[]>();
public isLoading = false;
public placeholder = '';
public pageSize = DEFAULT_PAGE_SIZE;
public totalItems = 0;
public user: User;
private unsubscribeSubject = new Subject<void>();
@ -80,7 +89,6 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private deviceService: DeviceDetectorService,
private dialog: MatDialog,
private route: ActivatedRoute,
@ -99,6 +107,8 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
dataSource: params['dataSource'],
symbol: params['symbol']
});
} else if (params['createAssetProfileDialog']) {
this.openCreateAssetProfileDialog();
}
});
@ -113,34 +123,40 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
);
}
});
this.filters$
.pipe(distinctUntilChanged(), takeUntil(this.unsubscribeSubject))
.subscribe((filters) => {
this.activeFilters = filters;
this.loadData();
});
}
public ngAfterViewInit() {
this.sort.sortChange.subscribe(
({ active: sortColumn, direction }: Sort) => {
this.paginator.pageIndex = 0;
this.loadData({
sortColumn,
sortDirection: <Prisma.SortOrder>direction,
pageIndex: this.paginator.pageIndex
});
}
);
}
public ngOnInit() {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
}
this.filters$
.pipe(
distinctUntilChanged(),
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
return this.dataService.fetchAdminMarketData({
filters: this.activeFilters
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
public onChangePage(page: PageEvent) {
this.loadData({
pageIndex: page.pageIndex,
sortColumn: this.sort.active,
sortDirection: <Prisma.SortOrder>this.sort.direction
});
}
public onDeleteProfileData({ dataSource, symbol }: UniqueAsset) {
@ -208,6 +224,47 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.unsubscribeSubject.complete();
}
private loadData(
{
pageIndex,
sortColumn,
sortDirection
}: {
pageIndex: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
} = { pageIndex: 0 }
) {
this.isLoading = true;
if (pageIndex === 0 && this.paginator) {
this.paginator.pageIndex = 0;
}
this.placeholder =
this.activeFilters.length <= 0 ? $localize`Filter by...` : '';
this.adminService
.fetchAdminMarketData({
sortColumn,
sortDirection,
filters: this.activeFilters,
skip: pageIndex * this.pageSize,
take: this.pageSize
})
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ count, marketData }) => {
this.totalItems = count;
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
private openAssetProfileDialog({
dataSource,
symbol
@ -241,4 +298,53 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
});
});
}
private openCreateAssetProfileDialog() {
this.userService
.get()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((user) => {
this.user = user;
const dialogRef = this.dialog.open(CreateAssetProfileDialog, {
autoFocus: false,
data: <CreateAssetProfileDialogParams>{
deviceType: this.deviceType,
locale: this.user?.settings?.locale
},
width: this.deviceType === 'mobile' ? '100vw' : '50rem'
});
dialogRef
.afterClosed()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ dataSource, symbol }) => {
if (dataSource && symbol) {
this.adminService
.addAssetProfile({ dataSource, symbol })
.pipe(
switchMap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
return this.adminService.fetchAdminMarketData({
filters: this.activeFilters,
take: this.pageSize
});
}),
takeUntil(this.unsubscribeSubject)
)
.subscribe(({ marketData }) => {
this.dataSource = new MatTableDataSource(marketData);
this.dataSource.sort = this.sort;
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
this.router.navigate(['.'], { relativeTo: this.route });
});
});
}
}

View File

@ -56,7 +56,7 @@
</ng-container>
<ng-container matColumnDef="date">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>First Activity</ng-container>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
@ -74,7 +74,7 @@
</ng-container>
<ng-container matColumnDef="marketDataItemCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Historical Data</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -83,7 +83,7 @@
</ng-container>
<ng-container matColumnDef="sectorsCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Sectors Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -92,7 +92,7 @@
</ng-container>
<ng-container matColumnDef="countriesCount">
<th *matHeaderCellDef class="px-1" mat-header-cell mat-sort-header>
<th *matHeaderCellDef class="px-1" mat-header-cell>
<ng-container i18n>Countries Count</ng-container>
</th>
<td *matCellDef="let element" class="px-1 text-right" mat-cell>
@ -162,6 +162,40 @@
(click)="onOpenAssetProfileDialog({ dataSource: row.dataSource, symbol: row.symbol })"
></tr>
</table>
<mat-paginator
[length]="totalItems"
[ngClass]="{
'd-none':
(isLoading && totalItems === 0) ||
totalItems <= pageSize
}"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
></mat-paginator>
<ngx-skeleton-loader
*ngIf="isLoading && totalItems === 0"
animation="pulse"
class="px-4 py-3"
[theme]="{
height: '1.5rem',
width: '100%'
}"
></ngx-skeleton-loader>
</div>
</div>
<div class="fab-container">
<a
class="align-items-center d-flex justify-content-center"
color="primary"
mat-fab
[queryParams]="{ createAssetProfileDialog: true }"
[routerLink]="[]"
>
<ion-icon name="add-outline" size="large"></ion-icon>
</a>
</div>
</div>

View File

@ -2,12 +2,16 @@ import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { MatMenuModule } from '@angular/material/menu';
import { MatPaginatorModule } from '@angular/material/paginator';
import { MatSortModule } from '@angular/material/sort';
import { MatTableModule } from '@angular/material/table';
import { RouterModule } from '@angular/router';
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { AdminMarketDataComponent } from './admin-market-data.component';
import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile-dialog.module';
import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/create-asset-profile-dialog.module';
@NgModule({
declarations: [AdminMarketDataComponent],
@ -15,10 +19,14 @@ import { GfAssetProfileDialogModule } from './asset-profile-dialog/asset-profile
CommonModule,
GfActivitiesFilterModule,
GfAssetProfileDialogModule,
GfCreateAssetProfileDialogModule,
MatButtonModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -2,4 +2,11 @@
:host {
display: block;
.fab-container {
bottom: 2rem;
position: fixed;
right: 2rem;
z-index: 999;
}
}

View File

@ -13,6 +13,7 @@ import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
AdminMarketDataDetails,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { translate } from '@ghostfolio/ui/i18n';
@ -34,6 +35,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
public assetProfile: AdminMarketDataDetails['assetProfile'];
public assetProfileForm = this.formBuilder.group({
comment: '',
scraperConfiguration: '',
symbolMapping: ''
});
public assetSubClass: string;
@ -103,6 +105,9 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
this.assetProfileForm.setValue({
comment: this.assetProfile?.comment ?? '',
scraperConfiguration: JSON.stringify(
this.assetProfile?.scraperConfiguration ?? {}
),
symbolMapping: JSON.stringify(this.assetProfile?.symbolMapping ?? {})
});
@ -148,8 +153,15 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
}
public onSubmit() {
let scraperConfiguration = {};
let symbolMapping = {};
try {
scraperConfiguration = JSON.parse(
this.assetProfileForm.controls['scraperConfiguration'].value
);
} catch {}
try {
symbolMapping = JSON.parse(
this.assetProfileForm.controls['symbolMapping'].value
@ -157,6 +169,7 @@ export class AssetProfileDialog implements OnDestroy, OnInit {
} catch {}
const assetProfileData: UpdateAssetProfileDto = {
scraperConfiguration,
symbolMapping,
comment: this.assetProfileForm.controls['comment'].value ?? null
};

View File

@ -162,6 +162,17 @@
></textarea>
</mat-form-field>
</div>
<div *ngIf="assetProfile?.dataSource === 'MANUAL'">
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Scraper Configuration</mat-label>
<textarea
cdkTextareaAutosize
formControlName="scraperConfiguration"
matInput
type="text"
></textarea>
</mat-form-field>
</div>
<div>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Note</mat-label>

View File

@ -0,0 +1,53 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
OnDestroy,
OnInit
} from '@angular/core';
import {
FormBuilder,
FormControl,
FormGroup,
Validators
} from '@angular/forms';
import { MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'h-100' },
selector: 'gf-create-asset-profile-dialog',
templateUrl: 'create-asset-profile-dialog.html'
})
export class CreateAssetProfileDialog implements OnInit, OnDestroy {
public createAssetProfileForm: FormGroup;
public constructor(
public readonly adminService: AdminService,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dialogRef: MatDialogRef<CreateAssetProfileDialog>,
public readonly formBuilder: FormBuilder
) {}
public ngOnInit() {
this.createAssetProfileForm = this.formBuilder.group({
searchSymbol: new FormControl(null, [Validators.required])
});
}
public onCancel() {
this.dialogRef.close();
}
public onSubmit() {
this.dialogRef.close({
dataSource:
this.createAssetProfileForm.controls['searchSymbol'].value.dataSource,
symbol: this.createAssetProfileForm.controls['searchSymbol'].value.symbol
});
}
public ngOnDestroy() {}
}

View File

@ -0,0 +1,28 @@
<form
class="d-flex flex-column h-100"
[formGroup]="createAssetProfileForm"
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Add Asset Profile</h1>
<div class="flex-grow-1 py-3" mat-dialog-content>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<gf-symbol-autocomplete
formControlName="searchSymbol"
[includeIndices]="true"
/>
</mat-form-field>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
<button i18n mat-button type="button" (click)="onCancel()">Cancel</button>
<button
color="primary"
mat-flat-button
type="submit"
[disabled]="!createAssetProfileForm.valid"
>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

View File

@ -0,0 +1,24 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete';
import { CreateAssetProfileDialog } from './create-asset-profile-dialog.component';
@NgModule({
declarations: [CreateAssetProfileDialog],
imports: [
CommonModule,
FormsModule,
GfSymbolAutocompleteModule,
MatDialogModule,
MatButtonModule,
MatFormFieldModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfCreateAssetProfileDialogModule {}

View File

@ -0,0 +1,4 @@
export interface CreateAssetProfileDialogParams {
deviceType: string;
locale: string;
}

View File

@ -1,5 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatCheckboxChange } from '@angular/material/checkbox';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { CacheService } from '@ghostfolio/client/services/cache.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -45,6 +46,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private cacheService: CacheService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
@ -197,7 +199,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
}
private fetchAdminData() {
this.dataService
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ exchangeRates, settings, transactionCount, userCount }) => {

View File

@ -1,4 +1,5 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
@ -30,6 +31,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
private unsubscribeSubject = new Subject<void>();
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
private dataService: DataService,
private impersonationStorageService: ImpersonationStorageService,
@ -112,7 +114,7 @@ export class AdminUsersComponent implements OnDestroy, OnInit {
}
private fetchAdminData() {
this.dataService
this.adminService
.fetchAdminData()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ users }) => {

View File

@ -25,6 +25,7 @@ const routes: Routes = [
...[
'license',
/////
'licenca',
'licence',
'licencia',
'licentie',
@ -37,13 +38,22 @@ const routes: Routes = [
(m) => m.LicensePageModule
)
})),
{
path: 'privacy-policy',
...[
'privacy-policy',
/////
'datenschutzbestimmungen',
'informativa-sulla-privacy',
'politique-de-confidentialite',
'politica-de-privacidad',
'politica-de-privacidade',
'privacybeleid'
].map((path) => ({
path,
loadChildren: () =>
import('./privacy-policy/privacy-policy-page.module').then(
(m) => m.PrivacyPolicyPageModule
)
}
}))
],
component: AboutPageComponent,
path: '',

View File

@ -15,6 +15,7 @@ import { takeUntil } from 'rxjs/operators';
})
export class AboutOverviewPageComponent implements OnDestroy, OnInit {
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;
public isLoggedIn: boolean;
public user: User;
@ -34,6 +35,11 @@ export class AboutOverviewPageComponent implements OnDestroy, OnInit {
permissions.enableBlog
);
this.hasPermissionForStatistics = hasPermission(
globalPermissions,
permissions.enableStatistics
);
this.hasPermissionForSubscription = hasPermission(
globalPermissions,
permissions.enableSubscription

View File

@ -19,9 +19,11 @@
title="GNU Affero General Public License"
>AGPL-3.0 license</a
>
and we share aggregated
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
of the platforms performance. The project has been initiated by
<ng-container *ngIf="hasPermissionForStatistics">
and we share aggregated
<a title="Open Startup" [routerLink]="['/open']">key metrics</a>
of the platforms performance</ng-container
>. The project has been initiated by
<a href="https://dotsilver.ch" title="Website of Thomas Kaul"
>Thomas Kaul</a
>

View File

@ -156,10 +156,10 @@
>Nederlands (<ng-container i18n>Community</ng-container
>)</mat-option
>
<!--<mat-option value="pt"
<mat-option value="pt"
>Português (<ng-container i18n>Community</ng-container
>)</mat-option
>-->
>
</mat-select>
</mat-form-field>
</div>

View File

@ -37,6 +37,7 @@
type="number"
[(ngModel)]="data.account.balance"
/>
<span class="ml-2" matTextSuffix>{{ data.account.currency }}</span>
</mat-form-field>
</div>
<div [ngClass]="{ 'd-none': platforms?.length < 1 }">

View File

@ -84,13 +84,13 @@
<section class="mb-4">
<p>
To participate in the ongoing development of Ghostfolio, please feel
free to reach out to us on our
free to contact us in the
<a
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
target="_blank"
>Slack channel</a
>Slack</a
>
or via Twitter
community or via Twitter
<a href="https://twitter.com/ghostfolio_" target="_blank"
>@ghostfolio_</a
>. We look forward to hearing from you!

View File

@ -92,12 +92,14 @@
<p>
Achieving
<a [routerLink]="['/resources']">financial independence</a>
including early retirement (FIRE) requires careful planning,
monitoring, and forecasting. Ghostfolios robust features equip
individuals with tools to analyze, optimize and simulate investment
strategies. By providing insights, performance tracking, and
portfolio analysis, Ghostfolio serves as a valuable companion in the
pursuit of financial freedom.
including early retirement (<a
href="../en/blog/2023/07/exploring-the-path-to-fire"
>FIRE</a
>) requires careful planning, monitoring, and forecasting.
Ghostfolios robust features equip individuals with tools to
analyze, optimize and simulate investment strategies. By providing
insights, performance tracking, and portfolio analysis, Ghostfolio
serves as a valuable companion in the pursuit of financial freedom.
</p>
</section>
<section class="mb-4">

View File

@ -0,0 +1,20 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { ExploringThePathToFirePageComponent } from './exploring-the-path-to-fire-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: ExploringThePathToFirePageComponent,
path: '',
title: 'Exploring the Path to FIRE'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class ExploringThePathToFireRoutingModule {}

View File

@ -0,0 +1,9 @@
import { Component } from '@angular/core';
@Component({
host: { class: 'page' },
selector: 'gf-exploring-the-path-to-fire-page-page',
styleUrls: ['./exploring-the-path-to-fire-page.scss'],
templateUrl: './exploring-the-path-to-fire-page.html'
})
export class ExploringThePathToFirePageComponent {}

View File

@ -0,0 +1,257 @@
<div class="blog container">
<div class="row">
<div class="col-md-8 offset-md-2">
<article>
<div class="mb-4 text-center">
<h1 class="mb-1">
Exploring the Path to Financial Independence and Retiring Early
(FIRE)
</h1>
<div class="mb-3 text-muted"><small>2023-07-01</small></div>
<img
alt="Exploring the Path to Financial Independence and Retiring Early (FIRE) Teaser"
class="border rounded w-100"
src="../assets/images/blog/20230701.jpg"
title="Exploring the Path to Financial Independence and Retiring Early (FIRE)"
/>
</div>
<section class="mb-4">
<p>
In this article, we will explore the concept behind FIRE and
thoroughly discuss the pros and cons of this increasingly popular
movement.
</p>
</section>
<section class="mb-4">
<h2 class="h4">The Idea behind FIRE</h2>
<p>
FIRE, short for <i>Financial Independence and Retiring Early</i>, is
a movement that promotes the idea of saving and investing a
significant portion of ones income in order to achieve financial
independence and retire at a young age. The core principle of FIRE
is not unconditionally quitting work, but rather attaining the
freedom to pursue meaningful activities and experiences without
relying on traditional employment.
</p>
<p>
At the heart of FIRE lies the famous 4% rule, originated in the
<a href="https://en.wikipedia.org/wiki/Trinity_study"
>Trinity study</a
>. This rule suggests that if you withdraw 4% of your investment
portfolio (usually based on low-cost index funds) in the first year
of retirement and adjust that amount for inflation in subsequent
years, your savings have a high likelihood of lasting for a 30-year
retirement period. The 4% rule serves as a guide to determine the
amount that can be safely withdrawn from a retirement portfolio each
year without depleting savings prematurely, allowing individuals to
sustain their desired lifestyle throughout retirement.
</p>
<p>
By multiplying your anticipated annual expenses by 25, you obtain an
estimate of the total retirement savings required to sustain your
lifestyle, assuming a 4% withdrawal rate.
</p>
</section>
<section class="mb-4">
<h2 class="h4">The power of FIRE</h2>
<p>
FIRE grants individuals the freedom to make choices based on their
personal values, liberated from financial constraints. By achieving
financial independence, individuals gain control over their time and
resources, enabling them to live life on their terms.
</p>
<p>
One of the primary benefits of FIRE is the ability to retire early,
affording individuals the opportunity to pursue their passions,
spend quality time with loved ones, engage in meaningful projects,
and prioritize personal growth. Early retirement promotes a more
balanced lifestyle and allows individuals to savor life while they
are still young and full of energy.
</p>
<p>
FIRE encourages adopting a frugal mindset and intentional spending,
resulting in reduced financial stress. By living within their means
and focusing on what truly brings them joy, individuals experience
greater peace of mind, improved well-being, and a stronger sense of
financial security.
</p>
<p>
FIRE grants individuals a higher level of autonomy and empowerment
over their schedules and the activities they choose to pursue.
Whether it involves exploring new career paths, starting a business,
or embarking on extensive travel, FIRE provides the flexibility to
determine how time is spent and how life is shaped.
</p>
</section>
<section class="mb-4">
<h2 class="h4">The challenges of FIRE</h2>
<p>
Achieving FIRE often demands strict budgeting, aggressive saving,
and making sacrifices for years to secure a financially independent
future. This may involve cutting back on discretionary expenses,
deferring major purchases, or adopting a simpler lifestyle to
achieve long-term goals.
</p>
<p>
FIRE heavily relies on investments and the performance of financial
markets. Economic downturns or unexpected market fluctuations can
impact investment portfolios and potentially delay or jeopardize
early retirement plans. Developing a robust financial plan,
diversifying investments, and periodically reassessing your
withdrawal strategy is crucial.
</p>
<p>
This particular aspect of FIRE requires thoughtful deliberation and
strategic planning to ensure a consistent and reliable stream of
retirement income throughout ones lifespan.
</p>
<p>
Retiring early often means stepping away from a career and
potentially losing the professional identity and social connections
associated with it. Giving careful thought to how early retirement
might influence ones sense of purpose is crucial, as it prompts the
exploration of alternative avenues to stay engaged and fulfilled.
</p>
</section>
<section class="mb-4">
<h2 class="h4">Achieving FIRE for a fulfilling future</h2>
<p>
Financial Independence and Retiring Early (FIRE) is a fascinating
concept that has gained significant traction in recent years. It
offers individuals the opportunity to break free from financial
constraints, pursue their passions, and gain control over their time
and resources. While FIRE comes with its own set of challenges, the
potential benefits are substantial. Whether you choose to fully
embrace FIRE or adapt its principles to suit your personal goals,
its crucial to approach it with careful planning and a clear
understanding of the advantages and disadvantages.
</p>
<p>
Knowing your financial situation and tracking it diligently is vital
on your journey to FIRE. This is where the power of
<a href="https://ghostfol.io">Ghostfolio</a>, a comprehensive open
source wealth management software, comes into play. By leveraging
Ghostfolio, you can gain deep insights into your financial health,
track your investments, and make informed decisions to accelerate
your progress towards financial independence. Ghostfolio also
provides a dedicated
<a [routerLink]="['/features']">FIRE calculator</a>, allowing you to
simulate your customized plan to achieve FIRE. You get the tools to
optimize your financial journey and confidently strive for a future
that is both personally fulfilling and financially secure.
</p>
</section>
<section class="mb-4 py-3">
<h2 class="h4 mb-0 text-center">
Are you ready for your <strong>journey</strong> towards
<strong>FIRE</strong>?
</h2>
<p class="lead mb-2 text-center">
Ghostfolio accompanies you on your path to financial independence.
</p>
<div class="text-center">
<a color="primary" href="https://ghostfol.io" mat-flat-button>
Get Started
</a>
</div>
</section>
<section class="mb-4">
<ul class="list-inline">
<li class="list-inline-item">
<span class="badge badge-light">App</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Budgeting</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Calculator</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">FIRE</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Frugality</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Ghostfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Health</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Independence</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Index Fund</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Investment</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Lifestyle</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Management</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Movement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Open Source</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Personal Finance</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Planning</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Portfolio</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Retirement</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Savings</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Simulation</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Software</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Strategy</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Trinity Study</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Wealth</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Withdrawal</span>
</li>
<li class="list-inline-item">
<span class="badge badge-light">Work-Life Balance</span>
</li>
</ul>
</section>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item">
<a i18n [routerLink]="['/blog']">Blog</a>
</li>
<li
aria-current="page"
class="active breadcrumb-item text-truncate"
>
Exploring the Path to Financial Independence and Retiring Early
(FIRE)
</li>
</ol>
</nav>
</article>
</div>
</div>
</div>

View File

@ -0,0 +1,19 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { ExploringThePathToFireRoutingModule } from './exploring-the-path-to-fire-page-routing.module';
import { ExploringThePathToFirePageComponent } from './exploring-the-path-to-fire-page.component';
@NgModule({
declarations: [ExploringThePathToFirePageComponent],
imports: [
CommonModule,
ExploringThePathToFireRoutingModule,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class ExploringThePathToFirePageModule {}

View File

@ -0,0 +1,3 @@
:host {
display: block;
}

View File

@ -2,6 +2,33 @@
<div class="mb-5 row">
<div class="col">
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Blog</h3>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">
<div class="flex-nowrap no-gutters row">
<a
class="d-flex overflow-hidden w-100"
href="../en/blog/2023/07/exploring-the-path-to-fire"
>
<div class="flex-grow-1 overflow-hidden">
<div class="h6 m-0 text-truncate">
Exploring the Path to Financial Independence and Retiring
Early (FIRE)
</div>
<div class="d-flex text-muted">2023-07-01</div>
</div>
<div class="align-items-center d-flex">
<ion-icon
class="chevron text-muted"
name="chevron-forward-outline"
size="small"
></ion-icon>
</div>
</a>
</div>
</div>
</mat-card-content>
</mat-card>
<mat-card appearance="outlined" class="mb-3">
<mat-card-content>
<div class="container p-0">

View File

@ -1,7 +1,7 @@
<div class="container">
<div class="row">
<div class="col">
<h3 class="d-none d-sm-block mb-3 text-center">Features</h3>
<h3 class="d-none d-sm-block mb-3 text-center" i18n>Features</h3>
<div class="mb-4">
<p>
Check out the numerous features of <strong>Ghostfolio</strong> to
@ -13,7 +13,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Stocks</h4>
<h4 i18n>Stocks</h4>
<p class="m-0">Keep track of your stock purchases and sales.</p>
</div>
</mat-card-content>
@ -23,7 +23,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>ETFs</h4>
<h4 i18n>ETFs</h4>
<p class="m-0">
Are you into ETFs (Exchange Traded Funds)? Track your ETF
investments.
@ -36,7 +36,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Bonds</h4>
<h4 i18n>Bonds</h4>
<p class="m-0">
Manage your investment in bonds and other assets with fixed
income.
@ -49,7 +49,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Cryptocurrencies</h4>
<h4 i18n>Cryptocurrencies</h4>
<p class="m-0">
Keep track of your Bitcoin and Altcoin holdings.
</p>
@ -61,7 +61,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Dividend</h4>
<h4 i18n>Dividend</h4>
<p class="m-0">
Are you building a dividend portfolio? Track your dividend in
Ghostfolio.
@ -74,7 +74,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">Wealth Items</h4>
<h4 class="align-items-center d-flex" i18n>Wealth Items</h4>
<p class="m-0">
Track all your treasuries, be it your luxury watch or rare
trading cards.
@ -87,7 +87,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">Emergency Fund</h4>
<h4 class="align-items-center d-flex" i18n>Emergency Fund</h4>
<p class="m-0">
Define your emergency fund you are comfortable with for
difficult times.
@ -100,7 +100,22 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">Import and Export</h4>
<h4 class="align-items-center d-flex" i18n>Liabilities</h4>
<p class="m-0">
Manage your financial liabilities, such as your student loan,
to stay ahead of your financial obligations.
</p>
</div>
</mat-card-content>
</mat-card>
</div>
<div class="col-xs-12 col-md-4 mb-3">
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex" i18n>
Import and Export
</h4>
<p class="m-0">Import and export your investment activities.</p>
</div>
</mat-card-content>
@ -110,7 +125,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Multi-Accounts</h4>
<h4 i18n>Multi-Accounts</h4>
<p class="m-0">
Keep an eye on all your accounts across multiple platforms
(multi-banking).
@ -124,7 +139,7 @@
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span>Portfolio Calculations</span>
<span i18n>Portfolio Calculations</span>
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1"
@ -144,7 +159,7 @@
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span>Portfolio Allocations</span>
<span i18n>Portfolio Allocations</span>
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1"
@ -162,7 +177,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">Dark Mode</h4>
<h4 class="align-items-center d-flex" i18n>Dark Mode</h4>
<p class="m-0">
Ghostfolio automatically switches to a dark color theme based
on your operating system's preferences.
@ -175,7 +190,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">Zen Mode</h4>
<h4 class="align-items-center d-flex" i18n>Zen Mode</h4>
<p class="m-0">
Keep calm and activate Zen Mode if the markets are going
crazy.
@ -192,7 +207,7 @@
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span>Market Mood</span>
<span i18n>Market Mood</span>
<gf-premium-indicator class="ml-1"></gf-premium-indicator>
</h4>
<p class="m-0">
@ -210,7 +225,7 @@
<mat-card-content>
<div class="flex-grow-1">
<h4 class="align-items-center d-flex">
<span>Static Analysis</span>
<span i18n>Static Analysis</span>
<gf-premium-indicator
*ngIf="hasPermissionForSubscription"
class="ml-1"
@ -228,13 +243,11 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Multi-Language</h4>
<h4 i18n>Multi-Language</h4>
<p class="m-0">
Use Ghostfolio in multiple languages: English, Dutch, French,
German, Italian<ng-container *ngIf="false"
>, Portuguese</ng-container
>
and Spanish are currently supported.
German, Italian, Portuguese and Spanish are currently
supported.
</p>
</div>
</mat-card-content>
@ -244,7 +257,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Community</h4>
<h4 i18n>Community</h4>
<p class="m-0">
Join the Ghostfolio
<a
@ -263,7 +276,7 @@
<mat-card appearance="outlined" class="d-flex flex-column h-100">
<mat-card-content>
<div class="flex-grow-1">
<h4>Open Source Software</h4>
<h4 i18n>Open Source Software</h4>
<p class="m-0">
The source code is fully available as
<a
@ -282,9 +295,9 @@
</div>
<div *ngIf="!user" class="row">
<div class="col mt-3 text-center">
<a color="primary" mat-flat-button [routerLink]="['/register']">
Get Started
</a>
<a color="primary" i18n mat-flat-button [routerLink]="['/register']"
>Get Started</a
>
</div>
</div>
</div>

View File

@ -44,14 +44,13 @@
<div *ngIf="hasPermissionForStatistics" class="row mb-5">
<div
*ngIf="hasPermissionForSubscription"
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
>
<a
class="d-block"
title="Ghostfolio in Numbers: Monthly Active Users (MAU)"
[routerLink]="['/about']"
[routerLink]="['/open']"
>
<gf-value
icon="people-outline"
@ -61,24 +60,6 @@
>
</a>
</div>
<div
*ngIf="!hasPermissionForSubscription"
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
>
<a
class="d-block"
title="Ghostfolio in Numbers: Contributors on GitHub"
[routerLink]="['/about']"
>
<gf-value
icon="people-outline"
size="large"
[value]="statistics?.gitHubContributors ?? '-'"
>Contributors on GitHub</gf-value
>
</a>
</div>
<div
class="col-md-4 d-flex my-1"
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
@ -86,7 +67,7 @@
<a
class="d-block"
title="Ghostfolio in Numbers: Stars on GitHub"
[routerLink]="['/about']"
[routerLink]="['/open']"
>
<gf-value
icon="star-outline"
@ -103,7 +84,7 @@
<a
class="d-block"
title="Ghostfolio in Numbers: Pulls on Docker Hub"
[routerLink]="['/about']"
[routerLink]="['/open']"
>
<gf-value
icon="cloud-download-outline"

View File

@ -55,8 +55,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
public currencies: string[] = [];
public currentMarketPrice = null;
public defaultDateFormat: string;
public filteredLookupItems: LookupItem[] = [];
public filteredLookupItemsObservable: Observable<LookupItem[]> = of([]);
public filteredTagsObservable: Observable<Tag[]> = of([]);
public isLoading = false;
public platforms: { id: string; name: string }[];
@ -120,10 +118,12 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
name: [this.data.activity?.SymbolProfile?.name, Validators.required],
quantity: [this.data.activity?.quantity, Validators.required],
searchSymbol: [
{
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
},
!!this.data.activity?.SymbolProfile
? {
dataSource: this.data.activity?.SymbolProfile?.dataSource,
symbol: this.data.activity?.SymbolProfile?.symbol
}
: null,
Validators.required
],
tags: [
@ -238,28 +238,19 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.changeDetectorRef.markForCheck();
});
this.filteredLookupItemsObservable = this.activityForm.controls[
'searchSymbol'
].valueChanges.pipe(
debounceTime(400),
distinctUntilChanged(),
switchMap((query: string) => {
if (isString(query) && query.length > 1) {
const filteredLookupItemsObservable =
this.dataService.fetchSymbols(query);
this.activityForm.controls['searchSymbol'].valueChanges.subscribe(() => {
if (this.activityForm.controls['searchSymbol'].invalid) {
this.data.activity.SymbolProfile = null;
} else {
this.activityForm.controls['dataSource'].setValue(
this.activityForm.controls['searchSymbol'].value.dataSource
);
filteredLookupItemsObservable
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems;
});
this.updateSymbol();
}
return filteredLookupItemsObservable;
}
return [];
})
);
this.changeDetectorRef.markForCheck();
});
this.filteredTagsObservable = this.activityForm.controls[
'tags'
@ -393,25 +384,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.tagInput.nativeElement.value = '';
}
public onBlurSymbol() {
const currentLookupItem = this.filteredLookupItems.find((lookupItem) => {
return (
lookupItem.symbol ===
this.activityForm.controls['searchSymbol'].value.symbol
);
});
if (currentLookupItem) {
this.updateSymbol(currentLookupItem.symbol);
} else {
this.activityForm.controls['searchSymbol'].setErrors({ incorrect: true });
this.data.activity.SymbolProfile = null;
}
this.changeDetectorRef.markForCheck();
}
public onCancel() {
this.dialogRef.close();
}
@ -455,13 +427,6 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
this.dialogRef.close({ activity });
}
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
this.activityForm.controls['dataSource'].setValue(
event.option.value.dataSource
);
this.updateSymbol(event.option.value.symbol);
}
public ngOnDestroy() {
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
@ -477,12 +442,8 @@ export class CreateOrUpdateActivityDialog implements OnDestroy {
});
}
private updateSymbol(symbol: string) {
private updateSymbol() {
this.isLoading = true;
this.activityForm.controls['searchSymbol'].setErrors(null);
this.activityForm.controls['searchSymbol'].setValue({ symbol });
this.changeDetectorRef.markForCheck();
this.dataService

View File

@ -48,34 +48,10 @@
>
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Name, symbol or ISIN</mat-label>
<input
autocapitalize="off"
autocomplete="off"
autocorrect="off"
<gf-symbol-autocomplete
formControlName="searchSymbol"
matInput
[matAutocomplete]="symbolAutocomplete"
(blur)="onBlurSymbol()"
[isLoading]="isLoading"
/>
<mat-autocomplete
#symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
<mat-option
*ngFor="let lookupItem of filteredLookupItemsObservable | async"
class="line-height-1"
[value]="lookupItem"
>
<span><b>{{ lookupItem.name }}</b></span>
<br />
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
}}</small
>
</mat-option>
</mat-autocomplete>
<mat-spinner *ngIf="isLoading" matSuffix [diameter]="20"></mat-spinner>
</mat-form-field>
</div>
<div

View File

@ -9,9 +9,8 @@ import { MatDatepickerModule } from '@angular/material/datepicker';
import { MatDialogModule } from '@angular/material/dialog';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { GfSymbolAutocompleteModule } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.module';
import { GfValueModule } from '@ghostfolio/ui/value';
import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog.component';
@ -21,7 +20,7 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
imports: [
CommonModule,
FormsModule,
GfSymbolModule,
GfSymbolAutocompleteModule,
GfValueModule,
MatAutocompleteModule,
MatButtonModule,
@ -31,7 +30,6 @@ import { CreateOrUpdateActivityDialog } from './create-or-update-activity-dialog
MatDialogModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
MatSelectModule,
ReactiveFormsModule
],

View File

@ -41,6 +41,7 @@ export class ImportActivitiesDialog implements OnDestroy {
public errorMessages: string[] = [];
public holdings: Position[] = [];
public importStep: ImportStep = ImportStep.UPLOAD_FILE;
public isLoading = false;
public maxSafeInteger = Number.MAX_SAFE_INTEGER;
public mode: 'DIVIDEND';
public selectedActivities: Activity[] = [];
@ -73,6 +74,8 @@ export class ImportActivitiesDialog implements OnDestroy {
this.data?.activityTypes?.length === 1 &&
this.data?.activityTypes?.[0] === 'DIVIDEND'
) {
this.isLoading = true;
this.dialogTitle = $localize`Import Dividends`;
this.mode = 'DIVIDEND';
this.uniqueAssetForm.controls['uniqueAsset'].disable();
@ -94,6 +97,8 @@ export class ImportActivitiesDialog implements OnDestroy {
});
this.uniqueAssetForm.controls['uniqueAsset'].enable();
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}

View File

@ -32,10 +32,14 @@
<mat-form-field appearance="outline" class="w-100">
<mat-label i18n>Holding</mat-label>
<mat-select formControlName="uniqueAsset">
<mat-select-trigger
>{{ uniqueAssetForm.controls['uniqueAsset']?.value?.name
}}</mat-select-trigger
>
<mat-option
*ngFor="let holding of holdings"
class="line-height-1"
[value]="{dataSource: holding.dataSource, symbol: holding.symbol}"
[value]="{ dataSource: holding.dataSource, name: holding.name, symbol: holding.symbol }"
>
<span><b>{{ holding.name }}</b></span>
<br />
@ -45,6 +49,11 @@
>
</mat-option>
</mat-select>
<mat-spinner
*ngIf="isLoading"
class="position-absolute"
[diameter]="20"
></mat-spinner>
</mat-form-field>
<div class="d-flex flex-column justify-content-center">
<button

View File

@ -5,6 +5,7 @@ import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatExpansionModule } from '@angular/material/expansion';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSelectModule } from '@angular/material/select';
import { MatStepperModule } from '@angular/material/stepper';
import { GfDialogFooterModule } from '@ghostfolio/client/components/dialog-footer/dialog-footer.module';
@ -27,6 +28,7 @@ import { ImportActivitiesDialog } from './import-activities-dialog.component';
MatDialogModule,
MatExpansionModule,
MatFormFieldModule,
MatProgressSpinnerModule,
MatSelectModule,
MatStepperModule,
ReactiveFormsModule

View File

@ -27,4 +27,9 @@
}
}
}
.mat-mdc-progress-spinner {
right: 1.5rem;
top: calc(50% - 10px);
}
}

View File

@ -142,6 +142,18 @@
</div>
</div>
</div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">FIRE</h3>
<div class="mb-1">
FIRE is a movement that promotes saving and investing to achieve
financial independence and early retirement.
</div>
<div>
<a href="../en/blog/2023/07/exploring-the-path-to-fire">FIRE →</a>
</div>
</div>
</div>
<div class="mb-4 media">
<div class="media-body">
<h3 class="h5 mt-0">Inflation</h3>

View File

@ -7,21 +7,35 @@ import { UpdatePlatformDto } from '@ghostfolio/api/app/platform/update-platform.
import { IDataProviderHistoricalResponse } from '@ghostfolio/api/services/interfaces/interfaces';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
AdminData,
AdminJobs,
AdminMarketData,
AdminMarketDataDetails,
EnhancedSymbolProfile,
Filter,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { DataSource, MarketData, Platform } from '@prisma/client';
import { DataSource, MarketData, Platform, Prisma } from '@prisma/client';
import { JobStatus } from 'bull';
import { format, parseISO } from 'date-fns';
import { Observable, map } from 'rxjs';
import { DataService } from './data.service';
@Injectable({
providedIn: 'root'
})
export class AdminService {
public constructor(private http: HttpClient) {}
public constructor(
private dataService: DataService,
private http: HttpClient
) {}
public addAssetProfile({ dataSource, symbol }: UniqueAsset) {
return this.http.post<void>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
null
);
}
public deleteJob(aId: string) {
return this.http.delete<void>(`/api/v1/admin/queue/job/${aId}`);
@ -49,6 +63,44 @@ export class AdminService {
);
}
public fetchAdminData() {
return this.http.get<AdminData>('/api/v1/admin');
}
public fetchAdminMarketData({
filters,
skip,
sortColumn,
sortDirection,
take
}: {
filters?: Filter[];
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take: number;
}) {
let params = this.dataService.buildFiltersAsQueryParams({ filters });
if (skip) {
params = params.append('skip', skip);
}
if (sortColumn) {
params = params.append('sortColumn', sortColumn);
}
if (sortDirection) {
params = params.append('sortDirection', sortDirection);
}
params = params.append('take', take);
return this.http.get<AdminMarketData>('/api/v1/admin/market-data', {
params
});
}
public fetchAdminMarketDataBySymbol({
dataSource,
symbol
@ -139,12 +191,13 @@ export class AdminService {
public patchAssetProfile({
comment,
dataSource,
scraperConfiguration,
symbol,
symbolMapping
}: UniqueAsset & UpdateAssetProfileDto) {
return this.http.patch<EnhancedSymbolProfile>(
`/api/v1/admin/profile-data/${dataSource}/${symbol}`,
{ comment, symbolMapping }
{ comment, scraperConfiguration, symbolMapping }
);
}

View File

@ -18,8 +18,6 @@ import { DATE_FORMAT } from '@ghostfolio/common/helper';
import {
Access,
Accounts,
AdminData,
AdminMarketData,
BenchmarkMarketDataDetails,
BenchmarkResponse,
Export,
@ -51,6 +49,67 @@ import { map } from 'rxjs/operators';
export class DataService {
public constructor(private http: HttpClient) {}
public buildFiltersAsQueryParams({ filters }: { filters?: Filter[] }) {
let params = new HttpParams();
if (filters?.length > 0) {
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount) {
params = params.append(
'accounts',
filtersByAccount
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByAssetClass) {
params = params.append(
'assetClasses',
filtersByAssetClass
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByAssetSubClass) {
params = params.append(
'assetSubClasses',
filtersByAssetSubClass
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByTag) {
params = params.append(
'tags',
filtersByTag
.map(({ id }) => {
return id;
})
.join(',')
);
}
}
return params;
}
public createCheckoutSession({
couponId,
priceId
@ -92,16 +151,6 @@ export class DataService {
);
}
public fetchAdminData() {
return this.http.get<AdminData>('/api/v1/admin');
}
public fetchAdminMarketData({ filters }: { filters?: Filter[] }) {
return this.http.get<AdminMarketData>('/api/v1/admin/market-data', {
params: this.buildFiltersAsQueryParams({ filters })
});
}
public fetchDividends({
filters,
groupBy = 'month',
@ -261,9 +310,21 @@ export class DataService {
});
}
public fetchSymbols(aQuery: string) {
public fetchSymbols({
includeIndices = false,
query
}: {
includeIndices?: boolean;
query: string;
}) {
let params = new HttpParams().set('query', query);
if (includeIndices) {
params = params.append('includeIndices', includeIndices);
}
return this.http
.get<{ items: LookupItem[] }>(`/api/v1/symbol/lookup?query=${aQuery}`)
.get<{ items: LookupItem[] }>('/api/v1/symbol/lookup', { params })
.pipe(
map((respose) => {
return respose.items;
@ -438,65 +499,4 @@ export class DataService {
couponCode
});
}
private buildFiltersAsQueryParams({ filters }: { filters?: Filter[] }) {
let params = new HttpParams();
if (filters?.length > 0) {
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
ASSET_SUB_CLASS: filtersByAssetSubClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount) {
params = params.append(
'accounts',
filtersByAccount
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByAssetClass) {
params = params.append(
'assetClasses',
filtersByAssetClass
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByAssetSubClass) {
params = params.append(
'assetSubClasses',
filtersByAssetSubClass
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByTag) {
params = params.append(
'tags',
filtersByTag
.map(({ id }) => {
return id;
})
.join(',')
);
}
}
return params;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -6,354 +6,410 @@
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/features</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/maerkte</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/preise</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/registrierung</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/changelog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/license</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/how-do-i-get-my-finances-in-order</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/markets</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/pricing</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/register</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/funcionalidades</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/mercados</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/precios</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/registro</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/changelog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/enregistrement</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/marches</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/prix</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/funzionalita</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/iscrizione</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/mercati</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/prezzi</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/kenmerken</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/markten</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/open</loc>
<changefreq>daily</changefreq>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/registratie</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
<lastmod>2023-06-01T00:00:00+00:00</lastmod>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/blog</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/funcionalidades</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/mercados</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/open</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/precos</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/registo</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>2023-07-01T00:00:00+00:00</lastmod>
</url>
</urlset>

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,7 @@
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
export interface AdminMarketData {
count: number;
marketData: AdminMarketDataItem[];
}

View File

@ -1,5 +1,6 @@
export interface ScraperConfiguration {
defaultMarketPrice?: number;
headers?: { [key: string]: string };
selector: string;
url: string;
}

View File

@ -562,14 +562,13 @@
</div>
<mat-paginator
showFirstLastButtons="true"
[ngClass]="{
'd-none':
isLoading ||
dataSource.data.length === 0 ||
(isLoading && dataSource.data.length === 0) ||
dataSource.data.length <= pageSize
}"
[pageSize]="pageSize"
[showFirstLastButtons]="true"
(page)="onChangePage($event)"
></mat-paginator>

View File

@ -206,7 +206,8 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy, OnInit {
} else if (
this.hasPermissionToOpenDetails &&
!activity.isDraft &&
activity.type !== 'ITEM'
activity.type !== 'ITEM' &&
activity.type !== 'LIABILITY'
) {
this.onOpenPositionDialog({
dataSource: activity.SymbolProfile.dataSource,

View File

@ -92,7 +92,8 @@
mat-header-cell
mat-sort-header
>
<ng-container i18n>Allocation</ng-container>
<span class="d-none d-sm-block" i18n>Allocation</span>
<span class="d-block d-sm-none" title="Allocation">%</span>
</th>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
@ -108,17 +109,14 @@
<ng-container matColumnDef="performance">
<th
*matHeaderCellDef
class="d-none d-lg-table-cell px-1 justify-content-end"
class="justify-content-end px-1"
mat-header-cell
mat-sort-header="netPerformancePercent"
>
<ng-container i18n>Performance</ng-container>
<span class="d-none d-sm-block" i18n>Performance</span>
<span class="d-block d-sm-none" title="Performance">±</span>
</th>
<td
*matCellDef="let element"
class="d-none d-lg-table-cell px-1"
mat-cell
>
<td *matCellDef="let element" class="px-1" mat-cell>
<div class="d-flex justify-content-end">
<gf-value
[colorizeSign]="true"

View File

@ -0,0 +1,178 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import { coerceBooleanProperty } from '@angular/cdk/coercion';
import {
Component,
DoCheck,
ElementRef,
HostBinding,
HostListener,
Input,
OnDestroy
} from '@angular/core';
import { ControlValueAccessor, NgControl } from '@angular/forms';
import { MatFormFieldControl } from '@angular/material/form-field';
import { Subject } from 'rxjs';
@Component({
template: ''
})
export abstract class AbstractMatFormField<T>
implements ControlValueAccessor, DoCheck, MatFormFieldControl<T>, OnDestroy
{
@HostBinding()
public id = `${this.controlType}-${AbstractMatFormField.nextId++}`;
@HostBinding('attr.aria-describedBy') public describedBy = '';
public readonly autofilled: boolean;
public errorState: boolean;
public focused = false;
public readonly stateChanges = new Subject<void>();
public readonly userAriaDescribedBy: string;
protected onChange?: (value: T) => void;
protected onTouched?: () => void;
private static nextId: number = 0;
protected constructor(
protected _elementRef: ElementRef,
protected _focusMonitor: FocusMonitor,
public readonly ngControl: NgControl
) {
if (this.ngControl) {
this.ngControl.valueAccessor = this;
}
_focusMonitor
.monitor(this._elementRef.nativeElement, true)
.subscribe((origin) => {
this.focused = !!origin;
this.stateChanges.next();
});
}
private _controlType: string;
public get controlType(): string {
return this._controlType;
}
protected set controlType(value: string) {
this._controlType = value;
this.id = `${this._controlType}-${AbstractMatFormField.nextId++}`;
}
private _value: T;
public get value(): T {
return this._value;
}
public set value(value: T) {
this._value = value;
if (this.onChange) {
this.onChange(value);
}
}
public get empty(): boolean {
return !this._value;
}
public _placeholder: string = '';
public get placeholder() {
return this._placeholder;
}
@Input()
public set placeholder(placeholder: string) {
this._placeholder = placeholder;
this.stateChanges.next();
}
public _required: boolean = false;
public get required() {
return this._required;
}
@Input()
public set required(required: any) {
this._required = coerceBooleanProperty(required);
this.stateChanges.next();
}
public _disabled: boolean = false;
public get disabled() {
if (this.ngControl && this.ngControl.disabled !== null) {
return this.ngControl.disabled;
}
return this._disabled;
}
@Input()
public set disabled(disabled: any) {
this._disabled = coerceBooleanProperty(disabled);
if (this.focused) {
this.focused = false;
this.stateChanges.next();
}
}
public abstract focus(): void;
public get shouldLabelFloat(): boolean {
return this.focused || !this.empty;
}
public ngDoCheck(): void {
if (this.ngControl) {
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public ngOnDestroy(): void {
this.stateChanges.complete();
this._focusMonitor.stopMonitoring(this._elementRef.nativeElement);
}
public registerOnChange(fn: (_: T) => void): void {
this.onChange = fn;
}
public registerOnTouched(fn: () => void): void {
this.onTouched = fn;
}
public setDescribedByIds(ids: string[]): void {
this.describedBy = ids.join(' ');
}
public writeValue(value: T): void {
this.value = value;
}
@HostListener('focusout')
public onBlur() {
this.focused = false;
if (this.onTouched) {
this.onTouched();
}
this.stateChanges.next();
}
public onContainerClick(): void {
if (!this.focused) {
this.focus();
}
}
}

View File

@ -0,0 +1 @@
export * from './symbol-autocomplete.module';

View File

@ -0,0 +1,37 @@
<input
autocapitalize="off"
autocomplete="off"
matInput
[formControl]="control"
[matAutocomplete]="symbolAutocomplete"
/>
<mat-autocomplete
#symbolAutocomplete="matAutocomplete"
[displayWith]="displayFn"
(optionSelected)="onUpdateSymbol($event)"
>
<ng-container *ngIf="!isLoading">
<mat-option
*ngFor="let lookupItem of filteredLookupItems"
class="line-height-1"
[value]="lookupItem"
>
<span
><b>{{ lookupItem.name }}</b></span
>
<br />
<small class="text-muted"
>{{ lookupItem.symbol | gfSymbol }} · {{ lookupItem.currency
}}<ng-container *ngIf="lookupItem.assetSubClass">
· {{ lookupItem.assetSubClassString }}</ng-container
></small
>
</mat-option>
</ng-container>
</mat-autocomplete>
<mat-spinner
*ngIf="isLoading"
class="position-absolute"
[diameter]="20"
></mat-spinner>

View File

@ -0,0 +1,8 @@
:host {
display: block;
.mat-mdc-progress-spinner {
right: 0;
top: calc(50% - 10px);
}
}

View File

@ -0,0 +1,185 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
ElementRef,
Input,
OnDestroy,
OnInit,
ViewChild
} from '@angular/core';
import { FormControl, NgControl, Validators } from '@angular/forms';
import {
MatAutocomplete,
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatFormFieldControl } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { DataService } from '@ghostfolio/client/services/data.service';
import { translate } from '@ghostfolio/ui/i18n';
import { isString } from 'lodash';
import { Subject, tap } from 'rxjs';
import {
debounceTime,
distinctUntilChanged,
filter,
switchMap
} from 'rxjs/operators';
import { AbstractMatFormField } from './abstract-mat-form-field';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[attr.aria-describedBy]': 'describedBy',
'[id]': 'id'
},
selector: 'gf-symbol-autocomplete',
styleUrls: ['./symbol-autocomplete.component.scss'],
templateUrl: 'symbol-autocomplete.component.html',
providers: [
{
provide: MatFormFieldControl,
useExisting: SymbolAutocompleteComponent
}
]
})
export class SymbolAutocompleteComponent
extends AbstractMatFormField<LookupItem>
implements OnInit, OnDestroy
{
@Input() private includeIndices = false;
@Input() public isLoading = false;
@ViewChild(MatInput, { static: false }) private input: MatInput;
@ViewChild('symbolAutocomplete') public symbolAutocomplete: MatAutocomplete;
public control = new FormControl();
public filteredLookupItems: (LookupItem & { assetSubClassString: string })[] =
[];
private unsubscribeSubject = new Subject<void>();
public constructor(
public readonly _elementRef: ElementRef,
public readonly _focusMonitor: FocusMonitor,
public readonly changeDetectorRef: ChangeDetectorRef,
public readonly dataService: DataService,
public readonly ngControl: NgControl
) {
super(_elementRef, _focusMonitor, ngControl);
this.controlType = 'symbol-autocomplete';
}
public ngOnInit() {
super.required = this.ngControl.control?.hasValidator(Validators.required);
if (this.disabled) {
this.control.disable();
}
this.control.valueChanges
.pipe(
debounceTime(400),
distinctUntilChanged(),
filter((query) => {
return isString(query) && query.length > 1;
}),
tap(() => {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
}),
switchMap((query: string) => {
return this.dataService.fetchSymbols({
query,
includeIndices: this.includeIndices
});
})
)
.subscribe((filteredLookupItems) => {
this.filteredLookupItems = filteredLookupItems.map((lookupItem) => {
return {
...lookupItem,
assetSubClassString: translate(lookupItem.assetSubClass)
};
});
this.isLoading = false;
this.changeDetectorRef.markForCheck();
});
}
public displayFn(aLookupItem: LookupItem) {
return aLookupItem?.symbol ?? '';
}
public get empty() {
return this.input?.empty;
}
public focus() {
this.input.focus();
}
public isValueInOptions(value: string) {
return this.filteredLookupItems.some((item) => {
return item.symbol === value;
});
}
public ngDoCheck() {
if (this.ngControl) {
this.validateRequired();
if (this.control.touched) {
this.validateSelection();
}
this.errorState = this.ngControl.invalid && this.ngControl.touched;
this.stateChanges.next();
}
}
public onUpdateSymbol(event: MatAutocompleteSelectedEvent) {
super.value = {
dataSource: event.option.value.dataSource,
symbol: event.option.value.symbol
} as LookupItem;
}
public set value(value: LookupItem) {
this.control.setValue(value);
super.value = value;
}
public ngOnDestroy() {
super.ngOnDestroy();
this.unsubscribeSubject.next();
this.unsubscribeSubject.complete();
}
private validateRequired() {
const requiredCheck = super.required
? !super.value?.dataSource || !super.value?.symbol
: false;
if (requiredCheck) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
private validateSelection() {
const error =
!this.isValueInOptions(this.input?.value) ||
this.input?.value !== super.value?.symbol;
if (error) {
this.ngControl.control.setErrors({ invalidData: true });
}
}
}

View File

@ -0,0 +1,26 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { GfSymbolModule } from '@ghostfolio/client/pipes/symbol/symbol.module';
import { SymbolAutocompleteComponent } from '@ghostfolio/ui/symbol-autocomplete/symbol-autocomplete.component';
@NgModule({
declarations: [SymbolAutocompleteComponent],
exports: [SymbolAutocompleteComponent],
imports: [
CommonModule,
FormsModule,
GfSymbolModule,
MatAutocompleteModule,
MatFormFieldModule,
MatInputModule,
MatProgressSpinnerModule,
ReactiveFormsModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfSymbolAutocompleteModule {}

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.280.0",
"version": "1.285.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -64,11 +64,11 @@
"@angular/router": "15.2.5",
"@angular/service-worker": "15.2.5",
"@codewithdan/observable-store": "2.2.15",
"@dfinity/agent": "0.15.1",
"@dfinity/auth-client": "0.15.1",
"@dfinity/candid": "0.15.1",
"@dfinity/identity": "0.15.1",
"@dfinity/principal": "0.15.1",
"@dfinity/agent": "0.15.7",
"@dfinity/auth-client": "0.15.7",
"@dfinity/candid": "0.15.7",
"@dfinity/identity": "0.15.7",
"@dfinity/principal": "0.15.7",
"@dinero.js/currencies": "2.0.0-alpha.8",
"@nestjs/bull": "0.6.3",
"@nestjs/common": "9.1.4",
@ -79,7 +79,7 @@
"@nestjs/platform-express": "9.1.4",
"@nestjs/schedule": "2.1.0",
"@nestjs/serve-static": "3.0.0",
"@prisma/client": "4.14.1",
"@prisma/client": "4.15.0",
"@simplewebauthn/browser": "5.2.1",
"@simplewebauthn/server": "5.2.1",
"@stripe/stripe-js": "1.47.0",
@ -105,6 +105,7 @@
"date-fns": "2.29.3",
"envalid": "7.3.1",
"google-spreadsheet": "3.2.0",
"helmet": "7.0.0",
"http-status-codes": "2.2.0",
"ionicons": "7.1.0",
"lodash": "4.17.21",
@ -119,7 +120,7 @@
"passport": "0.6.0",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "4.14.1",
"prisma": "4.15.0",
"reflect-metadata": "0.1.13",
"rxjs": "7.5.6",
"stripe": "11.12.0",

View File

@ -1,7 +1,7 @@
generator client {
provider = "prisma-client-js"
previewFeatures = []
binaryTargets = ["debian-openssl-1.1.x", "linux-arm64-openssl-1.1.x", "native"]
binaryTargets = ["debian-openssl-1.1.x", "linux-arm64-openssl-3.0.x", "native"]
}
datasource db {

776
yarn.lock

File diff suppressed because it is too large Load Diff