Compare commits

...

69 Commits

Author SHA1 Message Date
90dc34380e Release 1.296.0 (#2199) 2023-08-01 09:12:26 +02:00
286e41eb21 Feature/optimize import validation by reducing to unique asset profiles (#2198)
* Optimize activities validation

* Optimize data gathering in import

* Update changelog
2023-08-01 09:10:13 +02:00
4973d0261d Release 1.295.0 (#2197) 2023-07-30 19:41:22 +02:00
c4a62dfd68 Bugfix/remove stay signed in setting from local storage on sign in with fingerprint activation (#2196)
* Remove staySignedIn from local storage

* Update changelog
2023-07-30 19:36:06 +02:00
4d6be0a507 Exclude open-source-alternative-to-markets.sh (#2195) 2023-07-30 19:35:49 +02:00
b259ab7b0c Feature/add step by step introduction for new users (#2191)
* Add introduction for new users

* Update changelog
2023-07-30 18:49:38 +02:00
e1ac5245c7 Release 1.294.0 (#2192) 2023-07-29 20:33:31 +02:00
d4fea075af Feature/include unavailable data in allocations by market chart (#2190)
* Include unavailable data in allocations by market chart

* Update changelog
2023-07-28 20:20:08 +02:00
cef7fa79de Fix total account value calculation for liabilities (#2184)
* Fix calculation

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-07-28 19:42:57 +02:00
ca05397dcd Extend Community Projects section (#2188) 2023-07-27 17:39:29 +02:00
2a11977001 Release 1.293.0 (#2186) 2023-07-26 21:32:35 +02:00
fb1a5c93ef Bugfix/fix no such file or directory error caused by missing favicon.ico (#2185)
* Add instructions to copy favicon.ico

* Update changelog
2023-07-26 21:26:05 +02:00
77e9791e03 Feature/set lastmod dates of sitemap.xml dynamically (#2170)
* Setup template with interpolation for sitemap.xml

* Update changelog
2023-07-26 21:08:38 +02:00
efd9e7a5c7 Fix RedisClient import (#2183) 2023-07-26 20:36:34 +02:00
d9ced885e1 Feature/add error handling for redis connections (#2179)
* Add error handling

* Update changelog
2023-07-26 20:30:32 +02:00
5fe07cb85f Bugfix/fix value in holdings table (#2182)
* Fix missing value

* Update changelog
2023-07-26 20:05:26 +02:00
af008aa74f Release 1.292.0 (#2175) 2023-07-24 20:17:46 +02:00
ca7bf27c20 Feature/upgrade yahoo finance2 to version 2.4.3 (#2174)
* Upgrade yahoo-finance2 to version 2.4.3

* Update changelog
2023-07-24 20:16:14 +02:00
0866587cab Increase frequency (#2169) 2023-07-24 20:12:07 +02:00
622bb8b0cf Feature/add allocations by market chart (#2171)
* Add allocations by (advanced) market

* Fix public page

* Update changelog
2023-07-24 20:04:34 +02:00
16b9fbe00e Release 1.291.0 (#2168) 2023-07-23 16:06:45 +02:00
c9353d0a39 Support account balance time series (#2166)
* Initial setup

* Support account balance in export

* Handle account balance update

* Add schema migration

* Update changelog
2023-07-23 15:55:58 +02:00
ea101dd3bd Refactor value to valueInBaseCurrency (#2167)
* Revert value to valueInBaseCurrency refactoring
2023-07-23 14:13:02 +02:00
cd67ce82fa Feature/rename queries to presets in market data table of admin control (#2165)
* Rename queries to presets

* Update changelog
2023-07-21 11:40:49 +02:00
d5b3c52602 Refactor value to valueInBaseCurrency (#2164) 2023-07-20 20:28:56 +02:00
bdf72164b1 Feature/break down emergency fund by cash and assets (#2159)
* Break down emergency fund in cash and assets

* Update changelog
2023-07-19 11:30:48 +02:00
455a2d2e92 Refactor value to valueInBaseCurrency (#2160) 2023-07-18 21:29:08 +02:00
9c0f46b587 Add markets.sh (#2161) 2023-07-18 21:28:44 +02:00
8533606177 Release 1.290.0 (#2158) 2023-07-16 08:01:31 +02:00
6728e04ff7 Improve http response interceptor (#2157)
Do not show snack bar for login endpoint
2023-07-15 22:17:07 +02:00
2bf4f1237a Feature/Improve login dialog (#2124)
* Improve login dialog

* Update changelog

---------

Co-authored-by: Thomas <4159106+dtslvr@users.noreply.github.com>
2023-07-15 22:09:12 +02:00
4857b2e620 Update locales (#2154) 2023-07-15 19:50:11 +02:00
68a9a7f6f9 Feature/add queries to market data table in admin control (#2153)
* Add queries

* ETF_WITHOUT_COUNTRIES
* ETF_WITHOUT_SECTORS

* Update changelog
2023-07-15 17:54:16 +02:00
81ef95e13e Setup permissions (#2151) 2023-07-15 12:32:59 +02:00
b633132757 Feature/upgrade prisma to version 4.16.2 (#2109)
* Upgrade prisma to version 4.16.2

* Update changelog
2023-07-15 12:32:43 +02:00
2b0f961370 Feature/improve faq page (#2152)
* Extend content

* Update changelog
2023-07-15 12:16:19 +02:00
30f1a3514a Feature/add hints to activity types in create or edit activity dialog (#2150)
* Add hints

* Update changelog
2023-07-15 11:31:05 +02:00
ed735e0b29 Feature/disable caching in health check endpoints for data providers (#2147)
* Disable caching in health check endpoint

* Update changelog
2023-07-15 10:54:19 +02:00
b89ccd2dde Release 1.289.0 (#2149) 2023-07-14 10:26:27 +02:00
df6d39377f Upgrade @types/lodash to version 4.14.195 (#2125) 2023-07-14 07:53:39 +02:00
d5d14497d6 Release 1.288.0 (#2146) 2023-07-12 19:56:16 +02:00
09c300661a Feature/improve language localization for german 20230711 (#2144)
* Improve i18n

* Update changelog
2023-07-12 19:53:55 +02:00
92382e0b4d Feature/improve loading state during filtering on allocations page (#2141)
* Improve loading state

* Update changelog
2023-07-11 21:41:12 +02:00
c25f532487 Improve product pages (#2143) 2023-07-11 21:40:45 +02:00
5d26d94586 Sort imports (#2142) 2023-07-11 20:27:54 +02:00
73b6784e9f Feature/beautify ampersand in asset profile names (#2138)
* Beautify ampersand

* Update changelog
2023-07-10 20:16:38 +02:00
6159f48a62 Feature/setup personal finance tools pages 2 (#2140) 2023-07-10 20:16:20 +02:00
7d34fba7c1 Release 1.287.0 (#2136) 2023-07-09 10:44:41 +02:00
c434b730a8 Feature/hide average buy price in position detail chart if no holding (#2133)
* Hide the average buy price if no holding

* Update changelog
2023-07-09 10:42:53 +02:00
2d23c566f1 Feature/setup personal finance tools pages (#2135) 2023-07-09 10:42:10 +02:00
ba220eaee9 Bugfix/fix sorting by currency in activities table (#2122)
* Fix sorting by currency

* Update changelog
2023-07-09 09:38:48 +02:00
09023214ce Feature/French translation update (#2130)
* French translation update

* Update changelog
2023-07-07 21:26:51 +02:00
1ceabb6e6b Feature/refactor blog articles to standalone components (#2117)
* Refactor blog articles to standalone components

* Update changelog
2023-07-04 18:42:40 +02:00
421072c7fa Release 1.286.0 (#2120) 2023-07-03 22:30:33 +02:00
0d421e7181 Bugfix/fix adding 'Item' and 'Liability' activities (#2119)
* Fix adding activities of type item and liability

* Update changelog
2023-07-03 22:29:00 +02:00
f5180ce88f Improve wording (#2118) 2023-07-03 10:10:08 +02:00
aabf27dc96 Remove empty style files (#2116) 2023-07-02 08:17:39 +02:00
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
210 changed files with 26746 additions and 3629 deletions

View File

@ -5,7 +5,148 @@ 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.283.4 - 2023-06-24
## 1.296.0 - 2023-08-01
### Changed
- Optimized the validation in the activities import by reducing the list to unique asset profiles
- Optimized the data gathering in the activities import
## 1.295.0 - 2023-07-30
### Added
- Added a step by step introduction for new users
### Fixed
- Removed the _Stay signed in_ setting on _Sign in with fingerprint_ activation
## 1.294.0 - 2023-07-29
### Changed
- Extended the allocations by market chart on the allocations page by unavailable data
### Fixed
- Considered liabilities in the total account value calculation
## 1.293.0 - 2023-07-26
### Added
- Added error handling for the _Redis_ connections to keep the app running if the connection fails
### Changed
- Set the `lastmod` dates of `sitemap.xml` dynamically
### Fixed
- Fixed the missing values in the holdings table
- Fixed the `no such file or directory` error caused by the missing `favicon.ico` file
## 1.292.0 - 2023-07-24
### Added
- Introduced the allocations by market chart on the allocations page
### Changed
- Upgraded `yahoo-finance2` from version `2.4.2` to `2.4.3`
### Fixed
- Fixed an issue in the public page
## 1.291.0 - 2023-07-23
### Added
- Broken down the emergency fund by cash and assets
- Added support for account balance time series
### Changed
- Renamed queries to presets in the historical market data table of the admin control panel
## 1.290.0 - 2023-07-16
### Added
- Added hints to the activity types in the create or edit activity dialog
- Added queries to the historical market data table of the admin control panel
### Changed
- Improved the usability of the login dialog
- Disabled the caching in the health check endpoints for data providers
- Improved the content of the Frequently Asked Questions (FAQ) page
- Upgraded `prisma` from version `4.15.0` to `4.16.2`
## 1.289.0 - 2023-07-14
### Changed
- Upgraded `yahoo-finance2` from version `2.4.1` to `2.4.2`
## 1.288.0 - 2023-07-12
### Changed
- Improved the loading state during filtering on the allocations page
- Beautified the names with ampersand (`&amp;`) in the asset profile
- Improved the language localization for German (`de`)
## 1.287.0 - 2023-07-09
### Changed
- Hid the average buy price in the position detail chart if there is no holding
- Improved the language localization for French (`fr`)
- Refactored the blog articles to standalone components
### Fixed
- Fixed the sorting by currency in the activities table
## 1.286.0 - 2023-07-03
### Fixed
- Fixed the creation of (wealth) items and liabilities
## 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

View File

@ -263,7 +263,9 @@ Deprecated: `GET http://localhost:3333/api/v1/auth/anonymous/<INSERT_SECURITY_TO
## Community Projects
- [ghostfolio-cli](https://github.com/DerAndereJohannes/ghostfolio-cli): Command-line interface to access your portfolio
Discover a variety of community projects for Ghostfolio: https://github.com/topics/ghostfolio
Are you building your own project? Add the `ghostfolio` topic to your _GitHub_ repository to get listed as well. [Learn more →](https://docs.github.com/en/articles/classifying-your-repository-with-topics)
## Contributing

View File

@ -1,6 +1,7 @@
import { PortfolioModule } from '@ghostfolio/api/app/portfolio/portfolio.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceModule } from '@ghostfolio/api/services/account-balance/account-balance.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module';
@ -15,6 +16,7 @@ import { AccountService } from './account.service';
controllers: [AccountController],
exports: [AccountService],
imports: [
AccountBalanceModule,
ConfigurationModule,
DataProviderModule,
ExchangeRateDataModule,

View File

@ -1,3 +1,4 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Filter } from '@ghostfolio/common/interfaces';
@ -11,16 +12,21 @@ import { CashDetails } from './interfaces/cash-details.interface';
@Injectable()
export class AccountService {
public constructor(
private readonly accountBalanceService: AccountBalanceService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly prismaService: PrismaService
) {}
public async account(
accountWhereUniqueInput: Prisma.AccountWhereUniqueInput
): Promise<Account | null> {
return this.prismaService.account.findUnique({
where: accountWhereUniqueInput
public async account({
id_userId
}: Prisma.AccountWhereUniqueInput): Promise<Account | null> {
const { id, userId } = id_userId;
const [account] = await this.accounts({
where: { id, userId }
});
return account;
}
public async accountWithOrders(
@ -50,9 +56,11 @@ export class AccountService {
Platform?: Platform;
})[]
> {
const { include, skip, take, cursor, where, orderBy } = params;
const { include = {}, skip, take, cursor, where, orderBy } = params;
return this.prismaService.account.findMany({
include.balances = { orderBy: { date: 'desc' }, take: 1 };
const accounts = await this.prismaService.account.findMany({
cursor,
include,
orderBy,
@ -60,15 +68,36 @@ export class AccountService {
take,
where
});
return accounts.map((account) => {
account = { ...account, balance: account.balances[0]?.value ?? 0 };
delete account.balances;
return account;
});
}
public async createAccount(
data: Prisma.AccountCreateInput,
aUserId: string
): Promise<Account> {
return this.prismaService.account.create({
const account = await this.prismaService.account.create({
data
});
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: { id: account.id, userId: aUserId }
}
},
value: data.balance
}
});
return account;
}
public async deleteAccount(
@ -167,6 +196,18 @@ export class AccountService {
aUserId: string
): Promise<Account> {
const { data, where } = params;
await this.prismaService.accountBalance.create({
data: {
Account: {
connect: {
id_userId: where.id_userId
}
},
value: <number>data.balance
}
});
return this.prismaService.account.update({
data,
where
@ -202,16 +243,17 @@ export class AccountService {
);
if (amountInCurrencyOfAccount) {
await this.prismaService.account.update({
data: {
balance: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
},
where: {
id_userId: {
userId,
id: accountId
await this.accountBalanceService.createAccountBalance({
date,
Account: {
connect: {
id_userId: {
userId,
id: accountId
}
}
}
},
value: new Big(balance).plus(amountInCurrencyOfAccount).toNumber()
});
}
}

View File

@ -3,9 +3,11 @@ import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/da
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';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import {
AdminData,
AdminMarketData,
@ -14,7 +16,10 @@ import {
Filter
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import type { RequestWithUser } from '@ghostfolio/common/types';
import type {
MarketDataPreset,
RequestWithUser
} from '@ghostfolio/common/types';
import {
Body,
Controller,
@ -32,7 +37,7 @@ import {
} from '@nestjs/common';
import { REQUEST } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
import { DataSource, MarketData, Prisma, SymbolProfile } from '@prisma/client';
import { isDate } from 'date-fns';
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
@ -112,7 +117,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
};
})
@ -148,7 +153,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
};
})
@ -181,7 +186,7 @@ export class AdminController {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
});
}
@ -247,7 +252,12 @@ export class AdminController {
@Get('market-data')
@UseGuards(AuthGuard('jwt'))
public async getMarketData(
@Query('assetSubClasses') filterByAssetSubClasses?: string
@Query('assetSubClasses') filterByAssetSubClasses?: string,
@Query('presetId') presetId?: MarketDataPreset,
@Query('skip') skip?: number,
@Query('sortColumn') sortColumn?: string,
@Query('sortDirection') sortDirection?: Prisma.SortOrder,
@Query('take') take?: number
): Promise<AdminMarketData> {
if (
!hasPermission(
@ -272,7 +282,14 @@ export class AdminController {
})
];
return this.adminService.getMarketData(filters);
return this.adminService.getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip: isNaN(skip) ? undefined : skip,
take: isNaN(take) ? undefined : take
});
}
@Get('market-data/:dataSource/:symbol')

View File

@ -6,15 +6,18 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data/market-d
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 { MarketDataPreset } from '@ghostfolio/common/types';
import { BadRequestException, Injectable } from '@nestjs/common';
import { AssetSubClass, Prisma, Property, SymbolProfile } from '@prisma/client';
import { differenceInDays } from 'date-fns';
@ -99,9 +102,32 @@ export class AdminService {
};
}
public async getMarketData(filters?: Filter[]): Promise<AdminMarketData> {
public async getMarketData({
filters,
presetId,
sortColumn,
sortDirection,
skip,
take = Number.MAX_SAFE_INTEGER
}: {
filters?: Filter[];
presetId?: MarketDataPreset;
skip?: number;
sortColumn?: string;
sortDirection?: Prisma.SortOrder;
take?: number;
}): Promise<AdminMarketData> {
let orderBy: Prisma.Enumerable<Prisma.SymbolProfileOrderByWithRelationInput> =
[{ symbol: 'asc' }];
const where: Prisma.SymbolProfileWhereInput = {};
if (
presetId === 'ETF_WITHOUT_COUNTRIES' ||
presetId === 'ETF_WITHOUT_SECTORS'
) {
filters = [{ id: 'ETF', type: 'ASSET_SUB_CLASS' }];
}
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
filters,
(filter) => {
@ -109,42 +135,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
}
};
}
}
let [assetProfiles, count] = await Promise.all([
this.prismaService.symbolProfile.findMany({
orderBy,
skip,
take,
where,
orderBy: [{ symbol: 'asc' }],
select: {
_count: {
select: { Order: true }
@ -163,38 +180,64 @@ 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;
}),
this.prismaService.symbolProfile.count({ where })
]);
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
};
});
let 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
};
}
);
if (presetId) {
if (presetId === 'ETF_WITHOUT_COUNTRIES') {
marketData = marketData.filter(({ countriesCount }) => {
return countriesCount === 0;
});
} else if (presetId === 'ETF_WITHOUT_SECTORS') {
marketData = marketData.filter(({ sectorsCount }) => {
return sectorsCount === 0;
});
}
count = marketData.length;
}
return {
marketData: [...currencyPairsToGather, ...symbolProfilesToGather]
count,
marketData
};
}
@ -232,12 +275,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

@ -66,11 +66,11 @@ export class BenchmarkService {
const promises: Promise<number>[] = [];
const quotes = await this.dataProviderService.getQuotes(
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
const quotes = await this.dataProviderService.getQuotes({
items: benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
});
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
promises.push(this.marketDataService.getMax({ dataSource, symbol }));

View File

@ -1,8 +1,9 @@
import { AccountModule } from '@ghostfolio/api/app/account/account.module';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
import { ExportController } from './export.controller';
@ -10,10 +11,11 @@ import { ExportService } from './export.service';
@Module({
imports: [
AccountModule,
ConfigurationModule,
DataGatheringModule,
DataProviderModule,
PrismaModule,
OrderModule,
RedisCacheModule
],
controllers: [ExportController],

View File

@ -1,11 +1,15 @@
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { environment } from '@ghostfolio/api/environments/environment';
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Export } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
@Injectable()
export class ExportService {
public constructor(private readonly prismaService: PrismaService) {}
public constructor(
private readonly accountService: AccountService,
private readonly orderService: OrderService
) {}
public async export({
activityIds,
@ -14,36 +18,40 @@ export class ExportService {
activityIds?: string[];
userId: string;
}): Promise<Export> {
const accounts = await this.prismaService.account.findMany({
orderBy: {
name: 'asc'
},
select: {
accountType: true,
balance: true,
comment: true,
currency: true,
id: true,
isExcluded: true,
name: true,
platformId: true
},
where: { userId }
});
const accounts = (
await this.accountService.accounts({
orderBy: {
name: 'asc'
},
where: { userId }
})
).map(
({
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
}) => {
return {
accountType,
balance,
comment,
currency,
id,
isExcluded,
name,
platformId
};
}
);
let activities = await this.prismaService.order.findMany({
let activities = await this.orderService.orders({
include: { SymbolProfile: true },
orderBy: { date: 'desc' },
select: {
accountId: true,
comment: true,
date: true,
fee: true,
id: true,
quantity: true,
SymbolProfile: true,
type: true,
unitPrice: true
},
where: { userId }
});

View File

@ -4,7 +4,7 @@ import * as path from 'path';
import { environment } from '@ghostfolio/api/environments/environment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
import { DATE_FORMAT } from '@ghostfolio/common/helper';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { format } from 'date-fns';
import { NextFunction, Request, Response } from 'express';
@ -18,6 +18,7 @@ export class FrontendMiddleware implements NestMiddleware {
public indexHtmlIt = '';
public indexHtmlNl = '';
public indexHtmlPt = '';
public sitemapXml = '';
private static readonly DEFAULT_DESCRIPTION =
'Ghostfolio is a personal finance dashboard to keep track of your assets like stocks, ETFs or cryptocurrencies across multiple platforms.';
@ -54,6 +55,10 @@ export class FrontendMiddleware implements NestMiddleware {
this.getPathOfIndexHtmlFile('pt'),
'utf8'
);
this.sitemapXml = fs.readFileSync(
path.join(__dirname, 'assets', 'sitemap.xml'),
'utf8'
);
} catch {}
}
@ -104,6 +109,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 (
@ -113,6 +123,13 @@ export class FrontendMiddleware implements NestMiddleware {
) {
// Skip
next();
} else if (request.path === '/sitemap.xml') {
response.setHeader('content-type', 'application/xml');
response.send(
this.interpolate(this.sitemapXml, {
currentDate: format(getYesterday(), DATE_FORMAT)
})
);
} else if (request.path === '/de' || request.path.startsWith('/de/')) {
response.send(
this.interpolate(this.indexHtmlDe, {
@ -223,7 +240,13 @@ export class FrontendMiddleware implements NestMiddleware {
private isFileRequest(filename: string) {
if (filename === '/assets/LICENSE') {
return true;
} else if (filename.includes('auth/ey')) {
} else if (
filename === '/sitemap.xml' ||
filename.includes('auth/ey') ||
filename.includes(
'personal-finance-tools/open-source-alternative-to-markets.sh'
)
) {
return false;
}

View File

@ -8,10 +8,14 @@ import {
import { OrderService } from '@ghostfolio/api/app/order/order.service';
import { PlatformService } from '@ghostfolio/api/app/platform/platform.service';
import { PortfolioService } from '@ghostfolio/api/app/portfolio/portfolio.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.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 { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile/symbol-profile.service';
import { parseDate } from '@ghostfolio/common/helper';
import {
getAssetProfileIdentifier,
parseDate
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import {
AccountWithPlatform,
@ -21,12 +25,14 @@ import { Injectable } from '@nestjs/common';
import { DataSource, Prisma, SymbolProfile } from '@prisma/client';
import Big from 'big.js';
import { endOfToday, isAfter, isSameDay, parseISO } from 'date-fns';
import { uniqBy } from 'lodash';
import { v4 as uuidv4 } from 'uuid';
@Injectable()
export class ImportService {
public constructor(
private readonly accountService: AccountService,
private readonly dataGatheringService: DataGatheringService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
private readonly orderService: OrderService,
@ -220,8 +226,7 @@ export class ImportService {
const assetProfiles = await this.validateActivities({
activitiesDto,
maxActivitiesToImport,
userId
maxActivitiesToImport
});
const activitiesExtendedWithErrors = await this.extendActivitiesWithErrors({
@ -250,10 +255,37 @@ export class ImportService {
error,
fee,
quantity,
SymbolProfile: assetProfile,
SymbolProfile,
type,
unitPrice
} of activitiesExtendedWithErrors) {
const assetProfile = assetProfiles[
getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
})
] ?? {
currency: SymbolProfile.currency,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
const {
assetClass,
assetSubClass,
countries,
createdAt,
currency,
dataSource,
id,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
url,
updatedAt
} = assetProfile;
const validatedAccount = accounts.find(({ id }) => {
return id === accountId;
});
@ -279,23 +311,22 @@ export class ImportService {
id: uuidv4(),
isDraft: isAfter(date, endOfToday()),
SymbolProfile: {
assetClass: assetProfile.assetClass,
assetSubClass: assetProfile.assetSubClass,
comment: assetProfile.comment,
countries: assetProfile.countries,
createdAt: assetProfile.createdAt,
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
id: assetProfile.id,
isin: assetProfile.isin,
name: assetProfile.name,
scraperConfiguration: assetProfile.scraperConfiguration,
sectors: assetProfile.sectors,
symbol: assetProfile.currency,
symbolMapping: assetProfile.symbolMapping,
updatedAt: assetProfile.updatedAt,
url: assetProfile.url,
...assetProfiles[assetProfile.symbol]
assetClass,
assetSubClass,
countries,
createdAt,
currency,
dataSource,
id,
isin,
name,
scraperConfiguration,
sectors,
symbol,
symbolMapping,
updatedAt,
url,
comment: assetProfile.comment
},
Account: validatedAccount,
symbolProfileId: undefined,
@ -318,14 +349,14 @@ export class ImportService {
SymbolProfile: {
connectOrCreate: {
create: {
currency: assetProfile.currency,
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
currency,
dataSource,
symbol
},
where: {
dataSource_symbol: {
dataSource: assetProfile.dataSource,
symbol: assetProfile.symbol
dataSource,
symbol
}
}
}
@ -337,24 +368,49 @@ export class ImportService {
const value = new Big(quantity).mul(unitPrice).toNumber();
//@ts-ignore
activities.push({
...order,
error,
value,
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
fee,
assetProfile.currency,
currency,
userCurrency
),
//@ts-ignore
SymbolProfile: assetProfile,
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
value,
assetProfile.currency,
currency,
userCurrency
)
});
}
activities.sort((activity1, activity2) => {
return Number(activity1.date) - Number(activity2.date);
});
if (!isDryRun) {
// Gather symbol data in the background, if not dry run
const uniqueActivities = uniqBy(activities, ({ SymbolProfile }) => {
return getAssetProfileIdentifier({
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
});
});
this.dataGatheringService.gatherSymbols(
uniqueActivities.map(({ date, SymbolProfile }) => {
return {
date,
dataSource: SymbolProfile.dataSource,
symbol: SymbolProfile.symbol
};
})
);
}
return activities;
}
@ -446,25 +502,30 @@ export class ImportService {
private async validateActivities({
activitiesDto,
maxActivitiesToImport,
userId
maxActivitiesToImport
}: {
activitiesDto: Partial<CreateOrderDto>[];
maxActivitiesToImport: number;
userId: string;
}) {
if (activitiesDto?.length > maxActivitiesToImport) {
throw new Error(`Too many activities (${maxActivitiesToImport} at most)`);
}
const assetProfiles: {
[symbol: string]: Partial<SymbolProfile>;
[assetProfileIdentifier: string]: Partial<SymbolProfile>;
} = {};
const uniqueActivitiesDto = uniqBy(
activitiesDto,
({ dataSource, symbol }) => {
return getAssetProfileIdentifier({ dataSource, symbol });
}
);
for (const [
index,
{ currency, dataSource, symbol }
] of activitiesDto.entries()) {
] of uniqueActivitiesDto.entries()) {
if (dataSource !== 'MANUAL') {
const assetProfile = (
await this.dataProviderService.getAssetProfiles([
@ -484,7 +545,8 @@ export class ImportService {
);
}
assetProfiles[symbol] = assetProfile;
assetProfiles[getAssetProfileIdentifier({ dataSource, symbol })] =
assetProfile;
}
}

View File

@ -2,6 +2,7 @@ import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ApiService } from '@ghostfolio/api/services/api/api.service';
import { DataGatheringService } from '@ghostfolio/api/services/data-gathering/data-gathering.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation/impersonation.service';
import { HEADER_KEY_IMPERSONATION } from '@ghostfolio/common/config';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -36,6 +37,7 @@ import { UpdateOrderDto } from './update-order.dto';
export class OrderController {
public constructor(
private readonly apiService: ApiService,
private readonly dataGatheringService: DataGatheringService,
private readonly impersonationService: ImpersonationService,
private readonly orderService: OrderService,
@Inject(REQUEST) private readonly request: RequestWithUser
@ -123,7 +125,7 @@ export class OrderController {
);
}
return this.orderService.createOrder({
const order = await this.orderService.createOrder({
...data,
date: parseISO(data.date),
SymbolProfile: {
@ -144,6 +146,19 @@ export class OrderController {
User: { connect: { id: this.request.user.id } },
userId: this.request.user.id
});
if (!order.isDraft) {
// Gather symbol data in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.dataSource,
date: order.date,
symbol: data.symbol
}
]);
}
return order;
}
@Put(':id')

View File

@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -31,6 +32,6 @@ import { OrderService } from './order.service';
SymbolProfileModule,
UserModule
],
providers: [AccountService, OrderService]
providers: [AccountBalanceService, AccountService, OrderService]
})
export class OrderModule {}

View File

@ -7,6 +7,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Filter } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
@ -117,7 +118,7 @@ export class OrderService {
};
}
await this.dataGatheringService.addJobToQueue({
this.dataGatheringService.addJobToQueue({
data: {
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
@ -125,26 +126,13 @@ export class OrderService {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${data.SymbolProfile.connectOrCreate.create.dataSource}-${data.SymbolProfile.connectOrCreate.create.symbol}`
jobId: getAssetProfileIdentifier({
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
})
}
});
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
if (!isDraft) {
// Gather symbol data of order in the background, if not draft
this.dataGatheringService.gatherSymbols([
{
dataSource: data.SymbolProfile.connectOrCreate.create.dataSource,
date: <Date>data.date,
symbol: data.SymbolProfile.connectOrCreate.create.symbol
}
]);
}
delete data.accountId;
delete data.assetClass;
delete data.assetSubClass;
@ -162,6 +150,11 @@ export class OrderService {
const orderData: Prisma.OrderCreateInput = data;
const isDraft =
data.type === 'LIABILITY'
? false
: isAfter(data.date as Date, endOfToday());
const order = await this.prismaService.order.create({
data: {
...orderData,

View File

@ -38,7 +38,7 @@ export class CurrentRateService {
if (includeToday) {
promises.push(
this.dataProviderService
.getQuotes(dataGatheringItems)
.getQuotes({ items: dataGatheringItems })
.then((dataResultProvider) => {
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {

View File

@ -1,4 +1,4 @@
import { DataSource, Type as TypeOfOrder } from '@prisma/client';
import { DataSource, Tag, Type as TypeOfOrder } from '@prisma/client';
import Big from 'big.js';
export interface PortfolioOrder {
@ -9,6 +9,7 @@ export interface PortfolioOrder {
name: string;
quantity: Big;
symbol: string;
tags?: Tag[];
type: TypeOfOrder;
unitPrice: Big;
}

View File

@ -1,4 +1,4 @@
import { DataSource } from '@prisma/client';
import { DataSource, Tag } from '@prisma/client';
import Big from 'big.js';
export interface TransactionPointSymbol {
@ -9,5 +9,6 @@ export interface TransactionPointSymbol {
investment: Big;
quantity: Big;
symbol: string;
tags?: Tag[];
transactionCount: number;
}

View File

@ -114,6 +114,7 @@ export class PortfolioCalculator {
firstBuyDate: oldAccumulatedSymbol.firstBuyDate,
quantity: newQuantity,
symbol: order.symbol,
tags: order.tags,
transactionCount: oldAccumulatedSymbol.transactionCount + 1
};
} else {
@ -125,6 +126,7 @@ export class PortfolioCalculator {
investment: unitPrice.mul(order.quantity).mul(factor),
quantity: order.quantity.mul(factor),
symbol: order.symbol,
tags: order.tags,
transactionCount: 1
};
}
@ -492,6 +494,7 @@ export class PortfolioCalculator {
: null,
quantity: item.quantity,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount
});

View File

@ -134,7 +134,7 @@ export class PortfolioController {
portfolioPosition.netPerformance = null;
portfolioPosition.quantity = null;
portfolioPosition.valueInPercentage =
portfolioPosition.value / totalValue;
portfolioPosition.valueInBaseCurrency / totalValue;
}
for (const [name, { valueInBaseCurrency }] of Object.entries(accounts)) {
@ -161,10 +161,12 @@ export class PortfolioController {
'emergencyFund',
'excludedAccountsAndActivities',
'fees',
'fireWealth',
'items',
'liabilities',
'netWorth',
'totalBuy',
'totalInvestment',
'totalSell'
]);
}
@ -177,6 +179,9 @@ export class PortfolioController {
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
markets: hasDetails ? portfolioPosition.markets : undefined,
marketsAdvanced: hasDetails
? portfolioPosition.marketsAdvanced
: undefined,
sectors: hasDetails ? portfolioPosition.sectors : []
};
}
@ -445,7 +450,8 @@ export class PortfolioController {
for (const [symbol, portfolioPosition] of Object.entries(holdings)) {
portfolioPublicDetails.holdings[symbol] = {
allocationInPercentage: portfolioPosition.value / totalValue,
allocationInPercentage:
portfolioPosition.valueInBaseCurrency / totalValue,
countries: hasDetails ? portfolioPosition.countries : [],
currency: hasDetails ? portfolioPosition.currency : undefined,
dataSource: portfolioPosition.dataSource,
@ -456,7 +462,7 @@ export class PortfolioController {
sectors: hasDetails ? portfolioPosition.sectors : [],
symbol: portfolioPosition.symbol,
url: portfolioPosition.url,
valueInPercentage: portfolioPosition.value / totalValue
valueInPercentage: portfolioPosition.valueInBaseCurrency / totalValue
};
}

View File

@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
import { AccountService } from '@ghostfolio/api/app/account/account.service';
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
import { UserModule } from '@ghostfolio/api/app/user/user.module';
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering/data-gathering.module';
@ -36,6 +37,7 @@ import { RulesService } from './rules.service';
UserModule
],
providers: [
AccountBalanceService,
AccountService,
CurrentRateService,
PortfolioService,

View File

@ -42,7 +42,6 @@ import type {
AccountWithValue,
DateRange,
GroupBy,
Market,
OrderWithAccount,
RequestWithUser,
UserWithSettings
@ -84,8 +83,10 @@ import {
import { PortfolioCalculator } from './portfolio-calculator';
import { RulesService } from './rules.service';
const asiaPacificMarkets = require('../../assets/countries/asia-pacific-markets.json');
const developedMarkets = require('../../assets/countries/developed-markets.json');
const emergingMarkets = require('../../assets/countries/emerging-markets.json');
const europeMarkets = require('../../assets/countries/europe-markets.json');
@Injectable()
export class PortfolioService {
@ -504,15 +505,17 @@ export class PortfolioService {
);
}
const dataGatheringItems = currentPositions.positions.map((position) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
};
});
const dataGatheringItems = currentPositions.positions.map(
({ dataSource, symbol }) => {
return {
dataSource,
symbol
};
}
);
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems),
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(dataGatheringItems)
]);
@ -536,30 +539,79 @@ export class PortfolioService {
const symbolProfile = symbolProfileMap[item.symbol];
const dataProviderResponse = dataProviderResponses[item.symbol];
const markets: { [key in Market]: number } = {
const markets: PortfolioPosition['markets'] = {
[UNKNOWN_KEY]: 0,
developedMarkets: 0,
emergingMarkets: 0,
otherMarkets: 0
};
const marketsAdvanced: PortfolioPosition['marketsAdvanced'] = {
[UNKNOWN_KEY]: 0,
asiaPacific: 0,
emergingMarkets: 0,
europe: 0,
japan: 0,
northAmerica: 0,
otherMarkets: 0
};
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
if (symbolProfile.countries.length > 0) {
for (const country of symbolProfile.countries) {
if (developedMarkets.includes(country.code)) {
markets.developedMarkets = new Big(markets.developedMarkets)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
markets.emergingMarkets = new Big(markets.emergingMarkets)
.plus(country.weight)
.toNumber();
} else {
markets.otherMarkets = new Big(markets.otherMarkets)
.plus(country.weight)
.toNumber();
}
if (country.code === 'JP') {
marketsAdvanced.japan = new Big(marketsAdvanced.japan)
.plus(country.weight)
.toNumber();
} else if (country.code === 'CA' || country.code === 'US') {
marketsAdvanced.northAmerica = new Big(marketsAdvanced.northAmerica)
.plus(country.weight)
.toNumber();
} else if (asiaPacificMarkets.includes(country.code)) {
marketsAdvanced.asiaPacific = new Big(marketsAdvanced.asiaPacific)
.plus(country.weight)
.toNumber();
} else if (emergingMarkets.includes(country.code)) {
marketsAdvanced.emergingMarkets = new Big(
marketsAdvanced.emergingMarkets
)
.plus(country.weight)
.toNumber();
} else if (europeMarkets.includes(country.code)) {
marketsAdvanced.europe = new Big(marketsAdvanced.europe)
.plus(country.weight)
.toNumber();
} else {
marketsAdvanced.otherMarkets = new Big(marketsAdvanced.otherMarkets)
.plus(country.weight)
.toNumber();
}
}
} else {
markets[UNKNOWN_KEY] = new Big(markets[UNKNOWN_KEY])
.plus(value)
.toNumber();
marketsAdvanced[UNKNOWN_KEY] = new Big(marketsAdvanced[UNKNOWN_KEY])
.plus(value)
.toNumber();
}
holdings[item.symbol] = {
markets,
marketsAdvanced,
allocationInPercentage: filteredValueInBaseCurrency.eq(0)
? 0
: value.div(filteredValueInBaseCurrency).toNumber(),
@ -581,9 +633,10 @@ export class PortfolioService {
quantity: item.quantity.toNumber(),
sectors: symbolProfile.sectors,
symbol: item.symbol,
tags: item.tags,
transactionCount: item.transactionCount,
url: symbolProfile.url,
value: value.toNumber()
valueInBaseCurrency: value.toNumber()
};
}
@ -626,7 +679,7 @@ export class PortfolioService {
const emergencyFundInCash = emergencyFund
.minus(
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
holdings
})
)
.toNumber();
@ -643,7 +696,7 @@ export class PortfolioService {
holdings[userCurrency] = {
...emergencyFundCashPositions[userCurrency],
investment: emergencyFundInCash,
value: emergencyFundInCash
valueInBaseCurrency: emergencyFundInCash
};
}
@ -654,7 +707,7 @@ export class PortfolioService {
balanceInBaseCurrency: cashDetails.balanceInBaseCurrency,
emergencyFundPositionsValueInBaseCurrency:
this.getEmergencyFundPositionsValueInBaseCurrency({
activities: orders
holdings
})
});
@ -740,6 +793,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.SymbolProfile.symbol,
tags: order.tags,
type: order.type,
unitPrice: new Big(order.unitPrice)
}));
@ -897,9 +951,9 @@ export class PortfolioService {
)
};
} else {
const currentData = await this.dataProviderService.getQuotes([
{ dataSource: DataSource.YAHOO, symbol: aSymbol }
]);
const currentData = await this.dataProviderService.getQuotes({
items: [{ dataSource: DataSource.YAHOO, symbol: aSymbol }]
});
const marketPrice = currentData[aSymbol]?.marketPrice;
let historicalData = await this.dataProviderService.getHistorical(
@ -1000,15 +1054,15 @@ export class PortfolioService {
(item) => !item.quantity.eq(0)
);
const dataGatheringItem = positions.map((position) => {
const dataGatheringItems = positions.map(({ dataSource, symbol }) => {
return {
dataSource: position.dataSource,
symbol: position.symbol
dataSource,
symbol
};
});
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem),
this.dataProviderService.getQuotes({ items: dataGatheringItems }),
this.symbolProfileService.getSymbolProfiles(
positions.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
@ -1276,7 +1330,7 @@ export class PortfolioService {
if (cashPositions[account.currency]) {
cashPositions[account.currency].investment += convertedBalance;
cashPositions[account.currency].value += convertedBalance;
cashPositions[account.currency].valueInBaseCurrency += convertedBalance;
} else {
cashPositions[account.currency] = this.getInitialCashPosition({
balance: convertedBalance,
@ -1288,7 +1342,9 @@ export class PortfolioService {
for (const symbol of Object.keys(cashPositions)) {
// Calculate allocations for each currency
cashPositions[symbol].allocationInPercentage = value.gt(0)
? new Big(cashPositions[symbol].value).div(value).toNumber()
? new Big(cashPositions[symbol].valueInBaseCurrency)
.div(value)
.toNumber()
: 0;
}
@ -1388,13 +1444,13 @@ export class PortfolioService {
}
private getEmergencyFundPositionsValueInBaseCurrency({
activities
holdings
}: {
activities: Activity[];
holdings: PortfolioDetails['holdings'];
}) {
const emergencyFundOrders = activities.filter((activity) => {
const emergencyFundHoldings = Object.values(holdings).filter(({ tags }) => {
return (
activity.tags?.some(({ id }) => {
tags?.some(({ id }) => {
return id === EMERGENCY_FUND_TAG_ID;
}) ?? false
);
@ -1402,18 +1458,9 @@ export class PortfolioService {
let valueInBaseCurrencyOfEmergencyFundPositions = new Big(0);
for (const order of emergencyFundOrders) {
if (order.type === 'BUY') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(
order.valueInBaseCurrency
);
} else if (order.type === 'SELL') {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.minus(
order.valueInBaseCurrency
);
}
for (const { valueInBaseCurrency } of emergencyFundHoldings) {
valueInBaseCurrencyOfEmergencyFundPositions =
valueInBaseCurrencyOfEmergencyFundPositions.plus(valueInBaseCurrency);
}
return valueInBaseCurrencyOfEmergencyFundPositions.toNumber();
@ -1472,8 +1519,9 @@ export class PortfolioService {
quantity: 0,
sectors: [],
symbol: currency,
tags: [],
transactionCount: 0,
value: balance
valueInBaseCurrency: balance
};
}
@ -1499,7 +1547,13 @@ export class PortfolioService {
);
}
private getLiabilities(activities: OrderWithAccount[]) {
private getLiabilities({
activities,
userCurrency
}: {
activities: OrderWithAccount[];
userCurrency: string;
}) {
return activities
.filter(({ type }) => {
return type === TypeOfOrder.LIABILITY;
@ -1508,7 +1562,7 @@ export class PortfolioService {
return this.exchangeRateDataService.toCurrency(
new Big(quantity).mul(unitPrice).toNumber(),
SymbolProfile.currency,
this.request.user.Settings.settings.baseCurrency
userCurrency
);
})
.reduce(
@ -1618,7 +1672,10 @@ export class PortfolioService {
const fees = this.getFees({ activities, userCurrency }).toNumber();
const firstOrderDate = activities[0]?.date;
const items = this.getItems(activities).toNumber();
const liabilities = this.getLiabilities(activities).toNumber();
const liabilities = this.getLiabilities({
activities,
userCurrency
}).toNumber();
const totalBuy = this.getTotalByType(activities, userCurrency, 'BUY');
const totalSell = this.getTotalByType(activities, userCurrency, 'SELL');
@ -1683,7 +1740,16 @@ export class PortfolioService {
totalBuy,
totalSell,
committedFunds: committedFunds.toNumber(),
emergencyFund: emergencyFund.toNumber(),
emergencyFund: {
assets: emergencyFundPositionsValueInBaseCurrency,
cash: emergencyFund
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
total: emergencyFund.toNumber()
},
fireWealth: new Big(performanceInformation.performance.currentValue)
.minus(emergencyFundPositionsValueInBaseCurrency)
.toNumber(),
ordersCount: activities.filter(({ type }) => {
return type === 'BUY' || type === 'SELL';
}).length
@ -1735,6 +1801,7 @@ export class PortfolioService {
name: order.SymbolProfile?.name,
quantity: new Big(order.quantity),
symbol: order.SymbolProfile.symbol,
tags: order.tags,
type: order.type,
unitPrice: new Big(
this.exchangeRateDataService.toCurrency(
@ -1775,12 +1842,12 @@ export class PortfolioService {
userId: string;
withExcludedAccounts?: boolean;
}) {
const ordersOfTypeItem = await this.orderService.getOrders({
const ordersOfTypeItemOrLiability = await this.orderService.getOrders({
filters,
userCurrency,
userId,
withExcludedAccounts,
types: ['ITEM']
types: ['ITEM', 'LIABILITY']
});
const accounts: PortfolioDetails['accounts'] = {};
@ -1820,13 +1887,14 @@ export class PortfolioService {
return accountId === account.id;
});
const ordersOfTypeItemByAccount = ordersOfTypeItem.filter(
({ accountId }) => {
const ordersOfTypeItemOrLiabilityByAccount =
ordersOfTypeItemOrLiability.filter(({ accountId }) => {
return accountId === account.id;
}
);
});
ordersByAccount = ordersByAccount.concat(ordersOfTypeItemByAccount);
ordersByAccount = ordersByAccount.concat(
ordersOfTypeItemOrLiabilityByAccount
);
accounts[account.id] = {
balance: account.balance,
@ -1866,7 +1934,7 @@ export class PortfolioService {
order.unitPrice ??
0);
if (order.type === 'SELL') {
if (order.type === 'LIABILITY' || order.type === 'SELL') {
currentValueOfSymbolInBaseCurrency *= -1;
}

View File

@ -0,0 +1,7 @@
import { Cache } from 'cache-manager';
import type { RedisStore } from './redis-store.interface';
export interface RedisCache extends Cache {
store: RedisStore;
}

View File

@ -0,0 +1,8 @@
import { Store } from 'cache-manager';
import { RedisClient } from 'redis';
export interface RedisStore extends Store {
getClient: () => RedisClient;
isCacheableValue: (value: any) => boolean;
name: 'redis';
}

View File

@ -1,21 +1,29 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { CACHE_MANAGER, Inject, Injectable, Logger } from '@nestjs/common';
import type { RedisCache } from './interfaces/redis-cache.interface';
@Injectable()
export class RedisCacheService {
public constructor(
@Inject(CACHE_MANAGER) private readonly cache: Cache,
@Inject(CACHE_MANAGER) private readonly cache: RedisCache,
private readonly configurationService: ConfigurationService
) {}
) {
const client = cache.store.getClient();
client.on('error', (error) => {
Logger.error(error, 'RedisCacheService');
});
}
public async get(key: string): Promise<string> {
return await this.cache.get(key);
}
public getQuoteKey({ dataSource, symbol }: UniqueAsset) {
return `quote-${dataSource}-${symbol}`;
return `quote-${getAssetProfileIdentifier({ dataSource, symbol })}`;
}
public async remove(key: string) {

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

@ -27,9 +27,9 @@ export class SymbolService {
dataGatheringItem: IDataGatheringItem;
includeHistoricalData?: number;
}): Promise<SymbolItem> {
const quotes = await this.dataProviderService.getQuotes([
dataGatheringItem
]);
const quotes = await this.dataProviderService.getQuotes({
items: [dataGatheringItem]
});
const { currency, marketPrice } = quotes[dataGatheringItem.symbol] ?? {};
if (dataGatheringItem.dataSource && marketPrice >= 0) {
@ -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

@ -14,6 +14,7 @@ import {
import { UserWithSettings } from '@ghostfolio/common/types';
import { Injectable } from '@nestjs/common';
import { Prisma, Role, User } from '@prisma/client';
import { differenceInDays } from 'date-fns';
import { sortBy } from 'lodash';
const crypto = require('crypto');
@ -123,7 +124,7 @@ export class UserService {
id,
provider,
role,
Settings,
Settings: Settings as UserWithSettings['Settings'],
thirdPartyId,
updatedAt,
activityCount: Analytics?.activityCount
@ -165,11 +166,26 @@ export class UserService {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
if (
Analytics?.activityCount % 20 === 0 &&
user.subscription?.type === 'Basic'
) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
if (user.subscription?.type === 'Basic') {
const daysSinceRegistration = differenceInDays(
new Date(),
user.createdAt
);
let frequency = 20;
if (daysSinceRegistration > 180) {
frequency = 3;
} else if (daysSinceRegistration > 60) {
frequency = 5;
} else if (daysSinceRegistration > 30) {
frequency = 10;
} else if (daysSinceRegistration > 15) {
frequency = 15;
}
if (Analytics?.activityCount % frequency === 1) {
currentPermissions.push(permissions.enableSubscriptionInterstitial);
}
}
if (user.subscription?.type === 'Premium') {

View File

@ -0,0 +1 @@
["AU", "HK", "NZ", "SG"]

View File

@ -0,0 +1,19 @@
[
"AT",
"BE",
"CH",
"DE",
"DK",
"ES",
"FI",
"FR",
"GB",
"IE",
"IL",
"IT",
"LU",
"NL",
"NO",
"PT",
"SE"
]

View File

@ -0,0 +1,519 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset
xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
<url>
<loc>https://ghostfol.io/de</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/haeufig-gestellte-fragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/maerkte</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/preise</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/registrierung</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ressourcen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/datenschutzbestimmungen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/de/ueber-uns/lizenz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/about/license</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/07/ghostfolio-meets-internet-identity</loc>
<lastmod>${currentDate}T00: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>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/08/500-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/10/hacktoberfest-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/11/black-friday-2022</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2022/12/the-importance-of-tracking-your-personal-finances</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/02/ghostfolio-meets-umbrel</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/03/ghostfolio-reaches-1000-stars-on-github</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/05/unlock-your-financial-potential-with-ghostfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/blog/2023/07/exploring-the-path-to-fire</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/faq</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/features</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/markets</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/pricing</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/register</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-altoo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-copilot-money</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-delta</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-divvydiary</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-exirio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-folishare</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-getquin</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-gospatz</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-justetf</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-kubera</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-markets.sh</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-maybe-finance</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-monse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-parqet</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-plannix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portfolio-dividend-tracker</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-portseido</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-projectionlab</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-seeking-alpha</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sharesight</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-simple-portfolio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-snowball-analytics</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-sumio</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-utluna</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/en/resources/personal-finance-tools/open-source-alternative-to-yeekatee</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/mercados</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/precios</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/preguntas-mas-frecuentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/registro</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/licencia</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/es/sobre/politica-de-privacidad</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/licence</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/a-propos/politique-de-confidentialite</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/enregistrement</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/fonctionnalites</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/foire-aux-questions</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/marches</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/prix</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/fr/ressources</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/domande-piu-frequenti</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/funzionalita</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/licenza</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/informazioni-su/informativa-sulla-privacy</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/iscrizione</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/mercati</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/prezzi</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/it/risorse</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/bronnen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/kenmerken</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/markten</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/open</loc>
<changefreq>daily</changefreq>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/licentie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/over/privacybeleid</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/prijzen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/registratie</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/nl/vaak-gestelde-vragen</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/blog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/funcionalidades</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/mercados</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/open</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/perguntas-mais-frequentes</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/precos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/recursos</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/registo</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/changelog</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/licenca</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
<url>
<loc>https://ghostfol.io/pt/sobre/politica-de-privacidade</loc>
<lastmod>${currentDate}T00:00:00+00:00</lastmod>
</url>
</urlset>

View File

@ -35,18 +35,21 @@ async function bootstrap() {
// Support 10mb csv/json files for importing activities
app.use(bodyParser.json({ limit: '10mb' }));
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
}
}
})
);
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';

View File

@ -0,0 +1,10 @@
import { AccountBalanceService } from '@ghostfolio/api/services/account-balance/account-balance.service';
import { PrismaModule } from '@ghostfolio/api/services/prisma/prisma.module';
import { Module } from '@nestjs/common';
@Module({
exports: [AccountBalanceService],
imports: [PrismaModule],
providers: [AccountBalanceService]
})
export class AccountBalanceModule {}

View File

@ -0,0 +1,16 @@
import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { Injectable } from '@nestjs/common';
import { AccountBalance, Prisma } from '@prisma/client';
@Injectable()
export class AccountBalanceService {
public constructor(private readonly prismaService: PrismaService) {}
public async createAccountBalance(
data: Prisma.AccountBalanceCreateInput
): Promise<AccountBalance> {
return this.prismaService.accountBalance.create({
data
});
}
}

View File

@ -2,6 +2,7 @@ import {
GATHER_ASSET_PROFILE_PROCESS,
GATHER_ASSET_PROFILE_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { getAssetProfileIdentifier } from '@ghostfolio/common/helper';
import { Injectable } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
@ -48,7 +49,7 @@ export class CronService {
name: GATHER_ASSET_PROFILE_PROCESS,
opts: {
...GATHER_ASSET_PROFILE_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}`
jobId: getAssetProfileIdentifier({ dataSource, symbol })
}
};
})

View File

@ -10,7 +10,11 @@ import {
GATHER_HISTORICAL_MARKET_DATA_PROCESS,
GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS
} from '@ghostfolio/common/config';
import { DATE_FORMAT, resetHours } from '@ghostfolio/common/helper';
import {
DATE_FORMAT,
getAssetProfileIdentifier,
resetHours
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { InjectQueue } from '@nestjs/bull';
import { Inject, Injectable, Logger } from '@nestjs/common';
@ -221,7 +225,10 @@ export class DataGatheringService {
name: GATHER_HISTORICAL_MARKET_DATA_PROCESS,
opts: {
...GATHER_HISTORICAL_MARKET_DATA_PROCESS_OPTIONS,
jobId: `${dataSource}-${symbol}-${format(date, DATE_FORMAT)}`
jobId: `${getAssetProfileIdentifier({
dataSource,
symbol
})}-${format(date, DATE_FORMAT)}`
}
};
})

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

@ -135,6 +135,8 @@ export class YahooFinanceDataEnhancerService implements DataEnhancerInterface {
let name = longName;
if (name) {
name = name.replace('&amp;', '&');
name = name.replace('Amundi Index Solutions - ', '');
name = name.replace('iShares ETF (CH) - ', '');
name = name.replace('iShares III Public Limited Company - ', '');

View File

@ -3,7 +3,6 @@ import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.in
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataGatheringItem,
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
@ -12,6 +11,7 @@ import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_DATA_SOURCE_MAPPING } from '@ghostfolio/common/config';
import { DATE_FORMAT, getStartOfUtcDate } from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import type { Granularity, UserWithSettings } from '@ghostfolio/common/types';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { DataSource, MarketData, SymbolProfile } from '@prisma/client';
@ -45,12 +45,15 @@ export class DataProviderService {
const dataProvider = this.getDataProvider(dataSource);
const symbol = dataProvider.getTestSymbol();
const quotes = await this.getQuotes([
{
dataSource,
symbol
}
]);
const quotes = await this.getQuotes({
items: [
{
dataSource,
symbol
}
],
useCache: false
});
if (quotes[symbol]?.marketPrice > 0) {
return true;
@ -59,14 +62,16 @@ export class DataProviderService {
return false;
}
public async getAssetProfiles(items: IDataGatheringItem[]): Promise<{
public async getAssetProfiles(items: UniqueAsset[]): Promise<{
[symbol: string]: Partial<SymbolProfile>;
}> {
const response: {
[symbol: string]: Partial<SymbolProfile>;
} = {};
const itemsGroupedByDataSource = groupBy(items, (item) => item.dataSource);
const itemsGroupedByDataSource = groupBy(items, ({ dataSource }) => {
return dataSource;
});
const promises = [];
@ -127,7 +132,7 @@ export class DataProviderService {
}
public async getHistorical(
aItems: IDataGatheringItem[],
aItems: UniqueAsset[],
aGranularity: Granularity = 'month',
from: Date,
to: Date
@ -155,11 +160,11 @@ export class DataProviderService {
)}'`
: '';
const dataSources = aItems.map((item) => {
return item.dataSource;
const dataSources = aItems.map(({ dataSource }) => {
return dataSource;
});
const symbols = aItems.map((item) => {
return item.symbol;
const symbols = aItems.map(({ symbol }) => {
return symbol;
});
try {
@ -192,7 +197,7 @@ export class DataProviderService {
}
public async getHistoricalRaw(
aDataGatheringItems: IDataGatheringItem[],
aDataGatheringItems: UniqueAsset[],
from: Date,
to: Date
): Promise<{
@ -229,7 +234,13 @@ export class DataProviderService {
return result;
}
public async getQuotes(items: IDataGatheringItem[]): Promise<{
public async getQuotes({
items,
useCache = true
}: {
items: UniqueAsset[];
useCache?: boolean;
}): Promise<{
[symbol: string]: IDataProviderResponse;
}> {
const response: {
@ -238,23 +249,24 @@ export class DataProviderService {
const startTimeTotal = performance.now();
// Get items from cache
const itemsToFetch: IDataGatheringItem[] = [];
const itemsToFetch: UniqueAsset[] = [];
for (const { dataSource, symbol } of items) {
const quoteString = await this.redisCacheService.get(
this.redisCacheService.getQuoteKey({ dataSource, symbol })
);
if (useCache) {
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) {
try {
const cachedDataProviderResponse = JSON.parse(quoteString);
response[symbol] = cachedDataProviderResponse;
continue;
} catch {}
}
}
if (!quoteString) {
itemsToFetch.push({ dataSource, symbol });
}
itemsToFetch.push({ dataSource, symbol });
}
const numberOfItemsInCache = Object.keys(response)?.length;
@ -367,9 +379,11 @@ export class DataProviderService {
}
public async search({
includeIndices = false,
query,
user
}: {
includeIndices?: boolean;
query: string;
user: UserWithSettings;
}): Promise<{ items: LookupItem[] }> {
@ -392,7 +406,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

@ -64,11 +64,11 @@ export class ExchangeRateDataService {
if (Object.keys(result).length !== this.currencyPairs.length) {
// Load currencies directly from data provider as a fallback
// if historical data is not fully available
const quotes = await this.dataProviderService.getQuotes(
this.currencyPairs.map(({ dataSource, symbol }) => {
const quotes = await this.dataProviderService.getQuotes({
items: this.currencyPairs.map(({ dataSource, symbol }) => {
return { dataSource, symbol };
})
);
});
for (const symbol of Object.keys(quotes)) {
if (isNumber(quotes[symbol].marketPrice)) {
@ -125,9 +125,11 @@ export class ExchangeRateDataService {
return 0;
}
let factor = 1;
let factor: number;
if (aFromCurrency !== aToCurrency) {
if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
} else {
@ -171,7 +173,9 @@ export class ExchangeRateDataService {
let factor: number;
if (aFromCurrency !== aToCurrency) {
if (aFromCurrency === aToCurrency) {
factor = 1;
} else {
const dataSource =
this.dataProviderService.getDataSourceForExchangeRates();
const symbol = `${aFromCurrency}${aToCurrency}`;

View File

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

@ -29,6 +29,11 @@
"input": "",
"output": "./../assets"
},
{
"glob": "favicon.ico",
"input": "apps/client/src/assets",
"output": "./../"
},
{
"glob": "LICENSE",
"input": "",

View File

@ -47,97 +47,6 @@ const routes: Routes = [
loadChildren: () =>
import('./pages/blog/blog-page.module').then((m) => m.BlogPageModule)
})),
{
path: 'blog/2021/07/hallo-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hallo-ghostfolio/hallo-ghostfolio-page.module'
).then((m) => m.HalloGhostfolioPageModule)
},
{
path: 'blog/2021/07/hello-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2021/07/hello-ghostfolio/hello-ghostfolio-page.module'
).then((m) => m.HelloGhostfolioPageModule)
},
{
path: 'blog/2022/01/ghostfolio-first-months-in-open-source',
loadChildren: () =>
import(
'./pages/blog/2022/01/first-months-in-open-source/first-months-in-open-source-page.module'
).then((m) => m.FirstMonthsInOpenSourcePageModule)
},
{
path: 'blog/2022/07/ghostfolio-meets-internet-identity',
loadChildren: () =>
import(
'./pages/blog/2022/07/ghostfolio-meets-internet-identity/ghostfolio-meets-internet-identity-page.module'
).then((m) => m.GhostfolioMeetsInternetIdentityPageModule)
},
{
path: 'blog/2022/07/how-do-i-get-my-finances-in-order',
loadChildren: () =>
import(
'./pages/blog/2022/07/how-do-i-get-my-finances-in-order/how-do-i-get-my-finances-in-order-page.module'
).then((m) => m.HowDoIGetMyFinancesInOrderPageModule)
},
{
path: 'blog/2022/08/500-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
},
{
path: 'blog/2022/10/hacktoberfest-2022',
loadChildren: () =>
import(
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
).then((m) => m.Hacktoberfest2022PageModule)
},
{
path: 'blog/2022/11/black-friday-2022',
loadChildren: () =>
import(
'./pages/blog/2022/11/black-friday-2022/black-friday-2022-page.module'
).then((m) => m.BlackFriday2022PageModule)
},
{
path: 'blog/2022/12/the-importance-of-tracking-your-personal-finances',
loadChildren: () =>
import(
'./pages/blog/2022/12/the-importance-of-tracking-your-personal-finances/the-importance-of-tracking-your-personal-finances-page.module'
).then((m) => m.TheImportanceOfTrackingYourPersonalFinancesPageModule)
},
{
path: 'blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt',
loadChildren: () =>
import(
'./pages/blog/2023/01/ghostfolio-auf-sackgeld-vorgestellt/ghostfolio-auf-sackgeld-vorgestellt-page.module'
).then((m) => m.GhostfolioAufSackgeldVorgestelltPageModule)
},
{
path: 'blog/2023/02/ghostfolio-meets-umbrel',
loadChildren: () =>
import(
'./pages/blog/2023/02/ghostfolio-meets-umbrel/ghostfolio-meets-umbrel-page.module'
).then((m) => m.GhostfolioMeetsUmbrelPageModule)
},
{
path: 'blog/2023/03/ghostfolio-reaches-1000-stars-on-github',
loadChildren: () =>
import(
'./pages/blog/2023/03/1000-stars-on-github/1000-stars-on-github-page.module'
).then((m) => m.ThousandStarsOnGitHubPageModule)
},
{
path: 'blog/2023/05/unlock-your-financial-potential-with-ghostfolio',
loadChildren: () =>
import(
'./pages/blog/2023/05/unlock-your-financial-potential-with-ghostfolio/unlock-your-financial-potential-with-ghostfolio-page.module'
).then((m) => m.UnlockYourFinancialPotentialWithGhostfolioPageModule)
},
{
path: 'demo',
loadChildren: () =>

View File

@ -1,4 +1,5 @@
import {
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
@ -7,17 +8,18 @@ import {
ViewChild
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { MatSort } from '@angular/material/sort';
import { MatPaginator, PageEvent } from '@angular/material/paginator';
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 { DEFAULT_PAGE_SIZE } from '@ghostfolio/common/config';
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';
@ -33,7 +35,10 @@ import { CreateAssetProfileDialogParams } from './create-asset-profile-dialog/in
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[] = [];
@ -46,13 +51,26 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
AssetSubClass.PRECIOUS_METAL,
AssetSubClass.PRIVATE_EQUITY,
AssetSubClass.STOCK
].map((assetSubClass) => {
return {
id: assetSubClass,
label: translate(assetSubClass),
type: 'ASSET_SUB_CLASS'
};
});
]
.map((assetSubClass) => {
return {
id: assetSubClass.toString(),
label: translate(assetSubClass),
type: <Filter['type']>'ASSET_SUB_CLASS'
};
})
.concat([
{
id: 'ETF_WITHOUT_COUNTRIES',
label: $localize`ETFs without Countries`,
type: <Filter['type']>'PRESET_ID'
},
{
id: 'ETF_WITHOUT_SECTORS',
label: $localize`ETFs without Sectors`,
type: <Filter['type']>'PRESET_ID'
}
]);
public currentDataSource: DataSource;
public currentSymbol: string;
public dataSource: MatTableDataSource<AdminMarketDataItem> =
@ -75,6 +93,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>();
@ -82,7 +102,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,
@ -117,34 +136,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) {
@ -212,6 +237,53 @@ 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;
this.pageSize =
this.activeFilters.length === 1 &&
this.activeFilters[0].type === 'PRESET_ID'
? undefined
: DEFAULT_PAGE_SIZE;
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
@ -274,8 +346,9 @@ export class AdminMarketDataComponent implements OnDestroy, OnInit {
this.isLoading = true;
this.changeDetectorRef.markForCheck();
return this.dataService.fetchAdminMarketData({
filters: this.activeFilters
return this.adminService.fetchAdminMarketData({
filters: this.activeFilters,
take: this.pageSize
});
}),
takeUntil(this.unsubscribeSubject)

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,28 @@
(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>

View File

@ -2,10 +2,12 @@ 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';
@ -20,8 +22,10 @@ import { GfCreateAssetProfileDialogModule } from './create-asset-profile-dialog/
GfCreateAssetProfileDialogModule,
MatButtonModule,
MatMenuModule,
MatPaginatorModule,
MatSortModule,
MatTableModule,
NgxSkeletonLoaderModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

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

@ -4,11 +4,14 @@
(keyup.enter)="createAssetProfileForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 i18n mat-dialog-title>Create Asset Profile</h1>
<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" />
<gf-symbol-autocomplete
formControlName="searchSymbol"
[includeIndices]="true"
/>
</mat-form-field>
</div>
<div class="d-flex justify-content-end" mat-dialog-actions>
@ -19,7 +22,7 @@
type="submit"
[disabled]="!createAssetProfileForm.valid"
>
<ng-container i18n>Create</ng-container>
<ng-container i18n>Save</ng-container>
</button>
</div>
</form>

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

@ -1,57 +1,110 @@
<div
class="align-items-center container d-flex flex-column h-100 justify-content-center overview p-0 position-relative"
>
<div class="row w-100">
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0"
class="align-items-center d-flex h-100 justify-content-center w-100"
<div
*ngIf="hasPermissionToCreateOrder && historicalDataItems?.length === 0; else isUserActive"
class="justify-content-center row w-100"
>
<div class="col introduction">
<h4 i18n>Welcome to Ghostfolio</h4>
<p i18n>Ready to take control of your personal finances?</p>
<ol class="font-weight-bold">
<li
class="mb-2"
[ngClass]="{ 'text-muted': user?.accounts?.length > 1 }"
>
<div class="d-flex justify-content-center">
<gf-no-transactions-info-indicator></gf-no-transactions-info-indicator>
</div>
<a class="d-block" [routerLink]="['/accounts']"
><span i18n>Setup your accounts</span><br />
<span class="font-weight-normal" i18n
>Get a comprehensive financial overview by adding your bank and
brokerage accounts.</span
></a
>
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio', 'activities']">
<span i18n>Capture your activities</span><br />
<span class="font-weight-normal" i18n
>Record your investment activities to keep your portfolio up to
date.</span
></a
>
</li>
<li class="mb-2">
<a class="d-block" [routerLink]="['/portfolio']">
<span i18n>Monitor and analyze your portfolio</span><br />
<span class="font-weight-normal" i18n
>Track your progress in real-time with comprehensive analysis and
insights.</span
>
</a>
</li>
</ol>
<div class="d-flex justify-content-center">
<a
*ngIf="user?.accounts?.length === 1"
color="primary"
mat-flat-button
[routerLink]="['/accounts']"
>
<ng-container i18n>Setup accounts</ng-container>
</a>
<a
*ngIf="user?.accounts?.length > 1"
color="primary"
mat-flat-button
[routerLink]="['/portfolio', 'activities']"
>
<ng-container i18n>Add activity</ng-container>
</a>
</div>
</div>
</div>
<ng-template #isUserActive>
<div class="row w-100">
<div class="col p-0">
<div class="chart-container mx-auto position-relative">
<gf-line-chart
class="position-absolute"
symbol="Performance"
unit="%"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
<gf-line-chart
class="position-absolute"
symbol="Performance"
unit="%"
[colorScheme]="user?.settings?.colorScheme"
[hidden]="historicalDataItems?.length === 0"
[historicalDataItems]="historicalDataItems"
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
[locale]="user?.settings?.locale"
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
[showGradient]="true"
[showLoader]="false"
[showXAxis]="false"
[showYAxis]="false"
></gf-line-chart>
</div>
</div>
</div>
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
<div class="overview-container row mt-1">
<div class="col">
<gf-portfolio-performance
class="pb-4"
[baseCurrency]="user?.settings?.baseCurrency"
[deviceType]="deviceType"
[errors]="errors"
[isAllTimeHigh]="isAllTimeHigh"
[isAllTimeLow]="isAllTimeLow"
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
[locale]="user?.settings?.locale"
[performance]="performance"
[showDetails]="showDetails"
></gf-portfolio-performance>
<div *ngIf="showDetails" class="text-center">
<gf-toggle
[defaultValue]="user?.settings?.dateRange"
[isLoading]="isLoadingPerformance"
[options]="dateRangeOptions"
(change)="onChangeDateRange($event.value)"
></gf-toggle>
</div>
</div>
</div>
</div>
</ng-template>
</div>

View File

@ -1,5 +1,6 @@
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 { GfPortfolioPerformanceModule } from '@ghostfolio/client/components/portfolio-performance/portfolio-performance.module';
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
@ -16,6 +17,7 @@ import { HomeOverviewComponent } from './home-overview.component';
GfNoTransactionsInfoModule,
GfPortfolioPerformanceModule,
GfToggleModule,
MatButtonModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]

View File

@ -31,4 +31,8 @@
top: 0;
}
}
.introduction {
max-width: 50rem;
}
}

View File

@ -16,6 +16,8 @@ import { TokenStorageService } from '@ghostfolio/client/services/token-storage.s
templateUrl: 'login-with-access-token-dialog.html'
})
export class LoginWithAccessTokenDialog {
public isAccessTokenHidden = true;
public constructor(
@Inject(MAT_DIALOG_DATA) public data: any,
public dialogRef: MatDialogRef<LoginWithAccessTokenDialog>,
@ -38,6 +40,12 @@ export class LoginWithAccessTokenDialog {
this.dialogRef.close();
}
public onLoginWithAccessToken() {
if (this.data.accessToken) {
this.dialogRef.close(this.data);
}
}
public async onLoginWithInternetIdentity() {
try {
const { authToken } = await this.internetIdentityService.login();

View File

@ -6,15 +6,27 @@
<div class="py-3" mat-dialog-content>
<div class="align-items-center d-flex flex-column">
<mat-form-field appearance="outline" class="without-hint w-100">
<mat-label i18n>Security Token</mat-label>
<textarea
cdkTextareaAutosize
matInput
type="text"
[(ngModel)]="data.accessToken"
></textarea>
</mat-form-field>
<form class="w-100" (ngSubmit)="onLoginWithAccessToken()">
<mat-form-field appearance="outline" class="without-hint w-100">
<mat-label i18n>Security Token</mat-label>
<input
matInput
name="password"
[type]="isAccessTokenHidden ? 'password' : 'text'"
[(ngModel)]="data.accessToken"
/>
<button
mat-button
matSuffix
type="button"
(click)="isAccessTokenHidden = !isAccessTokenHidden"
>
<ion-icon
[name]="isAccessTokenHidden ? 'eye-outline' : 'eye-off-outline'"
></ion-icon>
</button>
</mat-form-field>
</form>
<ng-container *ngIf="data.hasPermissionToUseSocialLogin">
<div class="my-3 text-center text-muted" i18n>or</div>
<div class="d-flex flex-column">

View File

@ -163,7 +163,33 @@
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund"
[value]="isLoading ? undefined : summary?.emergencyFund?.total"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Cash</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.cash"
></gf-value>
</div>
</div>
<div class="flex-nowrap px-3 py-1 row">
<div class="flex-grow-1 ml-3 text-truncate" i18n>Assets</div>
<div class="flex-column flex-wrap justify-content-end">
<gf-value
class="justify-content-end"
position="end"
[isCurrency]="true"
[locale]="locale"
[unit]="baseCurrency"
[value]="isLoading ? undefined : summary?.emergencyFund?.assets"
></gf-value>
</div>
</div>

View File

@ -215,6 +215,15 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.benchmarkDataItems[0].value = this.averagePrice;
}
this.benchmarkDataItems = this.benchmarkDataItems.map(
({ date, value }) => {
return {
date,
value: value === 0 ? null : value
};
}
);
if (Number.isInteger(this.quantity)) {
this.quantityPrecision = 0;
} else if (this.SymbolProfile?.assetSubClass === 'CRYPTOCURRENCY') {

View File

@ -62,7 +62,7 @@ export class HttpResponseInterceptor implements HttpInterceptor {
undefined,
{ duration: 6000 }
);
} else {
} else if (!error.url.endsWith('auth/anonymous')) {
this.snackBarRef = this.snackBar.open(
$localize`This feature requires a subscription.`,
this.hasPermissionForSubscription

View File

@ -15,6 +15,10 @@ import {
import { ActivatedRoute, Router } from '@angular/router';
import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import {
STAY_SIGNED_IN,
SettingsStorageService
} from '@ghostfolio/client/services/settings-storage.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { getDateFormatString } from '@ghostfolio/common/helper';
@ -80,6 +84,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private snackBar: MatSnackBar,
private route: ActivatedRoute,
private router: Router,
private settingsStorageService: SettingsStorageService,
private stripeService: StripeService,
private userService: UserService,
public webAuthnService: WebAuthnService
@ -397,6 +402,8 @@ export class AccountPageComponent implements OnDestroy, OnInit {
})
)
.subscribe(() => {
this.settingsStorageService.removeSetting(STAY_SIGNED_IN);
this.update();
});
}

View File

@ -235,7 +235,12 @@
</div>
</div>
<div class="align-items-center d-flex mt-4 py-1">
<div class="pr-1 w-50" i18n>Sign in with fingerprint</div>
<div class="pr-1 w-50">
<div i18n>Biometric Authentication</div>
<div class="hint-text text-muted" i18n>
Sign in with fingerprint
</div>
</div>
<div class="pl-1 w-50">
<mat-checkbox
#toggleSignInWithFingerprintEnabledElement

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

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HalloGhostfolioPageComponent,
path: '',
title: 'Hallo Ghostfolio'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HalloGhostfolioPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hallo-ghostfolio-page',
styleUrls: ['./hallo-ghostfolio-page.scss'],
standalone: true,
templateUrl: './hallo-ghostfolio-page.html'
})
export class HalloGhostfolioPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HalloGhostfolioPageRoutingModule } from './hallo-ghostfolio-page-routing.module';
import { HalloGhostfolioPageComponent } from './hallo-ghostfolio-page.component';
@NgModule({
declarations: [HalloGhostfolioPageComponent],
imports: [CommonModule, HalloGhostfolioPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HalloGhostfolioPageModule {}

View File

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

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HelloGhostfolioPageComponent,
path: '',
title: 'Hello Ghostfolio'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HelloGhostfolioPageRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hello-ghostfolio-page',
styleUrls: ['./hello-ghostfolio-page.scss'],
standalone: true,
templateUrl: './hello-ghostfolio-page.html'
})
export class HelloGhostfolioPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HelloGhostfolioPageRoutingModule } from './hello-ghostfolio-page-routing.module';
import { HelloGhostfolioPageComponent } from './hello-ghostfolio-page.component';
@NgModule({
declarations: [HelloGhostfolioPageComponent],
imports: [CommonModule, HelloGhostfolioPageRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HelloGhostfolioPageModule {}

View File

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

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FirstMonthsInOpenSourcePageComponent,
path: '',
title: 'First months in Open Source'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FirstMonthsInOpenSourceRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-first-months-in-open-source-page',
styleUrls: ['./first-months-in-open-source-page.scss'],
standalone: true,
templateUrl: './first-months-in-open-source-page.html'
})
export class FirstMonthsInOpenSourcePageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FirstMonthsInOpenSourceRoutingModule } from './first-months-in-open-source-page-routing.module';
import { FirstMonthsInOpenSourcePageComponent } from './first-months-in-open-source-page.component';
@NgModule({
declarations: [FirstMonthsInOpenSourcePageComponent],
imports: [CommonModule, FirstMonthsInOpenSourceRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FirstMonthsInOpenSourcePageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: GhostfolioMeetsInternetIdentityPageComponent,
path: '',
title: 'Ghostfolio meets Internet Identity'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class GhostfolioMeetsInternetIdentityRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-ghostfolio-meets-internet-identity-page',
styleUrls: ['./ghostfolio-meets-internet-identity-page.scss'],
standalone: true,
templateUrl: './ghostfolio-meets-internet-identity-page.html'
})
export class GhostfolioMeetsInternetIdentityPageComponent {}

View File

@ -1,17 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { GhostfolioMeetsInternetIdentityRoutingModule } from './ghostfolio-meets-internet-identity-page-routing.module';
import { GhostfolioMeetsInternetIdentityPageComponent } from './ghostfolio-meets-internet-identity-page.component';
@NgModule({
declarations: [GhostfolioMeetsInternetIdentityPageComponent],
imports: [
CommonModule,
GhostfolioMeetsInternetIdentityRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GhostfolioMeetsInternetIdentityPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: HowDoIGetMyFinancesInOrderPageComponent,
path: '',
title: 'How do I get my finances in order?'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class HowDoIGetMyFinancesInOrderRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-how-do-i-get-my-finances-in-order-page',
styleUrls: ['./how-do-i-get-my-finances-in-order-page.scss'],
standalone: true,
templateUrl: './how-do-i-get-my-finances-in-order-page.html'
})
export class HowDoIGetMyFinancesInOrderPageComponent {}

View File

@ -1,17 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { HowDoIGetMyFinancesInOrderRoutingModule } from './how-do-i-get-my-finances-in-order-page-routing.module';
import { HowDoIGetMyFinancesInOrderPageComponent } from './how-do-i-get-my-finances-in-order-page.component';
@NgModule({
declarations: [HowDoIGetMyFinancesInOrderPageComponent],
imports: [
CommonModule,
HowDoIGetMyFinancesInOrderRoutingModule,
RouterModule
],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class HowDoIGetMyFinancesInOrderPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: FiveHundredStarsOnGitHubPageComponent,
path: '',
title: '500 Stars on GitHub'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class FiveHundredStarsOnGitHubRoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-500-stars-on-github-page',
styleUrls: ['./500-stars-on-github-page.scss'],
standalone: true,
templateUrl: './500-stars-on-github-page.html'
})
export class FiveHundredStarsOnGitHubPageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { FiveHundredStarsOnGitHubRoutingModule } from './500-stars-on-github-page-routing.module';
import { FiveHundredStarsOnGitHubPageComponent } from './500-stars-on-github-page.component';
@NgModule({
declarations: [FiveHundredStarsOnGitHubPageComponent],
imports: [CommonModule, FiveHundredStarsOnGitHubRoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class FiveHundredStarsOnGitHubPageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: Hacktoberfest2022PageComponent,
path: '',
title: 'Hacktoberfest 2022'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class Hacktoberfest2022RoutingModule {}

View File

@ -1,9 +1,12 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
@Component({
host: { class: 'page' },
imports: [MatButtonModule, RouterModule],
selector: 'gf-hacktoberfest-2022-page',
styleUrls: ['./hacktoberfest-2022-page.scss'],
standalone: true,
templateUrl: './hacktoberfest-2022-page.html'
})
export class Hacktoberfest2022PageComponent {}

View File

@ -1,13 +0,0 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { Hacktoberfest2022RoutingModule } from './hacktoberfest-2022-page-routing.module';
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
@NgModule({
declarations: [Hacktoberfest2022PageComponent],
imports: [CommonModule, Hacktoberfest2022RoutingModule, RouterModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class Hacktoberfest2022PageModule {}

View File

@ -1,20 +0,0 @@
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
import { BlackFriday2022PageComponent } from './black-friday-2022-page.component';
const routes: Routes = [
{
canActivate: [AuthGuard],
component: BlackFriday2022PageComponent,
path: '',
title: 'Black Friday 2022'
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class BlackFriday2022RoutingModule {}

View File

@ -1,9 +1,13 @@
import { Component } from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
import { RouterModule } from '@angular/router';
import { GfPremiumIndicatorModule } from '@ghostfolio/ui/premium-indicator';
@Component({
host: { class: 'page' },
imports: [GfPremiumIndicatorModule, MatButtonModule, RouterModule],
selector: 'gf-black-friday-2022-page',
styleUrls: ['./black-friday-2022-page.scss'],
standalone: true,
templateUrl: './black-friday-2022-page.html'
})
export class BlackFriday2022PageComponent {

Some files were not shown because too many files have changed in this diff Show More