Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
0c04f10e19 | |||
2c4c16ec99 | |||
4711b0d1ed | |||
a8521e0ecf | |||
424748ae90 | |||
9c4d8bdf4b | |||
332203b9e2 | |||
f48832c671 | |||
ae8a203526 | |||
d0c1506ded | |||
af0863d193 | |||
f5819cc399 | |||
977c5a9544 | |||
b9cd42cd53 | |||
379977008d | |||
38f9d54705 | |||
5cb6e5dec6 | |||
4a123c38f2 | |||
160335302a | |||
f1483569a2 | |||
5391b88c42 | |||
2b63f7e707 | |||
d5c96d1cb7 | |||
1a4dc51825 | |||
d094bae7de |
48
CHANGELOG.md
48
CHANGELOG.md
@ -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
|
||||
|
17
README.md
17
README.md
@ -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
|
||||
|
@ -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;
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
)
|
||||
};
|
||||
|
@ -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,
|
||||
|
32
apps/api/src/app/benchmark/benchmark.controller.ts
Normal file
32
apps/api/src/app/benchmark/benchmark.controller.ts
Normal 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)
|
||||
};
|
||||
}
|
||||
}
|
24
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
24
apps/api/src/app/benchmark/benchmark.module.ts
Normal 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 {}
|
77
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
77
apps/api/src/app/benchmark/benchmark.service.ts
Normal 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;
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
});
|
||||
}
|
||||
|
@ -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'
|
||||
}
|
||||
]);
|
||||
|
@ -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
|
||||
};
|
||||
});
|
||||
|
@ -1,5 +1,5 @@
|
||||
export interface GetValueObject {
|
||||
date: Date;
|
||||
marketPrice: number;
|
||||
marketPriceInBaseCurrency: number;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -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';
|
||||
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -55,7 +55,8 @@ export class SymbolService {
|
||||
currency,
|
||||
historicalData,
|
||||
marketPrice,
|
||||
dataSource: dataGatheringItem.dataSource
|
||||
dataSource: dataGatheringItem.dataSource,
|
||||
symbol: dataGatheringItem.symbol
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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]) }),
|
||||
|
@ -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) => {
|
||||
|
@ -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
|
||||
|
@ -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'],
|
||||
|
@ -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'),
|
||||
|
@ -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 () => {
|
||||
|
@ -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;
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -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}`
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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?
|
||||
|
@ -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
|
||||
|
@ -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(
|
||||
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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'
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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 });
|
||||
|
@ -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',
|
||||
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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]
|
||||
})
|
||||
|
@ -5,6 +5,7 @@ export interface PositionDetailDialogParams {
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
hasPermissionToReportDataGlitch: boolean;
|
||||
locale: string;
|
||||
symbol: string;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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',
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
BIN
apps/client/src/assets/images/video-preview.jpg
Normal file
BIN
apps/client/src/assets/images/video-preview.jpg
Normal file
Binary file not shown.
After Width: | Height: | Size: 63 KiB |
@ -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"
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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';
|
||||
|
10
libs/common/src/lib/interfaces/benchmark.interface.ts
Normal file
10
libs/common/src/lib/interfaces/benchmark.interface.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
|
||||
|
||||
export interface Benchmark {
|
||||
name: EnhancedSymbolProfile['name'];
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: number;
|
||||
};
|
||||
};
|
||||
}
|
@ -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;
|
6
libs/common/src/lib/interfaces/filter-group.interface.ts
Normal file
6
libs/common/src/lib/interfaces/filter-group.interface.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { Filter } from './filter.interface';
|
||||
|
||||
export interface FilterGroup {
|
||||
filters: Filter[];
|
||||
name: Filter['type'];
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
export interface Filter {
|
||||
id: string;
|
||||
label?: string;
|
||||
type: 'account' | 'tag';
|
||||
type: 'ACCOUNT' | 'ASSET_CLASS' | 'SYMBOL' | 'TAG';
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { AssetClass, DataSource } from '@prisma/client';
|
||||
|
||||
import { MarketState } from '../types';
|
||||
|
||||
export interface Position {
|
||||
|
@ -0,0 +1,5 @@
|
||||
import { Benchmark } from '../benchmark.interface';
|
||||
|
||||
export interface BenchmarkResponse {
|
||||
benchmarks: Benchmark[];
|
||||
}
|
@ -20,6 +20,7 @@ export const permissions = {
|
||||
enableStatistics: 'enableStatistics',
|
||||
enableSubscription: 'enableSubscription',
|
||||
enableSystemMessage: 'enableSystemMessage',
|
||||
reportDataGlitch: 'reportDataGlitch',
|
||||
toggleReadOnlyMode: 'toggleReadOnlyMode',
|
||||
updateAccount: 'updateAccount',
|
||||
updateAuthDevice: 'updateAuthDevice',
|
||||
|
@ -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
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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;
|
||||
|
32
libs/ui/src/lib/benchmark/benchmark.component.html
Normal file
32
libs/ui/src/lib/benchmark/benchmark.component.html
Normal 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>
|
3
libs/ui/src/lib/benchmark/benchmark.component.scss
Normal file
3
libs/ui/src/lib/benchmark/benchmark.component.scss
Normal file
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
15
libs/ui/src/lib/benchmark/benchmark.component.ts
Normal file
15
libs/ui/src/lib/benchmark/benchmark.component.ts
Normal 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() {}
|
||||
}
|
14
libs/ui/src/lib/benchmark/benchmark.module.ts
Normal file
14
libs/ui/src/lib/benchmark/benchmark.module.ts
Normal 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 {}
|
1
libs/ui/src/lib/benchmark/index.ts
Normal file
1
libs/ui/src/lib/benchmark/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './benchmark.module';
|
@ -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>
|
||||
|
@ -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"
|
||||
|
Reference in New Issue
Block a user