Compare commits

...

25 Commits

Author SHA1 Message Date
0c04f10e19 Release 1.152.0 (#955) 2022-05-26 19:01:08 +02:00
2c4c16ec99 Feature/extend markets overview by benchmarks (#953)
* Add benchmarks to markets overview

* Update changelog
2022-05-26 18:59:29 +02:00
4711b0d1ed Improve instructions for Unraid (#954) 2022-05-26 17:59:09 +02:00
a8521e0ecf Update README.md (#943)
* Update README.md for Unraid users
2022-05-26 17:52:14 +02:00
424748ae90 Feature/add ghostfolio trailer to landing page (#952)
* Add link to Ghostfolio trailer

* Update changelog
2022-05-26 10:30:13 +02:00
9c4d8bdf4b Release 1.151.0 (#949) 2022-05-24 20:58:24 +02:00
332203b9e2 Feature/add support to set the base currency via env variable (#948)
* Set base currency via environment variable

* Update changelog
2022-05-24 20:55:55 +02:00
f48832c671 Bugfix/add missing conversion of countries (#941)
* Add missing conversion of countries for SymbolProfileOverrides

* Update changelog
2022-05-23 18:04:09 +02:00
ae8a203526 Add type (#939) 2022-05-22 21:14:22 +02:00
d0c1506ded Release 1.150.0 (#940) 2022-05-21 20:00:34 +02:00
af0863d193 Bugfix/fix currency conversion in accounts (#937)
* Fix currency conversion in accounts

* Update changelog
2022-05-21 19:58:47 +02:00
f5819cc399 Bugfix/fix countries in symbol profile overrides (#936)
* Fix countries

* Update changelog
2022-05-20 20:16:23 +02:00
977c5a9544 Feature/skip data enhancement if data is inaccurate (#935)
* Skip data enhancer if data is inaccurate

* Update changelog
2022-05-20 20:15:19 +02:00
b9cd42cd53 Move dependencies to devDependencies (#934) 2022-05-20 20:14:33 +02:00
379977008d Simplify intro text (#933) 2022-05-19 21:05:14 +02:00
38f9d54705 Release 1.149.0 (#927) 2022-05-16 21:50:43 +02:00
5cb6e5dec6 Feature/support filtering by asset class on the allocations page (#926)
* Support filtering by asset class

* Update changelog
2022-05-16 21:49:22 +02:00
4a123c38f2 Refactor placeholder (#925) 2022-05-16 21:17:58 +02:00
160335302a Feature/group filters by type (#922)
* Add groups to activities filter component

* Update changelog
2022-05-15 21:51:31 +02:00
f1483569a2 Release 1.148.0 (#921) 2022-05-14 13:55:25 +02:00
5391b88c42 Feature/add report data glitch button (#920)
* Add report data glitch button

* Update changelog
2022-05-14 13:53:43 +02:00
2b63f7e707 Feature/support enter to submit create or update transaction dialog form (#913)
* Support enter key press to submit form

* Update changelog
2022-05-14 10:56:07 +02:00
d5c96d1cb7 Bugfix/fix date picker date format (#912)
* Fix date picker date format

* Update changelog
2022-05-14 10:55:09 +02:00
1a4dc51825 Bugfix/fix state of delete account button (#911)
* Fix disable state

* Update changelog
2022-05-13 06:48:40 +02:00
d094bae7de Bugfix/fix issue in activities filter component with typing (#910)
* Handle filter (selecting) or search term (typing)

* Update changelog
2022-05-12 07:48:12 +02:00
78 changed files with 1004 additions and 349 deletions

View File

@ -5,6 +5,54 @@ 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.152.0 - 26.05.2022
### Added
- Added the _Ghostfolio_ trailer to the landing page
- Extended the markets overview by benchmarks (current change to the all time high)
## 1.151.0 - 24.05.2022
### Added
- Added support to set the base currency as an environment variable (`BASE_CURRENCY`)
### Fixed
- Fixed an issue with the missing conversion of countries in the symbol profile overrides
## 1.150.0 - 21.05.2022
### Changed
- Skipped data enhancer (_Trackinsight_) if data is inaccurate
### Fixed
- Fixed an issue with the currency conversion in the account calculations
- Fixed an issue with countries in the symbol profile overrides
## 1.149.0 - 16.05.2022
### Added
- Added groups to the activities filter component
- Added support for filtering by asset class on the allocations page
## 1.148.0 - 14.05.2022
### Added
- Supported enter key press to submit the form of the create or edit transaction dialog
- Added a _Report Data Glitch_ button to the position detail dialog
### Fixed
- Fixed the date format of the date picker and support manual changes
- Fixed the state of the account delete button (disable if account contains activities)
- Fixed an issue in the activities filter component (typing a search term)
## 1.147.0 - 10.05.2022
### Changed

View File

@ -9,7 +9,7 @@
<h1>Ghostfolio</h1>
<p>
<strong>Open Source Wealth Management Software made for Humans</strong>
<strong>Open Source Wealth Management Software</strong>
</p>
<p>
<a href="https://ghostfol.io"><strong>Live Demo</strong></a> | <a href="https://ghostfol.io/pricing"><strong>Ghostfolio Premium</strong></a> | <a href="https://ghostfol.io/blog"><strong>Blog</strong></a> | <a href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"><strong>Slack</strong></a> | <a href="https://twitter.com/ghostfolio_"><strong>Twitter</strong></a>
@ -24,10 +24,11 @@
</p>
</div>
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of their wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
**Ghostfolio** is an open source wealth management software built with web technology. The application empowers busy people to keep track of stocks, ETFs or cryptocurrencies and make solid, data-driven investment decisions.
<div align="center">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<a href="https://www.youtube.com/watch?v=yY6ObSQVJZk">
<img src="./apps/client/src/assets/images/video-preview.jpg" width="600"></a>
</div>
## Ghostfolio Premium
@ -62,6 +63,10 @@ Ghostfolio is for you if you are...
- ✅ Zen Mode
- ✅ Mobile-first design
<div align="center" style="margin-top: 1rem; margin-bottom: 1rem;">
<img src="./apps/client/src/assets/images/screenshot.png" width="300">
</div>
## Technology Stack
Ghostfolio is a modern web application written in [TypeScript](https://www.typescriptlang.org) and organized as an [Nx](https://nx.dev) workspace.
@ -128,6 +133,10 @@ Open http://localhost:3333 in your browser and accomplish these steps:
1. Run the following command to start the new Docker image: `docker-compose -f docker/docker-compose.yml up -d`
1. Then, run the following command to keep your database schema in sync: `docker-compose -f docker/docker-compose.yml exec ghostfolio yarn database:migrate`
## Run with _Unraid_ (self-hosting)
Please follow the instructions of the Ghostfolio [Unraid Community App](https://unraid.net/community/apps?q=ghostfolio).
## Development
### Prerequisites

View File

@ -4,6 +4,7 @@ import { Filter } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import { Account, Order, Platform, Prisma } from '@prisma/client';
import Big from 'big.js';
import { groupBy } from 'lodash';
import { CashDetails } from './interfaces/cash-details.interface';
@ -116,15 +117,19 @@ export class AccountService {
const where: Prisma.AccountWhereInput = { userId };
if (filters?.length > 0) {
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount?.length > 0) {
where.id = {
in: filters
.filter(({ type }) => {
return type === 'account';
})
.map(({ id }) => {
return id;
})
in: filtersByAccount.map(({ id }) => {
return id;
})
};
}

View File

@ -6,7 +6,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import {
AdminData,
AdminMarketData,
@ -20,6 +20,8 @@ import { differenceInDays } from 'date-fns';
@Injectable()
export class AdminService {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataGatheringService: DataGatheringService,
@ -29,7 +31,9 @@ export class AdminService {
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly symbolProfileService: SymbolProfileService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async deleteProfileData({ dataSource, symbol }: UniqueAsset) {
await this.marketDataService.deleteMany({ dataSource, symbol });
@ -43,15 +47,15 @@ export class AdminService {
exchangeRates: this.exchangeRateDataService
.getCurrencies()
.filter((currency) => {
return currency !== baseCurrency;
return currency !== this.baseCurrency;
})
.map((currency) => {
return {
label1: baseCurrency,
label1: this.baseCurrency,
label2: currency,
value: this.exchangeRateDataService.toCurrency(
1,
baseCurrency,
this.baseCurrency,
currency
)
};

View File

@ -20,6 +20,7 @@ import { AccountModule } from './account/account.module';
import { AdminModule } from './admin/admin.module';
import { AppController } from './app.controller';
import { AuthModule } from './auth/auth.module';
import { BenchmarkModule } from './benchmark/benchmark.module';
import { CacheModule } from './cache/cache.module';
import { ExportModule } from './export/export.module';
import { ImportModule } from './import/import.module';
@ -37,6 +38,7 @@ import { UserModule } from './user/user.module';
AccountModule,
AuthDeviceModule,
AuthModule,
BenchmarkModule,
BullModule.forRoot({
redis: {
host: process.env.REDIS_HOST,

View File

@ -0,0 +1,32 @@
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 { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Controller, Get, UseGuards, UseInterceptors } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { BenchmarkService } from './benchmark.service';
@Controller('benchmark')
export class BenchmarkController {
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly propertyService: PropertyService
) {}
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(TransformDataSourceInRequestInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getBenchmark(): Promise<BenchmarkResponse> {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
return {
benchmarks: await this.benchmarkService.getBenchmarks(benchmarkAssets)
};
}
}

View File

@ -0,0 +1,24 @@
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
import { Module } from '@nestjs/common';
import { BenchmarkController } from './benchmark.controller';
import { BenchmarkService } from './benchmark.service';
@Module({
controllers: [BenchmarkController],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

View File

@ -0,0 +1,77 @@
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable } from '@nestjs/common';
import Big from 'big.js';
@Injectable()
export class BenchmarkService {
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
public constructor(
private readonly dataProviderService: DataProviderService,
private readonly marketDataService: MarketDataService,
private readonly redisCacheService: RedisCacheService,
private readonly symbolProfileService: SymbolProfileService
) {}
public async getBenchmarks(
benchmarkAssets: UniqueAsset[]
): Promise<BenchmarkResponse['benchmarks']> {
let benchmarks: BenchmarkResponse['benchmarks'];
try {
benchmarks = JSON.parse(
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
);
if (benchmarks) {
return benchmarks;
}
} catch {}
const promises: Promise<number>[] = [];
const [quotes, assetProfiles] = await Promise.all([
this.dataProviderService.getQuotes(benchmarkAssets),
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
]);
for (const benchmarkAsset of benchmarkAssets) {
promises.push(this.marketDataService.getMax(benchmarkAsset));
}
const allTimeHighs = await Promise.all(promises);
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
const performancePercentFromAllTimeHigh = new Big(marketPrice)
.div(allTimeHigh)
.minus(1);
return {
name: assetProfiles.find(({ dataSource, symbol }) => {
return (
dataSource === benchmarkAssets[index].dataSource &&
symbol === benchmarkAssets[index].symbol
);
})?.name,
performances: {
allTimeHigh: {
performancePercent: performancePercentFromAllTimeHigh.toNumber()
}
}
};
});
await this.redisCacheService.set(
this.CACHE_KEY_BENCHMARKS,
JSON.stringify(benchmarks)
);
return benchmarks;
}
}

View File

@ -103,6 +103,7 @@ export class InfoService {
isReadOnlyMode,
platforms,
systemMessage,
baseCurrency: this.configurationService.get('BASE_CURRENCY'),
currencies: this.exchangeRateDataService.getCurrencies(),
demoAuthToken: this.getDemoAuthToken(),
lastDataGathering: await this.getLastDataGathering(),

View File

@ -188,12 +188,13 @@ export class OrderService {
}): Promise<Activity[]> {
const where: Prisma.OrderWhereInput = { userId };
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount?.length > 0) {
where.accountId = {
@ -207,6 +208,34 @@ export class OrderService {
where.isDraft = false;
}
if (filtersByAssetClass?.length > 0) {
where.SymbolProfile = {
OR: [
{
AND: [
{
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
},
{
SymbolProfileOverrides: {
is: null
}
}
]
},
{
SymbolProfileOverrides: {
OR: filtersByAssetClass.map(({ id }) => {
return { assetClass: AssetClass[id] };
})
}
}
]
};
}
if (filtersByTag?.length > 0) {
where.tags = {
some: {

View File

@ -1,6 +1,7 @@
import { parseDate, resetHours } from '@ghostfolio/common/helper';
import { addDays, endOfDay, isBefore, isSameDay } from 'date-fns';
import { GetValueObject } from './interfaces/get-value-object.interface';
import { GetValuesParams } from './interfaces/get-values-params.interface';
function mockGetValue(symbol: string, date: Date) {
@ -33,8 +34,11 @@ function mockGetValue(symbol: string, date: Date) {
}
export const CurrentRateServiceMock = {
getValues: ({ dataGatheringItems, dateQuery }: GetValuesParams) => {
const result = [];
getValues: ({
dataGatheringItems,
dateQuery
}: GetValuesParams): Promise<GetValueObject[]> => {
const result: GetValueObject[] = [];
if (dateQuery.lt) {
for (
let date = resetHours(dateQuery.gte);
@ -44,8 +48,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol
});
}
@ -55,8 +61,10 @@ export const CurrentRateServiceMock = {
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date,
marketPrice: mockGetValue(dataGatheringItem.symbol, date)
.marketPrice,
marketPriceInBaseCurrency: mockGetValue(
dataGatheringItem.symbol,
date
).marketPrice,
symbol: dataGatheringItem.symbol
});
}

View File

@ -4,6 +4,7 @@ import { MarketDataService } from '@ghostfolio/api/services/market-data.service'
import { DataSource, MarketData } from '@prisma/client';
import { CurrentRateService } from './current-rate.service';
import { GetValueObject } from './interfaces/get-value-object.interface';
jest.mock('@ghostfolio/api/services/market-data.service', () => {
return {
@ -73,7 +74,12 @@ describe('CurrentRateService', () => {
beforeAll(async () => {
dataProviderService = new DataProviderService(null, [], null);
exchangeRateDataService = new ExchangeRateDataService(null, null, null);
exchangeRateDataService = new ExchangeRateDataService(
null,
null,
null,
null
);
marketDataService = new MarketDataService(null);
await exchangeRateDataService.initialize();
@ -96,15 +102,15 @@ describe('CurrentRateService', () => {
},
userCurrency: 'CHF'
})
).toMatchObject([
).toMatchObject<GetValueObject[]>([
{
date: undefined,
marketPrice: 1841.823902,
marketPriceInBaseCurrency: 1841.823902,
symbol: 'AMZN'
},
{
date: undefined,
marketPrice: 1847.839966,
marketPriceInBaseCurrency: 1847.839966,
symbol: 'AMZN'
}
]);

View File

@ -28,13 +28,7 @@ export class CurrentRateService {
(!dateQuery.gte || isBefore(dateQuery.gte, new Date())) &&
(!dateQuery.in || this.containsToday(dateQuery.in));
const promises: Promise<
{
date: Date;
marketPrice: number;
symbol: string;
}[]
>[] = [];
const promises: Promise<GetValueObject[]>[] = [];
if (includeToday) {
const today = resetHours(new Date());
@ -42,16 +36,17 @@ export class CurrentRateService {
this.dataProviderService
.getQuotes(dataGatheringItems)
.then((dataResultProvider) => {
const result = [];
const result: GetValueObject[] = [];
for (const dataGatheringItem of dataGatheringItems) {
result.push({
date: today,
marketPrice: this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]?.marketPrice ??
0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
dataResultProvider?.[dataGatheringItem.symbol]
?.marketPrice ?? 0,
dataResultProvider?.[dataGatheringItem.symbol]?.currency,
userCurrency
),
symbol: dataGatheringItem.symbol
});
}
@ -74,11 +69,12 @@ export class CurrentRateService {
return data.map((marketDataItem) => {
return {
date: marketDataItem.date,
marketPrice: this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
marketPriceInBaseCurrency:
this.exchangeRateDataService.toCurrency(
marketDataItem.marketPrice,
currencies[marketDataItem.symbol],
userCurrency
),
symbol: marketDataItem.symbol
};
});

View File

@ -1,5 +1,5 @@
export interface GetValueObject {
date: Date;
marketPrice: number;
marketPriceInBaseCurrency: number;
symbol: string;
}

View File

@ -1,5 +1,7 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import {
EnhancedSymbolProfile,
HistoricalDataItem
} from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { Tag } from '@prisma/client';

View File

@ -231,9 +231,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
marketSymbol.marketPriceInBaseCurrency
);
}
}
@ -548,9 +548,9 @@ export class PortfolioCalculator {
if (!marketSymbolMap[date]) {
marketSymbolMap[date] = {};
}
if (marketSymbol.marketPrice) {
if (marketSymbol.marketPriceInBaseCurrency) {
marketSymbolMap[date][marketSymbol.symbol] = new Big(
marketSymbol.marketPrice
marketSymbol.marketPriceInBaseCurrency
);
}
}

View File

@ -8,7 +8,6 @@ import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interce
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { parseDate } from '@ghostfolio/common/helper';
import {
Filter,
@ -43,6 +42,8 @@ import { PortfolioService } from './portfolio.service';
@Controller('portfolio')
export class PortfolioController {
private baseCurrency: string;
public constructor(
private readonly accessService: AccessService,
private readonly configurationService: ConfigurationService,
@ -50,7 +51,9 @@ export class PortfolioController {
private readonly portfolioService: PortfolioService,
@Inject(REQUEST) private readonly request: RequestWithUser,
private readonly userService: UserService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
@Get('chart')
@UseGuards(AuthGuard('jwt'))
@ -107,25 +110,33 @@ export class PortfolioController {
public async getDetails(
@Headers('impersonation-id') impersonationId: string,
@Query('accounts') filterByAccounts?: string,
@Query('assetClasses') filterByAssetClasses?: string,
@Query('range') range?: DateRange,
@Query('tags') filterByTags?: string
): Promise<PortfolioDetails & { hasError: boolean }> {
let hasError = false;
const accountIds = filterByAccounts?.split(',') ?? [];
const assetClasses = filterByAssetClasses?.split(',') ?? [];
const tagIds = filterByTags?.split(',') ?? [];
const filters: Filter[] = [
...accountIds.map((accountId) => {
return <Filter>{
id: accountId,
type: 'account'
type: 'ACCOUNT'
};
}),
...assetClasses.map((assetClass) => {
return <Filter>{
id: assetClass,
type: 'ASSET_CLASS'
};
}),
...tagIds.map((tagId) => {
return <Filter>{
id: tagId,
type: 'tag'
type: 'TAG'
};
})
];
@ -319,7 +330,7 @@ export class PortfolioController {
return this.exchangeRateDataService.toCurrency(
portfolioPosition.quantity * portfolioPosition.marketPrice,
portfolioPosition.currency,
this.request.user?.Settings?.currency ?? baseCurrency
this.request.user?.Settings?.currency ?? this.baseCurrency
);
})
.reduce((a, b) => a + b, 0);

View File

@ -15,19 +15,19 @@ import { CurrencyClusterRiskBaseCurrencyInitialInvestment } from '@ghostfolio/ap
import { CurrencyClusterRiskCurrentInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/current-investment';
import { CurrencyClusterRiskInitialInvestment } from '@ghostfolio/api/models/rules/currency-cluster-risk/initial-investment';
import { FeeRatioInitialInvestment } from '@ghostfolio/api/models/rules/fees/fee-ratio-initial-investment';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
import {
ASSET_SUB_CLASS_EMERGENCY_FUND,
UNKNOWN_KEY,
baseCurrency
UNKNOWN_KEY
} from '@ghostfolio/common/config';
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
import {
Accounts,
EnhancedSymbolProfile,
Filter,
HistoricalDataItem,
PortfolioDetails,
@ -82,8 +82,11 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
@Injectable()
export class PortfolioService {
private baseCurrency: string;
public constructor(
private readonly accountService: AccountService,
private readonly configurationService: ConfigurationService,
private readonly currentRateService: CurrentRateService,
private readonly dataProviderService: DataProviderService,
private readonly exchangeRateDataService: ExchangeRateDataService,
@ -93,7 +96,9 @@ export class PortfolioService {
private readonly rulesService: RulesService,
private readonly symbolProfileService: SymbolProfileService,
private readonly userService: UserService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getAccounts(aUserId: string): Promise<AccountWithValue[]> {
const [accounts, details] = await Promise.all([
@ -320,7 +325,7 @@ export class PortfolioService {
const userCurrency =
user.Settings?.currency ??
this.request.user?.Settings?.currency ??
baseCurrency;
this.baseCurrency;
const { orders, portfolioOrders, transactionPoints } =
await this.getTransactionPoints({
@ -370,7 +375,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItems),
this.symbolProfileService.getSymbolProfiles(symbols)
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -441,7 +446,12 @@ export class PortfolioService {
};
}
if (aFilters?.length === 0) {
if (
aFilters?.length === 0 ||
(aFilters?.length === 1 &&
aFilters[0].type === 'ASSET_CLASS' &&
aFilters[0].id === 'CASH')
) {
const cashPositions = await this.getCashPositions({
cashDetails,
emergencyFund,
@ -457,8 +467,9 @@ export class PortfolioService {
const accounts = await this.getValueOfAccounts({
orders,
userId,
portfolioItemsNow,
userCurrency,
userId,
filters: aFilters
});
@ -507,9 +518,8 @@ export class PortfolioService {
}
const positionCurrency = orders[0].SymbolProfile.currency;
const [SymbolProfile] = await this.symbolProfileService.getSymbolProfiles([
aSymbol
]);
const [SymbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([aSymbol]);
const portfolioOrders: PortfolioOrder[] = orders
.filter((order) => {
@ -757,7 +767,7 @@ export class PortfolioService {
const [dataProviderResponses, symbolProfiles] = await Promise.all([
this.dataProviderService.getQuotes(dataGatheringItem),
this.symbolProfileService.getSymbolProfiles(symbols)
this.symbolProfileService.getSymbolProfilesBySymbols(symbols)
]);
const symbolProfileMap: { [symbol: string]: EnhancedSymbolProfile } = {};
@ -894,7 +904,8 @@ export class PortfolioService {
const accounts = await this.getValueOfAccounts({
orders,
portfolioItemsNow,
userId
userId,
userCurrency: currency
});
return {
rules: {
@ -1206,7 +1217,8 @@ export class PortfolioService {
orders: OrderWithAccount[];
portfolioOrders: PortfolioOrder[];
}> {
const userCurrency = this.request.user?.Settings?.currency ?? baseCurrency;
const userCurrency =
this.request.user?.Settings?.currency ?? this.baseCurrency;
const orders = await this.orderService.getOrders({
filters,
@ -1263,11 +1275,13 @@ export class PortfolioService {
filters = [],
orders,
portfolioItemsNow,
userCurrency,
userId
}: {
filters?: Filter[];
orders: OrderWithAccount[];
portfolioItemsNow: { [p: string]: TimelinePosition };
userCurrency: string;
userId: string;
}) {
const accounts: PortfolioDetails['accounts'] = {};
@ -1296,34 +1310,47 @@ export class PortfolioService {
accounts[account.id] = {
balance: account.balance,
currency: account.currency,
current: account.balance,
current: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
),
name: account.name,
original: account.balance
original: this.exchangeRateDataService.toCurrency(
account.balance,
account.currency,
userCurrency
)
};
for (const order of ordersByAccount) {
let currentValueOfSymbol =
let currentValueOfSymbolInBaseCurrency =
order.quantity *
portfolioItemsNow[order.SymbolProfile.symbol].marketPrice;
let originalValueOfSymbol = order.quantity * order.unitPrice;
let originalValueOfSymbolInBaseCurrency =
this.exchangeRateDataService.toCurrency(
order.quantity * order.unitPrice,
order.SymbolProfile.currency,
userCurrency
);
if (order.type === 'SELL') {
currentValueOfSymbol *= -1;
originalValueOfSymbol *= -1;
currentValueOfSymbolInBaseCurrency *= -1;
originalValueOfSymbolInBaseCurrency *= -1;
}
if (accounts[order.Account?.id || UNKNOWN_KEY]?.current) {
accounts[order.Account?.id || UNKNOWN_KEY].current +=
currentValueOfSymbol;
currentValueOfSymbolInBaseCurrency;
accounts[order.Account?.id || UNKNOWN_KEY].original +=
originalValueOfSymbol;
originalValueOfSymbolInBaseCurrency;
} else {
accounts[order.Account?.id || UNKNOWN_KEY] = {
balance: 0,
currency: order.Account?.currency,
current: currentValueOfSymbol,
current: currentValueOfSymbolInBaseCurrency,
name: account.name,
original: originalValueOfSymbol
original: originalValueOfSymbolInBaseCurrency
};
}
}

View File

@ -1,9 +1,7 @@
import { HistoricalDataItem } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
import { HistoricalDataItem, UniqueAsset } from '@ghostfolio/common/interfaces';
export interface SymbolItem {
export interface SymbolItem extends UniqueAsset {
currency: string;
dataSource: DataSource;
historicalData: HistoricalDataItem[];
marketPrice: number;
}

View File

@ -55,7 +55,8 @@ export class SymbolService {
currency,
historicalData,
marketPrice,
dataSource: dataGatheringItem.dataSource
dataSource: dataGatheringItem.dataSource,
symbol: dataGatheringItem.symbol
};
}

View File

@ -3,11 +3,7 @@ import { ConfigurationService } from '@ghostfolio/api/services/configuration.ser
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import { TagService } from '@ghostfolio/api/services/tag/tag.service';
import {
PROPERTY_IS_READ_ONLY_MODE,
baseCurrency,
locale
} from '@ghostfolio/common/config';
import { PROPERTY_IS_READ_ONLY_MODE, locale } from '@ghostfolio/common/config';
import { User as IUser, UserWithSettings } from '@ghostfolio/common/interfaces';
import {
getPermissions,
@ -26,13 +22,17 @@ const crypto = require('crypto');
export class UserService {
public static DEFAULT_CURRENCY = 'USD';
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService,
private readonly subscriptionService: SubscriptionService,
private readonly tagService: TagService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public async getUser(
{
@ -102,19 +102,69 @@ export class UserService {
public async user(
userWhereUniqueInput: Prisma.UserWhereUniqueInput
): Promise<UserWithSettings | null> {
const userFromDatabase = await this.prismaService.user.findUnique({
const {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
Subscription,
thirdPartyId,
updatedAt
} = await this.prismaService.user.findUnique({
include: { Account: true, Settings: true, Subscription: true },
where: userWhereUniqueInput
});
const user: UserWithSettings = userFromDatabase;
const user: UserWithSettings = {
accessToken,
Account,
alias,
authChallenge,
createdAt,
id,
provider,
role,
Settings,
thirdPartyId,
updatedAt
};
let currentPermissions = getPermissions(userFromDatabase.role);
if (user?.Settings) {
if (!user.Settings.currency) {
// Set default currency if needed
user.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (user) {
// Set default settings if needed
user.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: user?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription =
this.subscriptionService.getSubscription(Subscription);
}
let currentPermissions = getPermissions(user.role);
if (this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX')) {
currentPermissions.push(permissions.accessFearAndGreedIndex);
}
if (user.subscription?.type === 'Premium') {
currentPermissions.push(permissions.reportDataGlitch);
}
if (this.configurationService.get('ENABLE_FEATURE_READ_ONLY_MODE')) {
if (hasRole(user, Role.ADMIN)) {
currentPermissions.push(permissions.toggleReadOnlyMode);
@ -135,29 +185,7 @@ export class UserService {
}
}
user.permissions = currentPermissions;
if (userFromDatabase?.Settings) {
if (!userFromDatabase.Settings.currency) {
// Set default currency if needed
userFromDatabase.Settings.currency = UserService.DEFAULT_CURRENCY;
}
} else if (userFromDatabase) {
// Set default settings if needed
userFromDatabase.Settings = {
currency: UserService.DEFAULT_CURRENCY,
settings: null,
updatedAt: new Date(),
userId: userFromDatabase?.id,
viewMode: ViewMode.DEFAULT
};
}
if (this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION')) {
user.subscription = this.subscriptionService.getSubscription(
userFromDatabase?.Subscription
);
}
user.permissions = currentPermissions.sort();
return user;
}
@ -196,14 +224,14 @@ export class UserService {
...data,
Account: {
create: {
currency: baseCurrency,
currency: this.baseCurrency,
isDefault: true,
name: 'Default Account'
}
},
Settings: {
create: {
currency: baseCurrency
currency: this.baseCurrency
}
}
}

View File

@ -12,6 +12,7 @@ export class ConfigurationService {
this.environmentConfiguration = cleanEnv(process.env, {
ACCESS_TOKEN_SALT: str(),
ALPHA_VANTAGE_API_KEY: str({ default: '' }),
BASE_CURRENCY: str({ default: 'USD' }),
CACHE_TTL: num({ default: 1 }),
DATA_SOURCE_PRIMARY: str({ default: DataSource.YAHOO }),
DATA_SOURCES: json({ default: JSON.stringify([DataSource.YAHOO]) }),

View File

@ -247,11 +247,12 @@ export class DataGatheringService {
const assetProfiles = await this.dataProviderService.getAssetProfiles(
uniqueAssets
);
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(
uniqueAssets.map(({ symbol }) => {
return symbol;
})
);
for (const [symbol, assetProfile] of Object.entries(assetProfiles)) {
const symbolMapping = symbolProfiles.find((symbolProfile) => {

View File

@ -32,7 +32,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
return response;
}
const holdings = await getJSON(
const result = await getJSON(
`${TrackinsightDataEnhancerService.baseUrl}/${symbol}.json`
).catch(() => {
return getJSON(
@ -42,12 +42,17 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
);
});
if (result.weight < 0.95) {
// Skip if data is inaccurate
return response;
}
if (
!response.countries ||
(response.countries as unknown as Country[]).length === 0
) {
response.countries = [];
for (const [name, value] of Object.entries<any>(holdings.countries)) {
for (const [name, value] of Object.entries<any>(result.countries)) {
let countryCode: string;
for (const [key, country] of Object.entries<any>(
@ -75,7 +80,7 @@ export class TrackinsightDataEnhancerService implements DataEnhancerInterface {
(response.sectors as unknown as Sector[]).length === 0
) {
response.sectors = [];
for (const [name, value] of Object.entries<any>(holdings.sectors)) {
for (const [name, value] of Object.entries<any>(result.sectors)) {
response.sectors.push({
name: TrackinsightDataEnhancerService.sectorsMapping[name] ?? name,
weight: value.weight

View File

@ -46,9 +46,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
try {
const symbol = aSymbol;
const [symbolProfile] = await this.symbolProfileService.getSymbolProfiles(
[symbol]
);
const [symbolProfile] =
await this.symbolProfileService.getSymbolProfilesBySymbols([symbol]);
const { defaultMarketPrice, selector, url } =
symbolProfile.scraperConfiguration;
@ -108,9 +107,8 @@ export class GhostfolioScraperApiService implements DataProviderInterface {
}
try {
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const marketData = await this.prismaService.marketData.findMany({
distinct: ['symbol'],

View File

@ -91,9 +91,8 @@ export class GoogleSheetsService implements DataProviderInterface {
try {
const response: { [symbol: string]: IDataProviderResponse } = {};
const symbolProfiles = await this.symbolProfileService.getSymbolProfiles(
aSymbols
);
const symbolProfiles =
await this.symbolProfileService.getSymbolProfilesBySymbols(aSymbols);
const sheet = await this.getSheet({
sheetId: this.configurationService.get('GOOGLE_SHEETS_ID'),

View File

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { YahooFinanceService } from './yahoo-finance.service';
@ -25,13 +26,18 @@ jest.mock(
);
describe('YahooFinanceService', () => {
let configurationService: ConfigurationService;
let cryptocurrencyService: CryptocurrencyService;
let yahooFinanceService: YahooFinanceService;
beforeAll(async () => {
configurationService = new ConfigurationService();
cryptocurrencyService = new CryptocurrencyService();
yahooFinanceService = new YahooFinanceService(cryptocurrencyService);
yahooFinanceService = new YahooFinanceService(
configurationService,
cryptocurrencyService
);
});
it('convertFromYahooFinanceSymbol', async () => {

View File

@ -1,11 +1,11 @@
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { CryptocurrencyService } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.service';
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
import {
IDataProviderHistoricalResponse,
IDataProviderResponse
} from '@ghostfolio/api/services/interfaces/interfaces';
import { baseCurrency } from '@ghostfolio/common/config';
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
import { Granularity } from '@ghostfolio/common/types';
import { Injectable, Logger } from '@nestjs/common';
@ -23,9 +23,14 @@ import type { Price } from 'yahoo-finance2/dist/esm/src/modules/quoteSummary-ifa
@Injectable()
export class YahooFinanceService implements DataProviderInterface {
private baseCurrency: string;
public constructor(
private readonly configurationService: ConfigurationService,
private readonly cryptocurrencyService: CryptocurrencyService
) {}
) {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
}
public canHandle(symbol: string) {
return true;
@ -33,8 +38,8 @@ export class YahooFinanceService implements DataProviderInterface {
public convertFromYahooFinanceSymbol(aYahooFinanceSymbol: string) {
const symbol = aYahooFinanceSymbol.replace(
new RegExp(`-${baseCurrency}$`),
baseCurrency
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
);
return symbol.replace('=X', '');
}
@ -47,12 +52,15 @@ export class YahooFinanceService implements DataProviderInterface {
* DOGEUSD -> DOGE-USD
*/
public convertToYahooFinanceSymbol(aSymbol: string) {
if (aSymbol.includes(baseCurrency) && aSymbol.length >= 6) {
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
return `${aSymbol}=X`;
} else if (
this.cryptocurrencyService.isCryptocurrency(
aSymbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
aSymbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)
) {
// Add a dash before the last three characters
@ -60,8 +68,8 @@ export class YahooFinanceService implements DataProviderInterface {
// DOGEUSD -> DOGE-USD
// SOL1USD -> SOL1-USD
return aSymbol.replace(
new RegExp(`-?${baseCurrency}$`),
`-${baseCurrency}`
new RegExp(`-?${this.baseCurrency}$`),
`-${this.baseCurrency}`
);
}
}
@ -255,7 +263,10 @@ export class YahooFinanceService implements DataProviderInterface {
return (
(quoteType === 'CRYPTOCURRENCY' &&
this.cryptocurrencyService.isCryptocurrency(
symbol.replace(new RegExp(`-${baseCurrency}$`), baseCurrency)
symbol.replace(
new RegExp(`-${this.baseCurrency}$`),
this.baseCurrency
)
)) ||
['EQUITY', 'ETF', 'FUTURE', 'MUTUALFUND'].includes(quoteType)
);
@ -264,7 +275,7 @@ export class YahooFinanceService implements DataProviderInterface {
if (quoteType === 'CRYPTOCURRENCY') {
// Only allow cryptocurrencies in base currency to avoid having redundancy in the database.
// Transactions need to be converted manually to the base currency before
return symbol.includes(baseCurrency);
return symbol.includes(this.baseCurrency);
} else if (quoteType === 'FUTURE') {
// Allow GC=F, but not MGC=F
return symbol.length === 4;

View File

@ -1,12 +1,18 @@
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { Module } from '@nestjs/common';
import { PrismaModule } from './prisma.module';
import { PropertyModule } from './property/property.module';
@Module({
imports: [DataProviderModule, PrismaModule, PropertyModule],
imports: [
ConfigurationModule,
DataProviderModule,
PrismaModule,
PropertyModule
],
providers: [ExchangeRateDataService],
exports: [ExchangeRateDataService]
})

View File

@ -1,9 +1,10 @@
import { PROPERTY_CURRENCIES, baseCurrency } from '@ghostfolio/common/config';
import { PROPERTY_CURRENCIES } from '@ghostfolio/common/config';
import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
import { Injectable, Logger } from '@nestjs/common';
import { format } from 'date-fns';
import { isNumber, uniq } from 'lodash';
import { ConfigurationService } from './configuration.service';
import { DataProviderService } from './data-provider/data-provider.service';
import { IDataGatheringItem } from './interfaces/interfaces';
import { PrismaService } from './prisma.service';
@ -11,11 +12,13 @@ import { PropertyService } from './property/property.service';
@Injectable()
export class ExchangeRateDataService {
private baseCurrency: string;
private currencies: string[] = [];
private currencyPairs: IDataGatheringItem[] = [];
private exchangeRates: { [currencyPair: string]: number } = {};
public constructor(
private readonly configurationService: ConfigurationService,
private readonly dataProviderService: DataProviderService,
private readonly prismaService: PrismaService,
private readonly propertyService: PropertyService
@ -24,7 +27,7 @@ export class ExchangeRateDataService {
}
public getCurrencies() {
return this.currencies?.length > 0 ? this.currencies : [baseCurrency];
return this.currencies?.length > 0 ? this.currencies : [this.baseCurrency];
}
public getCurrencyPairs() {
@ -32,6 +35,7 @@ export class ExchangeRateDataService {
}
public async initialize() {
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
this.currencies = await this.prepareCurrencies();
this.currencyPairs = [];
this.exchangeRates = {};
@ -212,14 +216,14 @@ export class ExchangeRateDataService {
private prepareCurrencyPairs(aCurrencies: string[]) {
return aCurrencies
.filter((currency) => {
return currency !== baseCurrency;
return currency !== this.baseCurrency;
})
.map((currency) => {
return {
currency1: baseCurrency,
currency1: this.baseCurrency,
currency2: currency,
dataSource: this.dataProviderService.getPrimaryDataSource(),
symbol: `${baseCurrency}${currency}`
symbol: `${this.baseCurrency}${currency}`
};
});
}

View File

@ -3,6 +3,7 @@ import { CleanedEnvAccessors } from 'envalid';
export interface Environment extends CleanedEnvAccessors {
ACCESS_TOKEN_SALT: string;
ALPHA_VANTAGE_API_KEY: string;
BASE_CURRENCY: string;
CACHE_TTL: number;
DATA_SOURCE_PRIMARY: string;
DATA_SOURCES: string | string[]; // string is not correct, error in envalid?

View File

@ -34,6 +34,20 @@ export class MarketDataService {
});
}
public async getMax({ dataSource, symbol }: UniqueAsset): Promise<number> {
const aggregations = await this.prismaService.marketData.aggregate({
_max: {
marketPrice: true
},
where: {
dataSource,
symbol
}
});
return aggregations._max.marketPrice;
}
public async getRange({
dateQuery,
symbols

View File

@ -1,6 +1,10 @@
import { EnhancedSymbolProfile } from '@ghostfolio/api/services/interfaces/symbol-profile.interface';
import { PrismaService } from '@ghostfolio/api/services/prisma.service';
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
import {
EnhancedSymbolProfile,
ScraperConfiguration,
UniqueAsset
} from '@ghostfolio/common/interfaces';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { Injectable } from '@nestjs/common';
@ -12,8 +16,6 @@ import {
} from '@prisma/client';
import { continents, countries } from 'countries-list';
import { ScraperConfiguration } from './data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
@Injectable()
export class SymbolProfileService {
public constructor(private readonly prismaService: PrismaService) {}
@ -37,6 +39,35 @@ export class SymbolProfileService {
}
public async getSymbolProfiles(
aUniqueAssets: UniqueAsset[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
.findMany({
include: { SymbolProfileOverrides: true },
where: {
AND: [
{
dataSource: {
in: aUniqueAssets.map(({ dataSource }) => {
return dataSource;
})
},
symbol: {
in: aUniqueAssets.map(({ symbol }) => {
return symbol;
})
}
}
]
}
})
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
}
/**
* @deprecated
*/
public async getSymbolProfilesBySymbols(
symbols: string[]
): Promise<EnhancedSymbolProfile[]> {
return this.prismaService.symbolProfile
@ -59,7 +90,9 @@ export class SymbolProfileService {
return symbolProfiles.map((symbolProfile) => {
const item = {
...symbolProfile,
countries: this.getCountries(symbolProfile),
countries: this.getCountries(
symbolProfile?.countries as unknown as Prisma.JsonArray
),
scraperConfiguration: this.getScraperConfiguration(symbolProfile),
sectors: this.getSectors(symbolProfile),
symbolMapping: this.getSymbolMapping(symbolProfile)
@ -70,9 +103,17 @@ export class SymbolProfileService {
item.SymbolProfileOverrides.assetClass ?? item.assetClass;
item.assetSubClass =
item.SymbolProfileOverrides.assetSubClass ?? item.assetSubClass;
item.countries =
(item.SymbolProfileOverrides.sectors as unknown as Country[]) ??
item.countries;
if (
(item.SymbolProfileOverrides.countries as unknown as Prisma.JsonArray)
?.length > 0
) {
item.countries = this.getCountries(
item.SymbolProfileOverrides
?.countries as unknown as Prisma.JsonArray
);
}
item.name = item.SymbolProfileOverrides?.name ?? item.name;
item.sectors =
(item.SymbolProfileOverrides.sectors as unknown as Sector[]) ??
@ -85,20 +126,22 @@ export class SymbolProfileService {
});
}
private getCountries(symbolProfile: SymbolProfile): Country[] {
return ((symbolProfile?.countries as Prisma.JsonArray) ?? []).map(
(country) => {
const { code, weight } = country as Prisma.JsonObject;
private getCountries(aCountries: Prisma.JsonArray = []): Country[] {
if (aCountries === null) {
return [];
}
return {
code: code as string,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY,
weight: weight as number
};
}
);
return aCountries.map((country: Pick<Country, 'code' | 'weight'>) => {
const { code, weight } = country;
return {
code,
weight,
continent:
continents[countries[code as string]?.continent] ?? UNKNOWN_KEY,
name: countries[code as string]?.name ?? UNKNOWN_KEY
};
});
}
private getScraperConfiguration(

View File

@ -1,20 +1,28 @@
import { Platform } from '@angular/cdk/platform';
import { Inject, forwardRef } from '@angular/core';
import { MAT_DATE_LOCALE, NativeDateAdapter } from '@angular/material/core';
import { format, isValid } from 'date-fns';
import * as deDateFnsLocale from 'date-fns/locale/de/index';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { format, parse } from 'date-fns';
export class CustomDateAdapter extends NativeDateAdapter {
/**
* @constructor
*/
public constructor(
@Inject(MAT_DATE_LOCALE) public locale: string,
@Inject(forwardRef(() => MAT_DATE_LOCALE)) matDateLocale: string,
platform: Platform
) {
super(matDateLocale, platform);
}
/**
* Formats a date as a string
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, getDateFormatString(this.locale));
}
/**
* Sets the first day of the week to Monday
*/
@ -22,44 +30,10 @@ export class CustomDateAdapter extends NativeDateAdapter {
return 1;
}
/**
* Formats a date as a string according to the given format
*/
public format(aDate: Date, aParseFormat: string): string {
return format(aDate, aParseFormat, {
locale: <any>deDateFnsLocale
});
}
/**
* Parses a date from a provided value
*/
public parse(aValue: any): Date {
let date: Date;
try {
// TODO
// Native date parser from the following formats:
// - 'd.M.yyyy'
// - 'dd.MM.yyyy'
// https://github.com/you-dont-need/You-Dont-Need-Momentjs#string--date-format
const datePattern = /^(\d{1,2}).(\d{1,2}).(\d{4})$/;
const [, day, month, year] = datePattern.exec(aValue);
date = new Date(
parseInt(year, 10),
parseInt(month, 10) - 1, // monthIndex
parseInt(day, 10)
);
} catch (error) {
} finally {
const isDateValid = date && isValid(date);
if (isDateValid) {
return date;
}
return null;
}
public parse(aValue: string): Date {
return parse(aValue, getDateFormatString(this.locale), new Date());
}
}

View File

@ -200,7 +200,7 @@
</button>
<button
mat-menu-item
[disabled]="element.isDefault || element.Order?.length > 0"
[disabled]="element.isDefault || element.transactionCount > 0"
(click)="onDeleteAccount(element.id)"
>
<ion-icon class="mr-2" name="trash-outline"></ion-icon>

View File

@ -8,11 +8,13 @@ import {
Output
} from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import {
DATE_FORMAT,
getDateFormatString,
getLocale
} from '@ghostfolio/common/helper';
import { User } from '@ghostfolio/common/interfaces';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { DataSource, MarketData } from '@prisma/client';
import {
@ -53,14 +55,24 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
[day: string]: Pick<MarketData, 'date' | 'marketPrice'> & { day: number };
};
} = {};
public user: User;
private unsubscribeSubject = new Subject<void>();
public constructor(
private deviceService: DeviceDetectorService,
private dialog: MatDialog
private dialog: MatDialog,
private userService: UserService
) {
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
this.userService.stateChanged
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((state) => {
if (state?.user) {
this.user = state.user;
}
});
}
public ngOnInit() {}
@ -145,7 +157,8 @@ export class AdminMarketDataDetailComponent implements OnChanges, OnInit {
date,
marketPrice,
dataSource: this.dataSource,
symbol: this.symbol
symbol: this.symbol,
user: this.user
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',
width: this.deviceType === 'mobile' ? '100vw' : '50rem'

View File

@ -1,3 +1,4 @@
import { User } from '@ghostfolio/common/interfaces';
import { DataSource } from '@prisma/client';
export interface MarketDataDetailDialogParams {
@ -5,4 +6,5 @@ export interface MarketDataDetailDialogParams {
date: Date;
marketPrice: number;
symbol: string;
user: User;
}

View File

@ -5,6 +5,7 @@ import {
Inject,
OnDestroy
} from '@angular/core';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { AdminService } from '@ghostfolio/client/services/admin.service';
import { Subject, takeUntil } from 'rxjs';
@ -24,11 +25,16 @@ export class MarketDataDetailDialog implements OnDestroy {
public constructor(
private adminService: AdminService,
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<MarketDataDetailDialog>,
@Inject(MAT_DIALOG_DATA) public data: MarketDataDetailDialogParams
@Inject(MAT_DATE_LOCALE) private locale: string
) {}
public ngOnInit() {}
public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
}
public onCancel(): void {
this.dialogRef.close({ withRefresh: false });

View File

@ -18,6 +18,8 @@ import { DeviceDetectorService } from 'ngx-device-detector';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
@Component({
selector: 'gf-home-holdings',
styleUrls: ['./home-holdings.scss'],
@ -126,12 +128,16 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -4,6 +4,7 @@ import { UserService } from '@ghostfolio/client/services/user/user.service';
import { ghostfolioFearAndGreedIndexSymbol } from '@ghostfolio/common/config';
import { resetHours } from '@ghostfolio/common/helper';
import {
Benchmark,
HistoricalDataItem,
InfoItem,
User
@ -18,6 +19,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './home-market.html'
})
export class HomeMarketComponent implements OnDestroy, OnInit {
public benchmarks: Benchmark[];
public fearAndGreedIndex: number;
public hasPermissionToAccessFearAndGreedIndex: boolean;
public historicalData: HistoricalDataItem[];
@ -73,6 +75,15 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
});
}
this.dataService
.fetchBenchmarks()
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe(({ benchmarks }) => {
this.benchmarks = benchmarks;
this.changeDetectorRef.markForCheck();
});
this.changeDetectorRef.markForCheck();
}
});

View File

@ -1,13 +1,12 @@
<div
class="align-items-center container d-flex flex-grow-1 h-100 justify-content-center w-100"
>
<div class="no-gutters row w-100">
<div class="container">
<h3 class="mb-3 text-center" i18n>Markets</h3>
<div class="mb-5 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<div class="mb-2 text-center text-muted">
<small i18n>Last {{ numberOfDays }} Days</small>
</div>
<gf-line-chart
class="mb-5"
class="mb-3"
yMax="100"
yMaxLabel="Greed"
yMin="0"
@ -23,4 +22,20 @@
></gf-fear-and-greed-index>
</div>
</div>
<div class="mb-3 row">
<div class="col-xs-12 col-md-8 offset-md-2">
<gf-benchmark
*ngFor="let benchmark of benchmarks"
class="py-2"
[benchmark]="benchmark"
[locale]="user?.settings?.locale"
></gf-benchmark>
<gf-benchmark
*ngIf="!benchmarks"
class="py-2"
[benchmark]="undefined"
></gf-benchmark>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { GfFearAndGreedIndexModule } from '@ghostfolio/client/components/fear-and-greed-index/fear-and-greed-index.module';
import { GfBenchmarkModule } from '@ghostfolio/ui/benchmark/benchmark.module';
import { GfLineChartModule } from '@ghostfolio/ui/line-chart/line-chart.module';
import { HomeMarketComponent } from './home-market.component';
@ -8,7 +9,12 @@ import { HomeMarketComponent } from './home-market.component';
@NgModule({
declarations: [HomeMarketComponent],
exports: [],
imports: [CommonModule, GfFearAndGreedIndexModule, GfLineChartModule],
imports: [
CommonModule,
GfBenchmarkModule,
GfFearAndGreedIndexModule,
GfLineChartModule
],
providers: [],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})

View File

@ -5,6 +5,7 @@ export interface PositionDetailDialogParams {
dataSource: DataSource;
deviceType: string;
hasImpersonationId: boolean;
hasPermissionToReportDataGlitch: boolean;
locale: string;
symbol: string;
}

View File

@ -9,9 +9,10 @@ import {
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { DataService } from '@ghostfolio/client/services/data.service';
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
import { OrderWithAccount } from '@ghostfolio/common/types';
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
import { SymbolProfile, Tag } from '@prisma/client';
import { Tag } from '@prisma/client';
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -44,10 +45,11 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public orders: OrderWithAccount[];
public quantity: number;
public quantityPrecision = 2;
public reportDataGlitchMail: string;
public sectors: {
[name: string]: { name: string; value: number };
};
public SymbolProfile: SymbolProfile;
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public transactionCount: number;
public value: number;
@ -91,6 +93,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
this.averagePrice = averagePrice;
this.benchmarkDataItems = [];
this.countries = {};
this.reportDataGlitchMail = `mailto:hi@ghostfol.io?Subject=Ghostfolio Data Glitch Report&body=Hello%0D%0DI would like to report a data glitch for%0D%0DSymbol: ${SymbolProfile?.symbol}%0DData Source: ${SymbolProfile?.dataSource}%0D%0DAdditional notes:%0D%0DCan you please take a look?%0D%0DKind regards`;
this.firstBuyDate = firstBuyDate;
this.grossPerformance = grossPerformance;
this.grossPerformancePercent = grossPerformancePercent;

View File

@ -214,13 +214,26 @@
</div>
<div *ngIf="tags?.length > 0" class="row">
<div class="col">
<div class="col mb-3">
<div class="h5" i18n>Tags</div>
<mat-chip-list>
<mat-chip *ngFor="let tag of tags">{{ tag.name }}</mat-chip>
</mat-chip-list>
</div>
</div>
<div
*ngIf="data.hasPermissionToReportDataGlitch === true && orders?.length > 0"
class="row"
>
<div class="col mb-3">
<hr />
<a color="warn" mat-stroked-button [href]="reportDataGlitchMail"
><ion-icon class="mr-1" name="flag-outline"></ion-icon
><span i18n>Report Data Glitch</span></a
>
</div>
</div>
</div>
</div>

View File

@ -1,7 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -17,7 +16,6 @@ import { environment } from '../../../environments/environment';
templateUrl: './about-page.html'
})
export class AboutPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public hasPermissionForBlog: boolean;
public hasPermissionForStatistics: boolean;
public hasPermissionForSubscription: boolean;

View File

@ -20,7 +20,6 @@ import { CreateAccessDto } from '@ghostfolio/api/app/access/create-access.dto';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { WebAuthnService } from '@ghostfolio/client/services/web-authn.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { getDateFormatString } from '@ghostfolio/common/helper';
import { Access, User } from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
@ -43,7 +42,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
signInWithFingerprintElement: MatSlideToggle;
public accesses: Access[];
public baseCurrency = baseCurrency;
public baseCurrency: string;
public coupon: number;
public couponId: string;
public currencies: string[] = [];
@ -79,8 +78,10 @@ export class AccountPageComponent implements OnDestroy, OnInit {
private userService: UserService,
public webAuthnService: WebAuthnService
) {
const { currencies, globalPermissions, subscriptions } =
const { baseCurrency, currencies, globalPermissions, subscriptions } =
this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = subscriptions?.[0]?.coupon;
this.couponId = subscriptions?.[0]?.couponId;
this.currencies = currencies;

View File

@ -47,9 +47,21 @@
<strong>personal investment strategy</strong>.
</h2>
<p class="lead">
<strong>Ghostfolio</strong> empowers busy people to keep track of their
wealth like stocks, ETFs or cryptocurrencies and make solid, data-driven
investment decisions.
<strong>Ghostfolio</strong> empowers busy people to keep track of
stocks, ETFs or cryptocurrencies and make solid, data-driven investment
decisions.
</p>
<p>
<a
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
title="Watch the Ghostfol.io Trailer on YouTube"
>
<img
alt="Ghostfol.io Trailer"
src="./assets/images/video-preview.jpg"
style="max-width: 100%; width: 40rem"
/>
</a>
</p>
</div>
</div>

View File

@ -1,6 +1,7 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { MatDialog } from '@angular/material/dialog';
import { ActivatedRoute, Router } from '@angular/router';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
@ -14,6 +15,7 @@ import {
UniqueAsset,
User
} from '@ghostfolio/common/interfaces';
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
import { Market, ToggleOption } from '@ghostfolio/common/types';
import { Account, AssetClass, DataSource } from '@prisma/client';
import { DeviceDetectorService } from 'ngx-device-detector';
@ -82,6 +84,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
public user: User;
private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...';
private unsubscribeSubject = new Subject<void>();
/**
@ -132,6 +135,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
switchMap((filters) => {
this.isLoading = true;
this.activeFilters = filters;
this.placeholder =
this.activeFilters.length <= 0 ? this.SEARCH_PLACEHOLDER : '';
return this.dataService.fetchPortfolioDetails({
filters: this.activeFilters
@ -163,19 +168,32 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
return {
id,
label: name,
type: 'account'
type: 'ACCOUNT'
};
});
const assetClassFilters: Filter[] = [];
for (const assetClass of Object.keys(AssetClass)) {
assetClassFilters.push({
id: assetClass,
label: assetClass,
type: 'ASSET_CLASS'
});
}
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
return {
id,
label: name,
type: 'tag'
type: 'TAG'
};
});
this.allFilters = [...accountFilters, ...tagFilters];
this.allFilters = [
...accountFilters,
...assetClassFilters,
...tagFilters
];
this.changeDetectorRef.markForCheck();
}
@ -404,12 +422,16 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -8,6 +8,7 @@ import {
} from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { MatAutocompleteSelectedEvent } from '@angular/material/autocomplete';
import { DateAdapter, MAT_DATE_LOCALE } from '@angular/material/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
@ -54,13 +55,18 @@ export class CreateOrUpdateTransactionDialog implements OnDestroy {
public constructor(
private changeDetectorRef: ChangeDetectorRef,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams,
private dataService: DataService,
private dateAdapter: DateAdapter<any>,
public dialogRef: MatDialogRef<CreateOrUpdateTransactionDialog>,
private formBuilder: FormBuilder,
@Inject(MAT_DIALOG_DATA) public data: CreateOrUpdateTransactionDialogParams
@Inject(MAT_DATE_LOCALE) private locale: string
) {}
public ngOnInit() {
this.locale = this.data.user?.settings?.locale;
this.dateAdapter.setLocale(this.locale);
const { currencies, platforms } = this.dataService.fetchInfo();
this.currencies = currencies;

View File

@ -1,6 +1,7 @@
<form
class="d-flex flex-column h-100"
[formGroup]="activityForm"
(keyup.enter)="activityForm.valid && onSubmit()"
(ngSubmit)="onSubmit()"
>
<h1 *ngIf="data.activity.id" mat-dialog-title i18n>Update activity</h1>

View File

@ -5,6 +5,7 @@ import { ActivatedRoute, Router } from '@angular/router';
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PositionDetailDialogParams } from '@ghostfolio/client/components/position/position-detail-dialog/interfaces/interfaces';
import { PositionDetailDialog } from '@ghostfolio/client/components/position/position-detail-dialog/position-detail-dialog.component';
import { DataService } from '@ghostfolio/client/services/data.service';
import { IcsService } from '@ghostfolio/client/services/ics/ics.service';
@ -406,12 +407,16 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
const dialogRef = this.dialog.open(PositionDetailDialog, {
autoFocus: false,
data: {
data: <PositionDetailDialogParams>{
dataSource,
symbol,
baseCurrency: this.user?.settings?.baseCurrency,
deviceType: this.deviceType,
hasImpersonationId: this.hasImpersonationId,
hasPermissionToReportDataGlitch: hasPermission(
this.user?.permissions,
permissions.reportDataGlitch
),
locale: this.user?.settings?.locale
},
height: this.deviceType === 'mobile' ? '97.5vh' : '80vh',

View File

@ -1,7 +1,6 @@
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
import { DataService } from '@ghostfolio/client/services/data.service';
import { UserService } from '@ghostfolio/client/services/user/user.service';
import { baseCurrency } from '@ghostfolio/common/config';
import { User } from '@ghostfolio/common/interfaces';
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -13,7 +12,7 @@ import { takeUntil } from 'rxjs/operators';
templateUrl: './pricing-page.html'
})
export class PricingPageComponent implements OnDestroy, OnInit {
public baseCurrency = baseCurrency;
public baseCurrency: string;
public coupon: number;
public isLoggedIn: boolean;
public price: number;
@ -29,8 +28,9 @@ export class PricingPageComponent implements OnDestroy, OnInit {
private dataService: DataService,
private userService: UserService
) {
const { subscriptions } = this.dataService.fetchInfo();
const { baseCurrency, subscriptions } = this.dataService.fetchInfo();
this.baseCurrency = baseCurrency;
this.coupon = this.price = subscriptions?.[0]?.coupon;
this.price = subscriptions?.[0]?.price;
}

View File

@ -6,6 +6,7 @@ import { UpdateAccountDto } from '@ghostfolio/api/app/account/update-account.dto
import { CreateOrderDto } from '@ghostfolio/api/app/order/create-order.dto';
import { Activities } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import { UpdateOrderDto } from '@ghostfolio/api/app/order/update-order.dto';
import { PortfolioPositionDetail } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-position-detail.interface';
import { PortfolioPositions } from '@ghostfolio/api/app/portfolio/interfaces/portfolio-positions.interface';
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
import { SymbolItem } from '@ghostfolio/api/app/symbol/interfaces/symbol-item.interface';
@ -18,6 +19,7 @@ import {
Accounts,
AdminData,
AdminMarketData,
BenchmarkResponse,
Export,
Filter,
InfoItem,
@ -89,6 +91,10 @@ export class DataService {
return this.http.get<Access[]>('/api/v1/access');
}
public fetchBenchmarks() {
return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
}
public fetchChart({ range }: { range: DateRange }) {
return this.http.get<PortfolioChart>('/api/v1/portfolio/chart', {
params: { range }
@ -187,12 +193,13 @@ export class DataService {
let params = new HttpParams();
if (filters?.length > 0) {
const { account: filtersByAccount, tag: filtersByTag } = groupBy(
filters,
(filter) => {
return filter.type;
}
);
const {
ACCOUNT: filtersByAccount,
ASSET_CLASS: filtersByAssetClass,
TAG: filtersByTag
} = groupBy(filters, (filter) => {
return filter.type;
});
if (filtersByAccount) {
params = params.append(
@ -205,6 +212,17 @@ export class DataService {
);
}
if (filtersByAssetClass) {
params = params.append(
'assetClasses',
filtersByAssetClass
.map(({ id }) => {
return id;
})
.join(',')
);
}
if (filtersByTag) {
params = params.append(
'tags',
@ -261,13 +279,15 @@ export class DataService {
symbol: string;
}) {
return this.http
.get<any>(`/api/v1/portfolio/position/${dataSource}/${symbol}`)
.get<PortfolioPositionDetail>(
`/api/v1/portfolio/position/${dataSource}/${symbol}`
)
.pipe(
map((data) => {
if (data.orders) {
for (const order of data.orders) {
order.createdAt = parseISO(order.createdAt);
order.date = parseISO(order.date);
order.createdAt = parseISO(<string>(<unknown>order.createdAt));
order.date = parseISO(<string>(<unknown>order.date));
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

@ -17,7 +17,7 @@
<meta name="twitter:card" content="summary_large_image" />
<meta
name="twitter:description"
content="Ghostfolio is a lightweight wealth management application for individuals to keep track of their wealth like stocks, ETFs or cryptocurrencies"
content="Ghostfolio is a lightweight wealth management application for individuals to keep track of stocks, ETFs or cryptocurrencies"
/>
<meta
name="twitter:image"

View File

@ -60,12 +60,8 @@ body {
}
ngx-skeleton-loader {
line-height: 0;
outline: 0;
.loader {
background-color: #323232;
outline: 0;
}
}
@ -117,9 +113,13 @@ ion-icon {
ngx-skeleton-loader {
display: block;
line-height: 0;
outline: 0;
.loader {
display: flex;
margin: 0 !important;
outline: 0;
}
}

View File

@ -2,8 +2,6 @@ import { DataSource } from '@prisma/client';
import { ToggleOption } from './types';
export const baseCurrency = 'USD';
export const defaultDateRangeOptions: ToggleOption[] = [
{ label: 'Today', value: '1d' },
{ label: 'YTD', value: 'ytd' },
@ -50,6 +48,7 @@ export const DEFAULT_DATE_FORMAT_MONTH_YEAR = 'MMM yyyy';
export const GATHER_ASSET_PROFILE_PROCESS = 'GATHER_ASSET_PROFILE';
export const PROPERTY_BENCHMARKS = 'BENCHMARKS';
export const PROPERTY_COUPONS = 'COUPONS';
export const PROPERTY_CURRENCIES = 'CURRENCIES';
export const PROPERTY_IS_READ_ONLY_MODE = 'IS_READ_ONLY_MODE';

View File

@ -0,0 +1,10 @@
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
export interface Benchmark {
name: EnhancedSymbolProfile['name'];
performances: {
allTimeHigh: {
performancePercent: number;
};
};
}

View File

@ -1,8 +1,9 @@
import { ScraperConfiguration } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/interfaces/scraper-configuration.interface';
import { Country } from '@ghostfolio/common/interfaces/country.interface';
import { Sector } from '@ghostfolio/common/interfaces/sector.interface';
import { AssetClass, AssetSubClass, DataSource } from '@prisma/client';
import { Country } from './country.interface';
import { ScraperConfiguration } from './scraper-configuration.interface';
import { Sector } from './sector.interface';
export interface EnhancedSymbolProfile {
assetClass: AssetClass;
assetSubClass: AssetSubClass;

View File

@ -0,0 +1,6 @@
import { Filter } from './filter.interface';
export interface FilterGroup {
filters: Filter[];
name: Filter['type'];
}

View File

@ -1,5 +1,5 @@
export interface Filter {
id: string;
label?: string;
type: 'account' | 'tag';
type: 'ACCOUNT' | 'ASSET_CLASS' | 'SYMBOL' | 'TAG';
}

View File

@ -6,8 +6,11 @@ import {
AdminMarketData,
AdminMarketDataItem
} from './admin-market-data.interface';
import { Benchmark } from './benchmark.interface';
import { Coupon } from './coupon.interface';
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
import { Export } from './export.interface';
import { FilterGroup } from './filter-group.interface';
import { Filter } from './filter.interface';
import { HistoricalDataItem } from './historical-data-item.interface';
import { InfoItem } from './info-item.interface';
@ -23,8 +26,10 @@ import { PortfolioReportRule } from './portfolio-report-rule.interface';
import { PortfolioReport } from './portfolio-report.interface';
import { PortfolioSummary } from './portfolio-summary.interface';
import { Position } from './position.interface';
import { BenchmarkResponse } from './responses/benchmark-response.interface';
import { ResponseError } from './responses/errors.interface';
import { PortfolioPerformanceResponse } from './responses/portfolio-performance-response.interface';
import { ScraperConfiguration } from './scraper-configuration.interface';
import { TimelinePosition } from './timeline-position.interface';
import { UniqueAsset } from './unique-asset.interface';
import { UserSettings } from './user-settings.interface';
@ -38,9 +43,13 @@ export {
AdminMarketData,
AdminMarketDataDetails,
AdminMarketDataItem,
Benchmark,
BenchmarkResponse,
Coupon,
EnhancedSymbolProfile,
Export,
Filter,
FilterGroup,
HistoricalDataItem,
InfoItem,
PortfolioChart,
@ -57,6 +66,7 @@ export {
PortfolioSummary,
Position,
ResponseError,
ScraperConfiguration,
TimelinePosition,
UniqueAsset,
User,

View File

@ -4,6 +4,7 @@ import { Statistics } from './statistics.interface';
import { Subscription } from './subscription.interface';
export interface InfoItem {
baseCurrency: string;
currencies: string[];
demoAuthToken: string;
fearAndGreedDataSource?: string;

View File

@ -1,4 +1,5 @@
import { AssetClass, DataSource } from '@prisma/client';
import { MarketState } from '../types';
export interface Position {

View File

@ -0,0 +1,5 @@
import { Benchmark } from '../benchmark.interface';
export interface BenchmarkResponse {
benchmarks: Benchmark[];
}

View File

@ -20,6 +20,7 @@ export const permissions = {
enableStatistics: 'enableStatistics',
enableSubscription: 'enableSubscription',
enableSystemMessage: 'enableSystemMessage',
reportDataGlitch: 'reportDataGlitch',
toggleReadOnlyMode: 'toggleReadOnlyMode',
updateAccount: 'updateAccount',
updateAuthDevice: 'updateAuthDevice',

View File

@ -26,9 +26,17 @@
#autocomplete="matAutocomplete"
(optionSelected)="onSelectFilter($event)"
>
<mat-option *ngFor="let filter of filters | async" [value]="filter">
{{ filter.label | gfSymbol }}
</mat-option>
<mat-optgroup
*ngFor="let filterGroup of filterGroups$ | async"
[label]="filterGroup.name"
>
<mat-option
*ngFor="let filter of filterGroup.filters"
[value]="filter.id"
>
{{ filter.label | gfSymbol }}
</mat-option>
</mat-optgroup>
</mat-autocomplete>
<mat-spinner
matSuffix

View File

@ -17,7 +17,8 @@ import {
MatAutocompleteSelectedEvent
} from '@angular/material/autocomplete';
import { MatChipInputEvent } from '@angular/material/chips';
import { Filter } from '@ghostfolio/common/interfaces';
import { Filter, FilterGroup } from '@ghostfolio/common/interfaces';
import { groupBy } from 'lodash';
import { BehaviorSubject, Observable, Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
@ -37,6 +38,7 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
@ViewChild('autocomplete') matAutocomplete: MatAutocomplete;
@ViewChild('searchInput') searchInput: ElementRef<HTMLInputElement>;
public filterGroups$: Subject<FilterGroup[]> = new BehaviorSubject([]);
public filters$: Subject<Filter[]> = new BehaviorSubject([]);
public filters: Observable<Filter[]> = this.filters$.asObservable();
public searchControl = new FormControl();
@ -48,36 +50,29 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
public constructor() {
this.searchControl.valueChanges
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((currentFilter: Filter) => {
if (currentFilter) {
this.filters$.next(
this.allFilters
.filter((filter) => {
// Filter selected filters
return !this.selectedFilters.some((selectedFilter) => {
return selectedFilter.id === filter.id;
});
})
.filter((filter) => {
return filter.label
.toLowerCase()
.startsWith(currentFilter.label.toLowerCase());
})
.sort((a, b) => a.label.localeCompare(b.label))
);
.subscribe((filterOrSearchTerm: Filter | string) => {
if (filterOrSearchTerm) {
const searchTerm =
typeof filterOrSearchTerm === 'string'
? filterOrSearchTerm
: filterOrSearchTerm?.label;
this.filterGroups$.next(this.getGroupedFilters(searchTerm));
} else {
this.filterGroups$.next(this.getGroupedFilters());
}
});
}
public ngOnChanges(changes: SimpleChanges) {
if (changes.allFilters?.currentValue) {
this.updateFilter();
this.updateFilters();
}
}
public onAddFilter({ input, value }: MatChipInputEvent): void {
if (value?.trim()) {
this.updateFilter();
this.updateFilters();
}
// Reset the input value
@ -93,12 +88,16 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
return filter.id !== aFilter.id;
});
this.updateFilter();
this.updateFilters();
}
public onSelectFilter(event: MatAutocompleteSelectedEvent): void {
this.selectedFilters.push(event.option.value);
this.updateFilter();
this.selectedFilters.push(
this.allFilters.find((filter) => {
return filter.id === event.option.value;
})
);
this.updateFilters();
this.searchInput.nativeElement.value = '';
this.searchControl.setValue(null);
}
@ -108,8 +107,8 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
this.unsubscribeSubject.complete();
}
private updateFilter() {
this.filters$.next(
private getGroupedFilters(searchTerm?: string) {
const filterGroupsMap = groupBy(
this.allFilters
.filter((filter) => {
// Filter selected filters
@ -117,9 +116,44 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
return selectedFilter.id === filter.id;
});
})
.sort((a, b) => a.label.localeCompare(b.label))
.filter((filter) => {
if (searchTerm) {
// Filter by search term
return filter.label
.toLowerCase()
.includes(searchTerm.toLowerCase());
}
return filter;
})
.sort((a, b) => a.label.localeCompare(b.label)),
(filter) => {
return filter.type;
}
);
const filterGroups: FilterGroup[] = [];
for (const type of Object.keys(filterGroupsMap)) {
filterGroups.push({
name: <Filter['type']>type,
filters: filterGroupsMap[type]
});
}
return filterGroups
.sort((a, b) => a.name.localeCompare(b.name))
.map((filterGroup) => {
return {
...filterGroup,
filters: filterGroup.filters
};
});
}
private updateFilters() {
this.filterGroups$.next(this.getGroupedFilters());
// Emit an array with a new reference
this.valueChanged.emit([...this.selectedFilters]);
}

View File

@ -22,9 +22,6 @@ import { endOfToday, format, isAfter } from 'date-fns';
import { isNumber } from 'lodash';
import { Subject, Subscription, distinctUntilChanged, takeUntil } from 'rxjs';
const SEARCH_PLACEHOLDER = 'Search for account, currency, symbol or type...';
const SEARCH_STRING_SEPARATOR = ',';
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
selector: 'gf-activities-table',
@ -70,6 +67,9 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
public totalFees: number;
public totalValue: number;
private readonly SEARCH_PLACEHOLDER =
'Filter by account, currency, symbol or type...';
private readonly SEARCH_STRING_SEPARATOR = ',';
private unsubscribeSubject = new Subject<void>();
public constructor(private router: Router) {
@ -105,19 +105,19 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
this.defaultDateFormat = getDateFormatString(this.locale);
if (this.activities) {
this.allFilters = this.getSearchableFieldValues(this.activities).map(
(label) => {
return { label, id: label, type: 'tag' };
}
);
this.allFilters = this.getSearchableFieldValues(this.activities);
this.dataSource = new MatTableDataSource(this.activities);
this.dataSource.filterPredicate = (data, filter) => {
const dataString = this.getFilterableValues(data)
.map((currentFilter) => {
return currentFilter.label;
})
.join(' ')
.toLowerCase();
let contains = true;
for (const singleFilter of filter.split(SEARCH_STRING_SEPARATOR)) {
for (const singleFilter of filter.split(this.SEARCH_STRING_SEPARATOR)) {
contains =
contains && dataString.includes(singleFilter.trim().toLowerCase());
}
@ -190,50 +190,51 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
private getFilterableValues(
activity: OrderWithAccount,
fieldValues: Set<string> = new Set<string>()
): string[] {
fieldValues.add(activity.Account?.name);
fieldValues.add(activity.Account?.Platform?.name);
fieldValues.add(activity.SymbolProfile.currency);
fieldValueMap: { [id: string]: Filter } = {}
): Filter[] {
fieldValueMap[activity.Account?.id] = {
id: activity.Account?.id,
label: activity.Account?.name,
type: 'ACCOUNT'
};
fieldValueMap[activity.SymbolProfile.currency] = {
id: activity.SymbolProfile.currency,
label: activity.SymbolProfile.currency,
type: 'TAG'
};
if (!isUUID(activity.SymbolProfile.symbol)) {
fieldValues.add(activity.SymbolProfile.symbol);
fieldValueMap[activity.SymbolProfile.symbol] = {
id: activity.SymbolProfile.symbol,
label: activity.SymbolProfile.symbol,
type: 'SYMBOL'
};
}
fieldValues.add(activity.type);
fieldValues.add(format(activity.date, 'yyyy'));
fieldValueMap[activity.type] = {
id: activity.type,
label: activity.type,
type: 'TAG'
};
return [...fieldValues].filter((item) => {
return item !== undefined;
});
fieldValueMap[format(activity.date, 'yyyy')] = {
id: format(activity.date, 'yyyy'),
label: format(activity.date, 'yyyy'),
type: 'TAG'
};
return Object.values(fieldValueMap);
}
private getSearchableFieldValues(activities: OrderWithAccount[]): string[] {
const fieldValues = new Set<string>();
private getSearchableFieldValues(activities: OrderWithAccount[]): Filter[] {
const fieldValueMap: { [id: string]: Filter } = {};
for (const activity of activities) {
this.getFilterableValues(activity, fieldValues);
this.getFilterableValues(activity, fieldValueMap);
}
return [...fieldValues]
.filter((item) => {
return item !== undefined;
})
.sort((a, b) => {
const aFirstChar = a.charAt(0);
const bFirstChar = b.charAt(0);
const isANumber = aFirstChar >= '0' && aFirstChar <= '9';
const isBNumber = bFirstChar >= '0' && bFirstChar <= '9';
// Sort priority: text, followed by numbers
if (isANumber && !isBNumber) {
return 1;
} else if (!isANumber && isBNumber) {
return -1;
} else {
return a.toLowerCase() < b.toLowerCase() ? -1 : 1;
}
});
return Object.values(fieldValueMap);
}
private getTotalFees() {
@ -275,13 +276,14 @@ export class ActivitiesTableComponent implements OnChanges, OnDestroy {
.map((filter) => {
return filter.label;
})
.join(SEARCH_STRING_SEPARATOR);
.join(this.SEARCH_STRING_SEPARATOR);
const lowercaseSearchKeywords = filters.map((filter) => {
return filter.label.trim().toLowerCase();
});
this.placeholder =
lowercaseSearchKeywords.length <= 0 ? SEARCH_PLACEHOLDER : '';
lowercaseSearchKeywords.length <= 0 ? this.SEARCH_PLACEHOLDER : '';
this.searchKeywords = filters.map((filter) => {
return filter.label;

View File

@ -0,0 +1,32 @@
<div class="align-items-center d-flex">
<div *ngIf="benchmark?.name" class="flex-grow-1 text-truncate">
{{ benchmark.name }}
</div>
<div *ngIf="!benchmark?.name" class="flex-grow-1">
<ngx-skeleton-loader
animation="pulse"
[theme]="{
width: '15rem'
}"
></ngx-skeleton-loader>
</div>
<gf-value
class="mx-2"
size="medium"
[isPercent]="true"
[locale]="locale"
[ngClass]="{
'text-danger':
benchmark?.performances?.allTimeHigh?.performancePercent < 0,
'text-success':
benchmark?.performances?.allTimeHigh?.performancePercent > 0
}"
[value]="
benchmark?.performances?.allTimeHigh?.performancePercent ?? undefined
"
></gf-value>
<div class="text-muted">
<small class="d-none d-sm-block text-nowrap" i18n>from All Time High</small
><small class="d-block d-sm-none text-nowrap" i18n>from ATH</small>
</div>
</div>

View File

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

View File

@ -0,0 +1,15 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { Benchmark } from '@ghostfolio/common/interfaces';
@Component({
selector: 'gf-benchmark',
changeDetection: ChangeDetectionStrategy.OnPush,
templateUrl: './benchmark.component.html',
styleUrls: ['./benchmark.component.scss']
})
export class BenchmarkComponent {
@Input() benchmark: Benchmark;
@Input() locale: string;
public constructor() {}
}

View File

@ -0,0 +1,14 @@
import { CommonModule } from '@angular/common';
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
import { GfValueModule } from '../value';
import { BenchmarkComponent } from './benchmark.component';
@NgModule({
declarations: [BenchmarkComponent],
exports: [BenchmarkComponent],
imports: [CommonModule, GfValueModule, NgxSkeletonLoaderModule],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class GfBenchmarkModule {}

View File

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

View File

@ -58,7 +58,7 @@
*ngIf="value === undefined"
animation="pulse"
[theme]="{
height: size === 'large' ? '2.5rem' : '1.5rem',
height: size === 'large' ? '2.5rem' : size === 'medium' ? '2rem' : '1.5rem',
width: '5rem'
}"
></ngx-skeleton-loader>

View File

@ -1,6 +1,6 @@
{
"name": "ghostfolio",
"version": "1.147.0",
"version": "1.152.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -77,7 +77,6 @@
"@simplewebauthn/server": "4.1.0",
"@simplewebauthn/typescript-types": "4.0.0",
"@stripe/stripe-js": "1.22.0",
"@types/papaparse": "5.2.6",
"alphavantage": "2.2.0",
"angular-material-css-vars": "3.0.0",
"bent": "7.3.12",
@ -117,7 +116,6 @@
"rxjs": "7.4.0",
"stripe": "8.199.0",
"svgmap": "2.6.0",
"tslib": "2.0.0",
"twitter-api-v2": "1.10.3",
"uuid": "8.3.2",
"yahoo-finance2": "2.3.2",
@ -156,6 +154,7 @@
"@types/jest": "27.4.1",
"@types/lodash": "4.14.174",
"@types/node": "14.14.33",
"@types/papaparse": "5.2.6",
"@types/passport-google-oauth20": "2.0.11",
"@typescript-eslint/eslint-plugin": "5.4.0",
"@typescript-eslint/parser": "5.4.0",
@ -175,6 +174,7 @@
"prettier": "2.5.1",
"replace-in-file": "6.2.0",
"rimraf": "3.0.2",
"tslib": "2.0.0",
"ts-jest": "27.1.4",
"ts-node": "9.1.1",
"typescript": "4.6.4"