Compare commits
36 Commits
Author | SHA1 | Date | |
---|---|---|---|
a96e89a86e | |||
b9c9443899 | |||
f1e06347d3 | |||
697e92f818 | |||
b678998801 | |||
de53cf1884 | |||
bbe30218bd | |||
15dda886a0 | |||
34d4212f55 | |||
f7060230b7 | |||
0fdafcb7e4 | |||
e79be9f2d6 | |||
69088b93a6 | |||
c3768a882d | |||
3498ed8549 | |||
c07c300fef | |||
c62a5af9eb | |||
0c04f10e19 | |||
2c4c16ec99 | |||
4711b0d1ed | |||
a8521e0ecf | |||
424748ae90 | |||
9c4d8bdf4b | |||
332203b9e2 | |||
f48832c671 | |||
ae8a203526 | |||
d0c1506ded | |||
af0863d193 | |||
f5819cc399 | |||
977c5a9544 | |||
b9cd42cd53 | |||
379977008d | |||
38f9d54705 | |||
5cb6e5dec6 | |||
4a123c38f2 | |||
160335302a |
7
.env
7
.env
@ -3,14 +3,15 @@ COMPOSE_PROJECT_NAME=ghostfolio-development
|
||||
# CACHE
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=<INSERT_REDIS_PASSWORD>
|
||||
|
||||
# POSTGRES
|
||||
POSTGRES_DB=ghostfolio-db
|
||||
POSTGRES_USER=user
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_PASSWORD=<INSERT_POSTGRES_PASSWORD>
|
||||
|
||||
ACCESS_TOKEN_SALT=GHOSTFOLIO
|
||||
ACCESS_TOKEN_SALT=<INSERT_RANDOM_STRING>
|
||||
ALPHA_VANTAGE_API_KEY=
|
||||
DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}?sslmode=prefer
|
||||
JWT_SECRET_KEY=123456
|
||||
JWT_SECRET_KEY=<INSERT_RANDOM_STRING>
|
||||
PORT=3333
|
||||
|
83
CHANGELOG.md
83
CHANGELOG.md
@ -5,6 +5,89 @@ 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.155.0 - 29.05.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added `EOD_HISTORICAL_DATA` as a new data source type
|
||||
|
||||
### Changed
|
||||
|
||||
- Exposed the environment variable `REDIS_PASSWORD`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the empty state of the portfolio proportion chart component (with 2 levels)
|
||||
|
||||
### Todo
|
||||
|
||||
- Apply data migration (`yarn database:migrate`)
|
||||
|
||||
## 1.154.0 - 28.05.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a vertical hover line to inspect data points in the line chart component
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the tooltips of the chart components (content and style)
|
||||
- Simplified the pricing page
|
||||
- Improved the rounding numbers in the twitter bot service
|
||||
- Removed the dependency `round-to`
|
||||
|
||||
## 1.153.0 - 27.05.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Extended the benchmarks of the markets overview by the current market condition (bear and bull market)
|
||||
- Extended the twitter bot service by benchmarks
|
||||
- Added value redaction for the impersonation mode in the API response as an interceptor
|
||||
|
||||
### Changed
|
||||
|
||||
- Changed the twitter bot service to rest on the weekend
|
||||
- Upgraded `prisma` from version `3.12.0` to `3.14.0`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a styling issue in the benchmark component on mobile
|
||||
|
||||
## 1.152.0 - 26.05.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the _Ghostfolio_ trailer to the landing page
|
||||
- Extended the markets overview by benchmarks (current change to the all time high)
|
||||
|
||||
## 1.151.0 - 24.05.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to set the base currency as an environment variable (`BASE_CURRENCY`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the missing conversion of countries in the symbol profile overrides
|
||||
|
||||
## 1.150.0 - 21.05.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Skipped data enhancer (_Trackinsight_) if data is inaccurate
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue with the currency conversion in the account calculations
|
||||
- Fixed an issue with countries in the symbol profile overrides
|
||||
|
||||
## 1.149.0 - 16.05.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added groups to the activities filter component
|
||||
- Added support for filtering by asset class on the allocations page
|
||||
|
||||
## 1.148.0 - 14.05.2022
|
||||
|
||||
### Added
|
||||
|
19
README.md
19
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
|
||||
@ -47,7 +48,7 @@ Ghostfolio is for you if you are...
|
||||
- 🧘 into minimalism
|
||||
- 🧺 caring about diversifying your financial resources
|
||||
- 🆓 interested in financial independence
|
||||
- 🙅 saying no to spreadsheets in 2021
|
||||
- 🙅 saying no to spreadsheets in 2022
|
||||
- 😎 still reading this list
|
||||
|
||||
## Features
|
||||
@ -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,10 +38,12 @@ import { UserModule } from './user/user.module';
|
||||
AccountModule,
|
||||
AuthDeviceModule,
|
||||
AuthModule,
|
||||
BenchmarkModule,
|
||||
BullModule.forRoot({
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST,
|
||||
port: parseInt(process.env.REDIS_PORT, 10)
|
||||
port: parseInt(process.env.REDIS_PORT, 10),
|
||||
password: process.env.REDIS_PASSWORD
|
||||
}
|
||||
}),
|
||||
CacheModule,
|
||||
|
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)
|
||||
};
|
||||
}
|
||||
}
|
25
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
25
apps/api/src/app/benchmark/benchmark.module.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
import { MarketDataModule } from '@ghostfolio/api/services/market-data.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { BenchmarkController } from './benchmark.controller';
|
||||
import { BenchmarkService } from './benchmark.service';
|
||||
|
||||
@Module({
|
||||
controllers: [BenchmarkController],
|
||||
exports: [BenchmarkService],
|
||||
imports: [
|
||||
ConfigurationModule,
|
||||
DataProviderModule,
|
||||
MarketDataModule,
|
||||
PropertyModule,
|
||||
RedisCacheModule,
|
||||
SymbolProfileModule
|
||||
],
|
||||
providers: [BenchmarkService]
|
||||
})
|
||||
export class BenchmarkModule {}
|
84
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
84
apps/api/src/app/benchmark/benchmark.service.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { RedisCacheService } from '@ghostfolio/api/app/redis-cache/redis-cache.service';
|
||||
import { DataProviderService } from '@ghostfolio/api/services/data-provider/data-provider.service';
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { BenchmarkResponse, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import Big from 'big.js';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
private readonly CACHE_KEY_BENCHMARKS = 'BENCHMARKS';
|
||||
|
||||
public constructor(
|
||||
private readonly dataProviderService: DataProviderService,
|
||||
private readonly marketDataService: MarketDataService,
|
||||
private readonly redisCacheService: RedisCacheService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {}
|
||||
|
||||
public async getBenchmarks(
|
||||
benchmarkAssets: UniqueAsset[]
|
||||
): Promise<BenchmarkResponse['benchmarks']> {
|
||||
let benchmarks: BenchmarkResponse['benchmarks'];
|
||||
|
||||
try {
|
||||
benchmarks = JSON.parse(
|
||||
await this.redisCacheService.get(this.CACHE_KEY_BENCHMARKS)
|
||||
);
|
||||
|
||||
if (benchmarks) {
|
||||
return benchmarks;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const [quotes, assetProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(benchmarkAssets),
|
||||
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
|
||||
]);
|
||||
|
||||
for (const benchmarkAsset of benchmarkAssets) {
|
||||
promises.push(this.marketDataService.getMax(benchmarkAsset));
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } = quotes[benchmarkAssets[index].symbol];
|
||||
|
||||
const performancePercentFromAllTimeHigh = new Big(marketPrice)
|
||||
.div(allTimeHigh)
|
||||
.minus(1);
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: assetProfiles.find(({ dataSource, symbol }) => {
|
||||
return (
|
||||
dataSource === benchmarkAssets[index].dataSource &&
|
||||
symbol === benchmarkAssets[index].symbol
|
||||
);
|
||||
})?.name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: performancePercentFromAllTimeHigh.toNumber()
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks)
|
||||
);
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: Big) {
|
||||
return aPerformanceInPercent.lte(-0.2) ? 'BEAR_MARKET' : 'NEUTRAL_MARKET';
|
||||
}
|
||||
}
|
@ -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(),
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { UserService } from '@ghostfolio/api/app/user/user.service';
|
||||
import { nullifyValuesInObjects } from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
@ -62,6 +63,7 @@ export class OrderController {
|
||||
|
||||
@Get()
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getAllOrders(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -4,11 +4,11 @@ import {
|
||||
hasNotDefinedValuesInObject,
|
||||
nullifyValuesInObject
|
||||
} from '@ghostfolio/api/helper/object.helper';
|
||||
import { RedactValuesInResponseInterceptor } from '@ghostfolio/api/interceptors/redact-values-in-response.interceptor';
|
||||
import { TransformDataSourceInRequestInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-request.interceptor';
|
||||
import { TransformDataSourceInResponseInterceptor } from '@ghostfolio/api/interceptors/transform-data-source-in-response.interceptor';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { baseCurrency } from '@ghostfolio/common/config';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Filter,
|
||||
@ -43,6 +43,8 @@ import { PortfolioService } from './portfolio.service';
|
||||
|
||||
@Controller('portfolio')
|
||||
export class PortfolioController {
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
@ -50,7 +52,9 @@ export class PortfolioController {
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
private readonly userService: UserService
|
||||
) {}
|
||||
) {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@ -103,29 +107,38 @@ export class PortfolioController {
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getDetails(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') range?: DateRange,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<PortfolioDetails & { hasError: boolean }> {
|
||||
let hasError = false;
|
||||
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'account'
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'tag'
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
@ -319,7 +332,7 @@ export class PortfolioController {
|
||||
return this.exchangeRateDataService.toCurrency(
|
||||
portfolioPosition.quantity * portfolioPosition.marketPrice,
|
||||
portfolioPosition.currency,
|
||||
this.request.user?.Settings?.currency ?? baseCurrency
|
||||
this.request.user?.Settings?.currency ?? this.baseCurrency
|
||||
);
|
||||
})
|
||||
.reduce((a, b) => a + b, 0);
|
||||
|
@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,7 @@ import { RedisCacheService } from './redis-cache.service';
|
||||
useFactory: async (configurationService: ConfigurationService) => ({
|
||||
host: configurationService.get('REDIS_HOST'),
|
||||
max: configurationService.get('MAX_ITEM_IN_CACHE'),
|
||||
password: configurationService.get('REDIS_PASSWORD'),
|
||||
port: configurationService.get('REDIS_PORT'),
|
||||
store: redisStore,
|
||||
ttl: configurationService.get('CACHE_TTL')
|
||||
|
@ -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(
|
||||
{
|
||||
@ -224,14 +224,14 @@ export class UserService {
|
||||
...data,
|
||||
Account: {
|
||||
create: {
|
||||
currency: baseCurrency,
|
||||
currency: this.baseCurrency,
|
||||
isDefault: true,
|
||||
name: 'Default Account'
|
||||
}
|
||||
},
|
||||
Settings: {
|
||||
create: {
|
||||
currency: baseCurrency
|
||||
currency: this.baseCurrency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,50 @@
|
||||
import { Activity } from '@ghostfolio/api/app/order/interfaces/activities.interface';
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class RedactValuesInResponseInterceptor<T>
|
||||
implements NestInterceptor<T, any>
|
||||
{
|
||||
public constructor() {}
|
||||
|
||||
public intercept(
|
||||
context: ExecutionContext,
|
||||
next: CallHandler<T>
|
||||
): Observable<any> {
|
||||
return next.handle().pipe(
|
||||
map((data: any) => {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const hasImpersonationId = !!request.headers?.['impersonation-id'];
|
||||
|
||||
if (hasImpersonationId) {
|
||||
if (data.accounts) {
|
||||
for (const accountId of Object.keys(data.accounts)) {
|
||||
if (data.accounts[accountId]?.balance !== undefined) {
|
||||
data.accounts[accountId].balance = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (data.activities) {
|
||||
data.activities = data.activities.map((activity: Activity) => {
|
||||
if (activity.Account?.balance !== undefined) {
|
||||
activity.Account.balance = null;
|
||||
}
|
||||
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
@ -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]) }),
|
||||
@ -24,6 +25,7 @@ export class ConfigurationService {
|
||||
ENABLE_FEATURE_STATISTICS: bool({ default: false }),
|
||||
ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }),
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }),
|
||||
EOD_HISTORICAL_DATA_API_KEY: str({ default: '' }),
|
||||
GOOGLE_CLIENT_ID: str({ default: 'dummyClientId' }),
|
||||
GOOGLE_SECRET: str({ default: 'dummySecret' }),
|
||||
GOOGLE_SHEETS_ACCOUNT: str({ default: '' }),
|
||||
@ -35,6 +37,7 @@ export class ConfigurationService {
|
||||
PORT: port({ default: 3333 }),
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: str({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
ROOT_URL: str({ default: 'http://localhost:4200' }),
|
||||
STRIPE_PUBLIC_KEY: str({ default: '' }),
|
||||
|
@ -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
|
||||
|
@ -1,5 +1,7 @@
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { CryptocurrencyModule } from '@ghostfolio/api/services/cryptocurrency/cryptocurrency.module';
|
||||
import { AlphaVantageService } from '@ghostfolio/api/services/data-provider/alpha-vantage/alpha-vantage.service';
|
||||
import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider/eod-historical-data/eod-historical-data.service';
|
||||
import { GhostfolioScraperApiService } from '@ghostfolio/api/services/data-provider/ghostfolio-scraper-api/ghostfolio-scraper-api.service';
|
||||
import { GoogleSheetsService } from '@ghostfolio/api/services/data-provider/google-sheets/google-sheets.service';
|
||||
import { ManualService } from '@ghostfolio/api/services/data-provider/manual/manual.service';
|
||||
@ -9,7 +11,6 @@ import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AlphaVantageService } from './alpha-vantage/alpha-vantage.service';
|
||||
import { DataProviderService } from './data-provider.service';
|
||||
|
||||
@Module({
|
||||
@ -22,6 +23,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
providers: [
|
||||
AlphaVantageService,
|
||||
DataProviderService,
|
||||
EodHistoricalDataService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
@ -30,6 +32,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
{
|
||||
inject: [
|
||||
AlphaVantageService,
|
||||
EodHistoricalDataService,
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
@ -39,6 +42,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
provide: 'DataProviderInterfaces',
|
||||
useFactory: (
|
||||
alphaVantageService,
|
||||
eodHistoricalDataService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
@ -46,6 +50,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
eodHistoricalDataService,
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
|
@ -0,0 +1,138 @@
|
||||
import { LookupItem } from '@ghostfolio/api/app/symbol/interfaces/lookup-item.interface';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DataProviderInterface } from '@ghostfolio/api/services/data-provider/interfaces/data-provider.interface';
|
||||
import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import bent from 'bent';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class EodHistoricalDataService implements DataProviderInterface {
|
||||
private apiKey: string;
|
||||
private readonly URL = 'https://eodhistoricaldata.com/api';
|
||||
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly symbolProfileService: SymbolProfileService
|
||||
) {
|
||||
this.apiKey = this.configurationService.get('EOD_HISTORICAL_DATA_API_KEY');
|
||||
}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
aSymbol: string
|
||||
): Promise<Partial<SymbolProfile>> {
|
||||
return {
|
||||
dataSource: this.getName()
|
||||
};
|
||||
}
|
||||
|
||||
public async getHistorical(
|
||||
aSymbol: string,
|
||||
aGranularity: Granularity = 'day',
|
||||
from: Date,
|
||||
to: Date
|
||||
): Promise<{
|
||||
[symbol: string]: { [date: string]: IDataProviderHistoricalResponse };
|
||||
}> {
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/eod/${aSymbol}?api_token=${
|
||||
this.apiKey
|
||||
}&fmt=json&from=${format(from, DATE_FORMAT)}&to=${format(
|
||||
to,
|
||||
DATE_FORMAT
|
||||
)}&period={aGranularity}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
|
||||
const response = await get();
|
||||
|
||||
return response.reduce(
|
||||
(result, historicalItem, index, array) => {
|
||||
result[aSymbol][historicalItem.date] = {
|
||||
marketPrice: historicalItem.close,
|
||||
performance: historicalItem.open - historicalItem.close
|
||||
};
|
||||
|
||||
return result;
|
||||
},
|
||||
{ [aSymbol]: {} }
|
||||
);
|
||||
} catch (error) {
|
||||
Logger.error(error, 'EodHistoricalDataService');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.EOD_HISTORICAL_DATA;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
aSymbols: string[]
|
||||
): Promise<{ [symbol: string]: IDataProviderResponse }> {
|
||||
if (aSymbols.length <= 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const get = bent(
|
||||
`${this.URL}/real-time/${aSymbols[0]}?api_token=${
|
||||
this.apiKey
|
||||
}&fmt=json&s=${aSymbols.join(',')}`,
|
||||
'GET',
|
||||
'json',
|
||||
200
|
||||
);
|
||||
|
||||
const [response, symbolProfiles] = await Promise.all([
|
||||
get(),
|
||||
this.symbolProfileService.getSymbolProfiles(
|
||||
aSymbols.map((symbol) => {
|
||||
return {
|
||||
symbol,
|
||||
dataSource: DataSource.EOD_HISTORICAL_DATA
|
||||
};
|
||||
})
|
||||
)
|
||||
]);
|
||||
|
||||
const quotes = aSymbols.length === 1 ? [response] : response;
|
||||
|
||||
return quotes.reduce((result, item, index, array) => {
|
||||
result[item.code] = {
|
||||
currency: symbolProfiles.find((symbolProfile) => {
|
||||
return symbolProfile.symbol === item.code;
|
||||
})?.currency,
|
||||
dataSource: DataSource.EOD_HISTORICAL_DATA,
|
||||
marketPrice: item.close,
|
||||
marketState: 'delayed'
|
||||
};
|
||||
|
||||
return result;
|
||||
}, {});
|
||||
} catch (error) {
|
||||
Logger.error(error, 'EodHistoricalDataService');
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public async search(aQuery: string): Promise<{ items: LookupItem[] }> {
|
||||
return { items: [] };
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@ import { DATE_FORMAT, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import bent from 'bent';
|
||||
import * as cheerio from 'cheerio';
|
||||
import { addDays, format, isBefore } from 'date-fns';
|
||||
|
||||
@ -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'),
|
||||
|
@ -11,7 +11,7 @@ import { DATE_FORMAT, getToday, getYesterday } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { DataSource, SymbolProfile } from '@prisma/client';
|
||||
import * as bent from 'bent';
|
||||
import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
|
@ -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?
|
||||
@ -15,6 +16,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
ENABLE_FEATURE_STATISTICS: boolean;
|
||||
ENABLE_FEATURE_SUBSCRIPTION: boolean;
|
||||
ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;
|
||||
EOD_HISTORICAL_DATA_API_KEY: string;
|
||||
GOOGLE_CLIENT_ID: string;
|
||||
GOOGLE_SECRET: string;
|
||||
GOOGLE_SHEETS_ACCOUNT: string;
|
||||
@ -26,6 +28,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
PORT: number;
|
||||
RAKUTEN_RAPID_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
REDIS_PASSWORD: string;
|
||||
REDIS_PORT: number;
|
||||
ROOT_URL: string;
|
||||
STRIPE_PUBLIC_KEY: string;
|
||||
|
@ -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,11 +1,13 @@
|
||||
import { BenchmarkModule } from '@ghostfolio/api/app/benchmark/benchmark.module';
|
||||
import { SymbolModule } from '@ghostfolio/api/app/symbol/symbol.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { PropertyModule } from '@ghostfolio/api/services/property/property.module';
|
||||
import { TwitterBotService } from '@ghostfolio/api/services/twitter-bot/twitter-bot.service';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@Module({
|
||||
exports: [TwitterBotService],
|
||||
imports: [ConfigurationModule, SymbolModule],
|
||||
imports: [BenchmarkModule, ConfigurationModule, PropertyModule, SymbolModule],
|
||||
providers: [TwitterBotService]
|
||||
})
|
||||
export class TwitterBotModule {}
|
||||
|
@ -1,12 +1,19 @@
|
||||
import { BenchmarkService } from '@ghostfolio/api/app/benchmark/benchmark.service';
|
||||
import { SymbolService } from '@ghostfolio/api/app/symbol/symbol.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import {
|
||||
PROPERTY_BENCHMARKS,
|
||||
ghostfolioFearAndGreedIndexDataSource,
|
||||
ghostfolioFearAndGreedIndexSymbol
|
||||
} from '@ghostfolio/common/config';
|
||||
import { resolveFearAndGreedIndex } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
resolveFearAndGreedIndex,
|
||||
resolveMarketCondition
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { isSunday } from 'date-fns';
|
||||
import { isWeekend } from 'date-fns';
|
||||
import { TwitterApi, TwitterApiReadWrite } from 'twitter-api-v2';
|
||||
|
||||
@Injectable()
|
||||
@ -14,7 +21,9 @@ export class TwitterBotService {
|
||||
private twitterClient: TwitterApiReadWrite;
|
||||
|
||||
public constructor(
|
||||
private readonly benchmarkService: BenchmarkService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly propertyService: PropertyService,
|
||||
private readonly symbolService: SymbolService
|
||||
) {
|
||||
this.twitterClient = new TwitterApi({
|
||||
@ -30,7 +39,7 @@ export class TwitterBotService {
|
||||
public async tweetFearAndGreedIndex() {
|
||||
if (
|
||||
!this.configurationService.get('ENABLE_FEATURE_FEAR_AND_GREED_INDEX') ||
|
||||
isSunday(new Date())
|
||||
isWeekend(new Date())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
@ -48,7 +57,16 @@ export class TwitterBotService {
|
||||
symbolItem.marketPrice
|
||||
);
|
||||
|
||||
const status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)\n\n#FearAndGreed #Markets #ServiceTweet`;
|
||||
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
|
||||
|
||||
const benchmarkListing = await this.getBenchmarkListing(3);
|
||||
|
||||
if (benchmarkListing?.length > 1) {
|
||||
status += '\n\n';
|
||||
status += '±% from ATH\n';
|
||||
status += benchmarkListing;
|
||||
}
|
||||
|
||||
const { data: createdTweet } = await this.twitterClient.v2.tweet(
|
||||
status
|
||||
);
|
||||
@ -62,4 +80,35 @@ export class TwitterBotService {
|
||||
Logger.error(error, 'TwitterBotService');
|
||||
}
|
||||
}
|
||||
|
||||
private async getBenchmarkListing(aMax: number) {
|
||||
const benchmarkAssets: UniqueAsset[] =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as UniqueAsset[]) ?? [];
|
||||
|
||||
const benchmarks = await this.benchmarkService.getBenchmarks(
|
||||
benchmarkAssets
|
||||
);
|
||||
|
||||
const benchmarkListing: string[] = [];
|
||||
|
||||
for (const [index, benchmark] of benchmarks.entries()) {
|
||||
if (index > aMax - 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
benchmarkListing.push(
|
||||
`${benchmark.name} ${(
|
||||
benchmark.performances.allTimeHigh.performancePercent * 100
|
||||
).toFixed(1)}%${
|
||||
benchmark.marketCondition !== 'NEUTRAL_MARKET'
|
||||
? ' ' + resolveMarketCondition(benchmark.marketCondition).emoji
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarkListing.join('\n');
|
||||
}
|
||||
}
|
||||
|
@ -2,8 +2,10 @@
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[locale]="locale"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
[symbol]="symbol"
|
||||
></gf-line-chart>
|
||||
<div *ngFor="let itemByMonth of marketDataByMonth | keyvalue" class="d-flex">
|
||||
<div class="date px-1 text-nowrap">{{ itemByMonth.key }}</div>
|
||||
|
@ -17,6 +17,7 @@ import { DataSource } from '@prisma/client';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
|
||||
import { PositionDetailDialogParams } from '../position/position-detail-dialog/interfaces/interfaces';
|
||||
|
||||
@Component({
|
||||
|
@ -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,18 +1,19 @@
|
||||
<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"
|
||||
symbol="Fear & Greed Index"
|
||||
yMax="100"
|
||||
yMaxLabel="Greed"
|
||||
yMin="0"
|
||||
yMinLabel="Fear"
|
||||
[historicalDataItems]="historicalData"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
></gf-line-chart>
|
||||
@ -23,4 +24,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]
|
||||
})
|
||||
|
@ -6,6 +6,7 @@
|
||||
<gf-line-chart
|
||||
symbol="Performance"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[locale]="user?.settings?.locale"
|
||||
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||
[showGradient]="true"
|
||||
[showLoader]="false"
|
||||
|
@ -6,11 +6,18 @@ import {
|
||||
Input,
|
||||
OnChanges,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {
|
||||
getTooltipOptions,
|
||||
getTooltipPositionerMapTop,
|
||||
getVerticalHoverLinePlugin
|
||||
} from '@ghostfolio/common/chart-helper';
|
||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||
import {
|
||||
getBackgroundColor,
|
||||
getDateFormatString,
|
||||
getTextColor,
|
||||
parseDate,
|
||||
transformTickToAbbreviation
|
||||
} from '@ghostfolio/common/helper';
|
||||
@ -21,7 +28,8 @@ import {
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
TimeScale
|
||||
TimeScale,
|
||||
Tooltip
|
||||
} from 'chart.js';
|
||||
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||
|
||||
@ -32,9 +40,11 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||
styleUrls: ['./investment-chart.component.scss']
|
||||
})
|
||||
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
@Input() currency: string;
|
||||
@Input() daysInMarket: number;
|
||||
@Input() investments: InvestmentItem[];
|
||||
@Input() isInPercent = false;
|
||||
@Input() locale: string;
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
@ -47,8 +57,12 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
LineController,
|
||||
LineElement,
|
||||
PointElement,
|
||||
TimeScale
|
||||
TimeScale,
|
||||
Tooltip
|
||||
);
|
||||
|
||||
Tooltip.positioners['top'] = (elements, position) =>
|
||||
getTooltipPositionerMapTop(this.chart, position);
|
||||
}
|
||||
|
||||
public ngOnChanges() {
|
||||
@ -98,6 +112,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
data: this.investments.map((position) => {
|
||||
return position.investment;
|
||||
}),
|
||||
label: 'Investment',
|
||||
segment: {
|
||||
borderColor: (context: unknown) =>
|
||||
this.isInFuture(
|
||||
@ -114,6 +129,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
if (this.chartCanvas) {
|
||||
if (this.chart) {
|
||||
this.chart.data = data;
|
||||
this.chart.options.plugins.tooltip = <unknown>(
|
||||
this.getTooltipPluginConfiguration()
|
||||
);
|
||||
this.chart.update();
|
||||
} else {
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
@ -124,13 +142,20 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
tension: 0
|
||||
},
|
||||
point: {
|
||||
hoverBackgroundColor: getBackgroundColor(),
|
||||
hoverRadius: 2,
|
||||
radius: 0
|
||||
}
|
||||
},
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
maintainAspectRatio: true,
|
||||
plugins: {
|
||||
plugins: <unknown>{
|
||||
legend: {
|
||||
display: false
|
||||
},
|
||||
tooltip: this.getTooltipPluginConfiguration(),
|
||||
verticalHoverLine: {
|
||||
color: `rgba(${getTextColor()}, 0.1)`
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
@ -138,16 +163,21 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
type: 'time',
|
||||
time: {
|
||||
tooltipFormat: getDateFormatString(this.locale),
|
||||
unit: 'year'
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: !this.isInPercent,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
ticks: {
|
||||
@ -161,6 +191,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
|
||||
type: 'line'
|
||||
});
|
||||
|
||||
@ -169,6 +200,19 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
private getTooltipPluginConfiguration() {
|
||||
return {
|
||||
...getTooltipOptions(
|
||||
this.isInPercent ? undefined : this.currency,
|
||||
this.isInPercent ? undefined : this.locale
|
||||
),
|
||||
mode: 'index',
|
||||
position: <unknown>'top',
|
||||
xAlign: 'center',
|
||||
yAlign: 'bottom'
|
||||
};
|
||||
}
|
||||
|
||||
private isInFuture<T>(aContext: any, aValue: T) {
|
||||
return isAfter(new Date(aContext?.p1?.parsed?.x), new Date())
|
||||
? aValue
|
||||
|
@ -9,9 +9,10 @@ import {
|
||||
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { DATE_FORMAT, downloadAsFile } from '@ghostfolio/common/helper';
|
||||
import { EnhancedSymbolProfile } from '@ghostfolio/common/interfaces';
|
||||
import { OrderWithAccount } from '@ghostfolio/common/types';
|
||||
import { LineChartItem } from '@ghostfolio/ui/line-chart/interfaces/line-chart.interface';
|
||||
import { SymbolProfile, Tag } from '@prisma/client';
|
||||
import { Tag } from '@prisma/client';
|
||||
import { format, isSameMonth, isToday, parseISO } from 'date-fns';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -48,7 +49,7 @@ export class PositionDetailDialog implements OnDestroy, OnInit {
|
||||
public sectors: {
|
||||
[name: string]: { name: string; value: number };
|
||||
};
|
||||
public SymbolProfile: SymbolProfile;
|
||||
public SymbolProfile: EnhancedSymbolProfile;
|
||||
public tags: Tag[];
|
||||
public transactionCount: number;
|
||||
public value: number;
|
||||
|
@ -23,7 +23,9 @@
|
||||
class="mb-4"
|
||||
benchmarkLabel="Average Unit Price"
|
||||
[benchmarkDataItems]="benchmarkDataItems"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[locale]="data.locale"
|
||||
[showGradient]="true"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
|
@ -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>
|
||||
|
@ -84,6 +84,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
public user: User;
|
||||
|
||||
private readonly SEARCH_PLACEHOLDER = 'Filter by account or tag...';
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
/**
|
||||
@ -134,6 +135,8 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
switchMap((filters) => {
|
||||
this.isLoading = true;
|
||||
this.activeFilters = filters;
|
||||
this.placeholder =
|
||||
this.activeFilters.length <= 0 ? this.SEARCH_PLACEHOLDER : '';
|
||||
|
||||
return this.dataService.fetchPortfolioDetails({
|
||||
filters: this.activeFilters
|
||||
@ -165,19 +168,32 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
return {
|
||||
id,
|
||||
label: name,
|
||||
type: 'account'
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
});
|
||||
|
||||
const assetClassFilters: Filter[] = [];
|
||||
for (const assetClass of Object.keys(AssetClass)) {
|
||||
assetClassFilters.push({
|
||||
id: assetClass,
|
||||
label: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
});
|
||||
}
|
||||
|
||||
const tagFilters: Filter[] = this.user.tags.map(({ id, name }) => {
|
||||
return {
|
||||
id,
|
||||
label: name,
|
||||
type: 'tag'
|
||||
type: 'TAG'
|
||||
};
|
||||
});
|
||||
|
||||
this.allFilters = [...accountFilters, ...tagFilters];
|
||||
this.allFilters = [
|
||||
...accountFilters,
|
||||
...assetClassFilters,
|
||||
...tagFilters
|
||||
];
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
}
|
||||
|
@ -2,21 +2,17 @@
|
||||
<div class="investment-chart row">
|
||||
<div class="col-lg">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header>
|
||||
<mat-card-title class="align-items-center d-flex" i18n
|
||||
>Investment Timeline</mat-card-title
|
||||
>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[investments]="investments"
|
||||
></gf-investment-chart>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="mb-3">
|
||||
<div class="h5 mb-3" i18n>Investment Timeline</div>
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[investments]="investments"
|
||||
[locale]="user?.settings?.locale"
|
||||
></gf-investment-chart>
|
||||
</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 { 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;
|
||||
}
|
||||
|
@ -4,22 +4,19 @@
|
||||
<h3 class="d-flex justify-content-center mb-3 text-center" i18n>
|
||||
Pricing Plans
|
||||
</h3>
|
||||
<mat-card class="mb-4">
|
||||
<mat-card-content>
|
||||
<p>
|
||||
Our official
|
||||
<strong>Ghostfolio Premium</strong> cloud offering is the easiest
|
||||
way to get started. Due to the time it saves, this will be the best
|
||||
option for most people. The revenue is used for covering the hosting
|
||||
costs.
|
||||
</p>
|
||||
<p>
|
||||
If you prefer to run <strong>Ghostfolio</strong> on your own
|
||||
infrastructure, please find the source code and further instructions
|
||||
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</p>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<div class="mb-4">
|
||||
<p>
|
||||
Our official
|
||||
<strong>Ghostfolio Premium</strong> cloud offering is the easiest way
|
||||
to get started. Due to the time it saves, this will be the best option
|
||||
for most people. The revenue is used for covering the hosting costs.
|
||||
</p>
|
||||
<p>
|
||||
If you prefer to run <strong>Ghostfolio</strong> on your own
|
||||
infrastructure, please find the source code and further instructions
|
||||
on <a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</p>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
|
@ -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 |
@ -6,46 +6,46 @@
|
||||
http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd">
|
||||
<url>
|
||||
<loc>https://ghostfol.io</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/about</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/about/changelog</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/blog</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/de/blog/2021/07/hallo-ghostfolio</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2021/07/hello-ghostfolio</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/en/blog/2022/01/ghostfolio-first-months-in-open-source</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/features</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/pricing</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/register</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
<url>
|
||||
<loc>https://ghostfol.io/resources</loc>
|
||||
<lastmod>2022-02-13T00:00:00+00:00</lastmod>
|
||||
<lastmod>2022-05-28T00:00:00+00:00</lastmod>
|
||||
</url>
|
||||
</urlset>
|
||||
|
@ -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"
|
||||
@ -42,7 +42,7 @@
|
||||
property="og:image"
|
||||
content="https://www.ghostfol.io/assets/cover.png"
|
||||
/>
|
||||
<meta property="og:updated_time" content="2021-03-20T00:00:00+00:00" />
|
||||
<meta property="og:updated_time" content="2022-05-28T00:00:00+00:00" />
|
||||
<meta
|
||||
property="og:site_name"
|
||||
content="Ghostfolio – Open Source Wealth Management Software"
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
||||
REDIS_HOST: 'redis'
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- 3333:3333
|
||||
|
||||
|
@ -7,6 +7,7 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: postgresql://user:password@postgres:5432/ghostfolio-db?sslmode=prefer
|
||||
REDIS_HOST: 'redis'
|
||||
REDIS_PASSWORD: ${REDIS_PASSWORD}
|
||||
ports:
|
||||
- 3333:3333
|
||||
|
||||
|
83
libs/common/src/lib/chart-helper.ts
Normal file
83
libs/common/src/lib/chart-helper.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { Chart, TooltipPosition } from 'chart.js';
|
||||
|
||||
import { getBackgroundColor, getTextColor } from './helper';
|
||||
|
||||
export function getTooltipOptions(currency = '', locale = '') {
|
||||
return {
|
||||
backgroundColor: getBackgroundColor(),
|
||||
bodyColor: `rgb(${getTextColor()})`,
|
||||
borderWidth: 1,
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
let label = context.dataset.label || '';
|
||||
if (label) {
|
||||
label += ': ';
|
||||
}
|
||||
if (context.parsed.y !== null) {
|
||||
if (currency) {
|
||||
label += `${context.parsed.y.toLocaleString(locale, {
|
||||
maximumFractionDigits: 2,
|
||||
minimumFractionDigits: 2
|
||||
})} ${currency}`;
|
||||
} else {
|
||||
label += context.parsed.y.toFixed(2);
|
||||
}
|
||||
}
|
||||
return label;
|
||||
}
|
||||
},
|
||||
caretSize: 0,
|
||||
cornerRadius: 2,
|
||||
footerColor: `rgb(${getTextColor()})`,
|
||||
itemSort: (a, b) => {
|
||||
// Reverse order
|
||||
return b.datasetIndex - a.datasetIndex;
|
||||
},
|
||||
titleColor: `rgb(${getTextColor()})`,
|
||||
usePointStyle: true
|
||||
};
|
||||
}
|
||||
|
||||
export function getTooltipPositionerMapTop(
|
||||
chart: Chart,
|
||||
position: TooltipPosition
|
||||
) {
|
||||
if (!position) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
x: position.x,
|
||||
y: chart.chartArea.top
|
||||
};
|
||||
}
|
||||
|
||||
export function getVerticalHoverLinePlugin(chartCanvas) {
|
||||
return {
|
||||
afterDatasetsDraw: (chart, x, options) => {
|
||||
const active = chart.getActiveElements();
|
||||
|
||||
if (!active || active.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const color = options.color || `rgb(${getTextColor()})`;
|
||||
const width = options.width || 1;
|
||||
|
||||
const {
|
||||
chartArea: { bottom, top }
|
||||
} = chart;
|
||||
const xValue = active[0].element.x;
|
||||
|
||||
const context = chartCanvas.nativeElement.getContext('2d');
|
||||
context.lineWidth = width;
|
||||
context.strokeStyle = color;
|
||||
|
||||
context.beginPath();
|
||||
context.moveTo(xValue, top);
|
||||
context.lineTo(xValue, bottom);
|
||||
context.stroke();
|
||||
},
|
||||
id: 'verticalHoverLine'
|
||||
};
|
||||
}
|
@ -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';
|
||||
|
@ -3,6 +3,7 @@ import { DataSource } from '@prisma/client';
|
||||
import { getDate, getMonth, getYear, parse, subDays } from 'date-fns';
|
||||
|
||||
import { ghostfolioScraperApiSymbolPrefix, locale } from './config';
|
||||
import { Benchmark } from './interfaces';
|
||||
|
||||
export function capitalize(aString: string) {
|
||||
return aString.charAt(0).toUpperCase() + aString.slice(1).toLowerCase();
|
||||
@ -178,6 +179,18 @@ export function resolveFearAndGreedIndex(aValue: number) {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveMarketCondition(
|
||||
aMarketCondition: Benchmark['marketCondition']
|
||||
) {
|
||||
if (aMarketCondition === 'BEAR_MARKET') {
|
||||
return { emoji: '🐻' };
|
||||
} else if (aMarketCondition === 'BULL_MARKET') {
|
||||
return { emoji: '🐮' };
|
||||
} else {
|
||||
return { emoji: '⚪' };
|
||||
}
|
||||
}
|
||||
|
||||
export const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
|
||||
export function parseDate(date: string) {
|
||||
|
11
libs/common/src/lib/interfaces/benchmark.interface.ts
Normal file
11
libs/common/src/lib/interfaces/benchmark.interface.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import { EnhancedSymbolProfile } from './enhanced-symbol-profile.interface';
|
||||
|
||||
export interface Benchmark {
|
||||
marketCondition: 'BEAR_MARKET' | 'BULL_MARKET' | 'NEUTRAL_MARKET';
|
||||
name: EnhancedSymbolProfile['name'];
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: number;
|
||||
};
|
||||
};
|
||||
}
|
@ -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[];
|
||||
}
|
@ -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();
|
||||
@ -50,40 +52,27 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((filterOrSearchTerm: Filter | string) => {
|
||||
if (filterOrSearchTerm) {
|
||||
this.filters$.next(
|
||||
this.allFilters
|
||||
.filter((filter) => {
|
||||
// Filter selected filters
|
||||
return !this.selectedFilters.some((selectedFilter) => {
|
||||
return selectedFilter.id === filter.id;
|
||||
});
|
||||
})
|
||||
.filter((filter) => {
|
||||
if (typeof filterOrSearchTerm === 'string') {
|
||||
return filter.label
|
||||
.toLowerCase()
|
||||
.startsWith(filterOrSearchTerm.toLowerCase());
|
||||
}
|
||||
const searchTerm =
|
||||
typeof filterOrSearchTerm === 'string'
|
||||
? filterOrSearchTerm
|
||||
: filterOrSearchTerm?.label;
|
||||
|
||||
return filter.label
|
||||
.toLowerCase()
|
||||
.startsWith(filterOrSearchTerm?.label?.toLowerCase());
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
);
|
||||
this.filterGroups$.next(this.getGroupedFilters(searchTerm));
|
||||
} else {
|
||||
this.filterGroups$.next(this.getGroupedFilters());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngOnChanges(changes: SimpleChanges) {
|
||||
if (changes.allFilters?.currentValue) {
|
||||
this.updateFilter();
|
||||
this.updateFilters();
|
||||
}
|
||||
}
|
||||
|
||||
public onAddFilter({ input, value }: MatChipInputEvent): void {
|
||||
if (value?.trim()) {
|
||||
this.updateFilter();
|
||||
this.updateFilters();
|
||||
}
|
||||
|
||||
// Reset the input value
|
||||
@ -99,12 +88,16 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
return filter.id !== aFilter.id;
|
||||
});
|
||||
|
||||
this.updateFilter();
|
||||
this.updateFilters();
|
||||
}
|
||||
|
||||
public onSelectFilter(event: MatAutocompleteSelectedEvent): void {
|
||||
this.selectedFilters.push(event.option.value);
|
||||
this.updateFilter();
|
||||
this.selectedFilters.push(
|
||||
this.allFilters.find((filter) => {
|
||||
return filter.id === event.option.value;
|
||||
})
|
||||
);
|
||||
this.updateFilters();
|
||||
this.searchInput.nativeElement.value = '';
|
||||
this.searchControl.setValue(null);
|
||||
}
|
||||
@ -114,8 +107,8 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private updateFilter() {
|
||||
this.filters$.next(
|
||||
private getGroupedFilters(searchTerm?: string) {
|
||||
const filterGroupsMap = groupBy(
|
||||
this.allFilters
|
||||
.filter((filter) => {
|
||||
// Filter selected filters
|
||||
@ -123,9 +116,44 @@ export class ActivitiesFilterComponent implements OnChanges, OnDestroy {
|
||||
return selectedFilter.id === filter.id;
|
||||
});
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label))
|
||||
.filter((filter) => {
|
||||
if (searchTerm) {
|
||||
// Filter by search term
|
||||
return filter.label
|
||||
.toLowerCase()
|
||||
.includes(searchTerm.toLowerCase());
|
||||
}
|
||||
|
||||
return filter;
|
||||
})
|
||||
.sort((a, b) => a.label.localeCompare(b.label)),
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
}
|
||||
);
|
||||
|
||||
const filterGroups: FilterGroup[] = [];
|
||||
|
||||
for (const type of Object.keys(filterGroupsMap)) {
|
||||
filterGroups.push({
|
||||
name: <Filter['type']>type,
|
||||
filters: filterGroupsMap[type]
|
||||
});
|
||||
}
|
||||
|
||||
return filterGroups
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((filterGroup) => {
|
||||
return {
|
||||
...filterGroup,
|
||||
filters: filterGroup.filters
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private updateFilters() {
|
||||
this.filterGroups$.next(this.getGroupedFilters());
|
||||
|
||||
// Emit an array with a new reference
|
||||
this.valueChanged.emit([...this.selectedFilters]);
|
||||
}
|
||||
|
@ -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;
|
||||
|
49
libs/ui/src/lib/benchmark/benchmark.component.html
Normal file
49
libs/ui/src/lib/benchmark/benchmark.component.html
Normal file
@ -0,0 +1,49 @@
|
||||
<div class="align-items-center d-flex">
|
||||
<div *ngIf="benchmark?.name" class="flex-grow-1 text-truncate">
|
||||
{{ benchmark.name }}
|
||||
</div>
|
||||
<div *ngIf="!benchmark?.name" class="flex-grow-1">
|
||||
<ngx-skeleton-loader
|
||||
animation="pulse"
|
||||
[theme]="{
|
||||
width: '67%'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
</div>
|
||||
<gf-value
|
||||
class="mx-2"
|
||||
size="medium"
|
||||
[isPercent]="true"
|
||||
[locale]="locale"
|
||||
[ngClass]="{
|
||||
'text-danger':
|
||||
benchmark?.performances?.allTimeHigh?.performancePercent < 0,
|
||||
'text-success':
|
||||
benchmark?.performances?.allTimeHigh?.performancePercent > 0
|
||||
}"
|
||||
[value]="
|
||||
benchmark?.performances?.allTimeHigh?.performancePercent ?? undefined
|
||||
"
|
||||
></gf-value>
|
||||
<div class="text-muted">
|
||||
<small class="d-none d-sm-block text-nowrap" i18n>from All Time High</small
|
||||
><small class="d-block d-sm-none text-nowrap" i18n>from ATH</small>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<div
|
||||
*ngIf="benchmark?.marketCondition"
|
||||
[title]="benchmark?.marketCondition"
|
||||
>
|
||||
{{ resolveMarketCondition(benchmark.marketCondition).emoji }}
|
||||
</div>
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="!benchmark?.marketCondition"
|
||||
animation="pulse"
|
||||
appearance="circle"
|
||||
[theme]="{
|
||||
height: '1rem',
|
||||
width: '1rem'
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
</div>
|
||||
</div>
|
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;
|
||||
}
|
18
libs/ui/src/lib/benchmark/benchmark.component.ts
Normal file
18
libs/ui/src/lib/benchmark/benchmark.component.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
|
||||
import { resolveMarketCondition } from '@ghostfolio/common/helper';
|
||||
import { Benchmark } from '@ghostfolio/common/interfaces';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-benchmark',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
templateUrl: './benchmark.component.html',
|
||||
styleUrls: ['./benchmark.component.scss']
|
||||
})
|
||||
export class BenchmarkComponent {
|
||||
@Input() benchmark: Benchmark;
|
||||
@Input() locale: string;
|
||||
|
||||
public resolveMarketCondition = resolveMarketCondition;
|
||||
|
||||
public constructor() {}
|
||||
}
|
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';
|
@ -13,6 +13,7 @@ import {
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { FormBuilder, FormControl } from '@angular/forms';
|
||||
import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
|
||||
import { primaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { transformTickToAbbreviation } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
@ -182,10 +183,7 @@ export class FireCalculatorComponent
|
||||
options: {
|
||||
plugins: {
|
||||
tooltip: {
|
||||
itemSort: (a, b) => {
|
||||
// Reverse order
|
||||
return b.datasetIndex - a.datasetIndex;
|
||||
},
|
||||
...getTooltipOptions(),
|
||||
mode: 'index',
|
||||
callbacks: {
|
||||
footer: (items) => {
|
||||
|
@ -10,8 +10,17 @@ import {
|
||||
OnDestroy,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import {
|
||||
getTooltipOptions,
|
||||
getTooltipPositionerMapTop,
|
||||
getVerticalHoverLinePlugin
|
||||
} from '@ghostfolio/common/chart-helper';
|
||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||
import { getBackgroundColor } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
getBackgroundColor,
|
||||
getDateFormatString,
|
||||
getTextColor
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Chart,
|
||||
Filler,
|
||||
@ -19,7 +28,8 @@ import {
|
||||
LineElement,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
TimeScale
|
||||
TimeScale,
|
||||
Tooltip
|
||||
} from 'chart.js';
|
||||
|
||||
import { LineChartItem } from './interfaces/line-chart.interface';
|
||||
@ -33,7 +43,9 @@ import { LineChartItem } from './interfaces/line-chart.interface';
|
||||
export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
@Input() benchmarkDataItems: LineChartItem[] = [];
|
||||
@Input() benchmarkLabel = '';
|
||||
@Input() currency: string;
|
||||
@Input() historicalDataItems: LineChartItem[];
|
||||
@Input() locale: string;
|
||||
@Input() showGradient = false;
|
||||
@Input() showLegend = false;
|
||||
@Input() showLoader = true;
|
||||
@ -57,8 +69,12 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
LineElement,
|
||||
PointElement,
|
||||
LinearScale,
|
||||
TimeScale
|
||||
TimeScale,
|
||||
Tooltip
|
||||
);
|
||||
|
||||
Tooltip.positioners['top'] = (elements, position) =>
|
||||
getTooltipPositionerMapTop(this.chart, position);
|
||||
}
|
||||
|
||||
public ngAfterViewInit() {
|
||||
@ -142,26 +158,43 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
if (this.chartCanvas) {
|
||||
if (this.chart) {
|
||||
this.chart.data = data;
|
||||
this.chart.options.plugins.tooltip = <unknown>(
|
||||
this.getTooltipPluginConfiguration()
|
||||
);
|
||||
this.chart.update();
|
||||
} else {
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
data,
|
||||
options: {
|
||||
animation: false,
|
||||
plugins: {
|
||||
elements: {
|
||||
point: {
|
||||
hoverBackgroundColor: getBackgroundColor(),
|
||||
hoverRadius: 2
|
||||
}
|
||||
},
|
||||
interaction: { intersect: false, mode: 'index' },
|
||||
plugins: <unknown>{
|
||||
legend: {
|
||||
align: 'start',
|
||||
display: this.showLegend,
|
||||
position: 'bottom'
|
||||
},
|
||||
tooltip: this.getTooltipPluginConfiguration(),
|
||||
verticalHoverLine: {
|
||||
color: `rgba(${getTextColor()}, 0.1)`
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: this.showXAxis,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
time: {
|
||||
tooltipFormat: getDateFormatString(this.locale),
|
||||
unit: 'year'
|
||||
},
|
||||
type: 'time'
|
||||
@ -169,6 +202,8 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
y: {
|
||||
display: this.showYAxis,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
max: this.yMax,
|
||||
@ -204,6 +239,7 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
},
|
||||
spanGaps: true
|
||||
},
|
||||
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
|
||||
type: 'line'
|
||||
});
|
||||
}
|
||||
@ -211,4 +247,14 @@ export class LineChartComponent implements AfterViewInit, OnChanges, OnDestroy {
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private getTooltipPluginConfiguration() {
|
||||
return {
|
||||
...getTooltipOptions(this.currency, this.locale),
|
||||
mode: 'index',
|
||||
position: <unknown>'top',
|
||||
xAlign: 'center',
|
||||
yAlign: 'bottom'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -10,6 +10,7 @@ import {
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { getTooltipOptions } from '@ghostfolio/common/chart-helper';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { getTextColor } from '@ghostfolio/common/helper';
|
||||
import { PortfolioPosition, UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
@ -247,6 +248,12 @@ export class PortfolioProportionChartComponent
|
||||
datasets[0].data[0] = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
if (datasets[1]?.data?.length === 0 || datasets[1]?.data?.[1] === 0) {
|
||||
labels = [''];
|
||||
datasets[1].backgroundColor = [this.colorMap[UNKNOWN_KEY]];
|
||||
datasets[1].data[1] = Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
const data: ChartConfiguration['data'] = {
|
||||
datasets,
|
||||
labels
|
||||
@ -255,8 +262,9 @@ export class PortfolioProportionChartComponent
|
||||
if (this.chartCanvas) {
|
||||
if (this.chart) {
|
||||
this.chart.data = data;
|
||||
this.chart.options.plugins.tooltip =
|
||||
this.getTooltipPluginConfiguration(data);
|
||||
this.chart.options.plugins.tooltip = <unknown>(
|
||||
this.getTooltipPluginConfiguration(data)
|
||||
);
|
||||
this.chart.update();
|
||||
} else {
|
||||
this.chart = new Chart(this.chartCanvas.nativeElement, {
|
||||
@ -339,6 +347,7 @@ export class PortfolioProportionChartComponent
|
||||
|
||||
private getTooltipPluginConfiguration(data: ChartConfiguration['data']) {
|
||||
return {
|
||||
...getTooltipOptions(this.baseCurrency, this.locale),
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const labelIndex =
|
||||
|
@ -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>
|
||||
|
13
package.json
13
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ghostfolio",
|
||||
"version": "1.148.0",
|
||||
"version": "1.155.0",
|
||||
"homepage": "https://ghostfol.io",
|
||||
"license": "AGPL-3.0",
|
||||
"scripts": {
|
||||
@ -72,12 +72,10 @@
|
||||
"@nestjs/schedule": "1.0.2",
|
||||
"@nestjs/serve-static": "2.2.2",
|
||||
"@nrwl/angular": "14.1.4",
|
||||
"@prisma/client": "3.12.0",
|
||||
"@prisma/client": "3.14.0",
|
||||
"@simplewebauthn/browser": "4.1.0",
|
||||
"@simplewebauthn/server": "4.1.0",
|
||||
"@simplewebauthn/typescript-types": "4.0.0",
|
||||
"@stripe/stripe-js": "1.22.0",
|
||||
"@types/papaparse": "5.2.6",
|
||||
"alphavantage": "2.2.0",
|
||||
"angular-material-css-vars": "3.0.0",
|
||||
"bent": "7.3.12",
|
||||
@ -111,13 +109,11 @@
|
||||
"passport": "0.4.1",
|
||||
"passport-google-oauth20": "2.0.0",
|
||||
"passport-jwt": "4.0.0",
|
||||
"prisma": "3.12.0",
|
||||
"prisma": "3.14.0",
|
||||
"reflect-metadata": "0.1.13",
|
||||
"round-to": "5.0.0",
|
||||
"rxjs": "7.4.0",
|
||||
"stripe": "8.199.0",
|
||||
"svgmap": "2.6.0",
|
||||
"tslib": "2.0.0",
|
||||
"twitter-api-v2": "1.10.3",
|
||||
"uuid": "8.3.2",
|
||||
"yahoo-finance2": "2.3.2",
|
||||
@ -143,6 +139,7 @@
|
||||
"@nrwl/nx-cloud": "14.0.3",
|
||||
"@nrwl/storybook": "14.1.4",
|
||||
"@nrwl/workspace": "14.1.4",
|
||||
"@simplewebauthn/typescript-types": "4.0.0",
|
||||
"@storybook/addon-essentials": "6.4.22",
|
||||
"@storybook/angular": "6.4.22",
|
||||
"@storybook/builder-webpack5": "6.4.22",
|
||||
@ -156,6 +153,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 +173,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"
|
||||
|
@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "DataSource" ADD VALUE 'EOD_HISTORICAL_DATA';
|
@ -201,6 +201,7 @@ enum AssetSubClass {
|
||||
|
||||
enum DataSource {
|
||||
ALPHA_VANTAGE
|
||||
EOD_HISTORICAL_DATA
|
||||
GHOSTFOLIO
|
||||
GOOGLE_SHEETS
|
||||
MANUAL
|
||||
|
41
yarn.lock
41
yarn.lock
@ -3596,22 +3596,22 @@
|
||||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.10.1.tgz#728ecd95ab207aab8a9a4e421f0422db329232be"
|
||||
integrity sha512-HnUhk1Sy9IuKrxEMdIRCxpIqPw6BFsbYSEUO9p/hNw5sMld/+3OLMWQP80F8/db9qsv3qUjs7ZR5bS/R+iinXw==
|
||||
|
||||
"@prisma/client@3.12.0":
|
||||
version "3.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.12.0.tgz#a0eb49ffea5c128dd11dffb896d7139a60073d12"
|
||||
integrity sha512-4NEQjUcWja/NVBvfuDFscWSk1/rXg3+wj+TSkqXCb1tKlx/bsUE00rxsvOvGg7VZ6lw1JFpGkwjwmsOIc4zvQw==
|
||||
"@prisma/client@3.14.0":
|
||||
version "3.14.0"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/client/-/client-3.14.0.tgz#bb90405c012fcca11f4647d91153ed4c58f3bd48"
|
||||
integrity sha512-atb41UpgTR1MCst0VIbiHTMw8lmXnwUvE1KyUCAkq08+wJyjRE78Due+nSf+7uwqQn+fBFYVmoojtinhlLOSaA==
|
||||
dependencies:
|
||||
"@prisma/engines-version" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
|
||||
"@prisma/engines-version" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
|
||||
|
||||
"@prisma/engines-version@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
|
||||
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#829ca3d9d0d92555f44644606d4edfd45b2f5886"
|
||||
integrity sha512-o+jo8d7ZEiVpcpNWUDh3fj2uPQpBxl79XE9ih9nkogJbhw6P33274SHnqheedZ7PyvPIK/mvU8MLNYgetgXPYw==
|
||||
"@prisma/engines-version@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a":
|
||||
version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines-version/-/engines-version-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#4edae57cf6527f35e22cebe75e49214fc0e99ac9"
|
||||
integrity sha512-D+yHzq4a2r2Rrd0ZOW/mTZbgDIkUkD8ofKgusEI1xPiZz60Daks+UM7Me2ty5FzH3p/TgyhBpRrfIHx+ha20RQ==
|
||||
|
||||
"@prisma/engines@3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980":
|
||||
version "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980.tgz#e52e364084c4d05278f62768047b788665e64a45"
|
||||
integrity sha512-zULjkN8yhzS7B3yeEz4aIym4E2w1ChrV12i14pht3ePFufvsAvBSoZ+tuXMvfSoNTgBS5E4bolRzLbMmbwkkMQ==
|
||||
"@prisma/engines@3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a":
|
||||
version "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
|
||||
resolved "https://registry.yarnpkg.com/@prisma/engines/-/engines-3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a.tgz#7fa11bc26a51d450185c816cc0ab8cac673fb4bf"
|
||||
integrity sha512-LwZvI3FY6f43xFjQNRuE10JM5R8vJzFTSmbV9X0Wuhv9kscLkjRlZt0BEoiHmO+2HA3B3xxbMfB5du7ZoSFXGg==
|
||||
|
||||
"@samverschueren/stream-to-observable@^0.3.0":
|
||||
version "0.3.1"
|
||||
@ -15660,12 +15660,12 @@ pretty-hrtime@^1.0.3:
|
||||
resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1"
|
||||
integrity sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=
|
||||
|
||||
prisma@3.12.0:
|
||||
version "3.12.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.12.0.tgz#9675e0e72407122759d3eadcb6d27cdccd3497bd"
|
||||
integrity sha512-ltCMZAx1i0i9xuPM692Srj8McC665h6E5RqJom999sjtVSccHSD8Z+HSdBN2183h9PJKvC5dapkn78dd0NWMBg==
|
||||
prisma@3.14.0:
|
||||
version "3.14.0"
|
||||
resolved "https://registry.yarnpkg.com/prisma/-/prisma-3.14.0.tgz#dd67ece37d7b5373e9fd9588971de0024b49be81"
|
||||
integrity sha512-l9MOgNCn/paDE+i1K2fp9NZ+Du4trzPTJsGkaQHVBufTGqzoYHuNk8JfzXuIn0Gte6/ZjyKj652Jq/Lc1tp2yw==
|
||||
dependencies:
|
||||
"@prisma/engines" "3.12.0-37.22b822189f46ef0dc5c5b503368d1bee01213980"
|
||||
"@prisma/engines" "3.14.0-36.2b0c12756921c891fec4f68d9444e18c7d5d4a6a"
|
||||
|
||||
prismjs@^1.21.0, prismjs@~1.24.0:
|
||||
version "1.24.1"
|
||||
@ -16563,11 +16563,6 @@ ripemd160@^2.0.0, ripemd160@^2.0.1:
|
||||
hash-base "^3.0.0"
|
||||
inherits "^2.0.1"
|
||||
|
||||
round-to@5.0.0:
|
||||
version "5.0.0"
|
||||
resolved "https://registry.yarnpkg.com/round-to/-/round-to-5.0.0.tgz#a66292701a93b194f630a0d57f04c08821b6eeed"
|
||||
integrity sha512-i4+Ntwmo5kY7UWWFSDEVN3RjT2PX1FqkZ9iCcAO3sKML3Ady9NgsjM/HLdYKUAnrxK4IlSvXzpBMDvMHZQALRQ==
|
||||
|
||||
rsvp@^4.8.4:
|
||||
version "4.8.5"
|
||||
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"
|
||||
|
Reference in New Issue
Block a user