Compare commits

...

26 Commits

Author SHA1 Message Date
0fdafcb7e4 Release 1.153.0 (#962) 2022-05-27 11:53:18 +02:00
e79be9f2d6 Feature/do not tweet on weekend (#961)
* Do not tweet on the weekend

* Update changelog
2022-05-27 11:37:48 +02:00
69088b93a6 Feature/add value redaction as interceptor (#960)
* Add value redaction as interceptor

* Update changelog
2022-05-27 11:21:47 +02:00
c3768a882d Feature/add benchmarks to twitter bot service (#959)
* Extend benchmarks with market condition and adapt twitter bot service

* Update changelog
2022-05-27 10:03:37 +02:00
3498ed8549 Feature/upgrade prisma to version 3.14.0 (#958)
* Upgrade prisma dependencies to version 3.14.0

* Update changelog
2022-05-27 09:50:38 +02:00
c07c300fef Move @simplewebauthn/typescript-types to devDependencies (#957) 2022-05-27 09:49:57 +02:00
c62a5af9eb Bugfix/fix width of skeleton loader in benchmark component (#956)
* Fix width

* Update changelog
2022-05-27 09:49:37 +02:00
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
73 changed files with 1043 additions and 304 deletions

View File

@ -5,6 +5,58 @@ 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.153.0 - 27.05.2022
### Added
- Extended the benchmarks of the markets overview by the current market condition (bear and bull market)
- Extended the twitter bot service by benchmarks
- Added value redaction for the impersonation mode in the API response as an interceptor
### Changed
- Changed the twitter bot service to rest on the weekend
- Upgraded `prisma` from version `3.12.0` to `3.14.0`
### Fixed
- Fixed a styling issue in the benchmark component on mobile
## 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

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,25 @@
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],
exports: [BenchmarkService],
imports: [
ConfigurationModule,
DataProviderModule,
MarketDataModule,
PropertyModule,
RedisCacheModule,
SymbolProfileModule
],
providers: [BenchmarkService]
})
export class BenchmarkModule {}

View File

@ -0,0 +1,84 @@
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 {
marketCondition: this.getMarketCondition(
performancePercentFromAllTimeHigh
),
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;
}
private getMarketCondition(aPerformanceInPercent: Big) {
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
}
}

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

@ -1,5 +1,6 @@
import { UserService } from '@ghostfolio/api/app/user/user.service';
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
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 { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
@ -62,6 +63,7 @@ export class OrderController {
@Get()
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
public async getAllOrders(
@Headers('impersonation-id') impersonationId

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

@ -4,11 +4,11 @@ import {
hasNotDefinedValuesInObject,
nullifyValuesInObject
} from '@ghostfolio/api/helper/object.helper';
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
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 { 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 +43,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 +52,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'))
@ -103,29 +107,38 @@ export class PortfolioController {
@Get('details')
@UseGuards(AuthGuard('jwt'))
@UseInterceptors(RedactValuesInResponseInterceptor)
@UseInterceptors(TransformDataSourceInResponseInterceptor)
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 +332,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(
{
@ -224,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

@ -0,0 +1,50 @@
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
@Injectable()
export class RedactValuesInResponseInterceptor<T>
implements NestInterceptor<T, any>
{
public constructor() {}
public intercept(
context: ExecutionContext,
next: CallHandler<T>
): Observable<any> {
return next.handle().pipe(
map((data: any) => {
const request = context.switchToHttp().getRequest();
const hasImpersonationId = !!request.headers?.['impersonation-id'];
if (hasImpersonationId) {
if (data.accounts) {
for (const accountId of Object.keys(data.accounts)) {
if (data.accounts[accountId]?.balance !== undefined) {
data.accounts[accountId].balance = null;
}
}
}
if (data.activities) {
data.activities = data.activities.map((activity: Activity) => {
if (activity.Account?.balance !== undefined) {
activity.Account.balance = null;
}
return activity;
});
}
}
return data;
})
);
}
}

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,11 +1,13 @@
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
import { Module } from '@nestjs/common';
@Module({
exports: [TwitterBotService],
imports: [ConfigurationModule, SymbolModule],
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule],
providers: [TwitterBotService]
})
export class TwitterBotModule {}

View File

@ -1,12 +1,20 @@
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
import {
PROPERTY_BENCHMARKS,
ghostfolioFearAndGreedIndexDataSource,
ghostfolioFearAndGreedIndexSymbol
} from '@ghostfolio/common/config';
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
import {
resolveFearAndGreedIndex,
resolveMarketCondition
} from '@ghostfolio/common/helper';
import { UniqueAsset } from '@ghostfolio/common/interfaces';
import { Injectable, Logger } from '@nestjs/common';
import { isSunday } from 'date-fns';
import { isWeekend } from 'date-fns';
import * as roundTo from 'round-to';
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
@Injectable()
@ -14,7 +22,9 @@ export class TwitterBotService {
private twitterClient: TwitterApiReadWrite;
public constructor(
private readonly benchmarkService: BenchmarkService,
private readonly configurationService: ConfigurationService,
private readonly propertyService: PropertyService,
private readonly symbolService: SymbolService
) {
this.twitterClient = new TwitterApi({
@ -30,7 +40,7 @@ export class TwitterBotService {
public async tweetFearAndGreedIndex() {
if (
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
isSunday(new Date())
isWeekend(new Date())
) {
return;
}
@ -48,7 +58,16 @@ export class TwitterBotService {
symbolItem.marketPrice
);
const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`;
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
const benchmarkListing = await this.getBenchmarkListing(3);
if (benchmarkListing?.length > 1) {
status += '\n\n';
status += '±% from ATH\n';
status += benchmarkListing;
}
const { data: createdTweet } = await this.twitterClient.v2.tweet(
status
);
@ -62,4 +81,36 @@ export class TwitterBotService {
Logger.error(error, 'TwitterBotService');
}
}
private async getBenchmarkListing(aMax: number) {
const benchmarkAssets: UniqueAsset[] =
((await this.propertyService.getByKey(
PROPERTY_BENCHMARKS
)) as UniqueAsset[]) ?? [];
const benchmarks = await this.benchmarkService.getBenchmarks(
benchmarkAssets
);
const benchmarkListing: string[] = [];
for (const [index, benchmark] of benchmarks.entries()) {
if (index > aMax - 1) {
break;
}
benchmarkListing.push(
`${benchmark.name} ${roundTo(
benchmark.performances.allTimeHigh.performancePercent * 100,
1
)}%${
benchmark.marketCondition !== 'NEUTRAL_MARKET'
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji
: ''
}`
);
}
return benchmarkListing.join('\n');
}
}

View File

@ -17,6 +17,7 @@ import { DataSource } from '@prisma/client';
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({

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

@ -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';
@ -48,7 +49,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
public sectors: {
[name: string]: { name: string; value: number };
};
public SymbolProfile: SymbolProfile;
public SymbolProfile: EnhancedSymbolProfile;
public tags: Tag[];
public transactionCount: number;
public value: number;

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

@ -84,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>();
/**
@ -134,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
@ -165,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();
}

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

@ -3,6 +3,7 @@ import { DataSource } from '@prisma/client';
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
import { Benchmark } from './interfaces';
export function capitalize(aString: string) {
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
@ -178,6 +179,18 @@ export function resolveFearAndGreedIndex(aValue: number) {
}
}
export function resolveMarketCondition(
aMarketCondition: Benchmark['marketCondition']
) {
if (aMarketCondition === 'BEAR_MARKET') {
return { emoji: '🐻' };
} else if (aMarketCondition === 'BULL_MARKET') {
return { emoji: '🐮' };
} else {
return { emoji: '⚪' };
}
}
export const DATE_FORMAT = 'yyyy-MM-dd';
export function parseDate(date: string) {

View File

@ -0,0 +1,11 @@
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
export interface Benchmark {
marketCondition: 'BEAR_MARKET' | 'BULL_MARKET' | 'NEUTRAL_MARKET';
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

@ -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();
@ -50,40 +52,27 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
.pipe(takeUntil(this.unsubscribeSubject))
.subscribe((filterOrSearchTerm: Filter | string) => {
if (filterOrSearchTerm) {
this.filters$.next(
this.allFilters
.filter((filter) => {
// Filter selected filters
return !this.selectedFilters.some((selectedFilter) => {
return selectedFilter.id === filter.id;
});
})
.filter((filter) => {
if (typeof filterOrSearchTerm === 'string') {
return filter.label
.toLowerCase()
.startsWith(filterOrSearchTerm.toLowerCase());
}
const searchTerm =
typeof filterOrSearchTerm === 'string'
? filterOrSearchTerm
: filterOrSearchTerm?.label;
return filter.label
.toLowerCase()
.startsWith(filterOrSearchTerm?.label?.toLowerCase());
})
.sort((a, b) => a.label.localeCompare(b.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
@ -99,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);
}
@ -114,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
@ -123,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,49 @@
<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: '67%'
}"
></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 class="ml-2">
<div
*ngIf="benchmark?.marketCondition"
[title]="benchmark?.marketCondition"
>
{{ resolveMarketCondition(benchmark.marketCondition).emoji }}
</div>
<ngx-skeleton-loader
*ngIf="!benchmark?.marketCondition"
animation="pulse"
appearance="circle"
[theme]="{
height: '1rem',
width: '1rem'
}"
></ngx-skeleton-loader>
</div>
</div>

View File

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

View File

@ -0,0 +1,18 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
import { resolveMarketCondition } from '@ghostfolio/common/helper';
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 resolveMarketCondition = resolveMarketCondition;
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.148.0",
"version": "1.153.0",
"homepage": "https://ghostfol.io",
"license": "AGPL-3.0",
"scripts": {
@ -72,12 +72,10 @@
"@nestjs/schedule": "1.0.2",
"@nestjs/serve-static": "2.2.2",
"@nrwl/angular": "14.1.4",
"@prisma/client": "3.12.0",
"@prisma/client": "3.14.0",
"@simplewebauthn/browser": "4.1.0",
"@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",
@ -111,13 +109,12 @@
"passport": "0.4.1",
"passport-google-oauth20": "2.0.0",
"passport-jwt": "4.0.0",
"prisma": "3.12.0",
"prisma": "3.14.0",
"reflect-metadata": "0.1.13",
"round-to": "5.0.0",
"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",
@ -143,6 +140,7 @@
"@nrwl/nx-cloud": "14.0.3",
"@nrwl/storybook": "14.1.4",
"@nrwl/workspace": "14.1.4",
"@simplewebauthn/typescript-types": "4.0.0",
"@storybook/addon-essentials": "6.4.22",
"@storybook/angular": "6.4.22",
"@storybook/builder-webpack5": "6.4.22",
@ -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"

View File

@ -3596,22 +3596,22 @@
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
"@prisma/client@3.12.0":
version "3.12.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.12.0.tgz#a0eb49ffea5c128dd11dffb896d7139a60073d12"
integrity sha512-4NEQjUcWja/NVBvfuDFscWSk1/rXg3+wj+TSkqXCb1tKlx/bsUE00rxsvOvGg7VZ6lw1JFpGkwjwmsOIc4zvQw==
"@prisma/client@3.14.0":
version "3.14.0"
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.14.0.tgz#bb90405c012fcca11f4647d91153ed4c58f3bd48"
integrity sha512-atb41UpgTR1MCst0VIbiHTMw8lmXnwUvE1KyUCAkq08+wJyjRE78Due+nSf+7uwqQn+fBFYVmoojtinhlLOSaA==
dependencies:
"@prisma/engines-version" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
"@prisma/engines-version" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
"@prisma/engines-version@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#829ca3d9d0d92555f44644606d4edfd45b2f5886"
integrity sha512-o+jo8d7ZEiVpcpNWUDh3fj2uPQpBxl79XE9ih9nkogJbhw6P33274SHnqheedZ7PyvPIK/mvU8MLNYgetgXPYw==
"@prisma/engines-version@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a":
version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#4edae57cf6527f35e22cebe75e49214fc0e99ac9"
integrity sha512-D+yHzq4a2r2Rrd0ZOW/mTZbgDIkUkD8ofKgusEI1xPiZz60Daks+UM7Me2ty5FzH3p/TgyhBpRrfIHx+ha20RQ==
"@prisma/engines@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#e52e364084c4d05278f62768047b788665e64a45"
integrity sha512-zULjkN8yhzS7B3yeEz4aIym4E2w1ChrV12i14pht3ePFufvsAvBSoZ+tuXMvfSoNTgBS5E4bolRzLbMmbwkkMQ==
"@prisma/engines@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a":
version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#7fa11bc26a51d450185c816cc0ab8cac673fb4bf"
integrity sha512-LwZvI3FY6f43xFjQNRuE10JM5R8vJzFTSmbV9X0Wuhv9kscLkjRlZt0BEoiHmO+2HA3B3xxbMfB5du7ZoSFXGg==
"@samverschueren/stream-to-observable@^0.3.0":
version "0.3.1"
@ -15660,12 +15660,12 @@ pretty-hrtime@^1.0.3:
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
prisma@3.12.0:
version "3.12.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.12.0.tgz#9675e0e72407122759d3eadcb6d27cdccd3497bd"
integrity sha512-ltCMZAx1i0i9xuPM692Srj8McC665h6E5RqJom999sjtVSccHSD8Z+HSdBN2183h9PJKvC5dapkn78dd0NWMBg==
prisma@3.14.0:
version "3.14.0"
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.14.0.tgz#dd67ece37d7b5373e9fd9588971de0024b49be81"
integrity sha512-l9MOgNCn/paDE+i1K2fp9NZ+Du4trzPTJsGkaQHVBufTGqzoYHuNk8JfzXuIn0Gte6/ZjyKj652Jq/Lc1tp2yw==
dependencies:
"@prisma/engines" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
"@prisma/engines" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
prismjs@^1.21.0, prismjs@~1.24.0:
version "1.24.1"