Compare commits
92 Commits
Author | SHA1 | Date | |
---|---|---|---|
32fe3e195f | |||
805f4b05be | |||
5b51a6840a | |||
36bd6164e6 | |||
eac52a215b | |||
9ff8cd5471 | |||
33cc7e4e7e | |||
47f84dab06 | |||
384d18b2a6 | |||
2363983bdc | |||
4af76764be | |||
a65424aafa | |||
f9cd629470 | |||
ccb8c86596 | |||
246de7aa86 | |||
a323313c71 | |||
538c8947cd | |||
1ec5fd12fe | |||
4376b8903e | |||
a8e096f9ac | |||
8e577592f6 | |||
c896bf9199 | |||
16145f18d9 | |||
5398da0dc8 | |||
2466f4ff5d | |||
8f3a9bdfbb | |||
44dfd2bd48 | |||
3fc2228f1d | |||
b018819a1f | |||
ac9311d783 | |||
e23ce0f35d | |||
f4b52aa41c | |||
655b040d4d | |||
0f637a5d0f | |||
3f85c327f5 | |||
c2df99072d | |||
e8afbcad9c | |||
e6d8de781b | |||
6e1935899f | |||
169cb85b66 | |||
fe6658d0ac | |||
1f0381228e | |||
f4b63b5de5 | |||
e45a0ad068 | |||
81c6cc021d | |||
859b24aa5b | |||
2bc325f182 | |||
a6186c23e2 | |||
cf234003ec | |||
8d3954304e | |||
9562139fa6 | |||
c857ea9a8f | |||
5c9fa71d95 | |||
fefbfa31d1 | |||
93a1fae51c | |||
3715edd9ba | |||
e3916e1ba3 | |||
76ceac4edc | |||
333b63bfe2 | |||
3006c21b12 | |||
f01a3f893d | |||
72974e888f | |||
0cee7a0b35 | |||
f3d337b044 | |||
7667af059c | |||
1095b47f45 | |||
dacd7271eb | |||
e093041184 | |||
8f2caa508a | |||
862f670ccf | |||
54bf4c7a43 | |||
c0ace51ee9 | |||
b1b5689242 | |||
b68cdaf8ea | |||
b387a80a0d | |||
6e4660295a | |||
d4c3a9d1e8 | |||
263f6b32f2 | |||
637f31ae3b | |||
547e27c7a1 | |||
f10dc176f2 | |||
0a966e46cd | |||
4f281d25e1 | |||
aaba8c35c2 | |||
7d27cb3398 | |||
91678028b5 | |||
5e3cac8ac9 | |||
33f20b6b48 | |||
e4fd255dd7 | |||
e320aa91f7 | |||
0fcfa6c1bd | |||
42d32ed652 |
45
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: '[BUG]'
|
||||
labels: ''
|
||||
assignees: ''
|
||||
---
|
||||
|
||||
The Issue tracker is **ONLY** used for reporting bugs. New features should be discussed on our [Slack channel](https://ghostfolio.slack.com) or in [Discussions](https://github.com/ghostfolio/ghostfolio/discussions).
|
||||
|
||||
**Bug Description**
|
||||
|
||||
<!-- A clear and concise description of what the bug is. -->
|
||||
|
||||
**To Reproduce**
|
||||
|
||||
<!-- Steps to reproduce the behavior -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
**Expected behavior**
|
||||
|
||||
<!-- A clear and concise description of what you expected to happen. -->
|
||||
|
||||
**Screenshots**
|
||||
|
||||
<!-- If applicable, add screenshots to help explain your problem. -->
|
||||
|
||||
**Logs**
|
||||
|
||||
<!-- If applicable, add logs to help explain your problem. -->
|
||||
|
||||
**Environment**
|
||||
|
||||
<!-- Please complete the following information -->
|
||||
|
||||
- Ghostfolio Version X.Y.Z
|
||||
- Browser
|
||||
- OS
|
||||
|
||||
**Additional context**
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
@ -1,4 +1,13 @@
|
||||
{
|
||||
"attributeGroups": [
|
||||
"$ANGULAR_ELEMENT_REF",
|
||||
"$ANGULAR_STRUCTURAL_DIRECTIVE",
|
||||
"$DEFAULT",
|
||||
"$ANGULAR_INPUT",
|
||||
"$ANGULAR_TWO_WAY_BINDING",
|
||||
"$ANGULAR_OUTPUT"
|
||||
],
|
||||
"attributeSort": "ASC",
|
||||
"endOfLine": "auto",
|
||||
"printWidth": 80,
|
||||
"singleQuote": true,
|
||||
|
183
CHANGELOG.md
@ -5,6 +5,183 @@ 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.205.2 - 16.10.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Persisted the language on url change
|
||||
- Improved the portfolio evolution chart
|
||||
- Refactored the appearance (dark mode) in user settings (from `appearance` to `colorScheme`)
|
||||
- Improved the wording on the landing page
|
||||
|
||||
## 1.204.1 - 15.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to change the appearance (dark mode) in user settings
|
||||
- Added the total amount chart to the investment timeline
|
||||
- Setup the `prettier` plugin `prettier-plugin-organize-attributes`
|
||||
|
||||
### Changed
|
||||
|
||||
- Respected the current date in the _FIRE_ calculator
|
||||
- Simplified the settings management in the admin control panel
|
||||
- Renamed the data source type `RAKUTEN` to `RAPID_API`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed some links in the blog posts
|
||||
- Fixed the alignment of the value component on the allocations page
|
||||
|
||||
### Todo
|
||||
|
||||
- Rename the environment variable from `RAKUTEN_RAPID_API_KEY` to `RAPID_API_API_KEY`
|
||||
|
||||
## 1.203.0 - 08.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Supported a progressive line animation in the line chart component
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the benchmark comparator from experimental to general availability
|
||||
- Improved the user interface of the benchmark comparator
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed an issue in the performance and chart calculation of today
|
||||
- Fixed the alignment of the value component in the admin control panel
|
||||
|
||||
## 1.202.0 - 07.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support for a translated 4% rule in the _FIRE_ section
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the caching of the benchmarks in the markets overview (only cache if fetching was successful)
|
||||
- Improved the wording in the twitter bot service
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the support for cryptocurrencies having a symbol with less than 3 characters (e.g. `SC-USD`)
|
||||
- Fixed the text truncation in the value component
|
||||
|
||||
## 1.201.0 - 01.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a blog post: _Hacktoberfest 2022_
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the usage of the value component in the admin control panel
|
||||
- Improved the language localization for Español (`es`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the usage of the value component on the allocations page
|
||||
|
||||
## 1.200.0 - 01.10.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added a mini statistics section to the landing page including pulls on _Docker Hub_
|
||||
- Added an _As seen in_ section to the landing page
|
||||
- Added support for an icon in the value component
|
||||
|
||||
### Changed
|
||||
|
||||
- Upgraded `prisma` from version `4.1.1` to `4.4.0`
|
||||
|
||||
## 1.199.1 - 27.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Español (`es`)
|
||||
- Added support for sectors in mutual funds
|
||||
|
||||
## 1.198.0 - 25.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added support to exclude an account from analysis
|
||||
- Set up the language localization for Nederlands (`nl`)
|
||||
|
||||
## 1.197.0 - 24.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added the value of the active filter in percentage on the allocations page
|
||||
- Extended the feature overview page by multi-language support (English, German, Italian)
|
||||
|
||||
### Changed
|
||||
|
||||
- Combined the performance and chart calculation
|
||||
- Improved the style of various selectors (density)
|
||||
|
||||
## 1.196.0 - 22.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Set up the language localization for Italiano (`it`)
|
||||
- Extended the landing page
|
||||
|
||||
## 1.195.0 - 20.09.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the algorithm of the performance chart calculation
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the chart tooltip of the benchmark comparator
|
||||
|
||||
## 1.194.0 - 17.09.2022
|
||||
|
||||
### Added
|
||||
|
||||
- Added `NODE_ENV: production` to the `docker-compose` files (`docker-compose.yml` and `docker-compose.build.yml`)
|
||||
- Visualized the percentage of the active filter on the allocations page
|
||||
|
||||
### Changed
|
||||
|
||||
- Improved the language localization for German (`de`)
|
||||
|
||||
### Fixed
|
||||
|
||||
- Respected the end date in the performance chart calculation
|
||||
|
||||
### Todo
|
||||
|
||||
- Set `NODE_ENV: production` as in [docker-compose.yml](https://github.com/ghostfolio/ghostfolio/blob/main/docker/docker-compose.yml)
|
||||
|
||||
## 1.193.0 - 14.09.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Sorted the benchmarks by name
|
||||
- Extended the pricing page
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the calculations of the exchange rate service by changing `USD` to the base currency
|
||||
- Fixed the missing assets during the local development
|
||||
|
||||
## 1.192.0 - 11.09.2022
|
||||
|
||||
### Changed
|
||||
|
||||
- Simplified the configuration of the benchmarks: `symbolProfileId` instead of `dataSource` and `symbol`
|
||||
- Upgraded `yahoo-finance2` from version `2.3.3` to `2.3.6`
|
||||
|
||||
### Fixed
|
||||
|
||||
- Improved the loading indicator of the benchmark comparator
|
||||
- Improved the error handling in the benchmark calculation
|
||||
|
||||
## 1.191.0 - 10.09.2022
|
||||
|
||||
### Changed
|
||||
@ -142,7 +319,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Added
|
||||
|
||||
- Set up `ng-extract-i18n-merge` to improve the i18n extraction and merge workflow
|
||||
- Set up language localization for German (`de`)
|
||||
- Set up the language localization for German (`de`)
|
||||
- Resolved the feature graphic of the blog post
|
||||
|
||||
### Changed
|
||||
@ -804,8 +981,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Changed
|
||||
|
||||
- Display the value in base currency in the accounts table on mobile
|
||||
- Display the value in base currency in the activities table on mobile
|
||||
- Displayed the value in base currency in the accounts table on mobile
|
||||
- Displayed the value in base currency in the activities table on mobile
|
||||
- Renamed `orders` to `activities` in import and export functionality
|
||||
- Harmonized the algebraic sign of `currentGrossPerformancePercent` and `currentNetPerformancePercent` with `currentGrossPerformance` and `currentNetPerformance`
|
||||
- Improved the pricing page
|
||||
|
@ -153,6 +153,7 @@ Please follow the instructions of the Ghostfolio [Unraid Community App](https://
|
||||
### Setup
|
||||
|
||||
1. Run `yarn install`
|
||||
1. Run `yarn build:dev` to build the source code including the assets
|
||||
1. Run `docker-compose --env-file ./.env -f docker/docker-compose.dev.yml up -d` to start [PostgreSQL](https://www.postgresql.org) and [Redis](https://redis.io)
|
||||
1. Run `yarn database:setup` to initialize the database schema and populate your database with (example) data
|
||||
1. Start the server and the client (see [_Development_](#Development))
|
||||
|
40
angular.json
@ -136,6 +136,18 @@
|
||||
"baseHref": "/en/",
|
||||
"localize": ["en"]
|
||||
},
|
||||
"development-es": {
|
||||
"baseHref": "/es/",
|
||||
"localize": ["es"]
|
||||
},
|
||||
"development-it": {
|
||||
"baseHref": "/it/",
|
||||
"localize": ["it"]
|
||||
},
|
||||
"development-nl": {
|
||||
"baseHref": "/nl/",
|
||||
"localize": ["nl"]
|
||||
},
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
@ -180,6 +192,15 @@
|
||||
"development-en": {
|
||||
"browserTarget": "client:build:development-en"
|
||||
},
|
||||
"development-es": {
|
||||
"browserTarget": "client:build:development-es"
|
||||
},
|
||||
"development-it": {
|
||||
"browserTarget": "client:build:development-it"
|
||||
},
|
||||
"development-nl": {
|
||||
"browserTarget": "client:build:development-nl"
|
||||
},
|
||||
"production": {
|
||||
"browserTarget": "client:build:production"
|
||||
}
|
||||
@ -191,7 +212,12 @@
|
||||
"browserTarget": "client:build",
|
||||
"includeContext": true,
|
||||
"outputPath": "src/locales",
|
||||
"targetFiles": ["messages.de.xlf"]
|
||||
"targetFiles": [
|
||||
"messages.de.xlf",
|
||||
"messages.es.xlf",
|
||||
"messages.it.xlf",
|
||||
"messages.nl.xlf"
|
||||
]
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
@ -214,6 +240,18 @@
|
||||
"de": {
|
||||
"baseHref": "/de/",
|
||||
"translation": "apps/client/src/locales/messages.de.xlf"
|
||||
},
|
||||
"es": {
|
||||
"baseHref": "/es/",
|
||||
"translation": "apps/client/src/locales/messages.es.xlf"
|
||||
},
|
||||
"it": {
|
||||
"baseHref": "/it/",
|
||||
"translation": "apps/client/src/locales/messages.it.xlf"
|
||||
},
|
||||
"nl": {
|
||||
"baseHref": "/nl/",
|
||||
"translation": "apps/client/src/locales/messages.nl.xlf"
|
||||
}
|
||||
},
|
||||
"sourceLocale": "en"
|
||||
|
@ -95,9 +95,10 @@ export class AccountController {
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id
|
||||
);
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
@ -137,10 +138,11 @@ export class AccountController {
|
||||
);
|
||||
|
||||
let accountsWithAggregations =
|
||||
await this.portfolioService.getAccountsWithAggregations(
|
||||
impersonationUserId || this.request.user.id,
|
||||
[{ id, type: 'ACCOUNT' }]
|
||||
);
|
||||
await this.portfolioService.getAccountsWithAggregations({
|
||||
filters: [{ id, type: 'ACCOUNT' }],
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationUserId ||
|
||||
|
@ -107,15 +107,23 @@ export class AccountService {
|
||||
public async getCashDetails({
|
||||
currency,
|
||||
filters = [],
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
currency: string;
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<CashDetails> {
|
||||
let totalCashBalanceInBaseCurrency = new Big(0);
|
||||
|
||||
const where: Prisma.AccountWhereInput = { userId };
|
||||
const where: Prisma.AccountWhereInput = {
|
||||
userId
|
||||
};
|
||||
|
||||
if (withExcludedAccounts === false) {
|
||||
where.isExcluded = false;
|
||||
}
|
||||
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateAccountDto {
|
||||
@IsString()
|
||||
@ -11,6 +17,10 @@ export class CreateAccountDto {
|
||||
@IsString()
|
||||
currency: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
@ -1,5 +1,11 @@
|
||||
import { AccountType } from '@prisma/client';
|
||||
import { IsNumber, IsString, ValidateIf } from 'class-validator';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateIf
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateAccountDto {
|
||||
@IsString()
|
||||
@ -14,6 +20,10 @@ export class UpdateAccountDto {
|
||||
@IsString()
|
||||
id: string;
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
isExcluded?: boolean;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
|
@ -181,10 +181,10 @@ export class AdminService {
|
||||
public async putSetting(key: string, value: string) {
|
||||
let response: Property;
|
||||
|
||||
if (value === '') {
|
||||
response = await this.propertyService.delete({ key });
|
||||
} else {
|
||||
if (value) {
|
||||
response = await this.propertyService.put({ key, value });
|
||||
} else {
|
||||
response = await this.propertyService.delete({ key });
|
||||
}
|
||||
|
||||
if (key === PROPERTY_CURRENCIES) {
|
||||
|
@ -4,7 +4,10 @@ import { DataProviderService } from '@ghostfolio/api/services/data-provider/data
|
||||
import { MarketDataService } from '@ghostfolio/api/services/market-data.service';
|
||||
import { PropertyService } from '@ghostfolio/api/services/property/property.service';
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import { PROPERTY_BENCHMARKS } from '@ghostfolio/common/config';
|
||||
import {
|
||||
MAX_CHART_ITEMS,
|
||||
PROPERTY_BENCHMARKS
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
BenchmarkMarketDataDetails,
|
||||
@ -12,10 +15,10 @@ import {
|
||||
UniqueAsset
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import { format } from 'date-fns';
|
||||
import ms from 'ms';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
@Injectable()
|
||||
export class BenchmarkService {
|
||||
@ -31,7 +34,11 @@ export class BenchmarkService {
|
||||
) {}
|
||||
|
||||
public calculateChangeInPercentage(baseValue: number, currentValue: number) {
|
||||
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||
if (baseValue && currentValue) {
|
||||
return new Big(currentValue).div(baseValue).minus(1).toNumber();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public async getBenchmarks({ useCache = true } = {}): Promise<
|
||||
@ -51,25 +58,26 @@ export class BenchmarkService {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const benchmarkAssets: UniqueAsset[] =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as UniqueAsset[]) ?? [];
|
||||
const benchmarkAssetProfiles = await this.getBenchmarkAssetProfiles();
|
||||
|
||||
const promises: Promise<number>[] = [];
|
||||
|
||||
const [quotes, assetProfiles] = await Promise.all([
|
||||
this.dataProviderService.getQuotes(benchmarkAssets),
|
||||
this.symbolProfileService.getSymbolProfiles(benchmarkAssets)
|
||||
]);
|
||||
const quotes = await this.dataProviderService.getQuotes(
|
||||
benchmarkAssetProfiles.map(({ dataSource, symbol }) => {
|
||||
return { dataSource, symbol };
|
||||
})
|
||||
);
|
||||
|
||||
for (const benchmarkAsset of benchmarkAssets) {
|
||||
promises.push(this.marketDataService.getMax(benchmarkAsset));
|
||||
for (const { dataSource, symbol } of benchmarkAssetProfiles) {
|
||||
promises.push(this.marketDataService.getMax({ dataSource, symbol }));
|
||||
}
|
||||
|
||||
const allTimeHighs = await Promise.all(promises);
|
||||
let storeInCache = true;
|
||||
|
||||
benchmarks = allTimeHighs.map((allTimeHigh, index) => {
|
||||
const { marketPrice } = quotes[benchmarkAssets[index].symbol] ?? {};
|
||||
const { marketPrice } =
|
||||
quotes[benchmarkAssetProfiles[index].symbol] ?? {};
|
||||
|
||||
let performancePercentFromAllTimeHigh = 0;
|
||||
|
||||
@ -78,18 +86,15 @@ export class BenchmarkService {
|
||||
allTimeHigh,
|
||||
marketPrice
|
||||
);
|
||||
} else {
|
||||
storeInCache = false;
|
||||
}
|
||||
|
||||
return {
|
||||
marketCondition: this.getMarketCondition(
|
||||
performancePercentFromAllTimeHigh
|
||||
),
|
||||
name: assetProfiles.find(({ dataSource, symbol }) => {
|
||||
return (
|
||||
dataSource === benchmarkAssets[index].dataSource &&
|
||||
symbol === benchmarkAssets[index].symbol
|
||||
);
|
||||
})?.name,
|
||||
name: benchmarkAssetProfiles[index].name,
|
||||
performances: {
|
||||
allTimeHigh: {
|
||||
performancePercent: performancePercentFromAllTimeHigh
|
||||
@ -98,31 +103,39 @@ export class BenchmarkService {
|
||||
};
|
||||
});
|
||||
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
if (storeInCache) {
|
||||
await this.redisCacheService.set(
|
||||
this.CACHE_KEY_BENCHMARKS,
|
||||
JSON.stringify(benchmarks),
|
||||
ms('4 hours') / 1000
|
||||
);
|
||||
}
|
||||
|
||||
return benchmarks;
|
||||
}
|
||||
|
||||
public async getBenchmarkAssetProfiles(): Promise<UniqueAsset[]> {
|
||||
const benchmarkAssets: UniqueAsset[] =
|
||||
((await this.propertyService.getByKey(
|
||||
PROPERTY_BENCHMARKS
|
||||
)) as UniqueAsset[]) ?? [];
|
||||
|
||||
const assetProfiles = await this.symbolProfileService.getSymbolProfiles(
|
||||
benchmarkAssets
|
||||
);
|
||||
|
||||
return assetProfiles.map(({ dataSource, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
symbol
|
||||
};
|
||||
public async getBenchmarkAssetProfiles(): Promise<Partial<SymbolProfile>[]> {
|
||||
const symbolProfileIds: string[] = (
|
||||
((await this.propertyService.getByKey(PROPERTY_BENCHMARKS)) as {
|
||||
symbolProfileId: string;
|
||||
}[]) ?? []
|
||||
).map(({ symbolProfileId }) => {
|
||||
return symbolProfileId;
|
||||
});
|
||||
|
||||
const assetProfiles =
|
||||
await this.symbolProfileService.getSymbolProfilesByIds(symbolProfileIds);
|
||||
|
||||
return assetProfiles
|
||||
.map(({ dataSource, id, name, symbol }) => {
|
||||
return {
|
||||
dataSource,
|
||||
id,
|
||||
name,
|
||||
symbol
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
public async getMarketDataBySymbol({
|
||||
@ -151,28 +164,44 @@ export class BenchmarkService {
|
||||
})
|
||||
]);
|
||||
|
||||
marketDataItems.push({
|
||||
...currentSymbolItem,
|
||||
createdAt: new Date(),
|
||||
date: new Date(),
|
||||
id: uuidv4()
|
||||
});
|
||||
const step = Math.round(
|
||||
marketDataItems.length / Math.min(marketDataItems.length, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
const marketPriceAtStartDate = marketDataItems?.[0]?.marketPrice ?? 0;
|
||||
return {
|
||||
marketData: marketDataItems.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
const response = {
|
||||
marketData: [
|
||||
...marketDataItems
|
||||
.filter((marketDataItem, index) => {
|
||||
return index % step === 0;
|
||||
})
|
||||
.map((marketDataItem) => {
|
||||
return {
|
||||
date: format(marketDataItem.date, DATE_FORMAT),
|
||||
value:
|
||||
marketPriceAtStartDate === 0
|
||||
? 0
|
||||
: this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
marketDataItem.marketPrice
|
||||
) * 100
|
||||
};
|
||||
})
|
||||
]
|
||||
};
|
||||
|
||||
if (currentSymbolItem?.marketPrice) {
|
||||
response.marketData.push({
|
||||
date: format(new Date(), DATE_FORMAT),
|
||||
value:
|
||||
this.calculateChangeInPercentage(
|
||||
marketPriceAtStartDate,
|
||||
currentSymbolItem.marketPrice
|
||||
) * 100
|
||||
});
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private getMarketCondition(aPerformanceInPercent: number) {
|
||||
|
@ -4,22 +4,51 @@ import * as path from 'path';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { DEFAULT_LANGUAGE_CODE } from '@ghostfolio/common/config';
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class FrontendMiddleware implements NestMiddleware {
|
||||
public indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
public indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
public indexHtmlDe = '';
|
||||
public indexHtmlEn = '';
|
||||
public indexHtmlEs = '';
|
||||
public indexHtmlIt = '';
|
||||
public indexHtmlNl = '';
|
||||
public isProduction: boolean;
|
||||
|
||||
public constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly configurationService: ConfigurationService
|
||||
) {}
|
||||
) {
|
||||
const NODE_ENV =
|
||||
this.configService.get<'development' | 'production'>('NODE_ENV') ??
|
||||
'development';
|
||||
|
||||
this.isProduction = NODE_ENV === 'production';
|
||||
|
||||
try {
|
||||
this.indexHtmlDe = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('de'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEn = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile(DEFAULT_LANGUAGE_CODE),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlEs = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('es'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlIt = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('it'),
|
||||
'utf8'
|
||||
);
|
||||
this.indexHtmlNl = fs.readFileSync(
|
||||
this.getPathOfIndexHtmlFile('nl'),
|
||||
'utf8'
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
public use(req: Request, res: Response, next: NextFunction) {
|
||||
let featureGraphicPath = 'assets/cover.png';
|
||||
@ -29,9 +58,18 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
req.path === '/en/blog/2022/08/500-stars-on-github/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/500-stars-on-github.jpg';
|
||||
} else if (
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022' ||
|
||||
req.path === '/en/blog/2022/10/hacktoberfest-2022/'
|
||||
) {
|
||||
featureGraphicPath = 'assets/images/blog/hacktoberfest-2022.png';
|
||||
}
|
||||
|
||||
if (req.path.startsWith('/api/') || this.isFileRequest(req.url)) {
|
||||
if (
|
||||
req.path.startsWith('/api/') ||
|
||||
this.isFileRequest(req.url) ||
|
||||
!this.isProduction
|
||||
) {
|
||||
// Skip
|
||||
next();
|
||||
} else if (req.path === '/de' || req.path.startsWith('/de/')) {
|
||||
@ -43,6 +81,33 @@ export class FrontendMiddleware implements NestMiddleware {
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/es' || req.path.startsWith('/es/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEs, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'es',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/it' || req.path.startsWith('/it/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlIt, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'it',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else if (req.path === '/nl' || req.path.startsWith('/nl/')) {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlNl, {
|
||||
featureGraphicPath,
|
||||
languageCode: 'nl',
|
||||
path: req.path,
|
||||
rootUrl: this.configurationService.get('ROOT_URL')
|
||||
})
|
||||
);
|
||||
} else {
|
||||
res.send(
|
||||
this.interpolate(this.indexHtmlEn, {
|
||||
|
@ -145,6 +145,27 @@ export class InfoService {
|
||||
});
|
||||
}
|
||||
|
||||
private async countDockerHubPulls(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
`https://hub.docker.com/v2/repositories/ghostfolio/ghostfolio`,
|
||||
'GET',
|
||||
'json',
|
||||
200,
|
||||
{
|
||||
'User-Agent': 'request'
|
||||
}
|
||||
);
|
||||
|
||||
const { pull_count } = await get();
|
||||
return pull_count;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'InfoService');
|
||||
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private async countGitHubContributors(): Promise<number> {
|
||||
try {
|
||||
const get = bent(
|
||||
@ -245,6 +266,8 @@ export class InfoService {
|
||||
const activeUsers1d = await this.countActiveUsers(1);
|
||||
const activeUsers30d = await this.countActiveUsers(30);
|
||||
const newUsers30d = await this.countNewUsers(30);
|
||||
|
||||
const dockerHubPulls = await this.countDockerHubPulls();
|
||||
const gitHubContributors = await this.countGitHubContributors();
|
||||
const gitHubStargazers = await this.countGitHubStargazers();
|
||||
const slackCommunityUsers = await this.countSlackCommunityUsers();
|
||||
@ -252,6 +275,7 @@ export class InfoService {
|
||||
statistics = {
|
||||
activeUsers1d,
|
||||
activeUsers30d,
|
||||
dockerHubPulls,
|
||||
gitHubContributors,
|
||||
gitHubStargazers,
|
||||
newUsers30d,
|
||||
|
@ -3,8 +3,8 @@ 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 { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ImpersonationService } from '@ghostfolio/api/services/impersonation.service';
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import type { RequestWithUser } from '@ghostfolio/common/types';
|
||||
import {
|
||||
@ -36,6 +36,7 @@ import { UpdateOrderDto } from './update-order.dto';
|
||||
@Controller('order')
|
||||
export class OrderController {
|
||||
public constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly impersonationService: ImpersonationService,
|
||||
private readonly orderService: OrderService,
|
||||
@Inject(REQUEST) private readonly request: RequestWithUser,
|
||||
@ -73,30 +74,11 @@ export class OrderController {
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('tags') filterByTags?: string
|
||||
): Promise<Activities> {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
const filters = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const impersonationUserId =
|
||||
await this.impersonationService.validateImpersonationId(
|
||||
@ -109,7 +91,8 @@ export class OrderController {
|
||||
filters,
|
||||
userCurrency,
|
||||
includeDrafts: true,
|
||||
userId: impersonationUserId || this.request.user.id
|
||||
userId: impersonationUserId || this.request.user.id,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
if (
|
||||
|
@ -2,6 +2,7 @@ import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { CacheModule } from '@ghostfolio/api/app/cache/cache.module';
|
||||
import { RedisCacheModule } from '@ghostfolio/api/app/redis-cache/redis-cache.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -18,6 +19,7 @@ import { OrderService } from './order.service';
|
||||
controllers: [OrderController],
|
||||
exports: [OrderService],
|
||||
imports: [
|
||||
ApiModule,
|
||||
CacheModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
|
@ -189,13 +189,15 @@ export class OrderService {
|
||||
includeDrafts = false,
|
||||
types,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
types?: TypeOfOrder[];
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Activity[]> {
|
||||
const where: Prisma.OrderWhereInput = { userId };
|
||||
|
||||
@ -284,24 +286,28 @@ export class OrderService {
|
||||
},
|
||||
orderBy: { date: 'asc' }
|
||||
})
|
||||
).map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
)
|
||||
.filter((order) => {
|
||||
return withExcludedAccounts || order.Account?.isExcluded === false;
|
||||
})
|
||||
.map((order) => {
|
||||
const value = new Big(order.quantity).mul(order.unitPrice).toNumber();
|
||||
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
return {
|
||||
...order,
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
feeInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
order.fee,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
),
|
||||
valueInBaseCurrency: this.exchangeRateDataService.toCurrency(
|
||||
value,
|
||||
order.SymbolProfile.currency,
|
||||
userCurrency
|
||||
)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async updateOrder({
|
||||
|
@ -14,14 +14,14 @@ import {
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
isSameYear,
|
||||
isWithinInterval,
|
||||
max,
|
||||
min,
|
||||
set
|
||||
} from 'date-fns';
|
||||
import { first, flatten, isNumber, sortBy } from 'lodash';
|
||||
import { first, flatten, isNumber, last, sortBy } from 'lodash';
|
||||
|
||||
import { CurrentRateService } from './current-rate.service';
|
||||
import { CurrentPositions } from './interfaces/current-positions.interface';
|
||||
@ -168,6 +168,133 @@ export class PortfolioCalculator {
|
||||
this.transactionPoints = transactionPoints;
|
||||
}
|
||||
|
||||
public async getChartData(start: Date, end = new Date(Date.now()), step = 1) {
|
||||
const symbols: { [symbol: string]: boolean } = {};
|
||||
|
||||
const transactionPointsBeforeEndDate =
|
||||
this.transactionPoints?.filter((transactionPoint) => {
|
||||
return isBefore(parseDate(transactionPoint.date), end);
|
||||
}) ?? [];
|
||||
|
||||
const firstIndex = transactionPointsBeforeEndDate.length;
|
||||
const dates: Date[] = [];
|
||||
const dataGatheringItems: IDataGatheringItem[] = [];
|
||||
const currencies: { [symbol: string]: string } = {};
|
||||
|
||||
let day = start;
|
||||
|
||||
while (isBefore(day, end)) {
|
||||
dates.push(resetHours(day));
|
||||
day = addDays(day, step);
|
||||
}
|
||||
|
||||
if (!isSameDay(last(dates), end)) {
|
||||
dates.push(resetHours(end));
|
||||
}
|
||||
|
||||
for (const item of transactionPointsBeforeEndDate[firstIndex - 1].items) {
|
||||
dataGatheringItems.push({
|
||||
dataSource: item.dataSource,
|
||||
symbol: item.symbol
|
||||
});
|
||||
currencies[item.symbol] = item.currency;
|
||||
symbols[item.symbol] = true;
|
||||
}
|
||||
|
||||
const marketSymbols = await this.currentRateService.getValues({
|
||||
currencies,
|
||||
dataGatheringItems,
|
||||
dateQuery: {
|
||||
in: dates
|
||||
},
|
||||
userCurrency: this.currency
|
||||
});
|
||||
|
||||
const marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
} = {};
|
||||
|
||||
for (const marketSymbol of marketSymbols) {
|
||||
const dateString = format(marketSymbol.date, DATE_FORMAT);
|
||||
if (!marketSymbolMap[dateString]) {
|
||||
marketSymbolMap[dateString] = {};
|
||||
}
|
||||
if (marketSymbol.marketPriceInBaseCurrency) {
|
||||
marketSymbolMap[dateString][marketSymbol.symbol] = new Big(
|
||||
marketSymbol.marketPriceInBaseCurrency
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const netPerformanceValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const investmentValuesBySymbol: {
|
||||
[symbol: string]: { [date: string]: Big };
|
||||
} = {};
|
||||
|
||||
const totalNetPerformanceValues: { [date: string]: Big } = {};
|
||||
const totalInvestmentValues: { [date: string]: Big } = {};
|
||||
|
||||
for (const symbol of Object.keys(symbols)) {
|
||||
const { netPerformanceValues, investmentValues } = this.getSymbolMetrics({
|
||||
end,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step,
|
||||
symbol,
|
||||
isChartMode: true
|
||||
});
|
||||
|
||||
netPerformanceValuesBySymbol[symbol] = netPerformanceValues;
|
||||
investmentValuesBySymbol[symbol] = investmentValues;
|
||||
}
|
||||
|
||||
for (const currentDate of dates) {
|
||||
const dateString = format(currentDate, DATE_FORMAT);
|
||||
|
||||
for (const symbol of Object.keys(netPerformanceValuesBySymbol)) {
|
||||
totalNetPerformanceValues[dateString] =
|
||||
totalNetPerformanceValues[dateString] ?? new Big(0);
|
||||
|
||||
if (netPerformanceValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalNetPerformanceValues[dateString] = totalNetPerformanceValues[
|
||||
dateString
|
||||
].add(netPerformanceValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
|
||||
totalInvestmentValues[dateString] =
|
||||
totalInvestmentValues[dateString] ?? new Big(0);
|
||||
|
||||
if (investmentValuesBySymbol[symbol]?.[dateString]) {
|
||||
totalInvestmentValues[dateString] = totalInvestmentValues[
|
||||
dateString
|
||||
].add(investmentValuesBySymbol[symbol][dateString]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(totalNetPerformanceValues).map((date) => {
|
||||
const netPerformanceInPercentage = totalInvestmentValues[date].eq(0)
|
||||
? 0
|
||||
: totalNetPerformanceValues[date]
|
||||
.div(totalInvestmentValues[date])
|
||||
.mul(100)
|
||||
.toNumber();
|
||||
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
netPerformance: totalNetPerformanceValues[date].toNumber(),
|
||||
totalInvestment: totalInvestmentValues[date].toNumber(),
|
||||
value: totalInvestmentValues[date]
|
||||
.plus(totalNetPerformanceValues[date])
|
||||
.toNumber()
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
public async getCurrentPositions(
|
||||
start: Date,
|
||||
end = new Date(Date.now())
|
||||
@ -710,15 +837,19 @@ export class PortfolioCalculator {
|
||||
|
||||
private getSymbolMetrics({
|
||||
end,
|
||||
isChartMode = false,
|
||||
marketSymbolMap,
|
||||
start,
|
||||
step = 1,
|
||||
symbol
|
||||
}: {
|
||||
end: Date;
|
||||
isChartMode?: boolean;
|
||||
marketSymbolMap: {
|
||||
[date: string]: { [symbol: string]: Big };
|
||||
};
|
||||
start: Date;
|
||||
step?: number;
|
||||
symbol: string;
|
||||
}) {
|
||||
let orders: PortfolioOrderItem[] = this.orders.filter((order) => {
|
||||
@ -767,10 +898,12 @@ export class PortfolioCalculator {
|
||||
let grossPerformanceFromSells = new Big(0);
|
||||
let initialValue: Big;
|
||||
let investmentAtStartDate: Big;
|
||||
const investmentValues: { [date: string]: Big } = {};
|
||||
let lastAveragePrice = new Big(0);
|
||||
let lastTransactionInvestment = new Big(0);
|
||||
let lastValueOfInvestmentBeforeTransaction = new Big(0);
|
||||
let maxTotalInvestment = new Big(0);
|
||||
const netPerformanceValues: { [date: string]: Big } = {};
|
||||
let timeWeightedGrossPerformancePercentage = new Big(1);
|
||||
let timeWeightedNetPerformancePercentage = new Big(1);
|
||||
let totalInvestment = new Big(0);
|
||||
@ -805,6 +938,41 @@ export class PortfolioCalculator {
|
||||
unitPrice: unitPriceAtEndDate
|
||||
});
|
||||
|
||||
let day = start;
|
||||
let lastUnitPrice: Big;
|
||||
|
||||
if (isChartMode) {
|
||||
const datesWithOrders = {};
|
||||
|
||||
for (const order of orders) {
|
||||
datesWithOrders[order.date] = true;
|
||||
}
|
||||
|
||||
while (isBefore(day, end)) {
|
||||
const hasDate = datesWithOrders[format(day, DATE_FORMAT)];
|
||||
|
||||
if (!hasDate) {
|
||||
orders.push({
|
||||
symbol,
|
||||
currency: null,
|
||||
date: format(day, DATE_FORMAT),
|
||||
dataSource: null,
|
||||
fee: new Big(0),
|
||||
name: '',
|
||||
quantity: new Big(0),
|
||||
type: TypeOfOrder.BUY,
|
||||
unitPrice:
|
||||
marketSymbolMap[format(day, DATE_FORMAT)]?.[symbol] ??
|
||||
lastUnitPrice
|
||||
});
|
||||
}
|
||||
|
||||
lastUnitPrice = last(orders).unitPrice;
|
||||
|
||||
day = addDays(day, step);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort orders so that the start and end placeholder order are at the right
|
||||
// position
|
||||
orders = sortBy(orders, (order) => {
|
||||
@ -967,6 +1135,18 @@ export class PortfolioCalculator {
|
||||
feesAtStartDate = fees;
|
||||
grossPerformanceAtStartDate = grossPerformance;
|
||||
}
|
||||
|
||||
if (isChartMode && i > indexOfStartOrder) {
|
||||
netPerformanceValues[order.date] = grossPerformance
|
||||
.minus(grossPerformanceAtStartDate)
|
||||
.minus(fees.minus(feesAtStartDate));
|
||||
|
||||
investmentValues[order.date] = totalInvestment;
|
||||
}
|
||||
|
||||
if (i === indexOfEndOrder) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
timeWeightedGrossPerformancePercentage =
|
||||
@ -1052,7 +1232,9 @@ export class PortfolioCalculator {
|
||||
return {
|
||||
initialValue,
|
||||
grossPerformancePercentage,
|
||||
investmentValues,
|
||||
netPerformancePercentage,
|
||||
netPerformanceValues,
|
||||
hasErrors: totalUnits.gt(0) && (!initialValue || !unitPriceAtEndDate),
|
||||
netPerformance: totalNetPerformance,
|
||||
grossPerformance: totalGrossPerformance
|
||||
|
@ -7,18 +7,16 @@ import {
|
||||
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 { ApiService } from '@ghostfolio/api/services/api/api.service';
|
||||
import { ConfigurationService } from '@ghostfolio/api/services/configuration.service';
|
||||
import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data.service';
|
||||
import { parseDate } from '@ghostfolio/common/helper';
|
||||
import {
|
||||
Filter,
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary
|
||||
PortfolioReport
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import type {
|
||||
@ -40,6 +38,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import Big from 'big.js';
|
||||
import { StatusCodes, getReasonPhrase } from 'http-status-codes';
|
||||
|
||||
import { PortfolioPositionDetail } from './interfaces/portfolio-position-detail.interface';
|
||||
@ -52,6 +51,7 @@ export class PortfolioController {
|
||||
|
||||
public constructor(
|
||||
private readonly accessService: AccessService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly exchangeRateDataService: ExchangeRateDataService,
|
||||
private readonly portfolioService: PortfolioService,
|
||||
@ -61,75 +61,6 @@ export class PortfolioController {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getChart(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChart(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
let chartData = historicalDataContainer.items;
|
||||
|
||||
let hasError = false;
|
||||
|
||||
chartData.forEach((chartDataItem) => {
|
||||
if (hasNotDefinedValuesInObject(chartDataItem)) {
|
||||
hasError = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
let maxValue = 0;
|
||||
|
||||
chartData.forEach((portfolioItem) => {
|
||||
if (portfolioItem.value > maxValue) {
|
||||
maxValue = portfolioItem.value;
|
||||
}
|
||||
});
|
||||
|
||||
chartData = chartData.map((historicalDataItem) => {
|
||||
return {
|
||||
...historicalDataItem,
|
||||
marketPrice: Number((historicalDataItem.value / maxValue).toFixed(2))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
hasError,
|
||||
chart: chartData,
|
||||
isAllTimeHigh: historicalDataContainer.isAllTimeHigh,
|
||||
isAllTimeLow: historicalDataContainer.isAllTimeLow
|
||||
};
|
||||
}
|
||||
|
||||
@Get('chart')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@Version('2')
|
||||
public async getChartV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
): Promise<PortfolioChart> {
|
||||
const historicalDataContainer = await this.portfolioService.getChartV2(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
|
||||
return {
|
||||
chart: historicalDataContainer.items,
|
||||
hasError: false,
|
||||
isAllTimeHigh: false,
|
||||
isAllTimeLow: false
|
||||
};
|
||||
}
|
||||
|
||||
@Get('details')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(RedactValuesInResponseInterceptor)
|
||||
@ -138,48 +69,38 @@ export class PortfolioController {
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('accounts') filterByAccounts?: string,
|
||||
@Query('assetClasses') filterByAssetClasses?: string,
|
||||
@Query('range') range?: DateRange,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@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 = this.apiService.buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
});
|
||||
|
||||
const filters: Filter[] = [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
|
||||
const { accounts, holdings, hasErrors } =
|
||||
await this.portfolioService.getDetails(
|
||||
impersonationId,
|
||||
this.request.user.id,
|
||||
range,
|
||||
filters
|
||||
);
|
||||
const {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasErrors,
|
||||
holdings,
|
||||
summary,
|
||||
totalValueInBaseCurrency
|
||||
} = await this.portfolioService.getDetails({
|
||||
dateRange,
|
||||
filters,
|
||||
impersonationId,
|
||||
userId: this.request.user.id
|
||||
});
|
||||
|
||||
if (hasErrors || hasNotDefinedValuesInObject(holdings)) {
|
||||
hasError = true;
|
||||
}
|
||||
|
||||
let portfolioSummary = summary;
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
@ -213,6 +134,22 @@ export class PortfolioController {
|
||||
accounts[name].current = current / totalValue;
|
||||
accounts[name].original = original / totalInvestment;
|
||||
}
|
||||
|
||||
portfolioSummary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'emergencyFund',
|
||||
'excludedAccountsAndActivities',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
let hasDetails = true;
|
||||
@ -234,8 +171,12 @@ export class PortfolioController {
|
||||
|
||||
return {
|
||||
accounts,
|
||||
filteredValueInBaseCurrency,
|
||||
filteredValueInPercentage,
|
||||
hasError,
|
||||
holdings
|
||||
holdings,
|
||||
totalValueInBaseCurrency,
|
||||
summary: hasDetails ? portfolioSummary : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@ -243,6 +184,7 @@ export class PortfolioController {
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getInvestments(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') dateRange: DateRange = 'max',
|
||||
@Query('groupBy') groupBy?: GroupBy
|
||||
): Promise<PortfolioInvestments> {
|
||||
if (
|
||||
@ -258,12 +200,16 @@ export class PortfolioController {
|
||||
let investments: InvestmentItem[];
|
||||
|
||||
if (groupBy === 'month') {
|
||||
investments = await this.portfolioService.getInvestments(
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
'month'
|
||||
);
|
||||
groupBy: 'month'
|
||||
});
|
||||
} else {
|
||||
investments = await this.portfolioService.getInvestments(impersonationId);
|
||||
investments = await this.portfolioService.getInvestments({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@ -281,29 +227,50 @@ export class PortfolioController {
|
||||
}));
|
||||
}
|
||||
|
||||
return { firstOrderDate: parseDate(investments[0]?.date), investments };
|
||||
return { investments };
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPerformance(
|
||||
@Version('2')
|
||||
public async getPerformanceV2(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const performanceInformation = await this.portfolioService.getPerformance(
|
||||
impersonationId,
|
||||
range
|
||||
);
|
||||
const performanceInformation = await this.portfolioService.getPerformance({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.request.user.Settings.settings.viewMode === 'ZEN' ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
performanceInformation.chart = performanceInformation.chart.map(
|
||||
({ date, netPerformanceInPercentage, totalInvestment, value }) => {
|
||||
return {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment: new Big(totalInvestment)
|
||||
.div(performanceInformation.performance.totalInvestment)
|
||||
.toNumber(),
|
||||
value: new Big(value)
|
||||
.div(performanceInformation.performance.currentValue)
|
||||
.toNumber()
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
performanceInformation.performance = nullifyValuesInObject(
|
||||
performanceInformation.performance,
|
||||
['currentGrossPerformance', 'currentValue']
|
||||
[
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'totalInvestment'
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@ -315,11 +282,11 @@ export class PortfolioController {
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
public async getPositions(
|
||||
@Headers('impersonation-id') impersonationId: string,
|
||||
@Query('range') range
|
||||
@Query('range') dateRange: DateRange = 'max'
|
||||
): Promise<PortfolioPositions> {
|
||||
const result = await this.portfolioService.getPositions(
|
||||
impersonationId,
|
||||
range
|
||||
dateRange
|
||||
);
|
||||
|
||||
if (
|
||||
@ -360,12 +327,12 @@ export class PortfolioController {
|
||||
hasDetails = user.subscription.type === 'Premium';
|
||||
}
|
||||
|
||||
const { holdings } = await this.portfolioService.getDetails(
|
||||
access.userId,
|
||||
access.userId,
|
||||
'max',
|
||||
[{ id: 'EQUITY', type: 'ASSET_CLASS' }]
|
||||
);
|
||||
const { holdings } = await this.portfolioService.getDetails({
|
||||
dateRange: 'max',
|
||||
filters: [{ id: 'EQUITY', type: 'ASSET_CLASS' }],
|
||||
impersonationId: access.userId,
|
||||
userId: access.userId
|
||||
});
|
||||
|
||||
const portfolioPublicDetails: PortfolioPublicDetails = {
|
||||
hasDetails,
|
||||
@ -402,46 +369,6 @@ export class PortfolioController {
|
||||
return portfolioPublicDetails;
|
||||
}
|
||||
|
||||
@Get('summary')
|
||||
@UseGuards(AuthGuard('jwt'))
|
||||
public async getSummary(
|
||||
@Headers('impersonation-id') impersonationId
|
||||
): Promise<PortfolioSummary> {
|
||||
if (
|
||||
this.configurationService.get('ENABLE_FEATURE_SUBSCRIPTION') &&
|
||||
this.request.user.subscription.type === 'Basic'
|
||||
) {
|
||||
throw new HttpException(
|
||||
getReasonPhrase(StatusCodes.FORBIDDEN),
|
||||
StatusCodes.FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
let summary = await this.portfolioService.getSummary(impersonationId);
|
||||
|
||||
if (
|
||||
impersonationId ||
|
||||
this.userService.isRestrictedView(this.request.user)
|
||||
) {
|
||||
summary = nullifyValuesInObject(summary, [
|
||||
'cash',
|
||||
'committedFunds',
|
||||
'currentGrossPerformance',
|
||||
'currentNetPerformance',
|
||||
'currentValue',
|
||||
'dividend',
|
||||
'emergencyFund',
|
||||
'fees',
|
||||
'items',
|
||||
'netWorth',
|
||||
'totalBuy',
|
||||
'totalSell'
|
||||
]);
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
@Get('position/:dataSource/:symbol')
|
||||
@UseInterceptors(TransformDataSourceInRequestInterceptor)
|
||||
@UseInterceptors(TransformDataSourceInResponseInterceptor)
|
||||
|
@ -2,6 +2,7 @@ import { AccessModule } from '@ghostfolio/api/app/access/access.module';
|
||||
import { AccountService } from '@ghostfolio/api/app/account/account.service';
|
||||
import { OrderModule } from '@ghostfolio/api/app/order/order.module';
|
||||
import { UserModule } from '@ghostfolio/api/app/user/user.module';
|
||||
import { ApiModule } from '@ghostfolio/api/services/api/api.module';
|
||||
import { ConfigurationModule } from '@ghostfolio/api/services/configuration.module';
|
||||
import { DataGatheringModule } from '@ghostfolio/api/services/data-gathering.module';
|
||||
import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module';
|
||||
@ -22,6 +23,7 @@ import { RulesService } from './rules.service';
|
||||
exports: [PortfolioService],
|
||||
imports: [
|
||||
AccessModule,
|
||||
ApiModule,
|
||||
ConfigurationModule,
|
||||
DataGatheringModule,
|
||||
DataProviderModule,
|
||||
|
@ -21,6 +21,7 @@ import { ImpersonationService } from '@ghostfolio/api/services/impersonation.ser
|
||||
import { SymbolProfileService } from '@ghostfolio/api/services/symbol-profile.service';
|
||||
import {
|
||||
ASSET_SUB_CLASS_EMERGENCY_FUND,
|
||||
MAX_CHART_ITEMS,
|
||||
UNKNOWN_KEY
|
||||
} from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, parseDate } from '@ghostfolio/common/helper';
|
||||
@ -49,15 +50,17 @@ import type {
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import {
|
||||
Account,
|
||||
AssetClass,
|
||||
DataSource,
|
||||
Order,
|
||||
Platform,
|
||||
Prisma,
|
||||
Tag,
|
||||
Type as TypeOfOrder
|
||||
} from '@prisma/client';
|
||||
import Big from 'big.js';
|
||||
import {
|
||||
addDays,
|
||||
differenceInDays,
|
||||
endOfToday,
|
||||
format,
|
||||
@ -72,7 +75,7 @@ import {
|
||||
subDays,
|
||||
subYears
|
||||
} from 'date-fns';
|
||||
import { isEmpty, last, sortBy, uniq, uniqBy } from 'lodash';
|
||||
import { isEmpty, sortBy, uniq, uniqBy } from 'lodash';
|
||||
|
||||
import {
|
||||
HistoricalDataContainer,
|
||||
@ -86,7 +89,6 @@ const emergingMarkets = require('../../assets/countries/emerging-markets.json');
|
||||
|
||||
@Injectable()
|
||||
export class PortfolioService {
|
||||
private static readonly MAX_CHART_ITEMS = 250;
|
||||
private baseCurrency: string;
|
||||
|
||||
public constructor(
|
||||
@ -105,14 +107,19 @@ export class PortfolioService {
|
||||
this.baseCurrency = this.configurationService.get('BASE_CURRENCY');
|
||||
}
|
||||
|
||||
public async getAccounts(
|
||||
aUserId: string,
|
||||
aFilters?: Filter[]
|
||||
): Promise<AccountWithValue[]> {
|
||||
const where: Prisma.AccountWhereInput = { userId: aUserId };
|
||||
public async getAccounts({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<AccountWithValue[]> {
|
||||
const where: Prisma.AccountWhereInput = { userId: userId };
|
||||
|
||||
if (aFilters?.[0].id && aFilters?.[0].type === 'ACCOUNT') {
|
||||
where.id = aFilters[0].id;
|
||||
if (filters?.[0].id && filters?.[0].type === 'ACCOUNT') {
|
||||
where.id = filters[0].id;
|
||||
}
|
||||
|
||||
const [accounts, details] = await Promise.all([
|
||||
@ -121,7 +128,12 @@ export class PortfolioService {
|
||||
include: { Order: true, Platform: true },
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
this.getDetails(aUserId, aUserId, undefined, aFilters)
|
||||
this.getDetails({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
impersonationId: userId
|
||||
})
|
||||
]);
|
||||
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
@ -159,11 +171,20 @@ export class PortfolioService {
|
||||
});
|
||||
}
|
||||
|
||||
public async getAccountsWithAggregations(
|
||||
aUserId: string,
|
||||
aFilters?: Filter[]
|
||||
): Promise<Accounts> {
|
||||
const accounts = await this.getAccounts(aUserId, aFilters);
|
||||
public async getAccountsWithAggregations({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<Accounts> {
|
||||
const accounts = await this.getAccounts({
|
||||
filters,
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
});
|
||||
let totalBalanceInBaseCurrency = new Big(0);
|
||||
let totalValueInBaseCurrency = new Big(0);
|
||||
let transactionCount = 0;
|
||||
@ -186,11 +207,16 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getInvestments(
|
||||
aImpersonationId: string,
|
||||
groupBy?: GroupBy
|
||||
): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getInvestments({
|
||||
dateRange,
|
||||
impersonationId,
|
||||
groupBy
|
||||
}: {
|
||||
dateRange: DateRange;
|
||||
impersonationId: string;
|
||||
groupBy?: GroupBy;
|
||||
}): Promise<InvestmentItem[]> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -262,105 +288,28 @@ export class PortfolioService {
|
||||
}
|
||||
}
|
||||
|
||||
return sortBy(investments, (investment) => {
|
||||
investments = sortBy(investments, (investment) => {
|
||||
return investment.date;
|
||||
});
|
||||
}
|
||||
|
||||
public async getChart(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<HistoricalDataContainer> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
const startDate = this.getStartDate(
|
||||
dateRange,
|
||||
parseDate(investments[0]?.date)
|
||||
);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
userId
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
currency: this.request.user.Settings.settings.baseCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: portfolioOrders
|
||||
return investments.filter(({ date }) => {
|
||||
return !isBefore(parseDate(date), startDate);
|
||||
});
|
||||
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
if (transactionPoints.length === 0) {
|
||||
return {
|
||||
isAllTimeHigh: false,
|
||||
isAllTimeLow: false,
|
||||
items: []
|
||||
};
|
||||
}
|
||||
let portfolioStart = parse(
|
||||
transactionPoints[0].date,
|
||||
DATE_FORMAT,
|
||||
new Date()
|
||||
);
|
||||
|
||||
// Get start date for the full portfolio because of because of the
|
||||
// min and max calculation
|
||||
portfolioStart = this.getStartDate('max', portfolioStart);
|
||||
|
||||
const timelineSpecification: TimelineSpecification[] = [
|
||||
{
|
||||
start: format(portfolioStart, DATE_FORMAT),
|
||||
accuracy: 'day'
|
||||
}
|
||||
];
|
||||
|
||||
const timelineInfo = await portfolioCalculator.calculateTimeline(
|
||||
timelineSpecification,
|
||||
format(new Date(), DATE_FORMAT)
|
||||
);
|
||||
|
||||
const timeline = timelineInfo.timelinePeriods;
|
||||
|
||||
const items = timeline
|
||||
.filter((timelineItem) => timelineItem !== null)
|
||||
.map((timelineItem) => ({
|
||||
date: timelineItem.date,
|
||||
value: timelineItem.netPerformance.toNumber()
|
||||
}));
|
||||
|
||||
let lastItem = null;
|
||||
if (timeline.length > 0) {
|
||||
lastItem = timeline[timeline.length - 1];
|
||||
}
|
||||
|
||||
let isAllTimeHigh = timelineInfo.maxNetPerformance?.eq(
|
||||
lastItem?.netPerformance ?? 0
|
||||
);
|
||||
let isAllTimeLow = timelineInfo.minNetPerformance?.eq(
|
||||
lastItem?.netPerformance ?? 0
|
||||
);
|
||||
if (isAllTimeHigh && isAllTimeLow) {
|
||||
isAllTimeHigh = false;
|
||||
isAllTimeLow = false;
|
||||
}
|
||||
|
||||
portfolioStart = startOfDay(
|
||||
this.getStartDate(
|
||||
aDateRange,
|
||||
parse(transactionPoints[0].date, DATE_FORMAT, new Date())
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
isAllTimeHigh,
|
||||
isAllTimeLow,
|
||||
items: items.filter((item) => {
|
||||
// Filter items of date range
|
||||
return !isAfter(portfolioStart, parseDate(item.date));
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
public async getChartV2(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<HistoricalDataContainer> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getChart({
|
||||
dateRange = 'max',
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
}): Promise<HistoricalDataContainer> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -384,57 +333,41 @@ export class PortfolioService {
|
||||
const endDate = new Date();
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), startDate);
|
||||
const step = Math.round(
|
||||
daysInMarket / Math.min(daysInMarket, PortfolioService.MAX_CHART_ITEMS)
|
||||
daysInMarket / Math.min(daysInMarket, MAX_CHART_ITEMS)
|
||||
);
|
||||
|
||||
const items: HistoricalDataItem[] = [];
|
||||
|
||||
let currentEndDate = startDate;
|
||||
|
||||
while (isBefore(currentEndDate, endDate)) {
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate,
|
||||
currentEndDate
|
||||
);
|
||||
|
||||
items.push({
|
||||
date: format(currentEndDate, DATE_FORMAT),
|
||||
value: currentPositions.netPerformancePercentage.toNumber() * 100
|
||||
});
|
||||
|
||||
currentEndDate = addDays(currentEndDate, step);
|
||||
}
|
||||
|
||||
const today = new Date();
|
||||
|
||||
if (last(items)?.date !== format(today, DATE_FORMAT)) {
|
||||
// Add today
|
||||
const { netPerformancePercentage } =
|
||||
await portfolioCalculator.getCurrentPositions(startDate, today);
|
||||
items.push({
|
||||
date: format(today, DATE_FORMAT),
|
||||
value: netPerformancePercentage.toNumber() * 100
|
||||
});
|
||||
}
|
||||
const items = await portfolioCalculator.getChartData(
|
||||
startDate,
|
||||
endDate,
|
||||
step
|
||||
);
|
||||
|
||||
return {
|
||||
items,
|
||||
isAllTimeHigh: false,
|
||||
isAllTimeLow: false,
|
||||
items: items
|
||||
isAllTimeLow: false
|
||||
};
|
||||
}
|
||||
|
||||
public async getDetails(
|
||||
aImpersonationId: string,
|
||||
aUserId: string,
|
||||
aDateRange: DateRange = 'max',
|
||||
aFilters?: Filter[]
|
||||
): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
const userId = await this.getUserId(aImpersonationId, aUserId);
|
||||
public async getDetails({
|
||||
impersonationId,
|
||||
userId,
|
||||
dateRange = 'max',
|
||||
filters,
|
||||
withExcludedAccounts = false
|
||||
}: {
|
||||
impersonationId: string;
|
||||
userId: string;
|
||||
dateRange?: DateRange;
|
||||
filters?: Filter[];
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<PortfolioDetails & { hasErrors: boolean }> {
|
||||
// TODO
|
||||
userId = await this.getUserId(impersonationId, userId);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
|
||||
const emergencyFund = new Big(
|
||||
@ -447,8 +380,9 @@ export class PortfolioService {
|
||||
|
||||
const { orders, portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
filters,
|
||||
userId,
|
||||
filters: aFilters
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
const portfolioCalculator = new PortfolioCalculator({
|
||||
@ -462,24 +396,33 @@ export class PortfolioService {
|
||||
const portfolioStart = parseDate(
|
||||
transactionPoints[0]?.date ?? format(new Date(), DATE_FORMAT)
|
||||
);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
|
||||
const cashDetails = await this.accountService.getCashDetails({
|
||||
filters,
|
||||
userId,
|
||||
currency: userCurrency,
|
||||
filters: aFilters
|
||||
currency: userCurrency
|
||||
});
|
||||
|
||||
const holdings: PortfolioDetails['holdings'] = {};
|
||||
const totalInvestment = currentPositions.totalInvestment.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
const totalValue = currentPositions.currentValue.plus(
|
||||
const totalInvestmentInBaseCurrency = currentPositions.totalInvestment.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
let filteredValueInBaseCurrency = currentPositions.currentValue;
|
||||
|
||||
if (
|
||||
filters?.length === 0 ||
|
||||
(filters?.length === 1 &&
|
||||
filters[0].type === 'ASSET_CLASS' &&
|
||||
filters[0].id === 'CASH')
|
||||
) {
|
||||
filteredValueInBaseCurrency = filteredValueInBaseCurrency.plus(
|
||||
cashDetails.balanceInBaseCurrency
|
||||
);
|
||||
}
|
||||
|
||||
const dataGatheringItems = currentPositions.positions.map((position) => {
|
||||
return {
|
||||
@ -540,10 +483,12 @@ export class PortfolioService {
|
||||
|
||||
holdings[item.symbol] = {
|
||||
markets,
|
||||
allocationCurrent: totalValue.eq(0)
|
||||
allocationCurrent: filteredValueInBaseCurrency.eq(0)
|
||||
? 0
|
||||
: value.div(totalValue).toNumber(),
|
||||
allocationInvestment: item.investment.div(totalInvestment).toNumber(),
|
||||
: value.div(filteredValueInBaseCurrency).toNumber(),
|
||||
allocationInvestment: item.investment
|
||||
.div(totalInvestmentInBaseCurrency)
|
||||
.toNumber(),
|
||||
assetClass: symbolProfile.assetClass,
|
||||
assetSubClass: symbolProfile.assetSubClass,
|
||||
countries: symbolProfile.countries,
|
||||
@ -568,17 +513,17 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
if (
|
||||
aFilters?.length === 0 ||
|
||||
(aFilters?.length === 1 &&
|
||||
aFilters[0].type === 'ASSET_CLASS' &&
|
||||
aFilters[0].id === 'CASH')
|
||||
filters?.length === 0 ||
|
||||
(filters?.length === 1 &&
|
||||
filters[0].type === 'ASSET_CLASS' &&
|
||||
filters[0].id === 'CASH')
|
||||
) {
|
||||
const cashPositions = await this.getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
userCurrency,
|
||||
investment: totalInvestment,
|
||||
value: totalValue
|
||||
investment: totalInvestmentInBaseCurrency,
|
||||
value: filteredValueInBaseCurrency
|
||||
});
|
||||
|
||||
for (const symbol of Object.keys(cashPositions)) {
|
||||
@ -587,14 +532,27 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
const accounts = await this.getValueOfAccounts({
|
||||
filters,
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId,
|
||||
filters: aFilters
|
||||
withExcludedAccounts
|
||||
});
|
||||
|
||||
return { accounts, holdings, hasErrors: currentPositions.hasErrors };
|
||||
const summary = await this.getSummary({ impersonationId });
|
||||
|
||||
return {
|
||||
accounts,
|
||||
holdings,
|
||||
summary,
|
||||
filteredValueInBaseCurrency: filteredValueInBaseCurrency.toNumber(),
|
||||
filteredValueInPercentage: summary.netWorth
|
||||
? filteredValueInBaseCurrency.div(summary.netWorth).toNumber()
|
||||
: 0,
|
||||
hasErrors: currentPositions.hasErrors,
|
||||
totalValueInBaseCurrency: summary.netWorth
|
||||
};
|
||||
}
|
||||
|
||||
public async getPosition(
|
||||
@ -606,7 +564,11 @@ export class PortfolioService {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
|
||||
const orders = (
|
||||
await this.orderService.getOrders({ userCurrency, userId })
|
||||
await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
).filter(({ SymbolProfile }) => {
|
||||
return (
|
||||
SymbolProfile.dataSource === aDataSource &&
|
||||
@ -919,11 +881,14 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getPerformance(
|
||||
aImpersonationId: string,
|
||||
aDateRange: DateRange = 'max'
|
||||
): Promise<PortfolioPerformanceResponse> {
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
public async getPerformance({
|
||||
dateRange = 'max',
|
||||
impersonationId
|
||||
}: {
|
||||
dateRange?: DateRange;
|
||||
impersonationId: string;
|
||||
}): Promise<PortfolioPerformanceResponse> {
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
|
||||
const { portfolioOrders, transactionPoints } =
|
||||
await this.getTransactionPoints({
|
||||
@ -938,13 +903,16 @@ export class PortfolioService {
|
||||
|
||||
if (transactionPoints?.length <= 0) {
|
||||
return {
|
||||
chart: [],
|
||||
firstOrderDate: undefined,
|
||||
hasErrors: false,
|
||||
performance: {
|
||||
currentGrossPerformance: 0,
|
||||
currentGrossPerformancePercent: 0,
|
||||
currentNetPerformance: 0,
|
||||
currentNetPerformancePercent: 0,
|
||||
currentValue: 0
|
||||
currentValue: 0,
|
||||
totalInvestment: 0
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -952,7 +920,7 @@ export class PortfolioService {
|
||||
portfolioCalculator.setTransactionPoints(transactionPoints);
|
||||
|
||||
const portfolioStart = parseDate(transactionPoints[0].date);
|
||||
const startDate = this.getStartDate(aDateRange, portfolioStart);
|
||||
const startDate = this.getStartDate(dateRange, portfolioStart);
|
||||
const currentPositions = await portfolioCalculator.getCurrentPositions(
|
||||
startDate
|
||||
);
|
||||
@ -960,24 +928,59 @@ export class PortfolioService {
|
||||
const hasErrors = currentPositions.hasErrors;
|
||||
const currentValue = currentPositions.currentValue.toNumber();
|
||||
const currentGrossPerformance = currentPositions.grossPerformance;
|
||||
let currentGrossPerformancePercent =
|
||||
const currentGrossPerformancePercent =
|
||||
currentPositions.grossPerformancePercentage;
|
||||
const currentNetPerformance = currentPositions.netPerformance;
|
||||
let currentNetPerformance = currentPositions.netPerformance;
|
||||
let currentNetPerformancePercent =
|
||||
currentPositions.netPerformancePercentage;
|
||||
const totalInvestment = currentPositions.totalInvestment;
|
||||
|
||||
if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
|
||||
// If algebraic sign is different, harmonize it
|
||||
currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
|
||||
}
|
||||
// if (currentGrossPerformance.mul(currentGrossPerformancePercent).lt(0)) {
|
||||
// // If algebraic sign is different, harmonize it
|
||||
// currentGrossPerformancePercent = currentGrossPerformancePercent.mul(-1);
|
||||
// }
|
||||
|
||||
if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
|
||||
// If algebraic sign is different, harmonize it
|
||||
currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
|
||||
// if (currentNetPerformance.mul(currentNetPerformancePercent).lt(0)) {
|
||||
// // If algebraic sign is different, harmonize it
|
||||
// currentNetPerformancePercent = currentNetPerformancePercent.mul(-1);
|
||||
// }
|
||||
|
||||
const historicalDataContainer = await this.getChart({
|
||||
dateRange,
|
||||
impersonationId
|
||||
});
|
||||
|
||||
const itemOfToday = historicalDataContainer.items.find((item) => {
|
||||
return item.date === format(new Date(), DATE_FORMAT);
|
||||
});
|
||||
|
||||
if (itemOfToday) {
|
||||
currentNetPerformance = new Big(itemOfToday.netPerformance);
|
||||
currentNetPerformancePercent = new Big(
|
||||
itemOfToday.netPerformanceInPercentage
|
||||
).div(100);
|
||||
}
|
||||
|
||||
return {
|
||||
chart: historicalDataContainer.items.map(
|
||||
({
|
||||
date,
|
||||
netPerformance,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
value
|
||||
}) => {
|
||||
return {
|
||||
date,
|
||||
netPerformance,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
value
|
||||
};
|
||||
}
|
||||
),
|
||||
errors: currentPositions.errors,
|
||||
firstOrderDate: parseDate(historicalDataContainer.items[0]?.date),
|
||||
hasErrors: currentPositions.hasErrors || hasErrors,
|
||||
performance: {
|
||||
currentValue,
|
||||
@ -985,7 +988,8 @@ export class PortfolioService {
|
||||
currentGrossPerformancePercent:
|
||||
currentGrossPerformancePercent.toNumber(),
|
||||
currentNetPerformance: currentNetPerformance.toNumber(),
|
||||
currentNetPerformancePercent: currentNetPerformancePercent.toNumber()
|
||||
currentNetPerformancePercent: currentNetPerformancePercent.toNumber(),
|
||||
totalInvestment: totalInvestment.toNumber()
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -1082,74 +1086,6 @@ export class PortfolioService {
|
||||
};
|
||||
}
|
||||
|
||||
public async getSummary(aImpersonationId: string): Promise<PortfolioSummary> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
const userId = await this.getUserId(aImpersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
|
||||
const performanceInformation = await this.getPerformance(aImpersonationId);
|
||||
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||
userId,
|
||||
currency: userCurrency
|
||||
});
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
|
||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||
|
||||
const netWorth = new Big(balanceInBaseCurrency)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: []
|
||||
})
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercent
|
||||
)
|
||||
})
|
||||
?.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
annualizedPerformancePercent,
|
||||
cash,
|
||||
dividend,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
emergencyFund: emergencyFund.toNumber(),
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
|
||||
private async getCashPositions({
|
||||
cashDetails,
|
||||
emergencyFund,
|
||||
@ -1325,14 +1261,121 @@ export class PortfolioService {
|
||||
return portfolioStart;
|
||||
}
|
||||
|
||||
private async getSummary({
|
||||
impersonationId
|
||||
}: {
|
||||
impersonationId: string;
|
||||
}): Promise<PortfolioSummary> {
|
||||
const userCurrency = this.request.user.Settings.settings.baseCurrency;
|
||||
const userId = await this.getUserId(impersonationId, this.request.user.id);
|
||||
const user = await this.userService.user({ id: userId });
|
||||
|
||||
const performanceInformation = await this.getPerformance({
|
||||
impersonationId
|
||||
});
|
||||
|
||||
const { balanceInBaseCurrency } = await this.accountService.getCashDetails({
|
||||
userId,
|
||||
currency: userCurrency
|
||||
});
|
||||
const orders = await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId
|
||||
});
|
||||
|
||||
const excludedActivities = (
|
||||
await this.orderService.getOrders({
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts: true
|
||||
})
|
||||
).filter(({ Account: account }) => {
|
||||
return account?.isExcluded ?? false;
|
||||
});
|
||||
|
||||
const dividend = this.getDividend(orders).toNumber();
|
||||
const emergencyFund = new Big(
|
||||
(user.Settings?.settings as UserSettings)?.emergencyFund ?? 0
|
||||
);
|
||||
const fees = this.getFees(orders).toNumber();
|
||||
const firstOrderDate = orders[0]?.date;
|
||||
const items = this.getItems(orders).toNumber();
|
||||
|
||||
const totalBuy = this.getTotalByType(orders, userCurrency, 'BUY');
|
||||
const totalSell = this.getTotalByType(orders, userCurrency, 'SELL');
|
||||
|
||||
const cash = new Big(balanceInBaseCurrency).minus(emergencyFund).toNumber();
|
||||
const committedFunds = new Big(totalBuy).minus(totalSell);
|
||||
const totalOfExcludedActivities = new Big(
|
||||
this.getTotalByType(excludedActivities, userCurrency, 'BUY')
|
||||
).minus(this.getTotalByType(excludedActivities, userCurrency, 'SELL'));
|
||||
|
||||
const cashDetailsWithExcludedAccounts =
|
||||
await this.accountService.getCashDetails({
|
||||
userId,
|
||||
currency: userCurrency,
|
||||
withExcludedAccounts: true
|
||||
});
|
||||
|
||||
const excludedBalanceInBaseCurrency = new Big(
|
||||
cashDetailsWithExcludedAccounts.balanceInBaseCurrency
|
||||
).minus(balanceInBaseCurrency);
|
||||
|
||||
const excludedAccountsAndActivities = excludedBalanceInBaseCurrency
|
||||
.plus(totalOfExcludedActivities)
|
||||
.toNumber();
|
||||
|
||||
const netWorth = new Big(balanceInBaseCurrency)
|
||||
.plus(performanceInformation.performance.currentValue)
|
||||
.plus(items)
|
||||
.plus(excludedAccountsAndActivities)
|
||||
.toNumber();
|
||||
|
||||
const daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
|
||||
const annualizedPerformancePercent = new PortfolioCalculator({
|
||||
currency: userCurrency,
|
||||
currentRateService: this.currentRateService,
|
||||
orders: []
|
||||
})
|
||||
.getAnnualizedPerformancePercent({
|
||||
daysInMarket,
|
||||
netPerformancePercent: new Big(
|
||||
performanceInformation.performance.currentNetPerformancePercent
|
||||
)
|
||||
})
|
||||
?.toNumber();
|
||||
|
||||
return {
|
||||
...performanceInformation.performance,
|
||||
annualizedPerformancePercent,
|
||||
cash,
|
||||
dividend,
|
||||
excludedAccountsAndActivities,
|
||||
fees,
|
||||
firstOrderDate,
|
||||
items,
|
||||
netWorth,
|
||||
totalBuy,
|
||||
totalSell,
|
||||
committedFunds: committedFunds.toNumber(),
|
||||
emergencyFund: emergencyFund.toNumber(),
|
||||
ordersCount: orders.filter((order) => {
|
||||
return order.type === 'BUY' || order.type === 'SELL';
|
||||
}).length
|
||||
};
|
||||
}
|
||||
|
||||
private async getTransactionPoints({
|
||||
filters,
|
||||
includeDrafts = false,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
includeDrafts?: boolean;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}): Promise<{
|
||||
transactionPoints: TransactionPoint[];
|
||||
orders: OrderWithAccount[];
|
||||
@ -1346,6 +1389,7 @@ export class PortfolioService {
|
||||
includeDrafts,
|
||||
userCurrency,
|
||||
userId,
|
||||
withExcludedAccounts,
|
||||
types: ['BUY', 'SELL']
|
||||
});
|
||||
|
||||
@ -1397,17 +1441,22 @@ export class PortfolioService {
|
||||
orders,
|
||||
portfolioItemsNow,
|
||||
userCurrency,
|
||||
userId
|
||||
userId,
|
||||
withExcludedAccounts
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
orders: OrderWithAccount[];
|
||||
portfolioItemsNow: { [p: string]: TimelinePosition };
|
||||
userCurrency: string;
|
||||
userId: string;
|
||||
withExcludedAccounts?: boolean;
|
||||
}) {
|
||||
const accounts: PortfolioDetails['accounts'] = {};
|
||||
|
||||
let currentAccounts = [];
|
||||
let currentAccounts: (Account & {
|
||||
Order?: Order[];
|
||||
Platform?: Platform;
|
||||
})[] = [];
|
||||
|
||||
if (filters.length === 0) {
|
||||
currentAccounts = await this.accountService.getAccounts(userId);
|
||||
@ -1427,6 +1476,10 @@ export class PortfolioService {
|
||||
});
|
||||
}
|
||||
|
||||
currentAccounts = currentAccounts.filter((account) => {
|
||||
return withExcludedAccounts || account.isExcluded === false;
|
||||
});
|
||||
|
||||
for (const account of currentAccounts) {
|
||||
const ordersByAccount = orders.filter(({ accountId }) => {
|
||||
return accountId === account.id;
|
||||
|
@ -1,10 +1,12 @@
|
||||
import { UniqueAsset } from '@ghostfolio/common/interfaces';
|
||||
import type { DateRange, ViewMode } from '@ghostfolio/common/types';
|
||||
import type {
|
||||
ColorScheme,
|
||||
DateRange,
|
||||
ViewMode
|
||||
} from '@ghostfolio/common/types';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNumber,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString
|
||||
} from 'class-validator';
|
||||
@ -14,9 +16,13 @@ export class UpdateUserSettingDto {
|
||||
@IsString()
|
||||
baseCurrency?: string;
|
||||
|
||||
@IsObject()
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
benchmark?: UniqueAsset;
|
||||
benchmark?: string;
|
||||
|
||||
@IsIn(<ColorScheme[]>['DARK', 'LIGHT'])
|
||||
@IsOptional()
|
||||
colorScheme?: ColorScheme;
|
||||
|
||||
@IsIn(<DateRange[]>['1d', '1y', '5y', 'max', 'ytd'])
|
||||
@IsOptional()
|
||||
|
@ -102,10 +102,10 @@ export class UserController {
|
||||
public async updateUserSetting(@Body() data: UpdateUserSettingDto) {
|
||||
if (
|
||||
size(data) === 1 &&
|
||||
data.dateRange &&
|
||||
(data.benchmark || data.dateRange) &&
|
||||
this.request.user.role === 'DEMO'
|
||||
) {
|
||||
// Allow date range change for demo user
|
||||
// Allow benchmark or date range change for demo user
|
||||
} else if (
|
||||
!hasPermission(
|
||||
this.request.user.permissions,
|
||||
|
@ -41,6 +41,14 @@ export class RedactValuesInResponseInterceptor<T>
|
||||
return activity;
|
||||
});
|
||||
}
|
||||
|
||||
if (data.filteredValueInBaseCurrency) {
|
||||
data.filteredValueInBaseCurrency = null;
|
||||
}
|
||||
|
||||
if (data.totalValueInBaseCurrency) {
|
||||
data.totalValueInBaseCurrency = null;
|
||||
}
|
||||
}
|
||||
|
||||
return data;
|
||||
|
9
apps/api/src/services/api/api.module.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { ApiService } from './api.service';
|
||||
|
||||
@Module({
|
||||
exports: [ApiService],
|
||||
providers: [ApiService]
|
||||
})
|
||||
export class ApiModule {}
|
42
apps/api/src/services/api/api.service.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { Filter } from '@ghostfolio/common/interfaces';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class ApiService {
|
||||
public constructor() {}
|
||||
|
||||
public buildFiltersFromQueryParams({
|
||||
filterByAccounts,
|
||||
filterByAssetClasses,
|
||||
filterByTags
|
||||
}: {
|
||||
filterByAccounts?: string;
|
||||
filterByAssetClasses?: string;
|
||||
filterByTags?: string;
|
||||
}): Filter[] {
|
||||
const accountIds = filterByAccounts?.split(',') ?? [];
|
||||
const assetClasses = filterByAssetClasses?.split(',') ?? [];
|
||||
const tagIds = filterByTags?.split(',') ?? [];
|
||||
|
||||
return [
|
||||
...accountIds.map((accountId) => {
|
||||
return <Filter>{
|
||||
id: accountId,
|
||||
type: 'ACCOUNT'
|
||||
};
|
||||
}),
|
||||
...assetClasses.map((assetClass) => {
|
||||
return <Filter>{
|
||||
id: assetClass,
|
||||
type: 'ASSET_CLASS'
|
||||
};
|
||||
}),
|
||||
...tagIds.map((tagId) => {
|
||||
return <Filter>{
|
||||
id: tagId,
|
||||
type: 'TAG'
|
||||
};
|
||||
})
|
||||
];
|
||||
}
|
||||
}
|
@ -38,7 +38,7 @@ export class ConfigurationService {
|
||||
MAX_ACTIVITIES_TO_IMPORT: num({ default: Number.MAX_SAFE_INTEGER }),
|
||||
MAX_ITEM_IN_CACHE: num({ default: 9999 }),
|
||||
PORT: port({ default: 3333 }),
|
||||
RAKUTEN_RAPID_API_KEY: str({ default: '' }),
|
||||
RAPID_API_API_KEY: str({ default: '' }),
|
||||
REDIS_HOST: host({ default: 'localhost' }),
|
||||
REDIS_PASSWORD: str({ default: '' }),
|
||||
REDIS_PORT: port({ default: 6379 }),
|
||||
|
@ -280,7 +280,7 @@ export class DataGatheringService {
|
||||
return (
|
||||
dataSource !== DataSource.GHOSTFOLIO &&
|
||||
dataSource !== DataSource.MANUAL &&
|
||||
dataSource !== DataSource.RAKUTEN
|
||||
dataSource !== DataSource.RAPID_API
|
||||
);
|
||||
})
|
||||
.map(({ dataSource, symbol }) => {
|
||||
|
@ -5,7 +5,7 @@ import { EodHistoricalDataService } from '@ghostfolio/api/services/data-provider
|
||||
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';
|
||||
import { RakutenRapidApiService } from '@ghostfolio/api/services/data-provider/rakuten-rapid-api/rakuten-rapid-api.service';
|
||||
import { RapidApiService } from '@ghostfolio/api/services/data-provider/rapid-api/rapid-api.service';
|
||||
import { YahooFinanceService } from '@ghostfolio/api/services/data-provider/yahoo-finance/yahoo-finance.service';
|
||||
import { PrismaModule } from '@ghostfolio/api/services/prisma.module';
|
||||
import { SymbolProfileModule } from '@ghostfolio/api/services/symbol-profile.module';
|
||||
@ -27,7 +27,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
RapidApiService,
|
||||
YahooFinanceService,
|
||||
{
|
||||
inject: [
|
||||
@ -36,7 +36,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
GhostfolioScraperApiService,
|
||||
GoogleSheetsService,
|
||||
ManualService,
|
||||
RakutenRapidApiService,
|
||||
RapidApiService,
|
||||
YahooFinanceService
|
||||
],
|
||||
provide: 'DataProviderInterfaces',
|
||||
@ -46,7 +46,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
) => [
|
||||
alphaVantageService,
|
||||
@ -54,7 +54,7 @@ import { DataProviderService } from './data-provider.service';
|
||||
ghostfolioScraperApiService,
|
||||
googleSheetsService,
|
||||
manualService,
|
||||
rakutenRapidApiService,
|
||||
rapidApiService,
|
||||
yahooFinanceService
|
||||
]
|
||||
}
|
||||
|
@ -1 +0,0 @@
|
||||
export interface IRakutenRapidApiResponse {}
|
@ -0,0 +1 @@
|
||||
export interface IRapidApiResponse {}
|
@ -15,14 +15,14 @@ import bent from 'bent';
|
||||
import { format, subMonths, subWeeks, subYears } from 'date-fns';
|
||||
|
||||
@Injectable()
|
||||
export class RakutenRapidApiService implements DataProviderInterface {
|
||||
export class RapidApiService implements DataProviderInterface {
|
||||
public constructor(
|
||||
private readonly configurationService: ConfigurationService,
|
||||
private readonly prismaService: PrismaService
|
||||
) {}
|
||||
|
||||
public canHandle(symbol: string) {
|
||||
return !!this.configurationService.get('RAKUTEN_RAPID_API_KEY');
|
||||
return !!this.configurationService.get('RAPID_API_API_KEY');
|
||||
}
|
||||
|
||||
public async getAssetProfile(
|
||||
@ -103,7 +103,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
}
|
||||
|
||||
public getName(): DataSource {
|
||||
return DataSource.RAKUTEN;
|
||||
return DataSource.RAPID_API;
|
||||
}
|
||||
|
||||
public async getQuotes(
|
||||
@ -129,7 +129,7 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error(error, 'RakutenRapidApiService');
|
||||
Logger.error(error, 'RapidApiService');
|
||||
}
|
||||
|
||||
return {};
|
||||
@ -155,16 +155,14 @@ export class RakutenRapidApiService implements DataProviderInterface {
|
||||
{
|
||||
useQueryString: true,
|
||||
'x-rapidapi-host': 'fear-and-greed-index.p.rapidapi.com',
|
||||
'x-rapidapi-key': this.configurationService.get(
|
||||
'RAKUTEN_RAPID_API_KEY'
|
||||
)
|
||||
'x-rapidapi-key': this.configurationService.get('RAPID_API_API_KEY')
|
||||
}
|
||||
);
|
||||
|
||||
const { fgi } = await get();
|
||||
return fgi;
|
||||
} catch (error) {
|
||||
Logger.error(error, 'RakutenRapidApiService');
|
||||
Logger.error(error, 'RapidApiService');
|
||||
|
||||
return undefined;
|
||||
}
|
@ -6,6 +6,7 @@ import {
|
||||
IDataProviderHistoricalResponse,
|
||||
IDataProviderResponse
|
||||
} from '@ghostfolio/api/services/interfaces/interfaces';
|
||||
import { UNKNOWN_KEY } from '@ghostfolio/common/config';
|
||||
import { DATE_FORMAT, isCurrency } from '@ghostfolio/common/helper';
|
||||
import { Granularity } from '@ghostfolio/common/types';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@ -57,8 +58,15 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
* DOGEUSD -> DOGE-USD
|
||||
*/
|
||||
public convertToYahooFinanceSymbol(aSymbol: string) {
|
||||
if (aSymbol.includes(this.baseCurrency) && aSymbol.length >= 6) {
|
||||
if (isCurrency(aSymbol.substring(0, aSymbol.length - 3))) {
|
||||
if (
|
||||
aSymbol.includes(this.baseCurrency) &&
|
||||
aSymbol.length > this.baseCurrency.length
|
||||
) {
|
||||
if (
|
||||
isCurrency(
|
||||
aSymbol.substring(0, aSymbol.length - this.baseCurrency.length)
|
||||
)
|
||||
) {
|
||||
return `${aSymbol}=X`;
|
||||
} else if (
|
||||
this.cryptocurrencyService.isCryptocurrency(
|
||||
@ -90,7 +98,7 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
try {
|
||||
const symbol = this.convertToYahooFinanceSymbol(aSymbol);
|
||||
const assetProfile = await yahooFinance.quoteSummary(symbol, {
|
||||
modules: ['price', 'summaryProfile']
|
||||
modules: ['price', 'summaryProfile', 'topHoldings']
|
||||
});
|
||||
|
||||
const { assetClass, assetSubClass } = this.parseAssetClass(
|
||||
@ -109,7 +117,16 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
});
|
||||
response.symbol = aSymbol;
|
||||
|
||||
if (
|
||||
if (assetSubClass === AssetSubClass.MUTUALFUND) {
|
||||
response.sectors = [];
|
||||
|
||||
for (const sectorWeighting of assetProfile.topHoldings
|
||||
?.sectorWeightings ?? []) {
|
||||
for (const [sector, weight] of Object.entries(sectorWeighting)) {
|
||||
response.sectors.push({ weight, name: this.parseSector(sector) });
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
assetSubClass === AssetSubClass.STOCK &&
|
||||
assetProfile.summaryProfile?.country
|
||||
) {
|
||||
@ -183,10 +200,10 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
for (const historicalItem of historicalResult) {
|
||||
let marketPrice = historicalItem.close;
|
||||
|
||||
if (symbol === 'USDGBp') {
|
||||
if (symbol === `${this.baseCurrency}GBp`) {
|
||||
// Convert GPB to GBp (pence)
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
} else if (symbol === 'USDILA') {
|
||||
} else if (symbol === `${this.baseCurrency}ILA`) {
|
||||
// Convert ILS to ILA
|
||||
marketPrice = new Big(marketPrice).mul(100).toNumber();
|
||||
}
|
||||
@ -246,9 +263,12 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
marketPrice: quote.regularMarketPrice || 0
|
||||
};
|
||||
|
||||
if (symbol === 'USDGBP' && yahooFinanceSymbols.includes('USDGBp=X')) {
|
||||
if (
|
||||
symbol === `${this.baseCurrency}GBP` &&
|
||||
yahooFinanceSymbols.includes(`${this.baseCurrency}GBp=X`)
|
||||
) {
|
||||
// Convert GPB to GBp (pence)
|
||||
response['USDGBp'] = {
|
||||
response[`${this.baseCurrency}GBp`] = {
|
||||
...response[symbol],
|
||||
currency: 'GBp',
|
||||
marketPrice: new Big(response[symbol].marketPrice)
|
||||
@ -256,11 +276,11 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
.toNumber()
|
||||
};
|
||||
} else if (
|
||||
symbol === 'USDILS' &&
|
||||
yahooFinanceSymbols.includes('USDILA=X')
|
||||
symbol === `${this.baseCurrency}ILS` &&
|
||||
yahooFinanceSymbols.includes(`${this.baseCurrency}ILA=X`)
|
||||
) {
|
||||
// Convert ILS to ILA
|
||||
response['USDILA'] = {
|
||||
response[`${this.baseCurrency}ILA`] = {
|
||||
...response[symbol],
|
||||
currency: 'ILA',
|
||||
marketPrice: new Big(response[symbol].marketPrice)
|
||||
@ -270,9 +290,9 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
}
|
||||
}
|
||||
|
||||
if (yahooFinanceSymbols.includes('USDUSX=X')) {
|
||||
if (yahooFinanceSymbols.includes(`${this.baseCurrency}USX=X`)) {
|
||||
// Convert USD to USX (cent)
|
||||
response['USDUSX'] = {
|
||||
response[`${this.baseCurrency}USX`] = {
|
||||
currency: 'USX',
|
||||
dataSource: this.getName(),
|
||||
marketPrice: new Big(1).mul(100).toNumber(),
|
||||
@ -434,4 +454,46 @@ export class YahooFinanceService implements DataProviderInterface {
|
||||
|
||||
return { assetClass, assetSubClass };
|
||||
}
|
||||
|
||||
private parseSector(aString: string): string {
|
||||
let sector = UNKNOWN_KEY;
|
||||
|
||||
switch (aString) {
|
||||
case 'basic_materials':
|
||||
sector = 'Basic Materials';
|
||||
break;
|
||||
case 'communication_services':
|
||||
sector = 'Communication Services';
|
||||
break;
|
||||
case 'consumer_cyclical':
|
||||
sector = 'Consumer Cyclical';
|
||||
break;
|
||||
case 'consumer_defensive':
|
||||
sector = 'Consumer Staples';
|
||||
break;
|
||||
case 'energy':
|
||||
sector = 'Energy';
|
||||
break;
|
||||
case 'financial_services':
|
||||
sector = 'Financial Services';
|
||||
break;
|
||||
case 'healthcare':
|
||||
sector = 'Healthcare';
|
||||
break;
|
||||
case 'industrials':
|
||||
sector = 'Industrials';
|
||||
break;
|
||||
case 'realestate':
|
||||
sector = 'Real Estate';
|
||||
break;
|
||||
case 'technology':
|
||||
sector = 'Technology';
|
||||
break;
|
||||
case 'utilities':
|
||||
sector = 'Utilities';
|
||||
break;
|
||||
}
|
||||
|
||||
return sector;
|
||||
}
|
||||
}
|
||||
|
@ -99,10 +99,12 @@ export class ExchangeRateDataService {
|
||||
this.exchangeRates[symbol] = resultExtended[symbol]?.[date]?.marketPrice;
|
||||
|
||||
if (!this.exchangeRates[symbol]) {
|
||||
// Not found, calculate indirectly via USD
|
||||
// Not found, calculate indirectly via base currency
|
||||
this.exchangeRates[symbol] =
|
||||
resultExtended[`${currency1}${'USD'}`]?.[date]?.marketPrice *
|
||||
resultExtended[`${'USD'}${currency2}`]?.[date]?.marketPrice;
|
||||
resultExtended[`${currency1}${this.baseCurrency}`]?.[date]
|
||||
?.marketPrice *
|
||||
resultExtended[`${this.baseCurrency}${currency2}`]?.[date]
|
||||
?.marketPrice;
|
||||
|
||||
// Calculate the opposite direction
|
||||
this.exchangeRates[`${currency2}${currency1}`] =
|
||||
@ -126,9 +128,11 @@ export class ExchangeRateDataService {
|
||||
if (this.exchangeRates[`${aFromCurrency}${aToCurrency}`]) {
|
||||
factor = this.exchangeRates[`${aFromCurrency}${aToCurrency}`];
|
||||
} else {
|
||||
// Calculate indirectly via USD
|
||||
const factor1 = this.exchangeRates[`${aFromCurrency}${'USD'}`];
|
||||
const factor2 = this.exchangeRates[`${'USD'}${aToCurrency}`];
|
||||
// Calculate indirectly via base currency
|
||||
const factor1 =
|
||||
this.exchangeRates[`${aFromCurrency}${this.baseCurrency}`];
|
||||
const factor2 =
|
||||
this.exchangeRates[`${this.baseCurrency}${aToCurrency}`];
|
||||
|
||||
factor = factor1 * factor2;
|
||||
|
||||
|
@ -26,7 +26,7 @@ export interface Environment extends CleanedEnvAccessors {
|
||||
MAX_ACTIVITIES_TO_IMPORT: number;
|
||||
MAX_ITEM_IN_CACHE: number;
|
||||
PORT: number;
|
||||
RAKUTEN_RAPID_API_KEY: string;
|
||||
RAPID_API_API_KEY: string;
|
||||
REDIS_HOST: string;
|
||||
REDIS_PASSWORD: string;
|
||||
REDIS_PORT: number;
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { IsString } from 'class-validator';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class PropertyDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
value: string;
|
||||
}
|
||||
|
@ -64,6 +64,23 @@ export class SymbolProfileService {
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
public async getSymbolProfilesByIds(
|
||||
symbolProfileIds: string[]
|
||||
): Promise<EnhancedSymbolProfile[]> {
|
||||
return this.prismaService.symbolProfile
|
||||
.findMany({
|
||||
include: { SymbolProfileOverrides: true },
|
||||
where: {
|
||||
id: {
|
||||
in: symbolProfileIds.map((symbolProfileId) => {
|
||||
return symbolProfileId;
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((symbolProfiles) => this.getSymbols(symbolProfiles));
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
@ -53,13 +53,15 @@ export class TwitterBotService {
|
||||
symbolItem.marketPrice
|
||||
);
|
||||
|
||||
let status = `Current Market Mood: ${emoji} ${text} (${symbolItem.marketPrice}/100)`;
|
||||
let status = `Current market mood is ${emoji} ${text.toLowerCase()} (${
|
||||
symbolItem.marketPrice
|
||||
}/100)`;
|
||||
|
||||
const benchmarkListing = await this.getBenchmarkListing(3);
|
||||
|
||||
if (benchmarkListing?.length > 1) {
|
||||
status += '\n\n';
|
||||
status += '±% from ATH\n';
|
||||
status += '± from ATH in %\n';
|
||||
status += benchmarkListing;
|
||||
}
|
||||
|
||||
|
@ -95,6 +95,13 @@ const routes: Routes = [
|
||||
'./pages/blog/2022/08/500-stars-on-github/500-stars-on-github-page.module'
|
||||
).then((m) => m.FiveHundredStarsOnGitHubPageModule)
|
||||
},
|
||||
{
|
||||
path: 'blog/2022/10/hacktoberfest-2022',
|
||||
loadChildren: () =>
|
||||
import(
|
||||
'./pages/blog/2022/10/hacktoberfest-2022/hacktoberfest-2022-page.module'
|
||||
).then((m) => m.Hacktoberfest2022PageModule)
|
||||
},
|
||||
{
|
||||
path: 'demo',
|
||||
loadChildren: () =>
|
||||
|
@ -13,6 +13,7 @@ import {
|
||||
} from '@ghostfolio/common/config';
|
||||
import { InfoItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { MaterialCssVarsService } from 'angular-material-css-vars';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
@ -77,6 +78,8 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
permissions.createUserAccount
|
||||
);
|
||||
|
||||
this.initializeTheme(this.user?.settings.colorScheme);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
@ -97,13 +100,17 @@ export class AppComponent implements OnDestroy, OnInit {
|
||||
this.unsubscribeSubject.complete();
|
||||
}
|
||||
|
||||
private initializeTheme() {
|
||||
this.materialCssVarsService.setDarkTheme(
|
||||
window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
);
|
||||
private initializeTheme(userPreferredColorScheme?: ColorScheme) {
|
||||
const isDarkTheme = userPreferredColorScheme
|
||||
? userPreferredColorScheme === 'DARK'
|
||||
: window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
this.materialCssVarsService.setDarkTheme(isDarkTheme);
|
||||
|
||||
window.matchMedia('(prefers-color-scheme: dark)').addListener((event) => {
|
||||
this.materialCssVarsService.setDarkTheme(event.matches);
|
||||
if (!this.user?.settings.colorScheme) {
|
||||
this.materialCssVarsService.setDarkTheme(event.matches);
|
||||
}
|
||||
});
|
||||
|
||||
this.materialCssVarsService.setPrimaryColor(primaryColorHex);
|
||||
|
@ -61,7 +61,7 @@ export class AccountDetailDialog implements OnDestroy, OnInit {
|
||||
.subscribe(({ accountType, name, Platform, valueInBaseCurrency }) => {
|
||||
this.accountType = accountType;
|
||||
this.name = name;
|
||||
this.platformName = Platform?.name;
|
||||
this.platformName = Platform?.name ?? '-';
|
||||
this.valueInBaseCurrency = valueInBaseCurrency;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
|
@ -21,10 +21,12 @@
|
||||
|
||||
<div class="row">
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value size="medium" [value]="accountType">Account Type</gf-value>
|
||||
<gf-value i18n size="medium" [value]="accountType"
|
||||
>Account Type</gf-value
|
||||
>
|
||||
</div>
|
||||
<div class="col-6 mb-3">
|
||||
<gf-value size="medium" [value]="platformName">Platform</gf-value>
|
||||
<gf-value i18n size="medium" [value]="platformName">Platform</gf-value>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@ -2,7 +2,10 @@
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<form class="align-items-center d-flex" [formGroup]="filterForm">
|
||||
<mat-form-field appearance="outline" class="flex-grow-1">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline flex-grow-1 mr-2 without-hint"
|
||||
>
|
||||
<mat-select formControlName="status">
|
||||
<mat-option></mat-option>
|
||||
<mat-option
|
||||
@ -13,7 +16,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button
|
||||
class="ml-1"
|
||||
class="mt-1"
|
||||
color="warn"
|
||||
mat-flat-button
|
||||
(click)="onDeleteJobs()"
|
||||
|
@ -2,6 +2,7 @@
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="true"
|
||||
[locale]="locale"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
|
@ -99,7 +99,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
...this.coupons,
|
||||
{ code: this.generateCouponCode(16), duration: this.couponDuration }
|
||||
];
|
||||
this.putCoupons(coupons);
|
||||
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||
}
|
||||
|
||||
public onAddCurrency() {
|
||||
@ -107,7 +107,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
|
||||
if (currency) {
|
||||
const currencies = uniq([...this.customCurrencies, currency]);
|
||||
this.putCurrencies(currencies);
|
||||
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||
}
|
||||
}
|
||||
|
||||
@ -124,7 +124,7 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
const coupons = this.coupons.filter((coupon) => {
|
||||
return coupon.code !== aCouponCode;
|
||||
});
|
||||
this.putCoupons(coupons);
|
||||
this.putAdminSetting({ key: PROPERTY_COUPONS, value: coupons });
|
||||
}
|
||||
}
|
||||
|
||||
@ -137,12 +137,12 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
const currencies = this.customCurrencies.filter((currency) => {
|
||||
return currency !== aCurrency;
|
||||
});
|
||||
this.putCurrencies(currencies);
|
||||
this.putAdminSetting({ key: PROPERTY_CURRENCIES, value: currencies });
|
||||
}
|
||||
}
|
||||
|
||||
public onDeleteSystemMessage() {
|
||||
this.putSystemMessage('');
|
||||
this.putAdminSetting({ key: PROPERTY_SYSTEM_MESSAGE, value: undefined });
|
||||
}
|
||||
|
||||
public onFlushCache() {
|
||||
@ -192,14 +192,20 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
public onReadOnlyModeChange(aEvent: MatSlideToggleChange) {
|
||||
this.setReadOnlyMode(aEvent.checked);
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_IS_READ_ONLY_MODE,
|
||||
value: aEvent.checked ? true : undefined
|
||||
});
|
||||
}
|
||||
|
||||
public onSetSystemMessage() {
|
||||
const systemMessage = prompt($localize`Please set your system message:`);
|
||||
|
||||
if (systemMessage) {
|
||||
this.putSystemMessage(systemMessage);
|
||||
this.putAdminSetting({
|
||||
key: PROPERTY_SYSTEM_MESSAGE,
|
||||
value: systemMessage
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -236,49 +242,10 @@ export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||
return couponCode;
|
||||
}
|
||||
|
||||
private putCoupons(aCoupons: Coupon[]) {
|
||||
private putAdminSetting({ key, value }: { key: string; value: any }) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_COUPONS, {
|
||||
value: JSON.stringify(aCoupons)
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private putCurrencies(aCurrencies: string[]) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_CURRENCIES, {
|
||||
value: JSON.stringify(aCurrencies)
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private putSystemMessage(aSystemMessage: string) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_SYSTEM_MESSAGE, {
|
||||
value: aSystemMessage
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
private setReadOnlyMode(aValue: boolean) {
|
||||
this.dataService
|
||||
.putAdminSetting(PROPERTY_IS_READ_ONLY_MODE, {
|
||||
value: aValue ? 'true' : ''
|
||||
.putAdminSetting(key, {
|
||||
value: value ? JSON.stringify(value) : undefined
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
|
@ -5,15 +5,26 @@
|
||||
<mat-card-content>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>User Count</div>
|
||||
<div class="w-50">{{ userCount }}</div>
|
||||
<div class="w-50">
|
||||
<gf-value
|
||||
precision="0"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="userCount"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
<div class="w-50" i18n>Activity Count</div>
|
||||
<div class="w-50">
|
||||
<ng-container *ngIf="transactionCount">
|
||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||
: '1.2-2' }} <span i18n>per User</span>)
|
||||
</ng-container>
|
||||
<gf-value
|
||||
precision="0"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="transactionCount"
|
||||
></gf-value>
|
||||
<div *ngIf="transactionCount && userCount">
|
||||
{{ transactionCount / userCount | number : '1.2-2' }}
|
||||
<span i18n>per User</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex my-3">
|
||||
@ -108,10 +119,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="align-items-start d-flex my-3">
|
||||
<div class="w-50" i18n>Benchmarks</div>
|
||||
<div class="w-50">
|
||||
<table>
|
||||
<tr *ngFor="let benchmark of info?.benchmarks">
|
||||
<td class="pl-1">{{ benchmark.symbol }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="hasPermissionForSystemMessage" class="d-flex my-3">
|
||||
<div class="w-50" i18n>System Message</div>
|
||||
<div class="w-50">
|
||||
<div *ngIf="info.systemMessage">
|
||||
<div *ngIf="info?.systemMessage">
|
||||
<span>{{ info.systemMessage }}</span>
|
||||
<button
|
||||
class="mini-icon mx-1 no-min-width px-2"
|
||||
@ -122,7 +143,7 @@
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="!info.systemMessage"
|
||||
*ngIf="!info?.systemMessage"
|
||||
color="accent"
|
||||
mat-flat-button
|
||||
(click)="onSetSystemMessage()"
|
||||
@ -162,8 +183,11 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<form #couponForm="ngForm">
|
||||
<mat-form-field appearance="outline" class="mr-2">
|
||||
<form #couponForm="ngForm" class="align-items-center d-flex">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline mr-2 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="duration"
|
||||
[value]="couponDuration"
|
||||
@ -176,6 +200,7 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
<button
|
||||
class="mt-1"
|
||||
color="primary"
|
||||
mat-flat-button
|
||||
(click)="onAddCoupon()"
|
||||
|
@ -43,23 +43,23 @@
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
<gf-value
|
||||
class="align-items-end"
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="userItem.accountCount"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
<gf-value
|
||||
class="align-items-end"
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[value]="userItem.transactionCount"
|
||||
></gf-value>
|
||||
</td>
|
||||
<td class="mat-cell px-1 py-2">
|
||||
<td class="mat-cell px-1 py-2 text-right">
|
||||
<gf-value
|
||||
class="align-items-end"
|
||||
class="d-inline-block justify-content-end"
|
||||
[locale]="user?.settings?.locale"
|
||||
[precision]="0"
|
||||
[value]="userItem.engagement"
|
||||
|
@ -1,8 +1,7 @@
|
||||
<div class="row">
|
||||
<div class="mb-2 row">
|
||||
<div class="col-md-6 col-xs-12 d-flex">
|
||||
<div class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate">
|
||||
<span i18n>Benchmarks</span>
|
||||
<sup i18n>Beta</sup>
|
||||
<span i18n>Performance</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
@ -10,31 +9,28 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 col-xs-12 d-flex justify-content-end">
|
||||
<mat-form-field appearance="outline" class="w-100" color="accent">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="w-100 without-hint"
|
||||
color="accent"
|
||||
[hidden]="benchmarks?.length === 0"
|
||||
>
|
||||
<mat-label i18n>Compare with...</mat-label>
|
||||
<mat-select
|
||||
name="benchmark"
|
||||
[compareWith]="compareUniqueAssets"
|
||||
[value]="benchmark"
|
||||
(selectionChange)="onChangeBenchmark($event.value)"
|
||||
>
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option
|
||||
*ngFor="let currentBenchmark of benchmarks"
|
||||
[value]="currentBenchmark"
|
||||
>{{ currentBenchmark.symbol }}</mat-option
|
||||
*ngFor="let symbolProfile of benchmarks"
|
||||
[value]="symbolProfile.id"
|
||||
>{{ symbolProfile.name }}</mat-option
|
||||
>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="user.settings.viewMode !== 'ZEN'" class="mb-3 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoading"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<ngx-skeleton-loader
|
||||
*ngIf="isLoading"
|
||||
|
@ -10,7 +10,6 @@ import {
|
||||
Output,
|
||||
ViewChild
|
||||
} from '@angular/core';
|
||||
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||
import {
|
||||
getTooltipOptions,
|
||||
getTooltipPositionerMapTop,
|
||||
@ -23,12 +22,9 @@ import {
|
||||
getTextColor,
|
||||
parseDate
|
||||
} from '@ghostfolio/common/helper';
|
||||
import {
|
||||
LineChartItem,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { DateRange } from '@ghostfolio/common/types';
|
||||
import { LineChartItem, User } from '@ghostfolio/common/interfaces';
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import {
|
||||
Chart,
|
||||
LineController,
|
||||
@ -48,21 +44,20 @@ import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
})
|
||||
export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
@Input() benchmarkDataItems: LineChartItem[] = [];
|
||||
@Input() benchmark: UniqueAsset;
|
||||
@Input() benchmarks: UniqueAsset[];
|
||||
@Input() benchmark: string;
|
||||
@Input() benchmarks: Partial<SymbolProfile>[];
|
||||
@Input() colorScheme: ColorScheme;
|
||||
@Input() daysInMarket: number;
|
||||
@Input() isLoading: boolean;
|
||||
@Input() locale: string;
|
||||
@Input() performanceDataItems: LineChartItem[];
|
||||
@Input() user: User;
|
||||
|
||||
@Output() benchmarkChanged = new EventEmitter<UniqueAsset>();
|
||||
@Output() dateRangeChanged = new EventEmitter<DateRange>();
|
||||
@Output() benchmarkChanged = new EventEmitter<string>();
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public chart: Chart<any>;
|
||||
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||
public isLoading = true;
|
||||
|
||||
public constructor() {
|
||||
Chart.register(
|
||||
@ -85,22 +80,8 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
public compareUniqueAssets(
|
||||
uniqueAsset1: UniqueAsset,
|
||||
uniqueAsset2: UniqueAsset
|
||||
) {
|
||||
return (
|
||||
uniqueAsset1?.dataSource === uniqueAsset2?.dataSource &&
|
||||
uniqueAsset1?.symbol === uniqueAsset2?.symbol
|
||||
);
|
||||
}
|
||||
|
||||
public onChangeBenchmark(benchmark: UniqueAsset) {
|
||||
this.benchmarkChanged.next(benchmark);
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRange: DateRange) {
|
||||
this.dateRangeChanged.next(dateRange);
|
||||
public onChangeBenchmark(symbolProfileId: string) {
|
||||
this.benchmarkChanged.next(symbolProfileId);
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
@ -108,8 +89,6 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.isLoading = true;
|
||||
|
||||
const data = {
|
||||
datasets: [
|
||||
{
|
||||
@ -150,7 +129,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
tension: 0
|
||||
},
|
||||
point: {
|
||||
hoverBackgroundColor: getBackgroundColor(),
|
||||
hoverBackgroundColor: getBackgroundColor(this.colorScheme),
|
||||
hoverRadius: 2,
|
||||
radius: 0
|
||||
}
|
||||
@ -161,7 +140,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
annotation: {
|
||||
annotations: {
|
||||
yAxis: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: 1,
|
||||
scaleID: 'y',
|
||||
type: 'line',
|
||||
@ -174,7 +153,7 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
},
|
||||
tooltip: this.getTooltipPluginConfiguration(),
|
||||
verticalHoverLine: {
|
||||
color: `rgba(${getTextColor()}, 0.1)`
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
@ -182,9 +161,9 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: 1,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
type: 'time',
|
||||
@ -196,8 +175,8 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
y: {
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
@ -213,22 +192,23 @@ export class BenchmarkComparatorComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
|
||||
plugins: [
|
||||
getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme)
|
||||
],
|
||||
type: 'line'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.isLoading = false;
|
||||
}
|
||||
|
||||
private getTooltipPluginConfiguration() {
|
||||
return {
|
||||
...getTooltipOptions({
|
||||
colorScheme: this.colorScheme,
|
||||
locale: this.locale,
|
||||
unit: '%'
|
||||
}),
|
||||
mode: 'index',
|
||||
mode: 'x',
|
||||
position: <unknown>'top',
|
||||
xAlign: 'center',
|
||||
yAlign: 'bottom'
|
||||
|
@ -2,7 +2,6 @@ import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
|
||||
|
||||
import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
||||
@ -13,7 +12,6 @@ import { BenchmarkComparatorComponent } from './benchmark-comparator.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
GfToggleModule,
|
||||
MatSelectModule,
|
||||
NgxSkeletonLoaderModule,
|
||||
ReactiveFormsModule
|
||||
|
@ -127,6 +127,7 @@ export class HomeHoldingsComponent implements OnDestroy, OnInit {
|
||||
dataSource,
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
colorScheme: this.user?.settings?.colorScheme,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToReportDataGlitch: hasPermission(
|
||||
|
@ -24,7 +24,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
public fearLabel = $localize`Fear`;
|
||||
public greedLabel = $localize`Greed`;
|
||||
public hasPermissionToAccessFearAndGreedIndex: boolean;
|
||||
public historicalData: HistoricalDataItem[];
|
||||
public historicalDataItems: HistoricalDataItem[];
|
||||
public info: InfoItem;
|
||||
public isLoading = true;
|
||||
public readonly numberOfDays = 180;
|
||||
@ -67,7 +67,7 @@ export class HomeMarketComponent implements OnDestroy, OnInit {
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ historicalData, marketPrice }) => {
|
||||
this.fearAndGreedIndex = marketPrice;
|
||||
this.historicalData = [
|
||||
this.historicalDataItems = [
|
||||
...historicalData,
|
||||
{
|
||||
date: resetHours(new Date()).toISOString(),
|
||||
|
@ -10,7 +10,8 @@
|
||||
symbol="Fear & Greed Index"
|
||||
yMax="100"
|
||||
yMin="0"
|
||||
[historicalDataItems]="historicalData"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[showXAxis]="true"
|
||||
[showYAxis]="true"
|
||||
|
@ -76,8 +76,6 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
!this.hasImpersonationId &&
|
||||
!this.user.settings.isRestrictedView &&
|
||||
this.user.settings.viewMode !== 'ZEN';
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
public onChangeDateRange(dateRange: DateRange) {
|
||||
@ -104,36 +102,29 @@ export class HomeOverviewComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private update() {
|
||||
this.historicalDataItems = null;
|
||||
this.isLoadingPerformance = true;
|
||||
|
||||
this.dataService
|
||||
.fetchChart({
|
||||
range: this.user?.settings?.dateRange,
|
||||
version: this.user?.settings?.isExperimentalFeatures ? 2 : 1
|
||||
.fetchPortfolioPerformance({
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((chartData) => {
|
||||
this.historicalDataItems = chartData.chart.map((chartDataItem) => {
|
||||
return {
|
||||
date: chartDataItem.date,
|
||||
value: chartDataItem.value
|
||||
};
|
||||
});
|
||||
this.isAllTimeHigh = chartData.isAllTimeHigh;
|
||||
this.isAllTimeLow = chartData.isAllTimeLow;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioPerformance({ range: this.user?.settings?.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response) => {
|
||||
this.errors = response.errors;
|
||||
this.hasError = response.hasErrors;
|
||||
this.performance = response.performance;
|
||||
this.isLoadingPerformance = false;
|
||||
|
||||
this.historicalDataItems = response.chart.map(
|
||||
({ date, netPerformanceInPercentage }) => {
|
||||
return {
|
||||
date,
|
||||
value: netPerformanceInPercentage
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
|
@ -15,16 +15,17 @@
|
||||
<gf-line-chart
|
||||
class="position-absolute"
|
||||
symbol="Performance"
|
||||
[currency]="user?.settings?.isExperimentalFeatures ? undefined : user?.settings?.baseCurrency"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
unit="%"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[hidden]="historicalDataItems?.length === 0"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="user?.settings?.dateRange === '1d' ? false : true"
|
||||
[locale]="user?.settings?.locale"
|
||||
[ngClass]="{ 'pr-3': deviceType === 'mobile' }"
|
||||
[showGradient]="true"
|
||||
[showLoader]="false"
|
||||
[showXAxis]="false"
|
||||
[showYAxis]="false"
|
||||
[unit]="user?.settings?.isExperimentalFeatures ? '%' : undefined"
|
||||
></gf-line-chart>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,8 +1,18 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import {
|
||||
MatSnackBar,
|
||||
MatSnackBarRef,
|
||||
TextOnlySnackBar
|
||||
} from '@angular/material/snack-bar';
|
||||
import { Router } from '@angular/router';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { PortfolioSummary, User } from '@ghostfolio/common/interfaces';
|
||||
import {
|
||||
InfoItem,
|
||||
PortfolioSummary,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { Subject } from 'rxjs';
|
||||
import { takeUntil } from 'rxjs/operators';
|
||||
@ -14,8 +24,11 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class HomeSummaryComponent implements OnDestroy, OnInit {
|
||||
public hasImpersonationId: boolean;
|
||||
public hasPermissionForSubscription: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public info: InfoItem;
|
||||
public isLoading = true;
|
||||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||
public summary: PortfolioSummary;
|
||||
public user: User;
|
||||
|
||||
@ -25,8 +38,17 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
|
||||
private changeDetectorRef: ChangeDetectorRef,
|
||||
private dataService: DataService,
|
||||
private impersonationStorageService: ImpersonationStorageService,
|
||||
private router: Router,
|
||||
private snackBar: MatSnackBar,
|
||||
private userService: UserService
|
||||
) {
|
||||
this.info = this.dataService.fetchInfo();
|
||||
|
||||
this.hasPermissionForSubscription = hasPermission(
|
||||
this.info?.globalPermissions,
|
||||
permissions.enableSubscription
|
||||
);
|
||||
|
||||
this.userService.stateChanged
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((state) => {
|
||||
@ -50,8 +72,6 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
|
||||
.subscribe((aId) => {
|
||||
this.hasImpersonationId = !!aId;
|
||||
});
|
||||
|
||||
this.update();
|
||||
}
|
||||
|
||||
public onChangeEmergencyFund(emergencyFund: number) {
|
||||
@ -81,12 +101,30 @@ export class HomeSummaryComponent implements OnDestroy, OnInit {
|
||||
this.isLoading = true;
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioSummary()
|
||||
.fetchPortfolioDetails({})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe((response) => {
|
||||
this.summary = response;
|
||||
.subscribe(({ summary }) => {
|
||||
this.summary = summary;
|
||||
this.isLoading = false;
|
||||
|
||||
if (!this.summary) {
|
||||
this.snackBarRef = this.snackBar.open(
|
||||
$localize`This feature requires a subscription.`,
|
||||
this.hasPermissionForSubscription
|
||||
? $localize`Upgrade Plan`
|
||||
: undefined,
|
||||
{ duration: 6000 }
|
||||
);
|
||||
|
||||
this.snackBarRef.afterDismissed().subscribe(() => {
|
||||
this.snackBarRef = undefined;
|
||||
});
|
||||
|
||||
this.snackBarRef.onAction().subscribe(() => {
|
||||
this.router.navigate(['/pricing']);
|
||||
});
|
||||
}
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
|
@ -15,14 +15,16 @@ import {
|
||||
} from '@ghostfolio/common/chart-helper';
|
||||
import { primaryColorRgb, secondaryColorRgb } from '@ghostfolio/common/config';
|
||||
import {
|
||||
DATE_FORMAT,
|
||||
getBackgroundColor,
|
||||
getDateFormatString,
|
||||
getTextColor,
|
||||
parseDate,
|
||||
transformTickToAbbreviation
|
||||
} from '@ghostfolio/common/helper';
|
||||
import { LineChartItem } from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import { GroupBy } from '@ghostfolio/common/types';
|
||||
import { ColorScheme, DateRange, GroupBy } from '@ghostfolio/common/types';
|
||||
import {
|
||||
BarController,
|
||||
BarElement,
|
||||
@ -35,7 +37,7 @@ import {
|
||||
Tooltip
|
||||
} from 'chart.js';
|
||||
import annotationPlugin from 'chartjs-plugin-annotation';
|
||||
import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||
import { addDays, format, isAfter, parseISO, subDays } from 'date-fns';
|
||||
|
||||
@Component({
|
||||
selector: 'gf-investment-chart',
|
||||
@ -44,17 +46,20 @@ import { addDays, isAfter, parseISO, subDays } from 'date-fns';
|
||||
styleUrls: ['./investment-chart.component.scss']
|
||||
})
|
||||
export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
@Input() benchmarkDataItems: InvestmentItem[] = [];
|
||||
@Input() colorScheme: ColorScheme;
|
||||
@Input() currency: string;
|
||||
@Input() daysInMarket: number;
|
||||
@Input() groupBy: GroupBy;
|
||||
@Input() investments: InvestmentItem[];
|
||||
@Input() historicalDataItems: LineChartItem[] = [];
|
||||
@Input() isInPercent = false;
|
||||
@Input() locale: string;
|
||||
@Input() range: DateRange = 'max';
|
||||
@Input() savingsRate = 0;
|
||||
|
||||
@ViewChild('chartCanvas') chartCanvas;
|
||||
|
||||
public chart: Chart;
|
||||
public chart: Chart<any>;
|
||||
public isLoading = true;
|
||||
|
||||
private data: InvestmentItem[];
|
||||
@ -77,7 +82,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
|
||||
public ngOnChanges() {
|
||||
if (this.investments) {
|
||||
if (this.benchmarkDataItems && this.historicalDataItems) {
|
||||
this.initialize();
|
||||
}
|
||||
}
|
||||
@ -90,55 +95,71 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
this.isLoading = true;
|
||||
|
||||
// Create a clone
|
||||
this.data = this.investments.map((a) => Object.assign({}, a));
|
||||
this.data = this.benchmarkDataItems.map((item) => Object.assign({}, item));
|
||||
|
||||
if (!this.groupBy && this.data?.length > 0) {
|
||||
// Extend chart by 5% of days in market (before)
|
||||
const firstItem = this.data[0];
|
||||
this.data.unshift({
|
||||
...firstItem,
|
||||
date: subDays(
|
||||
parseISO(firstItem.date),
|
||||
this.daysInMarket * 0.05 || 90
|
||||
).toISOString(),
|
||||
investment: 0
|
||||
});
|
||||
if (this.range === 'max') {
|
||||
// Extend chart by 5% of days in market (before)
|
||||
const firstItem = this.data[0];
|
||||
this.data.unshift({
|
||||
...firstItem,
|
||||
date: format(
|
||||
subDays(parseISO(firstItem.date), this.daysInMarket * 0.05 || 90),
|
||||
DATE_FORMAT
|
||||
),
|
||||
investment: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Extend chart by 5% of days in market (after)
|
||||
const lastItem = this.data[this.data.length - 1];
|
||||
this.data.push({
|
||||
...lastItem,
|
||||
date: addDays(
|
||||
parseDate(lastItem.date),
|
||||
this.daysInMarket * 0.05 || 90
|
||||
).toISOString()
|
||||
date: format(
|
||||
addDays(parseDate(lastItem.date), this.daysInMarket * 0.05 || 90),
|
||||
DATE_FORMAT
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
const data = {
|
||||
labels: this.data.map((investmentItem) => {
|
||||
return investmentItem.date;
|
||||
labels: this.historicalDataItems.map(({ date }) => {
|
||||
return parseDate(date);
|
||||
}),
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
borderWidth: this.groupBy ? 0 : 2,
|
||||
data: this.data.map((position) => {
|
||||
return this.isInPercent
|
||||
? position.investment * 100
|
||||
: position.investment;
|
||||
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
borderColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
borderWidth: this.groupBy ? 0 : 1,
|
||||
data: this.data.map(({ date, investment }) => {
|
||||
return {
|
||||
x: parseDate(date),
|
||||
y: this.isInPercent ? investment * 100 : investment
|
||||
};
|
||||
}),
|
||||
label: $localize`Deposit`,
|
||||
segment: {
|
||||
borderColor: (context: unknown) =>
|
||||
this.isInFuture(
|
||||
context,
|
||||
`rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.67)`
|
||||
`rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.67)`
|
||||
),
|
||||
borderDash: (context: unknown) => this.isInFuture(context, [2, 2])
|
||||
},
|
||||
stepped: true
|
||||
},
|
||||
{
|
||||
borderColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
borderWidth: 2,
|
||||
data: this.historicalDataItems.map(({ date, value }) => {
|
||||
return {
|
||||
x: parseDate(date),
|
||||
y: this.isInPercent ? value * 100 : value
|
||||
};
|
||||
}),
|
||||
fill: false,
|
||||
label: $localize`Total Amount`,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
};
|
||||
@ -160,7 +181,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
tension: 0
|
||||
},
|
||||
point: {
|
||||
hoverBackgroundColor: getBackgroundColor(),
|
||||
hoverBackgroundColor: getBackgroundColor(this.colorScheme),
|
||||
hoverRadius: 2,
|
||||
radius: 0
|
||||
}
|
||||
@ -172,13 +193,13 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
annotations: {
|
||||
savingsRate: this.savingsRate
|
||||
? {
|
||||
borderColor: `rgba(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b}, 0.75)`,
|
||||
borderColor: `rgba(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b}, 0.75)`,
|
||||
borderWidth: 1,
|
||||
label: {
|
||||
backgroundColor: `rgb(${secondaryColorRgb.r}, ${secondaryColorRgb.g}, ${secondaryColorRgb.b})`,
|
||||
backgroundColor: `rgb(${primaryColorRgb.r}, ${primaryColorRgb.g}, ${primaryColorRgb.b})`,
|
||||
borderRadius: 2,
|
||||
color: 'white',
|
||||
content: 'Savings Rate',
|
||||
content: $localize`Savings Rate`,
|
||||
display: true,
|
||||
font: { size: '10px', weight: 'normal' },
|
||||
padding: {
|
||||
@ -193,7 +214,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
: undefined,
|
||||
yAxis: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: 1,
|
||||
scaleID: 'y',
|
||||
type: 'line',
|
||||
@ -206,7 +227,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
},
|
||||
tooltip: this.getTooltipPluginConfiguration(),
|
||||
verticalHoverLine: {
|
||||
color: `rgba(${getTextColor()}, 0.1)`
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.1)`
|
||||
}
|
||||
},
|
||||
responsive: true,
|
||||
@ -214,9 +235,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
borderWidth: this.groupBy ? 0 : 1,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false
|
||||
},
|
||||
type: 'time',
|
||||
@ -228,8 +249,8 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
y: {
|
||||
display: !this.isInPercent,
|
||||
grid: {
|
||||
borderColor: `rgba(${getTextColor()}, 0.1)`,
|
||||
color: `rgba(${getTextColor()}, 0.8)`,
|
||||
borderColor: `rgba(${getTextColor(this.colorScheme)}, 0.1)`,
|
||||
color: `rgba(${getTextColor(this.colorScheme)}, 0.8)`,
|
||||
display: false,
|
||||
drawBorder: false
|
||||
},
|
||||
@ -245,7 +266,9 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [getVerticalHoverLinePlugin(this.chartCanvas)],
|
||||
plugins: [
|
||||
getVerticalHoverLinePlugin(this.chartCanvas, this.colorScheme)
|
||||
],
|
||||
type: this.groupBy ? 'bar' : 'line'
|
||||
});
|
||||
}
|
||||
@ -257,6 +280,7 @@ export class InvestmentChartComponent implements OnChanges, OnDestroy {
|
||||
private getTooltipPluginConfiguration() {
|
||||
return {
|
||||
...getTooltipOptions({
|
||||
colorScheme: this.colorScheme,
|
||||
currency: this.isInPercent ? undefined : this.currency,
|
||||
locale: this.isInPercent ? undefined : this.locale,
|
||||
unit: this.isInPercent ? '%' : undefined
|
||||
|
@ -172,6 +172,17 @@
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row px-3 py-1">
|
||||
<div class="d-flex flex-grow-1" i18n>Excluded from Analysis</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<gf-value
|
||||
class="justify-content-end"
|
||||
[currency]="baseCurrency"
|
||||
[locale]="locale"
|
||||
[value]="isLoading ? undefined : summary?.excludedAccountsAndActivities"
|
||||
></gf-value>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col"><hr /></div>
|
||||
</div>
|
||||
|
@ -1,7 +1,9 @@
|
||||
import { ColorScheme } from '@ghostfolio/common/types';
|
||||
import { DataSource } from '@prisma/client';
|
||||
|
||||
export interface PositionDetailDialogParams {
|
||||
baseCurrency: string;
|
||||
colorScheme: ColorScheme;
|
||||
dataSource: DataSource;
|
||||
deviceType: string;
|
||||
hasImpersonationId: boolean;
|
||||
|
@ -20,11 +20,13 @@
|
||||
</div>
|
||||
|
||||
<gf-line-chart
|
||||
class="mb-4"
|
||||
benchmarkLabel="Average Unit Price"
|
||||
class="mb-4"
|
||||
[benchmarkDataItems]="benchmarkDataItems"
|
||||
[colorScheme]="data.colorScheme"
|
||||
[currency]="SymbolProfile?.currency"
|
||||
[historicalDataItems]="historicalDataItems"
|
||||
[isAnimated]="true"
|
||||
[locale]="data.locale"
|
||||
[showGradient]="true"
|
||||
[showXAxis]="true"
|
||||
@ -187,6 +189,7 @@
|
||||
<div class="h5" i18n>Sectors</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="data.colorScheme"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -198,6 +201,7 @@
|
||||
<div class="h5" i18n>Countries</div>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="data.colorScheme"
|
||||
[isInPercent]="true"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
|
@ -5,6 +5,7 @@ import {
|
||||
Router,
|
||||
RouterStateSnapshot
|
||||
} from '@angular/router';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { SettingsStorageService } from '@ghostfolio/client/services/settings-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import { EMPTY } from 'rxjs';
|
||||
@ -30,6 +31,7 @@ export class AuthGuard implements CanActivate {
|
||||
];
|
||||
|
||||
constructor(
|
||||
private dataService: DataService,
|
||||
private router: Router,
|
||||
private settingsStorageService: SettingsStorageService,
|
||||
private userService: UserService
|
||||
@ -74,8 +76,17 @@ export class AuthGuard implements CanActivate {
|
||||
const userLanguage = user?.settings?.language;
|
||||
|
||||
if (userLanguage && document.documentElement.lang !== userLanguage) {
|
||||
window.location.href = `../${userLanguage}`;
|
||||
resolve(false);
|
||||
this.dataService
|
||||
.putUserSetting({ language: document.documentElement.lang })
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 300);
|
||||
});
|
||||
|
||||
resolve(true);
|
||||
return;
|
||||
} else if (
|
||||
state.url.startsWith('/home') &&
|
||||
|
@ -108,7 +108,6 @@
|
||||
<div class="row">
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
subLabel="(Last 24 hours)"
|
||||
[value]="statistics?.activeUsers1d ?? '-'"
|
||||
@ -117,7 +116,6 @@
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
subLabel="(Last 30 days)"
|
||||
[value]="statistics?.newUsers30d ?? '-'"
|
||||
@ -126,7 +124,6 @@
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 my-2">
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
subLabel="(Last 30 days)"
|
||||
[value]="statistics?.activeUsers30d ?? '-'"
|
||||
@ -139,7 +136,6 @@
|
||||
href="https://join.slack.com/t/ghostfolio/shared_invite/zt-vsaan64h-F_I0fEo5M0P88lP9ibCxFg"
|
||||
>
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
[value]="statistics?.slackCommunityUsers ?? '-'"
|
||||
>Users in Slack community</gf-value
|
||||
@ -152,7 +148,6 @@
|
||||
href="https://github.com/ghostfolio/ghostfolio/graphs/contributors"
|
||||
>
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
[value]="statistics?.gitHubContributors ?? '-'"
|
||||
>Contributors on GitHub</gf-value
|
||||
@ -165,7 +160,6 @@
|
||||
href="https://github.com/ghostfolio/ghostfolio/stargazers"
|
||||
>
|
||||
<gf-value
|
||||
i18n
|
||||
size="large"
|
||||
[value]="statistics?.gitHubStargazers ?? '-'"
|
||||
>Stars on GitHub</gf-value
|
||||
|
@ -42,6 +42,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
signInWithFingerprintElement: MatSlideToggle;
|
||||
|
||||
public accesses: Access[];
|
||||
public appearancePlaceholder = $localize`Auto`;
|
||||
public baseCurrency: string;
|
||||
public coupon: number;
|
||||
public couponId: string;
|
||||
@ -54,7 +55,7 @@ export class AccountPageComponent implements OnDestroy, OnInit {
|
||||
public hasPermissionToUpdateViewMode: boolean;
|
||||
public hasPermissionToUpdateUserSettings: boolean;
|
||||
public language = document.documentElement.lang;
|
||||
public locales = ['de', 'de-CH', 'en-GB', 'en-US'];
|
||||
public locales = ['de', 'de-CH', 'en-GB', 'en-US', 'es', 'it', 'nl'];
|
||||
public price: number;
|
||||
public priceId: string;
|
||||
public snackBarRef: MatSnackBarRef<TextOnlySnackBar>;
|
||||
|
@ -94,7 +94,10 @@
|
||||
<ng-container i18n>Base Currency</ng-container>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline w-100 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="baseCurrency"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
@ -116,7 +119,10 @@
|
||||
<div class="hint-text text-muted" i18n>Beta</div>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline w-100 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="language"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
@ -126,6 +132,9 @@
|
||||
<mat-option [value]="null"></mat-option>
|
||||
<mat-option value="de">Deutsch</mat-option>
|
||||
<mat-option value="en">English</mat-option>
|
||||
<mat-option value="es">Español</mat-option>
|
||||
<mat-option value="it">Italiano</mat-option>
|
||||
<mat-option value="nl">Nederlands</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
@ -138,7 +147,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline w-100 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="locale"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
@ -155,13 +167,16 @@
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="d-flex mb-2">
|
||||
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||
<ng-container i18n>View Mode</ng-container>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<div class="align-items-center d-flex overflow-hidden">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline w-100 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
name="viewMode"
|
||||
[disabled]="!hasPermissionToUpdateViewMode"
|
||||
@ -175,6 +190,30 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="align-items-center d-flex pr-1 pt-1 w-50">
|
||||
<ng-container i18n>Appearance</ng-container>
|
||||
</div>
|
||||
<div class="pl-1 w-50">
|
||||
<mat-form-field
|
||||
appearance="outline"
|
||||
class="compact-with-outline w-100 without-hint"
|
||||
>
|
||||
<mat-select
|
||||
class="with-placeholder-as-option"
|
||||
name="colorScheme"
|
||||
[disabled]="!hasPermissionToUpdateUserSettings"
|
||||
[placeholder]="appearancePlaceholder"
|
||||
[value]="user?.settings?.colorScheme"
|
||||
(selectionChange)="onChangeUserSetting('colorScheme', $event.value)"
|
||||
>
|
||||
<mat-option i18n [value]="null">Auto</mat-option>
|
||||
<mat-option i18n value="LIGHT">Light</mat-option>
|
||||
<mat-option i18n value="DARK">Dark</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="align-items-center d-flex mt-4 py-1">
|
||||
@ -228,8 +267,8 @@
|
||||
class="align-items-center d-flex justify-content-center"
|
||||
color="primary"
|
||||
mat-fab
|
||||
[routerLink]="[]"
|
||||
[queryParams]="{ createDialog: true }"
|
||||
[routerLink]="[]"
|
||||
>
|
||||
<ion-icon name="add-outline" size="large"></ion-icon>
|
||||
</a>
|
||||
|
@ -59,8 +59,8 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
this.openCreateAccountDialog();
|
||||
} else if (params['editDialog']) {
|
||||
if (this.accounts) {
|
||||
const account = this.accounts.find((account) => {
|
||||
return account.id === params['accountId'];
|
||||
const account = this.accounts.find(({ id }) => {
|
||||
return id === params['accountId'];
|
||||
});
|
||||
|
||||
this.openUpdateAccountDialog(account);
|
||||
@ -155,6 +155,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
balance,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
}: AccountModel): void {
|
||||
@ -165,6 +166,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
balance,
|
||||
currency,
|
||||
id,
|
||||
isExcluded,
|
||||
name,
|
||||
platformId
|
||||
}
|
||||
@ -231,6 +233,7 @@ export class AccountsPageComponent implements OnDestroy, OnInit {
|
||||
accountType: AccountType.SECURITIES,
|
||||
balance: 0,
|
||||
currency: this.user?.settings?.baseCurrency,
|
||||
isExcluded: false,
|
||||
name: null,
|
||||
platformId: null
|
||||
}
|
||||
|
@ -50,6 +50,14 @@
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
</div>
|
||||
<div class="mb-3 px-2">
|
||||
<mat-checkbox
|
||||
color="primary"
|
||||
name="isExcluded"
|
||||
[(ngModel)]="data.account.isExcluded"
|
||||
>Exclude from Analysis</mat-checkbox
|
||||
>
|
||||
</div>
|
||||
<div *ngIf="data.account.id">
|
||||
<mat-form-field appearance="outline" class="w-100">
|
||||
<mat-label i18n>Account ID</mat-label>
|
||||
|
@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { NgModule } from '@angular/core';
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
@ -15,6 +16,7 @@ import { CreateOrUpdateAccountDialog } from './create-or-update-account-dialog.c
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
MatButtonModule,
|
||||
MatCheckboxModule,
|
||||
MatDialogModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
|
@ -119,7 +119,7 @@
|
||||
Anlagestrategie? Ich freue mich über alle, die Ghostfolio
|
||||
ausprobieren. Bist du überzeugt vom Potential der Software? Jede
|
||||
Unterstützung für Ghostfolio ist willkommen. Sei es mit einer
|
||||
<a href="https://ghostfol.io/pricing">Ghostfolio Premium</a>
|
||||
<a [routerLink]="['/pricing']">Ghostfolio Premium</a>
|
||||
Subscription zur Finanzierung des Hostings, einem positiven Rating
|
||||
im
|
||||
<a
|
||||
|
@ -115,7 +115,7 @@
|
||||
strategy? I'm happy for everyone who tries Ghostfolio. Are you
|
||||
convinced of its potential? Any support for Ghostfolio is welcome.
|
||||
Be it with a
|
||||
<a href="https://ghostfol.io/pricing">Ghostfolio Premium</a>
|
||||
<a [routerLink]="['/pricing']">Ghostfolio Premium</a>
|
||||
Subscription to finance the hosting, a positive rating in the
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=ch.dotsilver.ghostfolio.twa"
|
||||
|
@ -74,7 +74,7 @@
|
||||
<a [routerLink]="['/markets']">economic situation</a> at this time,
|
||||
the goal set at the beginning of the year to build a sustainable
|
||||
business and reach break-even with the SaaS offering (<a
|
||||
[routerLink]="['/markets']"
|
||||
[routerLink]="['/pricing']"
|
||||
>Ghostfolio Premium</a
|
||||
>) has been achieved. We will continue to leverage the revenue to
|
||||
further improve the fully managed cloud offering for our paying
|
||||
|
@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { RouterModule, Routes } from '@angular/router';
|
||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||
|
||||
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
canActivate: [AuthGuard],
|
||||
component: Hacktoberfest2022PageComponent,
|
||||
path: '',
|
||||
title: 'Hacktoberfest 2022'
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule]
|
||||
})
|
||||
export class Hacktoberfest2022RoutingModule {}
|
@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
host: { class: 'page' },
|
||||
selector: 'gf-hacktoberfest-2022-page',
|
||||
styleUrls: ['./hacktoberfest-2022-page.scss'],
|
||||
templateUrl: './hacktoberfest-2022-page.html'
|
||||
})
|
||||
export class Hacktoberfest2022PageComponent {}
|
@ -0,0 +1,178 @@
|
||||
<div class="blog container">
|
||||
<div class="row">
|
||||
<div class="col-md-8 offset-md-2">
|
||||
<article>
|
||||
<div class="mb-4 text-center">
|
||||
<h1 class="mb-1">Hacktoberfest 2022</h1>
|
||||
<div class="mb-3 text-muted"><small>2022-10-01</small></div>
|
||||
<img
|
||||
alt="Hacktoberfest 2022 with Ghostfolio Teaser"
|
||||
class="rounded w-100"
|
||||
src="../assets/images/blog/hacktoberfest-2022.png"
|
||||
title="Hacktoberfest 2022 with Ghostfolio"
|
||||
/>
|
||||
</div>
|
||||
<section class="mb-4">
|
||||
<p>
|
||||
We are very excited to join
|
||||
<a href="https://hacktoberfest.com">Hacktoberfest</a> for the first
|
||||
time with <a href="https://ghostfol.io">Ghostfolio</a> and meet new
|
||||
and ambitious open-source contributors. Hacktoberfest is a
|
||||
month-long celebration of open-source projects, their maintainers,
|
||||
and the entire community of contributors. Each October, open source
|
||||
maintainers from all over the world give extra attention to new
|
||||
contributors while guiding them through their first pull requests on
|
||||
<a href="https://github.com/ghostfolio/ghostfolio">GitHub</a>.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">About Ghostfolio</h2>
|
||||
<p>
|
||||
Ghostfolio is a modern web application to manage your personal
|
||||
finance. The software presents the current assets in real time and
|
||||
supports the decision making of future investments. Whether
|
||||
rebalancing the asset classes (stocks, ETFs, cryptocurrencies, etc.)
|
||||
of your portfolio or financing an apartment, Ghostfolio offers
|
||||
solid, data-driven decision support.
|
||||
</p>
|
||||
<p>
|
||||
Ghostfolio is written in
|
||||
<a href="https://www.typescriptlang.org">TypeScript</a> and
|
||||
organized as an <a href="https://nx.dev">Nx</a> workspace. The
|
||||
backend is based on <a href="https://nestjs.com">NestJS</a> using
|
||||
<a href="https://www.postgresql.org">PostgreSQL</a> as a database
|
||||
together with <a href="https://www.prisma.io">Prisma</a> and
|
||||
<a href="https://redis.io">Redis</a> for caching. The frontend is
|
||||
built with <a href="https://angular.io">Angular</a>.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">How to contribute?</h2>
|
||||
<p>
|
||||
Every contribution matters. This can be implementing new features,
|
||||
fixing bugs, refactoring the code, improving the documentation,
|
||||
adding more unit tests, or translating into another language.
|
||||
</p>
|
||||
<p>
|
||||
Are you not yet familiar with our code base? That is not a problem.
|
||||
We have applied the label <code>hacktoberfest</code> to a few
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio/issues?q=is%3Aissue+is%3Aopen+label%3Ahacktoberfest"
|
||||
>issues</a
|
||||
>
|
||||
and
|
||||
<a
|
||||
href="https://github.com/ghostfolio/ghostfolio/discussions?discussions_q=label%3Ahacktoberfest"
|
||||
>ideas</a
|
||||
>
|
||||
that are well suited for newcomers.
|
||||
</p>
|
||||
<p>
|
||||
The official Hacktoberfest website provides some valuable
|
||||
<a
|
||||
href="https://hacktoberfest.com/participation/#beginner-resources"
|
||||
>resources for beginners</a
|
||||
>
|
||||
to start contributing in open source.
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<h2 class="h4">Get support</h2>
|
||||
<p>
|
||||
If you have further questions or ideas, please join our growing
|
||||
<a href="https://ghostfolio.slack.com">Slack community</a> or get in
|
||||
touch on Twitter
|
||||
<a href="https://twitter.com/ghostfolio_">@ghostfolio_</a> or by
|
||||
email via <a href="mailto:hi@ghostfol.io">hi@ghostfol.io</a>.
|
||||
</p>
|
||||
<p>
|
||||
We look forward to hearing from you.<br />
|
||||
Thomas from Ghostfolio
|
||||
</p>
|
||||
</section>
|
||||
<section class="mb-4">
|
||||
<ul class="list-inline">
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Angular</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Community</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Cryptocurrency</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">ETF</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Fintech</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Ghostfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">GitHub</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Hacktoberfest</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Investment</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">NestJS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Nx</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">October</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Open Source</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">OSS</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Personal Finance</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Portfolio Tracker</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Prisma</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Software</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Stock</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">TypeScript</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Wealth Management</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Web3</span>
|
||||
</li>
|
||||
<li class="list-inline-item">
|
||||
<span class="badge badge-light">Web 3.0</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { RouterModule } from '@angular/router';
|
||||
|
||||
import { Hacktoberfest2022RoutingModule } from './hacktoberfest-2022-page-routing.module';
|
||||
import { Hacktoberfest2022PageComponent } from './hacktoberfest-2022-page.component';
|
||||
|
||||
@NgModule({
|
||||
declarations: [Hacktoberfest2022PageComponent],
|
||||
imports: [CommonModule, Hacktoberfest2022RoutingModule, RouterModule],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
export class Hacktoberfest2022PageModule {}
|
@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@ -2,6 +2,30 @@
|
||||
<div class="mb-5 row">
|
||||
<div class="col">
|
||||
<h3 class="mb-3 text-center" i18n>Blog</h3>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
<div class="flex-nowrap no-gutters row">
|
||||
<a
|
||||
class="d-flex w-100"
|
||||
href="../en/blog/2022/10/hacktoberfest-2022"
|
||||
>
|
||||
<div class="flex-grow-1">
|
||||
<div class="h6 m-0 text-truncate">Hacktoberfest 2022</div>
|
||||
<div class="d-flex text-muted">2022-10-01</div>
|
||||
</div>
|
||||
<div class="align-items-center d-flex">
|
||||
<ion-icon
|
||||
class="chevron text-muted"
|
||||
name="chevron-forward-outline"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-content>
|
||||
<div class="container p-0">
|
||||
|
@ -192,6 +192,17 @@
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
<h4>Multi-Language</h4>
|
||||
<p class="m-0">
|
||||
Use Ghostfolio in multiple languages: English, Dutch, German,
|
||||
Italian and Spanish are currently supported.
|
||||
</p>
|
||||
</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-xs-12 col-md-4 mb-3">
|
||||
<mat-card class="d-flex flex-column h-100">
|
||||
<div class="flex-grow-1">
|
||||
|
@ -1,5 +1,9 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { Statistics } from '@ghostfolio/common/interfaces/statistics.interface';
|
||||
import { hasPermission, permissions } from '@ghostfolio/common/permissions';
|
||||
import { format } from 'date-fns';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
@ -11,6 +15,9 @@ import { Subject } from 'rxjs';
|
||||
export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
public currentYear = format(new Date(), 'yyyy');
|
||||
public demoAuthToken: string;
|
||||
public deviceType: string;
|
||||
public hasPermissionForStatistics: boolean;
|
||||
public statistics: Statistics;
|
||||
public testimonials = [
|
||||
{
|
||||
author: 'Philipp',
|
||||
@ -36,9 +43,23 @@ export class LandingPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
private unsubscribeSubject = new Subject<void>();
|
||||
|
||||
public constructor() {}
|
||||
public constructor(
|
||||
private dataService: DataService,
|
||||
private deviceService: DeviceDetectorService
|
||||
) {
|
||||
const { globalPermissions, statistics } = this.dataService.fetchInfo();
|
||||
|
||||
public ngOnInit() {}
|
||||
this.hasPermissionForStatistics = hasPermission(
|
||||
globalPermissions,
|
||||
permissions.enableStatistics
|
||||
);
|
||||
|
||||
this.statistics = statistics;
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
}
|
||||
|
||||
public ngOnDestroy() {
|
||||
this.unsubscribeSubject.next();
|
||||
|
@ -1,10 +1,15 @@
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col text-center">
|
||||
<h1 class="font-weight-bold intro my-5">
|
||||
<h1 class="font-weight-bold intro mt-5">
|
||||
Manage your wealth like a boss
|
||||
</h1>
|
||||
<div>
|
||||
<p class="lead mb-4">
|
||||
Ghostfolio is a privacy-first, open source dashboard to manage your
|
||||
personal finances. Break down your asset allocation, know your net worth
|
||||
and make solid, data-driven investment decisions.
|
||||
</p>
|
||||
<div class="mb-4">
|
||||
<a
|
||||
href="https://www.youtube.com/watch?v=yY6ObSQVJZk"
|
||||
target="_blank"
|
||||
@ -23,9 +28,9 @@
|
||||
</div>
|
||||
|
||||
<div class="container">
|
||||
<div class="button-container row">
|
||||
<div class="button-container mb-5 row">
|
||||
<div class="align-items-center col d-flex justify-content-center">
|
||||
<div class="py-5 text-center">
|
||||
<div class="text-center">
|
||||
<a
|
||||
class="d-inline-block"
|
||||
color="primary"
|
||||
@ -42,19 +47,147 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row my-5">
|
||||
<div *ngIf="hasPermissionForStatistics" class="row mb-5">
|
||||
<div
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Monthly Active Users (MAU)"
|
||||
[routerLink]="['/about']"
|
||||
>
|
||||
<gf-value
|
||||
icon="people-outline"
|
||||
size="large"
|
||||
[value]="statistics?.activeUsers30d ?? '-'"
|
||||
>Monthly Active Users</gf-value
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Stars on GitHub"
|
||||
[routerLink]="['/about']"
|
||||
>
|
||||
<gf-value
|
||||
icon="star-outline"
|
||||
size="large"
|
||||
[value]="statistics?.gitHubStargazers ?? '-'"
|
||||
>Stars on GitHub</gf-value
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
<div
|
||||
class="col-md-4 d-flex my-1"
|
||||
[ngClass]="{ 'justify-content-center': this.deviceType !== 'mobile' }"
|
||||
>
|
||||
<a
|
||||
class="d-block"
|
||||
title="Ghostfolio in Numbers: Pulls on Docker Hub"
|
||||
[routerLink]="['/about']"
|
||||
>
|
||||
<gf-value
|
||||
icon="cloud-download-outline"
|
||||
size="large"
|
||||
[value]="statistics?.dockerHubPulls ?? '-'"
|
||||
>Pulls on Docker Hub</gf-value
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row mb-5">
|
||||
<div class="col-12 text-center text-muted"><small>As seen in</small></div>
|
||||
<div class="col-md-2 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-alternative-to mask"
|
||||
href="https://alternativeto.net"
|
||||
target="_blank"
|
||||
title="AlternativeTo - Crowdsourced software recommendations"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-awesome"
|
||||
href="https://github.com/awesome-selfhosted/awesome-selfhosted"
|
||||
target="_blank"
|
||||
title="Awesome-Selfhosted: A list of Free Software network services and web applications which can be hosted on your own servers"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-openstartup"
|
||||
href="https://openstartup.tm"
|
||||
target="_blank"
|
||||
title="Open Startup: The most complete list of open startups"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-privacy-tools mask"
|
||||
href="https://www.privacytools.io"
|
||||
target="_blank"
|
||||
title="Privacy Tools: Software Alternatives and Encryption"
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-product-hunt"
|
||||
href="https://www.producthunt.com"
|
||||
target="_blank"
|
||||
title="Product Hunt – The best new products in tech."
|
||||
></a>
|
||||
</div>
|
||||
<div class="col-md-2 d-flex justify-content-center my-1">
|
||||
<a
|
||||
class="d-block logo logo-unraid mask"
|
||||
href="https://unraid.net"
|
||||
target="_blank"
|
||||
title="Unraid | Unleash Your Hardware"
|
||||
></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-3 row">
|
||||
<div class="col text-center">
|
||||
<h2 class="h4 mb-1 text-center">
|
||||
Protect your <strong>assets</strong>. Refine your
|
||||
<strong>personal investment strategy</strong>.
|
||||
</h2>
|
||||
<p class="lead">
|
||||
<p class="lead m-0">
|
||||
Ghostfolio empowers busy people to keep track of stocks, ETFs or
|
||||
cryptocurrencies and make solid, data-driven investment decisions.
|
||||
cryptocurrencies without being tracked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row my-3">
|
||||
<div class="col-md-4 my-2">
|
||||
<mat-card>
|
||||
<mat-card-title>360° View</mat-card-title>
|
||||
Get the full picture of your personal finances across multiple
|
||||
platforms.
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-4 my-2">
|
||||
<mat-card>
|
||||
<mat-card-title>Web3 Ready</mat-card-title>
|
||||
Use Ghostfolio anonymously and own your financial data.
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-4 my-2">
|
||||
<mat-card>
|
||||
<mat-card-title>Open Source</mat-card-title>
|
||||
Benefit from continuous improvements through a strong community.
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row my-5">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<h2 class="h4 mb-1 text-center">Why <strong>Ghostfolio</strong>?</h2>
|
||||
@ -101,7 +234,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
<div class="mt-4 text-center">
|
||||
<a [routerLink]="['/about']" mat-stroked-button
|
||||
<a mat-stroked-button [routerLink]="['/about']"
|
||||
>Learn more about Ghostfolio</a
|
||||
>
|
||||
</div>
|
||||
@ -133,24 +266,48 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row my-5">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="row my-3">
|
||||
<div class="col-12">
|
||||
<h2 class="h4 mb-1 text-center">
|
||||
How does <strong>Ghostfolio</strong> work?
|
||||
</h2>
|
||||
<p class="lead mb-3 text-center">Get started in only 3 steps</p>
|
||||
<ol class="m-0 pl-3">
|
||||
<li class="mb-2">
|
||||
Sign up anonymously<br />(no e-mail address nor credit card required)
|
||||
</li>
|
||||
<li class="mb-2">Add any of your historical transactions</li>
|
||||
<li>Get valuable insights of your portfolio composition</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="col-md-4 my-2">
|
||||
<mat-card class="d-flex flex-row h-100">
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-bold">Sign up anonymously*</div>
|
||||
<div class="text-muted">
|
||||
<small>* no e-mail address nor credit card required</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-2 text-muted text-right">1</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-4 my-2">
|
||||
<mat-card class="d-flex flex-row h-100">
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-bold">
|
||||
Add any of your historical transactions
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-2 text-muted text-right">2</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
<div class="col-md-4 my-2">
|
||||
<mat-card class="d-flex flex-row h-100">
|
||||
<div class="flex-grow-1">
|
||||
<div class="font-weight-bold">
|
||||
Get valuable insights of your portfolio composition
|
||||
</div>
|
||||
</div>
|
||||
<div class="pl-2 text-muted text-right">3</div>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row my-5">
|
||||
<div class="col-md-6 offset-md-3">
|
||||
<div class="col">
|
||||
<h2 class="h4 mb-1 text-center">Are <strong>you</strong> ready?</h2>
|
||||
<p class="lead mb-3 text-center">
|
||||
Join now or check out the example account
|
||||
@ -194,7 +351,7 @@
|
||||
<div class="row">
|
||||
<div class="align-items-center d-flex flex-column w-100">
|
||||
<a
|
||||
class="agplv3-logo"
|
||||
class="logo logo-agplv3 mask"
|
||||
href="https://www.gnu.org/licenses/agpl-3.0.html"
|
||||
target="_blank"
|
||||
title="GNU Affero General Public License Version 3"
|
||||
|
@ -1,8 +1,10 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { RouterModule } from '@angular/router';
|
||||
import { GfLogoModule } from '@ghostfolio/ui/logo';
|
||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||
|
||||
import { LandingPageRoutingModule } from './landing-page-routing.module';
|
||||
import { LandingPageComponent } from './landing-page.component';
|
||||
@ -12,8 +14,10 @@ import { LandingPageComponent } from './landing-page.component';
|
||||
imports: [
|
||||
CommonModule,
|
||||
GfLogoModule,
|
||||
GfValueModule,
|
||||
LandingPageRoutingModule,
|
||||
MatButtonModule,
|
||||
MatCardModule,
|
||||
RouterModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
|
@ -3,16 +3,6 @@
|
||||
:host {
|
||||
display: block;
|
||||
|
||||
.agplv3-logo {
|
||||
background-color: rgba(var(--dark-primary-text));
|
||||
height: 3rem;
|
||||
mask-image: url('/assets/images/AGPLv3-logo.svg');
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
width: 7.5rem;
|
||||
}
|
||||
|
||||
.button-container {
|
||||
.mat-stroked-button {
|
||||
background-color: var(--light-background);
|
||||
@ -34,6 +24,58 @@
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 3rem;
|
||||
width: 7.5rem;
|
||||
|
||||
&.mask {
|
||||
background-color: rgba(var(--dark-secondary-text));
|
||||
mask-position: center;
|
||||
mask-repeat: no-repeat;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
&.logo-agplv3 {
|
||||
mask-image: url('/assets/images/logo-AGPLv3.svg');
|
||||
}
|
||||
|
||||
&.logo-alternative-to {
|
||||
mask-image: url('/assets/images/logo-alternative-to.svg');
|
||||
}
|
||||
|
||||
&.logo-awesome {
|
||||
background-image: url('/assets/images/logo-awesome.png');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
&.logo-openstartup {
|
||||
background-image: url('/assets/images/logo-openstartup.png');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
&.logo-privacy-tools {
|
||||
mask-image: url('/assets/images/logo-privacy-tools.svg');
|
||||
}
|
||||
|
||||
&.logo-product-hunt {
|
||||
background-image: url('/assets/images/logo-product-hunt.png');
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
filter: grayscale(1);
|
||||
}
|
||||
|
||||
&.logo-unraid {
|
||||
mask-image: url('/assets/images/logo-unraid.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.outro-inner-container {
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 66vh;
|
||||
@ -56,16 +98,21 @@
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.agplv3-logo {
|
||||
background-color: rgba(var(--light-primary-text));
|
||||
}
|
||||
|
||||
.button-container {
|
||||
.mat-stroked-button {
|
||||
background-color: var(--dark-background);
|
||||
}
|
||||
}
|
||||
|
||||
.logo {
|
||||
&.logo-agplv3,
|
||||
&.logo-alternative-to,
|
||||
&.logo-privacy-tools,
|
||||
&.logo-unraid {
|
||||
background-color: rgba(var(--light-primary-text));
|
||||
}
|
||||
}
|
||||
|
||||
.outro-inner-container {
|
||||
div {
|
||||
background-image: url('/assets/intro-dark.jpg') !important;
|
||||
|
@ -450,6 +450,7 @@ export class AllocationsPageComponent implements OnDestroy, OnInit {
|
||||
dataSource,
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
colorScheme: this.user?.settings?.colorScheme,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToReportDataGlitch: hasPermission(
|
||||
|
@ -10,6 +10,30 @@
|
||||
></gf-activities-filter>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<mat-card class="mb-3">
|
||||
<mat-card-header class="mb-2 overflow-hidden w-100">
|
||||
<mat-card-title class="m-0 text-truncate" i18n
|
||||
>Proportion of Net Worth</mat-card-title
|
||||
>
|
||||
<gf-value
|
||||
class="justify-content-end l-2"
|
||||
size="medium"
|
||||
[isPercent]="true"
|
||||
[value]="isLoading ? undefined : portfolioDetails?.filteredValueInPercentage"
|
||||
></gf-value>
|
||||
</mat-card-header>
|
||||
<mat-card-content>
|
||||
<mat-progress-bar
|
||||
mode="determinate"
|
||||
[title]="(portfolioDetails?.filteredValueInPercentage * 100).toFixed(2) + '%'"
|
||||
[value]="portfolioDetails?.filteredValueInPercentage * 100"
|
||||
></mat-progress-bar>
|
||||
</mat-card-content>
|
||||
</mat-card>
|
||||
</div>
|
||||
</div>
|
||||
<div class="proportion-charts row">
|
||||
<div class="col-md-4">
|
||||
<mat-card class="mb-3">
|
||||
@ -26,6 +50,7 @@
|
||||
<gf-portfolio-proportion-chart
|
||||
cursor="pointer"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['id']"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -55,6 +80,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['currency']"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -83,6 +109,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['assetClass', 'assetSubClass']"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -109,6 +136,7 @@
|
||||
class="mx-auto"
|
||||
cursor="pointer"
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['symbol']"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -139,6 +167,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -168,6 +197,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
@ -196,6 +226,7 @@
|
||||
<mat-card-content>
|
||||
<gf-portfolio-proportion-chart
|
||||
[baseCurrency]="user?.settings?.baseCurrency"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[keys]="['name']"
|
||||
[locale]="user?.settings?.locale"
|
||||
|
@ -1,6 +1,7 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { GfToggleModule } from '@ghostfolio/client/components/toggle/toggle.module';
|
||||
import { GfWorldMapChartModule } from '@ghostfolio/client/components/world-map-chart/world-map-chart.module';
|
||||
import { GfActivitiesFilterModule } from '@ghostfolio/ui/activities-filter/activities-filter.module';
|
||||
@ -22,7 +23,8 @@ import { AllocationsPageComponent } from './allocations-page.component';
|
||||
GfToggleModule,
|
||||
GfWorldMapChartModule,
|
||||
GfValueModule,
|
||||
MatCardModule
|
||||
MatCardModule,
|
||||
MatProgressBarModule
|
||||
],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||
})
|
||||
|
@ -20,6 +20,7 @@
|
||||
::ng-deep {
|
||||
.mat-card-header-text {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
@ -28,4 +29,33 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.mat-progress-bar {
|
||||
border-radius: 0.25rem;
|
||||
height: 0.5rem;
|
||||
|
||||
::ng-deep {
|
||||
.mat-progress-bar-background {
|
||||
fill: rgb(var(--palette-background-unselected-chip));
|
||||
}
|
||||
|
||||
.mat-progress-bar-buffer {
|
||||
background-color: rgb(var(--palette-background-unselected-chip));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:host-context(.is-dark-theme) {
|
||||
.mat-progress-bar {
|
||||
::ng-deep {
|
||||
.mat-progress-bar-background {
|
||||
fill: rgb(var(--palette-background-unselected-chip-dark));
|
||||
}
|
||||
|
||||
.mat-progress-bar-buffer {
|
||||
background-color: rgb(var(--palette-background-unselected-chip-dark));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,15 +1,16 @@
|
||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||
import { ToggleComponent } from '@ghostfolio/client/components/toggle/toggle.component';
|
||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||
import { ImpersonationStorageService } from '@ghostfolio/client/services/impersonation-storage.service';
|
||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||
import {
|
||||
HistoricalDataItem,
|
||||
Position,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
import { InvestmentItem } from '@ghostfolio/common/interfaces/investment-item.interface';
|
||||
import { DateRange, GroupBy, ToggleOption } from '@ghostfolio/common/types';
|
||||
import { SymbolProfile } from '@prisma/client';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
import { sortBy } from 'lodash';
|
||||
import { DeviceDetectorService } from 'ngx-device-detector';
|
||||
@ -24,20 +25,22 @@ import { takeUntil } from 'rxjs/operators';
|
||||
})
|
||||
export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
public benchmarkDataItems: HistoricalDataItem[] = [];
|
||||
public benchmarks: UniqueAsset[];
|
||||
public benchmarks: Partial<SymbolProfile>[];
|
||||
public bottom3: Position[];
|
||||
public dateRangeOptions = ToggleComponent.DEFAULT_DATE_RANGE_OPTIONS;
|
||||
public daysInMarket: number;
|
||||
public deviceType: string;
|
||||
public firstOrderDate: Date;
|
||||
public hasImpersonationId: boolean;
|
||||
public investments: InvestmentItem[];
|
||||
public investmentsByMonth: InvestmentItem[];
|
||||
public mode: GroupBy;
|
||||
public isLoadingBenchmarkComparator: boolean;
|
||||
public mode: GroupBy = 'month';
|
||||
public modeOptions: ToggleOption[] = [
|
||||
{ label: $localize`Monthly`, value: 'month' },
|
||||
{ label: $localize`Accumulating`, value: undefined }
|
||||
{ label: $localize`Monthly`, value: 'month' }
|
||||
];
|
||||
public performanceDataItems: HistoricalDataItem[];
|
||||
public performanceDataItemsInPercentage: HistoricalDataItem[];
|
||||
public top3: Position[];
|
||||
public user: User;
|
||||
|
||||
@ -75,9 +78,9 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
public onChangeBenchmark(benchmark: UniqueAsset) {
|
||||
public onChangeBenchmark(symbolProfileId: string) {
|
||||
this.dataService
|
||||
.putUserSetting({ benchmark })
|
||||
.putUserSetting({ benchmark: symbolProfileId })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(() => {
|
||||
this.userService.remove();
|
||||
@ -121,32 +124,48 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
}
|
||||
|
||||
private update() {
|
||||
if (this.user.settings.isExperimentalFeatures) {
|
||||
this.dataService
|
||||
.fetchChart({ range: this.user?.settings?.dateRange, version: 2 })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ chart }) => {
|
||||
this.firstOrderDate = new Date(chart?.[0]?.date ?? new Date());
|
||||
this.performanceDataItems = chart;
|
||||
|
||||
this.updateBenchmarkDataItems();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
}
|
||||
this.isLoadingBenchmarkComparator = true;
|
||||
|
||||
this.dataService
|
||||
.fetchInvestments()
|
||||
.fetchPortfolioPerformance({
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ firstOrderDate, investments }) => {
|
||||
.subscribe(({ chart, firstOrderDate }) => {
|
||||
this.firstOrderDate = firstOrderDate ?? new Date();
|
||||
this.daysInMarket = differenceInDays(new Date(), firstOrderDate);
|
||||
this.investments = investments;
|
||||
|
||||
this.investments = [];
|
||||
this.performanceDataItems = [];
|
||||
this.performanceDataItemsInPercentage = [];
|
||||
|
||||
for (const {
|
||||
date,
|
||||
netPerformanceInPercentage,
|
||||
totalInvestment,
|
||||
value
|
||||
} of chart) {
|
||||
this.investments.push({ date, investment: totalInvestment });
|
||||
this.performanceDataItems.push({
|
||||
date,
|
||||
value
|
||||
});
|
||||
this.performanceDataItemsInPercentage.push({
|
||||
date,
|
||||
value: netPerformanceInPercentage
|
||||
});
|
||||
}
|
||||
|
||||
this.updateBenchmarkDataItems();
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchInvestmentsByMonth()
|
||||
.fetchInvestments({
|
||||
groupBy: 'month',
|
||||
range: this.user?.settings?.dateRange
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ investments }) => {
|
||||
this.investmentsByMonth = investments;
|
||||
@ -155,7 +174,7 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
});
|
||||
|
||||
this.dataService
|
||||
.fetchPositions({ range: 'max' })
|
||||
.fetchPositions({ range: this.user?.settings?.dateRange })
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ positions }) => {
|
||||
const positionsSorted = sortBy(
|
||||
@ -179,9 +198,15 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
|
||||
private updateBenchmarkDataItems() {
|
||||
if (this.user.settings.benchmark) {
|
||||
const { dataSource, symbol } =
|
||||
this.benchmarks.find(({ id }) => {
|
||||
return id === this.user.settings.benchmark;
|
||||
}) ?? {};
|
||||
|
||||
this.dataService
|
||||
.fetchBenchmarkBySymbol({
|
||||
...this.user.settings.benchmark,
|
||||
dataSource,
|
||||
symbol,
|
||||
startDate: this.firstOrderDate
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
@ -193,8 +218,14 @@ export class AnalysisPageComponent implements OnDestroy, OnInit {
|
||||
};
|
||||
});
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
|
||||
this.changeDetectorRef.markForCheck();
|
||||
});
|
||||
} else {
|
||||
this.benchmarkDataItems = [];
|
||||
|
||||
this.isLoadingBenchmarkComparator = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,18 +1,27 @@
|
||||
<div class="container">
|
||||
<h3 class="d-flex justify-content-center mb-3" i18n>Analysis</h3>
|
||||
<div *ngIf="user?.settings?.isExperimentalFeatures" class="mb-5 row">
|
||||
<h3 class="d-flex justify-content-center" i18n>Analysis</h3>
|
||||
<div *ngIf="user?.settings?.viewMode !== 'ZEN'" class="my-4 text-center">
|
||||
<gf-toggle
|
||||
[defaultValue]="user?.settings?.dateRange"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[options]="dateRangeOptions"
|
||||
(change)="onChangeDateRange($event.value)"
|
||||
></gf-toggle>
|
||||
</div>
|
||||
<div class="mb-5 row">
|
||||
<div class="col-lg">
|
||||
<gf-benchmark-comparator
|
||||
class="h-100"
|
||||
[benchmark]="user?.settings?.benchmark"
|
||||
[benchmarkDataItems]="benchmarkDataItems"
|
||||
[benchmarks]="benchmarks"
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isLoading]="isLoadingBenchmarkComparator"
|
||||
[locale]="user?.settings?.locale"
|
||||
[performanceDataItems]="performanceDataItems"
|
||||
[performanceDataItems]="performanceDataItemsInPercentage"
|
||||
[user]="user"
|
||||
(benchmarkChanged)="onChangeBenchmark($event)"
|
||||
(dateRangeChanged)="onChangeDateRange($event)"
|
||||
></gf-benchmark-comparator>
|
||||
</div>
|
||||
</div>
|
||||
@ -95,6 +104,34 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-5 row">
|
||||
<div class="col-lg">
|
||||
<div class="align-items-center d-flex mb-4">
|
||||
<div
|
||||
class="align-items-center d-flex flex-grow-1 h5 mb-0 text-truncate"
|
||||
>
|
||||
<span i18n>Portfolio Evolution</span>
|
||||
<gf-premium-indicator
|
||||
*ngIf="user?.subscription?.type === 'Basic'"
|
||||
class="ml-1"
|
||||
></gf-premium-indicator>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
[benchmarkDataItems]="investments"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[historicalDataItems]="performanceDataItems"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[locale]="user?.settings?.locale"
|
||||
[range]="user?.settings?.dateRange"
|
||||
></gf-investment-chart>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg">
|
||||
<div class="align-items-center d-flex mb-4">
|
||||
@ -116,24 +153,15 @@
|
||||
></gf-toggle>
|
||||
</div>
|
||||
<div class="chart-container">
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[investments]="investments"
|
||||
[locale]="user?.settings?.locale"
|
||||
[ngClass]="{ 'd-none': mode }"
|
||||
></gf-investment-chart>
|
||||
<gf-investment-chart
|
||||
class="h-100"
|
||||
groupBy="month"
|
||||
[benchmarkDataItems]="investmentsByMonth"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[daysInMarket]="daysInMarket"
|
||||
[isInPercent]="hasImpersonationId || user.settings.isRestrictedView"
|
||||
[investments]="investmentsByMonth"
|
||||
[locale]="user?.settings?.locale"
|
||||
[ngClass]="{ 'd-none': !mode }"
|
||||
[range]="user?.settings?.dateRange"
|
||||
[savingsRate]="(hasImpersonationId || user.settings.isRestrictedView) ? undefined : user?.settings?.savingsRate"
|
||||
></gf-investment-chart>
|
||||
</div>
|
||||
|
@ -37,14 +37,14 @@ export class FirePageComponent implements OnDestroy, OnInit {
|
||||
this.deviceType = this.deviceService.getDeviceInfo().deviceType;
|
||||
|
||||
this.dataService
|
||||
.fetchPortfolioSummary()
|
||||
.fetchPortfolioDetails({})
|
||||
.pipe(takeUntil(this.unsubscribeSubject))
|
||||
.subscribe(({ cash, currentValue }) => {
|
||||
if (cash === null || currentValue === null) {
|
||||
.subscribe(({ summary }) => {
|
||||
if (summary.cash === null || summary.currentValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.fireWealth = new Big(currentValue);
|
||||
this.fireWealth = new Big(summary.currentValue);
|
||||
this.withdrawalRatePerYear = this.fireWealth.mul(4).div(100);
|
||||
this.withdrawalRatePerMonth = this.withdrawalRatePerYear.div(12);
|
||||
|
||||
|
@ -5,6 +5,7 @@
|
||||
<div>
|
||||
<h4 class="mb-3" i18n>Calculator</h4>
|
||||
<gf-fire-calculator
|
||||
[colorScheme]="user?.settings?.colorScheme"
|
||||
[currency]="user?.settings?.baseCurrency"
|
||||
[deviceType]="deviceType"
|
||||
[fireWealth]="fireWealth?.toNumber()"
|
||||
@ -35,7 +36,7 @@
|
||||
}"
|
||||
></ngx-skeleton-loader>
|
||||
</div>
|
||||
<div *ngIf="!isLoading">
|
||||
<div *ngIf="!isLoading" i18n>
|
||||
If you retire today, you would be able to withdraw
|
||||
<span class="font-weight-bold"
|
||||
><gf-value
|
||||
|
@ -192,6 +192,7 @@ export class HoldingsPageComponent implements OnDestroy, OnInit {
|
||||
dataSource,
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
colorScheme: this.user?.settings?.colorScheme,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToReportDataGlitch: hasPermission(
|
||||
|
@ -405,6 +405,7 @@ export class TransactionsPageComponent implements OnDestroy, OnInit {
|
||||
dataSource,
|
||||
symbol,
|
||||
baseCurrency: this.user?.settings?.baseCurrency,
|
||||
colorScheme: this.user?.settings?.colorScheme,
|
||||
deviceType: this.deviceType,
|
||||
hasImpersonationId: this.hasImpersonationId,
|
||||
hasPermissionToReportDataGlitch: hasPermission(
|
||||
|
@ -10,6 +10,15 @@
|
||||
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 *ngIf="user?.subscription?.type === 'Basic'">
|
||||
If you plan to open an account at <i>DEGIRO</i>, <i>frankly</i>,
|
||||
<i>Interactive Brokers</i>, <i>Swissquote</i>, or <i>VIAC</i>, please
|
||||
<a href="mailto:hi@ghostfol.io?Subject=Referral link for..."
|
||||
>contact us</a
|
||||
>
|
||||
to use our referral link and get a Ghostfolio Premium membership for
|
||||
one year.
|
||||
</p>
|
||||
<p>
|
||||
If you prefer to run Ghostfolio on your own infrastructure, please
|
||||
find the source code and further instructions on
|
||||
|
@ -25,13 +25,11 @@ import {
|
||||
Filter,
|
||||
InfoItem,
|
||||
OAuthResponse,
|
||||
PortfolioChart,
|
||||
PortfolioDetails,
|
||||
PortfolioInvestments,
|
||||
PortfolioPerformanceResponse,
|
||||
PortfolioPublicDetails,
|
||||
PortfolioReport,
|
||||
PortfolioSummary,
|
||||
UniqueAsset,
|
||||
User
|
||||
} from '@ghostfolio/common/interfaces';
|
||||
@ -75,60 +73,19 @@ export class DataService {
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
}): Observable<Activities> {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (filters?.length > 0) {
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
});
|
||||
|
||||
if (filtersByAccount) {
|
||||
params = params.append(
|
||||
'accounts',
|
||||
filtersByAccount
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByAssetClass) {
|
||||
params = params.append(
|
||||
'assetClasses',
|
||||
filtersByAssetClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByTag) {
|
||||
params = params.append(
|
||||
'tags',
|
||||
filtersByTag
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<any>('/api/v1/order', { params }).pipe(
|
||||
map(({ activities }) => {
|
||||
for (const activity of activities) {
|
||||
activity.createdAt = parseISO(activity.createdAt);
|
||||
activity.date = parseISO(activity.date);
|
||||
}
|
||||
return { activities };
|
||||
return this.http
|
||||
.get<any>('/api/v1/order', {
|
||||
params: this.buildFiltersAsQueryParams({ filters })
|
||||
})
|
||||
);
|
||||
.pipe(
|
||||
map(({ activities }) => {
|
||||
for (const activity of activities) {
|
||||
activity.createdAt = parseISO(activity.createdAt);
|
||||
activity.date = parseISO(activity.date);
|
||||
}
|
||||
return { activities };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public fetchAdminData() {
|
||||
@ -136,30 +93,8 @@ export class DataService {
|
||||
}
|
||||
|
||||
public fetchAdminMarketData({ filters }: { filters?: Filter[] }) {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (filters?.length > 0) {
|
||||
const { ASSET_SUB_CLASS: filtersByAssetSubClass } = groupBy(
|
||||
filters,
|
||||
(filter) => {
|
||||
return filter.type;
|
||||
}
|
||||
);
|
||||
|
||||
if (filtersByAssetSubClass) {
|
||||
params = params.append(
|
||||
'assetSubClasses',
|
||||
filtersByAssetSubClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<AdminMarketData>('/api/v1/admin/market-data', {
|
||||
params
|
||||
params: this.buildFiltersAsQueryParams({ filters })
|
||||
});
|
||||
}
|
||||
|
||||
@ -202,12 +137,6 @@ export class DataService {
|
||||
return this.http.get<BenchmarkResponse>('/api/v1/benchmark');
|
||||
}
|
||||
|
||||
public fetchChart({ range, version }: { range: DateRange; version: number }) {
|
||||
return this.http.get<PortfolioChart>(`/api/v${version}/portfolio/chart`, {
|
||||
params: { range }
|
||||
});
|
||||
}
|
||||
|
||||
public fetchExport(activityIds?: string[]) {
|
||||
let params = new HttpParams();
|
||||
|
||||
@ -234,34 +163,19 @@ export class DataService {
|
||||
return info;
|
||||
}
|
||||
|
||||
public fetchInvestments(): Observable<PortfolioInvestments> {
|
||||
return this.http.get<any>('/api/v1/portfolio/investments').pipe(
|
||||
map((response) => {
|
||||
if (response.firstOrderDate) {
|
||||
response.firstOrderDate = parseISO(response.firstOrderDate);
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
public fetchInvestments({
|
||||
groupBy,
|
||||
range
|
||||
}: {
|
||||
groupBy?: 'month';
|
||||
range: DateRange;
|
||||
}) {
|
||||
return this.http.get<PortfolioInvestments>(
|
||||
'/api/v1/portfolio/investments',
|
||||
{ params: { groupBy, range } }
|
||||
);
|
||||
}
|
||||
|
||||
public fetchInvestmentsByMonth(): Observable<PortfolioInvestments> {
|
||||
return this.http
|
||||
.get<any>('/api/v1/portfolio/investments', {
|
||||
params: { groupBy: 'month' }
|
||||
})
|
||||
.pipe(
|
||||
map((response) => {
|
||||
if (response.firstOrderDate) {
|
||||
response.firstOrderDate = parseISO(response.firstOrderDate);
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public fetchSymbolItem({
|
||||
dataSource,
|
||||
includeHistoricalData,
|
||||
@ -302,64 +216,43 @@ export class DataService {
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPortfolioDetails({ filters }: { filters?: Filter[] }) {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (filters?.length > 0) {
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
});
|
||||
|
||||
if (filtersByAccount) {
|
||||
params = params.append(
|
||||
'accounts',
|
||||
filtersByAccount
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByAssetClass) {
|
||||
params = params.append(
|
||||
'assetClasses',
|
||||
filtersByAssetClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByTag) {
|
||||
params = params.append(
|
||||
'tags',
|
||||
filtersByTag
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<PortfolioDetails>('/api/v1/portfolio/details', {
|
||||
params
|
||||
});
|
||||
public fetchPortfolioDetails({
|
||||
filters
|
||||
}: {
|
||||
filters?: Filter[];
|
||||
}): Observable<PortfolioDetails> {
|
||||
return this.http
|
||||
.get<any>('/api/v1/portfolio/details', {
|
||||
params: this.buildFiltersAsQueryParams({ filters })
|
||||
})
|
||||
.pipe(
|
||||
map((response) => {
|
||||
if (response.summary?.firstOrderDate) {
|
||||
response.summary.firstOrderDate = parseISO(
|
||||
response.summary.firstOrderDate
|
||||
);
|
||||
}
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPortfolioPerformance(params: { [param: string]: any }) {
|
||||
return this.http.get<PortfolioPerformanceResponse>(
|
||||
'/api/v1/portfolio/performance',
|
||||
{
|
||||
params
|
||||
}
|
||||
);
|
||||
public fetchPortfolioPerformance({
|
||||
range
|
||||
}: {
|
||||
range: DateRange;
|
||||
}): Observable<PortfolioPerformanceResponse> {
|
||||
return this.http
|
||||
.get<any>(`/api/v2/portfolio/performance`, { params: { range } })
|
||||
.pipe(
|
||||
map((response) => {
|
||||
if (response.firstOrderDate) {
|
||||
response.firstOrderDate = parseISO(response.firstOrderDate);
|
||||
}
|
||||
|
||||
return response;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPortfolioPublic(aId: string) {
|
||||
@ -372,18 +265,6 @@ export class DataService {
|
||||
return this.http.get<PortfolioReport>('/api/v1/portfolio/report');
|
||||
}
|
||||
|
||||
public fetchPortfolioSummary(): Observable<PortfolioSummary> {
|
||||
return this.http.get<any>('/api/v1/portfolio/summary').pipe(
|
||||
map((summary) => {
|
||||
if (summary.firstOrderDate) {
|
||||
summary.firstOrderDate = parseISO(summary.firstOrderDate);
|
||||
}
|
||||
|
||||
return summary;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
public fetchPositionDetail({
|
||||
dataSource,
|
||||
symbol
|
||||
@ -452,4 +333,53 @@ export class DataService {
|
||||
couponCode
|
||||
});
|
||||
}
|
||||
|
||||
private buildFiltersAsQueryParams({ filters }: { filters?: Filter[] }) {
|
||||
let params = new HttpParams();
|
||||
|
||||
if (filters?.length > 0) {
|
||||
const {
|
||||
ACCOUNT: filtersByAccount,
|
||||
ASSET_CLASS: filtersByAssetClass,
|
||||
TAG: filtersByTag
|
||||
} = groupBy(filters, (filter) => {
|
||||
return filter.type;
|
||||
});
|
||||
|
||||
if (filtersByAccount) {
|
||||
params = params.append(
|
||||
'accounts',
|
||||
filtersByAccount
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByAssetClass) {
|
||||
params = params.append(
|
||||
'assetClasses',
|
||||
filtersByAssetClass
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
|
||||
if (filtersByTag) {
|
||||
params = params.append(
|
||||
'tags',
|
||||
filtersByTag
|
||||
.map(({ id }) => {
|
||||
return id;
|
||||
})
|
||||
.join(',')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
}
|
||||
|
BIN
apps/client/src/assets/images/blog/hacktoberfest-2022.png
Normal file
After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
1
apps/client/src/assets/images/logo-alternative-to.svg
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
apps/client/src/assets/images/logo-awesome.png
Normal file
After Width: | Height: | Size: 17 KiB |
BIN
apps/client/src/assets/images/logo-openstartup.png
Normal file
After Width: | Height: | Size: 56 KiB |
35
apps/client/src/assets/images/logo-privacy-tools.svg
Normal file
@ -0,0 +1,35 @@
|
||||
<svg version="1.2" baseProfile="tiny-ps" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 508 101" width="508" height="101">
|
||||
<title>horizontal-svg</title>
|
||||
<style>
|
||||
tspan { white-space:pre }
|
||||
.shp0 { fill: #3498db }
|
||||
.shp1 { fill: transparent }
|
||||
.shp2 { fill: #8a9595 }
|
||||
</style>
|
||||
<g id="Logo">
|
||||
<g id="Shield">
|
||||
<g id="Layer">
|
||||
<path id="Layer" class="shp0" d="M284.81 0C279.86 0.24 254.33 8.51 242.56 14.89C240.87 15.81 239.01 18.96 239 20.34C239 33 242.05 51.67 250.66 68.26L267.5 51.42C264.25 43.19 266.19 33.81 272.44 27.56C276.25 23.94 283.41 21.26 289.64 21.15C292.47 21.1 295.11 21.58 297.15 22.74C298.34 23.43 297.78 24.32 297.42 24.68L284.52 37.05C282.89 38.8 284.3 43.29 285.36 46.15C288.21 47.2 292.7 48.61 294.45 46.98L306.83 34.08C307.18 33.73 308.07 33.17 308.76 34.36C312.47 40.88 309.21 53.53 303.95 59.06C297.69 65.32 288.31 67.26 280.09 64L260.76 83.33C267.04 90.61 274.95 96.69 284.81 100.54C320.46 86.63 330.62 43.46 330.62 20.34C330.61 18.96 328.76 15.81 327.06 14.89C315.29 8.51 289.76 0.24 284.81 0L284.81 0Z" />
|
||||
<g id="Layer">
|
||||
<path id="Layer" class="shp1" d="M250.69 68.23C250.93 68.68 251.17 69.13 251.41 69.58C252.43 71.46 253.52 73.31 254.69 75.12C255.86 76.93 257.1 78.69 258.43 80.4C259.19 81.38 259.98 82.35 260.8 83.3L280.12 63.97C288.35 67.23 297.73 65.29 303.98 59.03C309.25 53.5 312.51 40.85 308.8 34.33C308.11 33.14 307.22 33.7 306.86 34.05L294.49 46.95C292.74 48.58 288.25 47.17 285.39 46.12C284.34 43.26 282.93 38.77 284.56 37.02L297.46 24.65C297.81 24.29 298.37 23.4 297.18 22.71C295.14 21.55 292.51 21.07 289.68 21.12C283.45 21.23 276.28 23.91 272.48 27.53C266.22 33.79 264.28 43.16 267.54 51.39L250.69 68.23Z" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Tools">
|
||||
<path id="Layer" class="shp2" d="M343.3 31.55L343.3 24.61L387.83 24.61L387.83 31.55L369.48 31.55L369.48 76.83L361.65 76.83L361.65 31.55L343.3 31.55Z" />
|
||||
<path id="Layer" fill-rule="evenodd" class="shp2" d="M381.33 50.12C382.38 47.68 383.79 45.59 385.58 43.85C387.37 42.06 389.46 40.67 391.85 39.67C394.29 38.63 396.87 38.11 399.61 38.11C402.34 38.11 404.9 38.63 407.29 39.67C409.73 40.67 411.82 42.06 413.56 43.85C415.35 45.59 416.74 47.68 417.73 50.12C418.78 52.5 419.3 55.06 419.3 57.8C419.3 60.58 418.78 63.2 417.73 65.63C416.74 68.02 415.35 70.11 413.56 71.9C411.82 73.64 409.73 75.03 407.29 76.08C404.9 77.07 402.34 77.57 399.61 77.57C396.87 77.57 394.29 77.07 391.85 76.08C389.46 75.03 387.37 73.64 385.58 71.9C383.79 70.11 382.38 68.02 381.33 65.63C380.34 63.2 379.84 60.58 379.84 57.8C379.84 55.06 380.34 52.5 381.33 50.12ZM408.33 67.2C410.77 64.61 411.99 61.48 411.99 57.8C411.99 54.12 410.77 51.01 408.33 48.48C405.95 45.94 403.04 44.67 399.61 44.67C396.18 44.67 393.24 45.94 390.8 48.48C388.37 51.01 387.15 54.12 387.15 57.8C387.15 61.48 388.34 64.61 390.73 67.2C393.17 69.73 396.13 71 399.61 71C403.04 71 405.95 69.73 408.33 67.2Z" />
|
||||
<path id="Layer" fill-rule="evenodd" class="shp2" d="M423.45 50.12C424.5 47.68 425.91 45.59 427.7 43.85C429.49 42.06 431.58 40.67 433.97 39.67C436.41 38.63 438.99 38.11 441.73 38.11C444.46 38.11 447.02 38.63 449.41 39.67C451.85 40.67 453.94 42.06 455.68 43.85C457.47 45.59 458.86 47.68 459.85 50.12C460.9 52.5 461.42 55.06 461.42 57.8C461.42 60.58 460.9 63.2 459.85 65.63C458.86 68.02 457.47 70.11 455.68 71.9C453.94 73.64 451.85 75.03 449.41 76.08C447.02 77.07 444.46 77.57 441.73 77.57C438.99 77.57 436.41 77.07 433.97 76.08C431.58 75.03 429.49 73.64 427.7 71.9C425.91 70.11 424.5 68.02 423.45 65.63C422.46 63.2 421.96 60.58 421.96 57.8C421.96 55.06 422.46 52.5 423.45 50.12ZM450.45 67.2C452.89 64.61 454.11 61.48 454.11 57.8C454.11 54.12 452.89 51.01 450.45 48.48C448.07 45.94 445.16 44.67 441.73 44.67C438.3 44.67 435.36 45.94 432.92 48.48C430.49 51.01 429.27 54.12 429.27 57.8C429.27 61.48 430.46 64.61 432.85 67.2C435.29 69.73 438.25 71 441.73 71C445.16 71 448.07 69.73 450.45 67.2Z" />
|
||||
<path id="Layer" class="shp2" d="M473.1 22.97L473.1 76.83L465.64 76.83L465.64 24.61L473.1 22.97Z" />
|
||||
<path id="Layer" class="shp2" d="M475.48 71.9L479.51 66.98C481.5 68.52 483.51 69.71 485.55 70.56C487.64 71.35 489.75 71.75 491.89 71.75C494.58 71.75 496.76 71.23 498.45 70.18C500.15 69.14 500.99 67.77 500.99 66.08C500.99 64.74 500.49 63.67 499.5 62.87C498.5 62.08 496.96 61.53 494.87 61.23L488.01 60.26C484.28 59.72 481.45 58.57 479.51 56.83C477.62 55.04 476.67 52.68 476.67 49.75C476.67 46.31 478.04 43.55 480.78 41.47C483.51 39.33 487.09 38.26 491.52 38.26C494.3 38.26 496.94 38.66 499.42 39.45C501.96 40.25 504.42 41.47 506.81 43.11L503 48.03C500.92 46.64 498.88 45.64 496.89 45.05C494.95 44.4 492.98 44.08 490.99 44.08C488.71 44.08 486.87 44.55 485.48 45.49C484.08 46.44 483.39 47.68 483.39 49.22C483.39 50.62 483.86 51.69 484.8 52.43C485.8 53.13 487.41 53.65 489.65 54L496.51 54.97C500.24 55.51 503.08 56.68 505.02 58.47C507.01 60.26 508 62.63 508 65.56C508 67.25 507.58 68.84 506.73 70.33C505.94 71.78 504.82 73.02 503.38 74.06C501.99 75.11 500.32 75.95 498.38 76.6C496.44 77.2 494.35 77.49 492.11 77.49C488.83 77.49 485.75 77.02 482.86 76.08C480.03 75.13 477.57 73.74 475.48 71.9L475.48 71.9Z" />
|
||||
</g>
|
||||
<g id="Privacy">
|
||||
<path id="Layer" fill-rule="evenodd" class="shp2" d="M1.46 76.82L1.46 24.6L25.63 24.6C30.7 24.6 34.8 26.07 37.94 29.01C41.12 31.89 42.71 35.64 42.71 40.27C42.71 44.85 41.12 48.57 37.94 51.46C34.75 54.34 30.65 55.79 25.63 55.79L9.29 55.79L9.29 76.82L1.46 76.82ZM24.81 31.47L9.29 31.47L9.29 49.15L24.81 49.15C27.84 49.15 30.25 48.35 32.04 46.76C33.88 45.12 34.8 42.95 34.8 40.27C34.8 37.58 33.88 35.45 32.04 33.85C30.25 32.26 27.84 31.47 24.81 31.47L24.81 31.47Z" />
|
||||
<path id="Layer" class="shp2" d="M46.91 76.82L46.91 38.85L54.37 38.85L54.37 43.7C55.51 41.86 56.98 40.47 58.77 39.52C60.56 38.53 62.57 38.03 64.81 38.03C65.61 38.03 66.3 38.08 66.9 38.18C67.5 38.28 68.07 38.43 68.62 38.63L68.62 45.34C67.92 45.09 67.2 44.89 66.45 44.74C65.71 44.6 64.96 44.52 64.22 44.52C62.03 44.52 60.06 45.12 58.32 46.31C56.63 47.46 55.31 49.17 54.37 51.46L54.37 76.82L46.91 76.82Z" />
|
||||
<path id="Layer" class="shp2" d="M75.41 32.74C74.17 32.74 73.1 32.29 72.2 31.4C71.31 30.45 70.86 29.36 70.86 28.12C70.86 26.87 71.31 25.8 72.2 24.91C73.1 23.96 74.17 23.49 75.41 23.49C76.65 23.49 77.72 23.96 78.62 24.91C79.56 25.8 80.03 26.87 80.03 28.12C80.03 29.36 79.56 30.45 78.62 31.4C77.72 32.29 76.65 32.74 75.41 32.74L75.41 32.74ZM79.14 38.86L79.14 76.82L71.68 76.82L71.68 38.86L79.14 38.86Z" />
|
||||
<path id="Layer" class="shp2" d="M97.33 76.82L80.92 38.85L89.12 38.85L100.98 67.27L112.84 38.85L120.83 38.85L104.41 76.82L97.33 76.82Z" />
|
||||
<path id="Layer" fill-rule="evenodd" class="shp2" d="M134.56 77.5C130.43 77.5 127.08 76.43 124.49 74.29C121.9 72.15 120.61 69.37 120.61 65.94C120.61 62.36 121.98 59.55 124.71 57.51C127.45 55.42 131.2 54.38 135.98 54.38C137.82 54.38 139.61 54.57 141.35 54.97C143.09 55.32 144.75 55.82 146.35 56.46L146.35 52.44C146.35 49.75 145.55 47.74 143.96 46.39C142.37 45.05 140.08 44.38 137.1 44.38C135.36 44.38 133.54 44.65 131.65 45.2C129.76 45.7 127.65 46.49 125.31 47.59L122.55 41.99C125.39 40.65 128.07 39.68 130.61 39.08C133.14 38.44 135.65 38.11 138.14 38.11C143.06 38.11 146.87 39.31 149.55 41.7C152.29 44.03 153.66 47.36 153.66 51.69L153.66 76.83L146.35 76.83L146.35 73.55C144.66 74.89 142.84 75.88 140.9 76.53C138.96 77.18 136.85 77.5 134.56 77.5L134.56 77.5ZM127.77 65.79C127.77 67.63 128.54 69.12 130.08 70.26C131.68 71.41 133.74 71.98 136.28 71.98C138.26 71.98 140.1 71.68 141.8 71.09C143.49 70.49 145 69.59 146.35 68.4L146.35 61.84C144.9 61.04 143.39 60.47 141.8 60.12C140.2 59.72 138.46 59.52 136.57 59.52C133.89 59.52 131.75 60.09 130.16 61.24C128.57 62.38 127.77 63.9 127.77 65.79L127.77 65.79Z" />
|
||||
<path id="Layer" class="shp2" d="M177.4 70.93C179.29 70.93 181.08 70.56 182.77 69.81C184.46 69.02 186.08 67.85 187.62 66.31L192.09 71.15C190.11 73.14 187.82 74.71 185.23 75.85C182.65 76.95 179.94 77.49 177.1 77.49C174.37 77.49 171.81 77 169.42 76C167.03 74.96 164.97 73.57 163.23 71.83C161.49 70.08 160.09 68.02 159.05 65.63C158.06 63.2 157.56 60.59 157.56 57.8C157.56 55.07 158.06 52.51 159.05 50.12C160.09 47.68 161.49 45.59 163.23 43.85C164.97 42.06 167.03 40.67 169.42 39.68C171.81 38.63 174.37 38.11 177.1 38.11C179.94 38.11 182.7 38.68 185.38 39.82C188.07 40.92 190.4 42.46 192.39 44.45L187.69 49.52C186.25 47.98 184.64 46.81 182.85 46.02C181.06 45.17 179.19 44.75 177.25 44.75C173.82 44.75 170.91 46.02 168.52 48.55C166.14 51.09 164.94 54.17 164.94 57.8C164.94 61.53 166.14 64.66 168.52 67.2C170.96 69.69 173.92 70.93 177.4 70.93L177.4 70.93Z" />
|
||||
<path id="Layer" class="shp2" d="M204.75 80.4L206.24 76.97L191.1 38.85L199.31 38.85L210.42 67.87L222.65 38.85L230.71 38.85L212.36 81.37C210.62 85.4 208.63 88.28 206.39 90.02C204.15 91.81 201.29 92.71 197.81 92.71C197.02 92.71 196.27 92.66 195.57 92.56C194.93 92.51 194.36 92.41 193.86 92.26L193.86 85.7C194.36 85.8 194.85 85.87 195.35 85.92C195.9 85.97 196.52 85.99 197.22 85.99C198.91 85.99 200.37 85.52 201.62 84.58C202.91 83.68 203.95 82.29 204.75 80.4L204.75 80.4Z" />
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 8.9 KiB |
BIN
apps/client/src/assets/images/logo-product-hunt.png
Normal file
After Width: | Height: | Size: 21 KiB |
9
apps/client/src/assets/images/logo-unraid.svg
Normal file
@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 222.4 139.8">
|
||||
<path fill="#ffffff" d="M146.70000000000002 130.2H135l-3 9h-6.5l13.4-38.5h8l13.4 38.5h-7.1l-10.6-31.6-5.8 16.9h8.2l1.7 5.7zM29.7 100.8v25.4c0 8.9-5.8 13.6-14.9 13.6-9 0-14.8-4.7-14.8-13.6v-25.4h6.5v25.4c0 5.2 3.2 7.9 8.2 7.9 5.2 0 8.4-2.7 8.4-7.9v-25.4h6.6zM50.9 112.7v26.5h-6.5v-38.5h6.1l17 26.5v-26.5H74v38.5h-6.1l-17-26.5zM171.3 100.8h6.5v38.5h-6.5v-38.5zM222.4 125.4c0 9-5.9 13.8-15.2 13.8h-14.5v-38.5h14.6c9.2 0 15.1 4.8 15.1 13.8v10.9zm-6.6-10.8c0-5.3-3.3-8.1-8.5-8.1h-8.1v27.1h8c5.3 0 8.6-2.8 8.6-8.1v-10.9zM108.3 124.7c4.3-1.6 6.9-5.3 6.9-11.5 0-8.7-5.1-12.4-12.8-12.4H88.8v38.5h6.5v-32.8h6.9c3.8 0 6.2 1.8 6.2 6.7s-2.4 6.8-6.2 6.8h-3.4l9.2 19.4h7.5l-7.2-14.7z" class="st0"></path>
|
||||
<linearGradient id="SVGID_1_" x1="68.07" x2="154.073" y1="81.489" y2="-4.514" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#e32929"></stop>
|
||||
<stop offset="1" stop-color="#ff8d30"></stop>
|
||||
</linearGradient>
|
||||
<path fill="url(#SVGID_1_)" d="M107.8 19.2h6.5v38.5h-6.5V19.2zM50.9 57.7h-6.5V19.2h6.5v38.5zm25.2 4.6h6.5V77h-6.5V62.3zM60.2 45.8h6.5v23.8h-6.5V45.8zm31.7 0h6.5v23.8h-6.5V45.8zm79.4-26.6h6.5v38.5h-6.5V19.2zm-25.2-4.5h-6.5V0h6.5v14.7zM162 31.1h-6.5V7.4h6.5v23.7zm-31.8 0h-6.5V7.4h6.5v23.7z"></path>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |